Skip to content

Sign-in is returning 403: Apple now requires a SAP signature on authenticate - #88

Draft
Tardisyuan wants to merge 3 commits into
Lakr233:mainfrom
Tardisyuan:feat/browser-sap-signing
Draft

Tardisyuan wants to merge 3 commits into
Lakr233:mainfrom
Tardisyuan:feat/browser-sap-signing

Conversation

@Tardisyuan

Copy link
Copy Markdown
Contributor

Opening this as a draft to ask whether you want the approach, not to push it. Issues are disabled on this repo, so a PR is the only way I could find to raise it. Happy to close it and leave just the analysis if you would rather solve this differently.

Sign-in is broken for everyone

The UI reports errors.auth.emptyBody. The underlying response is 403 with Content-Length: 0, returned in about 6 ms — refused at the edge before credentials are looked at.

Reproducible with plain curl, outside the app entirely:

curl -sS -o /dev/null -D - -X POST --http1.1 \
  -A 'Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6' \
  -H 'Content-Type: application/x-apple-plist' \
  --data-binary @body.plist \
  'https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?guid=<guid>'

HTTP/1.1 403 Apple WebObjects 5.9.7-2
Content-Length: 0
apple-timing-app: 6 ms

Same with application/x-www-form-urlencoded, with and without ?guid=, with X-Apple-Store-Front added, over HTTP/2, and against p{pod}-buy. Reproduced from a residential IP, so it is not IP reputation, a WAF, or the Wisp tunnel.

What changed

The live bag says it outright. urlBag.sign-sap-request lists these as requiring a SAP-signed X-Apple-ActionSignature:

MZFinance:        ['authenticate']
auth/v1:          ['native']
auth/v1/native:   ['fast']

urlBag.sign-sap-version     = 200
urlBag.sign-sap-setup       = https://fpinit.itunes.apple.com/v1/signSapSetup/legacy
urlBag.sign-sap-setup-cert  = https://s.mzstatic.com/sap/setupCert.plist

All three sign-in endpoints are covered, so switching between them does not help.

Separately: urlBag.authenticateAccount currently returns the legacy buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate, not auth.itunes.apple.com, which makes normalizeAuthURL() in frontend/src/apple/bag.ts inert today. Worth knowing regardless of what happens to this PR.

ipatool hit exactly this — #522, #523 — and fixed it in v2.4.0 by running Apple's own signing code, taken from a 2013 OS X update package, under a CPU emulator.

A signature does fix it

Sending the same deliberately wrong credentials with and without one separates the cases cleanly:

without signature:  HTTP 403, empty body
with signature:     HTTP 200, 326 bytes, a parseable plist
                    customerMessage: MZFinance.BadLogin.Configurator_message

The second is the ordinary wrong-password answer — the request reached the credential check. No account is needed to establish that, since what is being tested is how far the request gets.

What this branch does

Ports that approach to run entirely client-side, so the server still never sees Apple credentials. The SAP handshake carries none itself: the only identity in it is the device's hardware id, which is the guid already sent in the clear, so it can complete before anyone types a password.

Three commits: the backend fetching and serving the binaries, the signer itself, and wiring it into sign-in behind a Web Worker. frontend/src/apple/sap/README.md has the details and the measurements.

What you would be taking on

These are your calls, which is why this is a draft:

  • It runs Apple's proprietary binaries. CommerceKit, CommerceCore, CoreFP and CoreFP.icxs are fetched from Apple's software update CDN and executed under emulation. ipatool made that call; this project has not had to.
  • Deployment footprint. ~38 MB per deployment in the data directory, a ~2 MB dynamic chunk for unicorn.js, and three new outbound hosts (swcdn.apple.com, s.mzstatic.com, fpinit.itunes.apple.com).
  • Speed. Setup is ~35 s on WebKit and ~115 s on Chrome; each signature 4–12 s. It runs in a worker, starts in the background, and reports progress, but every sign-in pays it.
  • Maintenance. ~3,600 lines: a Mach-O loader with the dyld bind/rebase opcodes, an x86-64 instruction length decoder, the emulator shims. Two fragile external anchors — the update package URL and a byte offset within it — which ipatool shares.
  • Untested on real iOS devices. It works under Playwright's WebKit with an iPhone 15 profile, but the guest mapping is 144 MB plus 38 MB of assets, and whether a phone tolerates that is unknown.

Also unrelated but noticed while working here: downloads.package.downloadFailed and settings.data.exportFailed are referenced by t() but missing from every locale, so those paths show the raw key.

