From e6d8d45bef8de9f92c7a1a601d9f44abfa03b309 Mon Sep 17 00:00:00 2001 From: fenleon Date: Tue, 13 Oct 2026 11:42:08 +0200 Subject: [PATCH] =?UTF-8?q?fairplay:=20out-of-repo=20identity=20override?= =?UTF-8?q?=20=E2=80=94=20the=20committed=20identity=20is=20a=20placeholde?= =?UTF-8?q?r=20(live=20verdict=20SIGNATURE=5FVERIFICATION=5FFAILED);=20rea?= =?UTF-8?q?l=20pair=20accepted=20live;=20spec=20rev=2044?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/imessage-protocol-spec.md | 3 ++- .../registration/BundledFairPlaySigner.kt | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index c47b7ce..f015798 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -1,6 +1,6 @@ # iMessage protocol spec (clean-room) -**Status:** draft — revision 43. The full revision history is at the bottom ([Revision history](#revision-history)); the sections below state the facts as of rev 31. +**Status:** draft — revision 44. The full revision history is at the bottom ([Revision history](#revision-history)); the sections below state the facts as of rev 31. **Firewall rule:** this document contains facts only — no code, no function/type names, no copied expression from any reference implementation. Wire-format field names are quoted as on-the-wire protocol data (observable in captures, capture-arbitrated), never as source expression. Facts derived from SSPL/AGPL sources are used facts-only; nothing from them is reproduced. Where the source-derived fact sets agree, the fact is stated once with both citations; where they disagree, the disagreement is stated and tagged `TODO(capture):` — running mitmproxy captures arbitrate. All `TODO(capture)` items are collected in §7. @@ -334,6 +334,7 @@ Ordered by value. IDs match the inline `TODO(capture):` markers above. - Rev 37 (2026-10-12, live-provisioning wire facts + the step-2 EM A/B): driving the Kotlin `ClearAnisetteProvider` against Apple's live endpoints for the first time (the session-26 EM A/B, NAC-NOTES session-26 addendum) surfaced two wire facts the offline scripted-HTTP tests could not catch: (1) the GSA lookup GET **404s without `X-Mme-Client-Info`**, and accepts only the angle-bracket form ` ` — a bare `com.apple.AuthKit/1 (com.apple.akd/1.0)` value 404s (the app's real value: ` `; imessage's `loginInfoFor`/`DEFAULT_LOGIN_INFO` already use the angle form); (2) the start/finish provisioning POST body keys are **case-sensitive**: `{header, request}` (lowercase — what the Kotlin sent) gets a **silent HTTP 200 with an EMPTY body**, `{Header, Request}` (as §1.5/C6 records) gets the real `Response.spim`/`ptm`/`tk` — fixed in `ClearAnisetteProvider` (Kotlin now sends the capitalized keys; 217 registration tests green; first live Kotlin provisioning run succeeded: metadata 400 B / mid 60 B). The vendored anisette client's fixed companion headers (rev-14 set) were confirmed against a live 2026-10-03 capture of the app's own lookup. Step-2 gate A/B (all via the identical transport): the app's genuine 10-08 blob replay → 5003/EM; our minted blob → 5003/EM; a FRESH blob minted by the app's own unmodified `ValidationCtx::new` the same hour (replay-state eliminated) → 5003/EM; with the full anisette header set on step 2 (± hardware headers, both blobs) → 5003/EM ×4 — **the anisette-on-step-2 hypothesis is falsified**. Residue: transport fingerprinting vs a server-side gate for everyone; the app's own-flow verdict is blocked on interactive IDS re-registration (user Apple-ID login) — removing `anisette_test/state.plist` re-fires provisioning but never the mint (no `ValidationCtx::new` hit in a fully-trapped run), and the vendored anisette client no longer traverses an intercepting proxy (baked DER root, SSL_CERT_FILE ignored). - Rev 38 (2026-10-12 late, the §1.1 step-2 5003/EM gate CLOSED — it is a TLS-stack fingerprint, not a blob/server-gate problem): after the user's 2FA re-login re-fired the app's own IDS registration and the full mint chain was observed ACCEPTED live (`vctx_new → step2_serialize → key_establishment → vctx_sign` ×3, gdb traps), fresh genuine blobs minted per-test (gdb `ValidationCtx::new`) settled the transport matrix: reqwest 0.11 + rustls 0.21 (+ the vendored client's baked Apple root DER, extracted from the .so at 0x24afa9 — webpki- roots/Mozilla does not include this Apple private root) POSTing the identical XML-plist envelope gets a real `session-info` response; curl (OpenSSL 3) over BOTH HTTP/2 and HTTP/1.1, and curl with UA/Accept headers removed (Accept-Language only), gets 5003/EM on the same fresh blobs. HTTP version and request headers are irrelevant: the VEN endpoint (Akamai-fronted identity.ess.apple.com) fingerprints the TLS ClientHello and rejects OpenSSL-shaped handshakes with the app-layer error 5003/EM. Earlier probes (JDK HttpURLConnection, python urllib, both cert-verif-off) were rejected for the same reason. Kotlin `NacRequestBlob` and the §1.1 framing are fully vindicated — the blob and envelope were never wrong. Consequence: the imessage tool's §1.1 HTTP must present a rustls-shaped handshake — JVM/Android default stacks (conscrypt/BoringSSL, OpenSSL-shaped) are expected to be rejected (unverified on-device); Conscrypt (Android's default stack, BoringSSL) is ALSO rejected — verified with a plain `HttpsURLConnection` probe on the lightos AVD posting a fresh genuine blob (EM-REJECTED) — so the accepted shape so far is rustls specifically, and the platform TLS stacks cannot do §1.1 step 2 at all. Implementation: a rust helper (rustls) behind UniFFI in `:server` (the calls/ pattern); a fingerprint-shaped JVM stack is not viable given the BoringSSL result. Evidence + probe sources: `registration/dev/clearadi/captures/c56j/tls-ab/` (NAC-NOTES session-26 EM-gate closure). - Rev 35 (2026-10-12, C56-a CLOSED — direct in-process replay of ValidationCtx::new, two byte-verified runs): the request blob's full recipe — header ‖ OAEP(pubkey, pearKey16 ‖ hmacKey20) ‖ RNG16 ‖ u32be(32) ‖ field32 ‖ HMAC-SHA1(hmacKey20, field32), with field32 = mapA[encrypt_cbc(mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros], iv, init_key(pearKey16))] and mapA/mapB the 0x2249f9/0x224af9 byte-substitution tables (table[byte], not xor). Kotlin `NacRequestBlob`; imessage unstubbed at C56-a; live-mint gate = C56-c only. +- Rev 44 (2026-10-13 later, the albert GENERIC_ERROR root-caused — it was OURS, not Apple's: **the public-repo port shipped a PLACEHOLDER FairPlay identity**): the staged `leaf.crt`/`identity.key` are self-signed (`CN = open-madrid-placeholder`, valid to 2036) — Apple bytes cannot ship in the public repo, and the port never wired the real pair. Apple's verdict for it is `SIGNATURE_VERIFICATION_FAILED` (not GENERIC_ERROR — that earlier page was the string-form `ActivationInfoXML`; with the rev-43 shapes the error is specific). The real identity (the one accepted live 2026-10-03) verifies fine: **host A/B with the genuine pair → device certificate on the FIRST probe, anisette/hardware headers unnecessary** (UA-only accepted too). Changes: `BundledFairPlaySigner` accepts an out-of-repo identity override (DER leaf + PKCS#1 PEM; null = committed placeholder — shape tests stay green); the imessage tool loads `/data/local/tmp/imessage-fairplay/{leaf.crt,identity.key}` (adb push, world-readable, no Apple bytes in any repo) and logs which identity is in use; the real pair lives in the gitignored captures (`captures/fairplay-identity/`). **On-device: activation ACCEPTED, courier connected, delegate sign-in reached the session-15 final wall `409 ICLOUD_UNSUPPORTED_DEVICE`** ("Cannot create another iCloud account with this device") — the recorded Nas-Qualify device-flag hypothesis (session 15) is now the only remaining §1.2 residue. Logs: `captures/c56k/register-final-log-2026-10-13.txt` + the anisette A/B (`AlbertAnisetteAbTest.kt`, also disproving the anisette-gate hypothesis). - Rev 43 (2026-10-13, REGISTER step-2 ACCEPTED on-device; the chain then moved to §1.2 and stalled at a NEW albert refusal): the rev-42 fix held live — the tool's step-2 POST returned a real `session-info` on the lightos AVD (validation → `initializing the validation session`, no 5080). Two follow-on fixes, then a new residue. (1) **Transport trust**: the engine SSLContext trusted ONLY the embedded Apple roots, but albert.apple.com chains to **DigiCert Global Root G2** (complete 3-cert chain on the wire) — `AppleTrust.platformTrustManager()` = system store + the Apple roots merged; the imessage engine context uses it (GSA's rev-40 pool + the public-CA endpoints in one manager). (2) **Albert request-shape regression**: the 7f960db port dropped two accepted shapes that session 15 carried — `ActivationInfoXML` must ride plist **`` (bytes)**, not a string (the string form draws the `` GENERIC_ERROR page — pinned live 2026-10-02, re-confirmed 2026-10-13 by a host A/B), and the post carries **`User-Agent: ApplePushService/4.0 com.apple.iCloudHelper/282`** (rustpush `get_normal_ua("ApplePushService/4.0")`); both restored + shape tests updated. (3) **OPEN residue — albert GENERIC_ERROR**: with the restored shapes the activation is STILL refused with the generic wrapped-UI page (`ALEndPointErrorPage.genericError`, "Device activation failed. Please contact support.") for BOTH the user's real C58 identifiers and a synthetic identity (host A/B, same transport) — the shape now matches the session-15 accepted construction, so the refusal is server-side: top hypothesis is enforcement of the legacy FairPlay identity's leaf expiry (valid to 2024-10-11; activations stopped being observable after 2026-10-03), alternative is a new serial/record check. Next session: characterize against a CURRENT genuine activation (the vendored client's re-activation under gdb is user-gated) or a fresh FairPlay identity. Registration log excerpts archived (gitignored) in `captures/c56k/`; the A/B probe source archived beside it (`AlbertAbTest.kt`). - Rev 42 (2026-10-13, C56-a fully CLOSED — the 5080 root cause pinned and fixed: **the pear-AES IV is derived from the wire slot, never drawn**): the slot16 at `[266:282]` IS a fresh RNG16 draw (rev 35 was right about that) — but the `encrypt_cbc` IV is **`blobFieldMap⁻¹(slot16)`**, the inverse of the same 0x2249f9 substitution table applied to field32's ciphertext. Pinned from the c56j replay dumps without a gdb round: the IV reaches `encrypt_cbc` (r8 @0x17944f8) through a `SpecFromIterNested::from_iter` Vec built over the `[rsp+0x260]` sample (entry-arg rdx = the closure context), and on the genuine data `ecbc_arg_r8 == tableA⁻¹[blob[266:282]]` byte-exactly (tableA = the mapA at 0x2249f9; `xortable.bin` in the c56j dumps is its first 64 B). The server recomputes the IV from the blob's slot — which explains every observed verdict: the rev-41 draw-isolation 5080s were the Kotlin builder drawing an INDEPENDENT fourth random (iv16) instead of deriving it, so any fresh slot or fresh iv broke the slot↔iv pairing the server checks; the rev-41 "consistency value derived from the four draws" reading is corrected (the slot is an input, not a derived value). Fix: `NacRequestBlob` draws THREE values (hmacKey20, innerKey16, slot16) and computes `iv16 = blobFieldMap⁻¹(slot16)`; new c56j genuine-draw vector test (deterministic regions byte-exact incl. field32 + HMAC). **Live proof: a fully fresh Kotlin blob (fresh pear/hmac/inner/slot, derived IV, our own Java OAEP) through the accepted rustls probe → real `session-info` (HTTP 200, 1263 B)** — probe log `captures/c56k/ab_step2_fresh_iv_2026-10-13.txt`. `:registration:test` green. - Rev 41 (2026-10-13, live REGISTER attempt #1-2 — the §1.1 step-2 rejectionis ISOLATED to the 16-B blob slot [266:282]): the real export-code flow ran end-to-end for the first time (export-code parse → the hardware-configuration serialization **byte-identical to the recorded capture** — the identifier grammar is verified with a real machine); the Apple-ID login chain completed live on-device including the second-factor exchange and the §1.5 re-login; then step-2 answered the error verdict **`EM status=5080`** through the rustls transport (distinct from the TLS-gate 5003 — the application layer is reached). Host A/B through the previously-accepted probe client: the genuine blobs remain accepted today, the freshly built blob is rejected, and a **field-by-field reconstruction of a genuine blob from its recorded randomness is byte-exact in every deterministic region and ACCEPTED with our own RSA-OAEP block** — exonerating the OAEP encoding, the whole derivation chain (map→white-box-AES→map, HMAC), and the transport. Isolation: replacing ANY one of the five random inputs with fresh bytes (including the `[266:282]` slot) while keeping the rest genuine → 5080. Conclusion: **the rev-35 reading of the 20-B `[266:286]` slot head as "a third fresh random 16" is falsified — the slot is a consistency value derived from the other four inputs** (the server checks it; its bytes are visible materializing between the white-box-AES step and the serializer in the session-25 replay dumps — the same rewritten-buffer window left untrapped in rev 34). C58/session250: CLOSED with real data; remaining: the slot derivation (trap the rewrite window 0x17945cc-0x1794651 / the `[rsp+0x260]` source at 0x179494c), then the register completes. Probe sources + inputs archived (gitignored) in `registration/dev/clearadi/captures/c56k/`. diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt index 3a046f8..6098dac 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/BundledFairPlaySigner.kt @@ -54,16 +54,30 @@ import java.security.spec.PKCS8EncodedKeySpec */ class BundledFairPlaySigner( private val logEvent: (String) -> Unit = {}, + /** + * Out-of-repo identity override (DER leaf, PEM PKCS#1 key) — the committed classpath + * identity is a self-signed PLACEHOLDER (`CN = open-madrid-placeholder`): Apple bytes + * cannot ship in the public repo, so real activations need the genuine FairPlay pair + * supplied here (the accepted live identity; kept in a gitignored local directory). + * Null = the bundled placeholder (fine for shape tests, refused live with + * SIGNATURE_VERIFICATION_FAILED — pinned 2026-10-13). + */ + private val overrideLeafCertDer: ByteArray? = null, + private val overrideIdentityKeyPem: ByteArray? = null, ) : FairPlaySigner { private val leafChain: ByteArray by lazy { - resource(LEAF_CERT_RESOURCE) + resource(FAIRPLAY_CA_RESOURCE) + resource(ROOT_CA_RESOURCE) + (overrideLeafCertDer ?: resource(LEAF_CERT_RESOURCE)) + + resource(FAIRPLAY_CA_RESOURCE) + resource(ROOT_CA_RESOURCE) } private val identityKey: RSAPrivateKey by lazy { loadPrivateKey() } override fun sign(activationInfoPlist: ByteArray): FairPlayMaterial { - logEvent("fairplay: signing the activation-info plist with the bundled identity") + logEvent( + if (overrideLeafCertDer != null) "fairplay: signing the activation-info plist with the supplied identity" + else "fairplay: signing the activation-info plist with the bundled identity", + ) val signer = Signature.getInstance(SIGNATURE_ALGORITHM) signer.initSign(identityKey) signer.update(activationInfoPlist) @@ -85,7 +99,8 @@ class BundledFairPlaySigner( * never re-encoded beyond the envelope. */ private fun loadPrivateKey(): RSAPrivateKey { - val pkcs1 = Pem.decode(resource(IDENTITY_KEY_RESOURCE).decodeToString(), PKCS1_TYPE) + val pkcs1Pem = overrideIdentityKeyPem ?: resource(IDENTITY_KEY_RESOURCE) + val pkcs1 = Pem.decode(pkcs1Pem.decodeToString(), PKCS1_TYPE) val pkcs8 = Der.sequence( Der.integer(BigInteger.ZERO), Der.sequence(Der.oid(OID_RSA_ENCRYPTION), Der.nullValue()),