Skip to content

Add browser-side SAP request signing (X-Apple-ActionSignature) - #89

Open
hubo1989 wants to merge 5 commits into
Lakr233:mainfrom
hubo1989:feat/sap-action-signature
Open

hubo1989 wants to merge 5 commits into
Lakr233:mainfrom
hubo1989:feat/sap-action-signature

Conversation

@hubo1989

@hubo1989 hubo1989 commented Sep 4, 2026

Copy link
Copy Markdown

Companion proposal to #88 — same problem, same zero-trust approach, different engine. Also credits below, since three of its UX ideas are already folded into this branch.

Summary

Apple's auth endpoints now require X-Apple-ActionSignature: base64(Sign(bodyBytes)) (403 with empty body in ~6 ms otherwise, per the analysis in #88). This PR runs Apple's obfuscated SAP entry points under Unicorn 2.1.4 TCI emulation compiled to WebAssembly, entirely in the browser, preserving the zero-trust invariant: the signer's inputs are only the per-account deviceIdentifier and public Apple assets — the password never leaves the client except as the signed body inside the wisp tunnel.

Engine: why not @alexaltea/unicorn-js

That package (used by #88) is the same Unicorn 2.1.4 built to wasm, but it runs the 32-bit TCI register path and calls helpers across the JS boundary. Both are expensive:

#88 (unicorn-js) this PR (patched TCI)
Setup 35 s WebKit / 63 s Node / 115 s Chrome ~0.15–0.3 s (Node-measured, same work)
Per signature 3.5–12 s sub-second
Engine size ~2 MB chunk ~730 KB wasm

The build chain (frontend/scripts/unicorn-wasm-patch/) clones unicorn 2.1.4 and applies a 112 KB patch that:

  1. Restores the QEMU 5.0.1 TCI interpreter (unicorn strips it)
  2. Forces 64-bit virtual TCI registers on wasm32 — upstream's 32-bit path is riddled with TODO() stubs, and i64 guests otherwise split every operation across register pairs
  3. Generates uniform-signature helper trampolines (~470 macro-expanded) so TCI's single-cast helper dispatch survives wasm's strict indirect-call checks — no per-block length splitting needed
  4. Adapts glib-compat comparators, disables inline hook callbacks, and replaces the timeout thread with a deadline checked inside the interpreter loop

Fidelity verified against native ipatool on the same assets: relocated guest images are byte-identical, Initialize returns the identical context (0x400000000200), Sign without the Apple key exchange fails with the identical -42085, and a 200-instruction trace aligns 1:1.

Assets: 37.7 MB → 22.5 MB → ~14 MB on the wire

CoreFP ships as an i386+x86_64 universal and the emulated guest is x86_64 only, so the backend strips fat binaries to their x86_64 slice after verifying Apple's original digests (a second, deterministic pin set covers the stripped bytes served to browsers). The route gzips responses. Release images prebake the stripped assets in a build stage (layer-cached, digest-pinned), so a fresh VPS serves them with zero network use and zero extraction — verified: first asset request on a fresh container returns in 0.8 s.

UX adopted from #88

  • Signer singleton bound to the deviceIdentifier — rebuilding per authenticate() copied the 22.5 MB bundle into a fresh worker every attempt (2FA retries included)
  • Background warmup once an account exists (useSapWarmup); with the fast engine this mostly covers the one-time 14 MB download
  • Inline progress (SapStatus + zustand store) in all five locales

Notes

  • Builds on the BUILDPLATFORM pattern for all JS stages; runtime deps install per-target-platform (@node-rs/crc32 napi binaries). Platforms: linux/amd64, linux/arm64. No 386 — official node images dropped it and the napi dependency has no linux-ia32 build
  • Documented in AGENTS.md, including the legacy MZFinance endpoint finding from Sign-in is returning 403: Apple now requires a SAP signature on authenticate #88 (all three advertised endpoints sit in sign-sap-request, so signing applies regardless)

Test plan

  • frontend 84/84 vitest (machImage: fat slicing, bind/rebase opcode semantics, addends, BSS-tail fixups)
  • backend 49/49 vitest; extraction pipeline run end-to-end twice from the live CDN (all four digests match)
  • tests/sap/machine-live.mjs: full chain against real assets from the running container — Initialize matches native bit-for-bit, Sign gating identical
  • Docker image built and run locally: prebaked seeding, gzip wire size, served digests
  • Real-account sign-in (needs credentials; Sign-in is returning 403: Apple now requires a SAP signature on authenticate #88 already demonstrated a valid signature turns the 403 into 200 + MZFinance.BadLogin.Configurator_message, and this machine matches ipatool bit-for-bit)

Supersedes neither — if the engine approach is welcome, the assets/UX layers here are directly reusable either way.

Apple's auth endpoint now requires X-Apple-ActionSignature on every
request. The signer runs Apple's obfuscated CommerceKit/CoreFP entry
points under Unicorn emulation entirely in the browser, preserving the
zero-trust invariant: the signer sees only the deviceIdentifier and
public Apple assets, while the password stays client-side as opaque
body bytes it signs in place.

Engine: Unicorn 2.x only ships JIT TCG backends, which cannot execute
under WebAssembly. This restores the QEMU 5.0.1 TCI interpreter into
the tree (patched build chain in frontend/scripts/unicorn-wasm-patch),
forces 64-bit virtual TCI registers on wasm32 (the 32-bit path is
incomplete upstream), generates uniform-signature helper trampolines,
adapts glib-compat comparators, and replaces the timeout thread with a
deadline checked inside the interpreter loop.

Browser: Mach-O 64 loader with dyld_info rebase/bind/weak/lazy fixups,
libc/CF/IOKit guest shims, and the SAP machine orchestration running
in a Web Worker. Verified against the native ipatool runtime bit-for-
bit: identical Initialize context (0x400000000200) and identical Sign
gating error (-42085 without the Apple key exchange).

Backend: extracts the four public Apple binaries from OSXUpd10.9.pkg
(xar range download -> bzip2 -> cpio, digest-pinned), strips fat
binaries to the x86_64 slice (37.7 MB -> 22.5 MB, ~14 MB gzipped on
the wire), and serves them via /api/sap-assets.

Docker: release images prebake the stripped assets (fresh VPS serves
them with zero network use); build stages run on BUILDPLATFORM with
per-platform runtime dependency install. linux/386 is not published by
official node images and lacks napi binaries - amd64/arm64 only.

Auth flow: bag advertises sign-sap-setup* keys -> assets fetched and
cached (Cache API) -> signer created per authenticate() call -> each
attempt's exact body bytes signed and attached. Bags without the SAP
keys gracefully degrade to the legacy flow.
…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.
…nature

# Conflicts:
#	frontend/src/components/Account/AddAccountForm.tsx
wisp-js depends on bufferutil as a hard dependency; it ships musl
prebuilds for x64 but not arm64, so the per-platform runtime install
fell back to node-gyp without a toolchain present. Add python3/make/g++
as a layer-local virtual package removed after npm ci.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to see this PR. And I wonder, is this .wasm file committed to the PR by accident? Also the frontend/src/apple/sap/vendor/unicorn.mjs file is a compressed mis, looks like a compiled output instead of a source code?

lge6 added a commit to lge6/AssppWeb that referenced this pull request Sep 11, 2026
…ages

Preserve upstream browser-side signing, allow SAP certificate and exchange hosts through Wisp, and test exact signed request bytes. Repair the Docker workflow, gate publication on tests and container checks, and publish amd64/arm64 images for this fork.
@Douglaspa

Copy link
Copy Markdown

Confirmed this working end-to-end on a self-hosted Docker instance built from
this branch — with one missing piece.

The wisp hostname whitelist in backend/src/services/wsProxy.ts does not cover
the hosts the bag advertises for SAP. With the list as-is, sign-in dies at
"Failed to prepare signing components: Request failed with error code 35:
SSL connect error", and the proxy log shows the actual cause:

opening new TCP stream to s.mzstatic.com:443
warn: refusing to create a stream to s.mzstatic.com:443

The bag (note: it only advertises these keys when fetched with a Configurator
UA — a plain curl UA gets a bag without sign-sap-* or authenticateAccount)
points at:

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

Adding

/^s.mzstatic.com$/,
/^fpinit.itunes.apple.com$/,

to hostname_whitelist completes the flow: the tunnel then goes
s.mzstatic.com -> fpinit.itunes.apple.com -> buy.itunes.apple.com, and even a
bogus account returns a real plist (MZFinance.BadLogin.Configurator_message ->
the 2FA prompt) instead of the empty 403.

Verified afterwards: real-account sign-in succeeds, and search + download work
on the same build. The test plan still has "Real-account sign-in" unchecked,
which is exactly why this gap survived — worth adding to the checklist and to
the whitelist in the PR itself.

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.

3 participants