🤖 Generated with Claude Code

Tardisyuan and others added 3 commits September 1, 2026 20:34
The browser-side signer runs four binaries from a 2013 OS X release under
emulation. They carry no credentials and are identical for everyone, so they
are plain static files — but they are 38 MB and they are Apple's, so they stay
out of the image and out of git.

The backend fetches them from Apple's software update CDN the first time the
signer asks and keeps them in DATA_DIR/sap, so a fresh deployment needs
nothing done to it by hand. They live inside a xar container holding a
bzip2-compressed cpio archive; reading it the way ipatool does — locate
Payload in the table of contents, range-request from where its bzip2 stream
resumes, put the header back in front, skip to the cpio, stop once the four
are out — costs a fraction of the package.

Assets are verified by SHA-256 rather than size. A file of the right length
but wrong contents loads fine and then fails deep inside the emulator with
nothing pointing back at the download. Verdicts are cached until a file's size
or mtime changes, so the status endpoint stays cheap to poll, and a corrupt
file is replaced rather than skipped.

Also proxies the two setup endpoints. Neither carries credentials — the only
identity in the handshake is the device's hardware id, the guid already sent
in the clear — so this is the same kind of proxy as /api/bag and leaves intact
the guarantee that the server never sees Apple credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple began requiring a SAP-signed X-Apple-ActionSignature on authenticate in
August. The bag says so directly: urlBag.sign-sap-request lists MZFinance:
authenticate, auth/v1: native and auth/v1/native: fast, and all three answer
an unsigned request with 403 and an empty body about 6 ms in, before looking
at credentials.

Ported from ipatool's internal/sap. macho.ts loads the guest images, length.ts
decodes x86-64 instruction lengths, engine.ts wraps unicorn.js, shims.ts and
platform.ts stand in for the macOS the guest expects, machine.ts drives the
four entry points, and signer.ts runs the setup protocol.

unicorn.js is the same Unicorn 2.1.4 ipatool loads, built to WebAssembly. Two
things that build forces: a guest call is bounded by instruction count alone,
since a non-zero timeout makes Unicorn spawn a timer thread it cannot create;
and it aborts inside QEMU's Tiny Code Interpreter on a long basic block, so
machine.ts splits long blocks itself by planting a HLT at a safe boundary and
resuming from it. README.md in that directory carries the measurements and the
reasoning.

None of the setup involves credentials — the only identity is the hardware id,
which is the guid already sent in the clear — so it can happen well before
anyone types a password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the signer into sign-in: authenticate prepares one and sends
X-Apple-ActionSignature with the request.

Preparing it runs about ten million emulated instructions — roughly 35 s on
WebKit, 115 s on Chrome — and each signature a few million more, so both
happen in a Web Worker rather than freezing the tab. The signer starts
preparing as soon as there is an account to bind it to, so a button pressed
later usually finds it ready, and SapStatus shows whatever progress is under
way rather than leaving a button looking dead.

A signer is bound to the hardware id it was initialised with, so it is rebuilt
when that changes rather than signing with another account's identity. And
re-authenticating refuses immediately when an account has no password rather
than spending two minutes preparing a signer it cannot use.

Vite needs worker.format "es": the worker loads unicorn.js dynamically, and a
code-splitting build cannot emit that as IIFE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rubiduong

rubiduong commented Sep 1, 2026 via email

Copy link
Copy Markdown

hubo1989 added a commit to hubo1989/AssppWeb that referenced this pull request Sep 4, 2026
…cator

The upstream draft PR (Lakr233#88) solved the same problem with
an npm unicorn build; three of its UX decisions are worth taking
regardless of engine:

- Signer singleton bound to the deviceIdentifier. Rebuilding per
  authenticate() call copied the 22.5 MB asset bundle into a fresh
  worker on every attempt (2FA retries included); the singleton reuses
  the worker and SAP session across attempts and rebuilds only when the
  account or bag endpoints change.
- Background warmup (useSapWarmup): once an account exists, fetch the
  bag and prepare the signer so sign-in usually finds it ready. The
  one-time asset download dominates preparation; later runs are served
  from the Cache API.
- Inline progress (SapStatus) wired to a zustand store, with strings in
  all five locales.

Also documents the upstream finding that the live bag hands out the
legacy MZFinance authenticate endpoint and all advertised endpoints are
covered by sign-sap-request.
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.

2 participants