Skip to content

feat(cli): create APNs keys with a native Apple sign-in instead of Node.js - #1991

Closed
ArnabChatterjee20k wants to merge 6 commits into
messaging-provider-cli-apns-create-keyfrom
messaging-provider-cli-apns-native
Closed

ArnabChatterjee20k wants to merge 6 commits into
messaging-provider-cli-apns-create-keyfrom
messaging-provider-cli-apns-native

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1989. Draft until it has signed in to a real Apple account.

What

appwrite init apns --create-key no longer needs Node.js or @expo/apple-utils. The CLI signs in to Apple and calls the Developer portal itself, in Go.

How

Apple has no public API for creating APNs keys, so this does what the portal's web pages do. The sign-in follows fastlane's spaceship (readable, MIT, kept current), and the key calls follow @expo/apple-utils 2.2.1.

Sign-in (internal/apple/auth.go, srp.go)

  1. Widget key from HEAD appstoreconnect.apple.com/logout (the widgetKey in the redirect), sent without cookies and without following the redirect. Falls back to the copy saved by an earlier run, then the old olympus/v1/app/config (removed by Apple in September 2026).
  2. SRP-6a: POST idmsa.apple.com/appleauth/auth/signin/init, then PBKDF2-SHA256 over the password's SHA-256 (hex-encoded first for s2k_fo), then signin/complete with m1/m2. The password never leaves the machine.
  3. Hashcash from GET /appleauth/auth/signin?widgetKey=… (X-Apple-HC-Bits / X-Apple-HC-Challenge), sent as X-Apple-HC.
  4. On 409, two-factor: GET /appleauth/auth for the options, then the code pushed to trusted devices, or a text message (typing sms, or automatically when the account has no trusted devices), POST …/verify/{trusteddevice|phone}/securitycode. A wrong code is asked again up to three times. Then GET /appleauth/auth/2sv/trust.
  5. GET olympus/v1/session confirms the session. Cookies are saved to ~/.appwrite/apple/session.json (0600), so later runs skip the sign-in. A saved session for a different Apple ID than the one in the environment is not reused.

Keys (portal.go), under developer.apple.com/services-account/QH65B2/:

  • account/listTeams.action: one team is used directly, several are offered as a choice.
  • account/auth/key/list: also hands out the csrf / csrf_ts tokens the create needs.
  • account/auth/key/v2/create with scope: team, serviceConfigurations: {U27F4V844T: []} and one APNs request for environment: all.
  • account/auth/key/download returns the .p8.
  • "maximum allowed number of Keys" maps to the existing MaxKeysError, which lists the team's keys.

Credentials: APPWRITE_APPLE_ID / APPWRITE_APPLE_PASSWORD only, so a variable set up for another tool never signs in by accident. The CLI prints which variable it used, never the value. Otherwise it asks through the CLI's prompts, with the password hidden.

Environment: --environment production|sandbox|all (default all) decides which Appwrite APNs providers are set up and must exist for "already set up", and, with --create-key, the environment the Apple key works in. spaceship and apple-utils only ever send "all"; "production" and "sandbox" are unconfirmed until a real run.

Failures: with --create-key, a failed sign-in or key creation stops the command with the reason instead of falling back to a .p8 in the project folder. Choosing the Apple sign-in from the prompt still falls back to the other options. The session is saved as soon as Apple accepts the code, and an expired session's trust cookie is kept so signing in again skips two-factor.

Correctness

  • srp_test.go checks the PBKDF2 key, A, m1 and m2 for both s2k and s2k_fo against values computed by fastlane-sirp (the gem spaceship uses), and hashcash against spaceship's. These include the non-obvious parts: x has no username; A, S and m2 drop leading zero bytes; k and u pad to 256 bytes.
  • apple_test.go runs the whole flow against a fake Apple that does the server side of SRP: two-factor with a wrong code first, session reuse with no questions or sign-in requests, credentials from the environment (password never logged), wrong password (no session saved), full team, team choice and an unknown team, and widget-key fallback.
  • The init apns tests are unchanged apart from the fake no longer being a Node error.

Not yet verified

  • A real sign-in against Apple. The protocol was cross-checked against spaceship's and apple-utils' current code, but only a real account proves it.
  • Accounts with security keys or Managed Apple IDs, which this sign-in can't handle (neither can spaceship's). Those users keep the browser and file options.

Notes against the reverse-engineering doc

Checked against the sources, a few details differ from the first write-up:

  • Cookie portal calls use services-account/QH65B2/. services-account/v1/ is for the token client.
  • The hashcash challenge comes from GET /appleauth/auth/signin?widgetKey=, not from the signin/init response.
  • Codes are verified with POST, not PUT.
  • Key fields are keyId / keyName.

Apple signs in with SRP-6a over the RFC 5054 2048-bit group and SHA-256,
with PBKDF2 over the password (hex-encoded first for s2k_fo), and asks for
a SHA-1 hashcash on the final step. This ports fastlane-sirp and
spaceship's hashcash, which spaceship signs in with, and checks them
against values those Ruby implementations computed for fixed inputs.
…de.js

init apns --create-key no longer needs Node.js or @expo/apple-utils. The
CLI signs in to Apple itself, as spaceship does: the widget key from the
App Store Connect sign out redirect, SRP against idmsa.apple.com,
hashcash, two-factor by device code or text message, and a trusted
session saved in ~/.appwrite/apple so later runs skip two-factor. It then
calls the Developer portal's key API the way @expo/apple-utils does:
list the team's keys (which hands out the CSRF tokens), create a
team-scoped key with APNs for all environments, and download the .p8.

The Apple ID and password can come from APPWRITE_APPLE_ID and
APPWRITE_APPLE_PASSWORD, or EXPO_APPLE_ID and EXPO_APPLE_PASSWORD for
anyone set up for eas-cli; the CLI names the variable it used. The
questions go through the CLI's prompts.
APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the sign-in
questions. The EXPO_APPLE_* fallback is gone, so a variable set up for
eas-cli never signs in to Apple with an account nobody picked here.
The session was saved only after App Store Connect confirmed it, so a
failure after two-factor cost another code on the next run. It is now
saved as soon as Apple accepts the sign-in. An expired session's cookies
are also kept when signing in again, as spaceship does: the one that
marks the machine as trusted lets Apple skip two-factor.
After a failed Apple sign-in, init apns --create-key fell back to a .p8
already in the project folder and set the providers up with it, so a
run asked for a new key reported success with the old one. It now stops
with the reason. Choosing the Apple sign-in from the prompt still falls
back to the other ways of providing the key.
--environment (production, sandbox or all, the default) decides which
Appwrite APNs providers are set up, which ones must exist for the
command to report APNs already set up, and, with --create-key, which
environment the new Apple key works in. Spaceship and apple-utils only
ever send "all"; "production" and "sandbox" are the values the
portal's choices stand for.
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

Closing in favour of the adapter split. Everything here is carried over:

The branch is kept.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant