From 2ff994751ccf0b4f8e07b5aea0ea75eb17c76ef2 Mon Sep 17 00:00:00 2001 From: fenleon Date: Thu, 15 Oct 2026 18:57:00 +0200 Subject: [PATCH] registration: the prefpane presents the LOGIN RUN's anisette set (session-wide reuse, wire-pinned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trapped-binary decode (2026-10-15) shows ONE anisette mint serves the whole session — GSA hops, 2FA validate, postdata, both prefpane POSTs — so the PET is paired with the OTP window it was issued under. The rev-25/26 'fresh set at the sign-in POST' reading is corrected (confounded with the replay-guard era). GsaLoginChain mints one cpd per login run and exposes it as lastLoginAnisette (Client-Time frozen at the mint); GsaDelegateLoginClient's prefpane branch reuses a supplied Client-Time instead of re-minting. VEN step-2 wire fact recorded in spec rev 46: zero anisette on initializeValidation, no step-2 binding. Also wire-proven live: the GSA init 'ps' field is REQUIRED (omission answers ec=-80001). --- docs/imessage-protocol-spec.md | 1 + .../dev/fenn/imessage/registration/GsaCpd.kt | 4 ++ .../imessage/registration/GsaDelegateLogin.kt | 9 ++-- .../fenn/imessage/registration/GsaLogin.kt | 2 + .../imessage/registration/GsaLoginChain.kt | 41 ++++++++++++++----- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index 68d510f..21b3b2e 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -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 46 (2026-10-15, the trapped login decoded offline — the anisette is SESSION-WIDE, one mint; the prefpane "fresh anisette" reading corrected; VEN step-2 is anisette-free): full decode of the trapped 2FA login (`captures/c56k/ob-trap-20261014-201103/`, rustls + static-OpenSSL frames, HPACK-decoded): (1) **ONE anisette set serves the ENTIRE session** — one `X-Apple-I-MD` OTP, one `X-Apple-I-MD-M`, one `X-Apple-I-Client-Time` frozen at the session's mint (never refreshed) — byte-identical (hash-compared) across GsService2 init/complete, circle, 2FA validate, postdata ×5, CloudKit/escrowproxy, and BOTH prefpane `loginDelegates` POSTs. The rev-25/26 "OTP spent per request / the sign-in POST needs a fresh set" reading is CORRECTED: those observations predate the loginDelegates endpoint fix (session 29) and are confounded with the stamp replay-guard refusals of that era. The accepted prefpane POST pairs the PET with the OTP window it was issued under — the anisette↔login-session binding. (2) **The VEN step-2 (`TDIdentityService.woa/wa/initializeValidation`, 4 occurrences) carries ZERO anisette** — 8 headers only (h2 pseudo + accept/accept-language/accept-encoding/content-length), no OTP/MID/Client-Time/UA — no step-2 binding exists and our header-less step-2 is at parity; body = single `session-info-request` key, the 338-B blob with the constant `01 08 4b 2c 91 48 1d 9b 7d a0` header (`NacRequestBlob` exact). No separate keyEstablishment request exists on the wire. (3) A fresh stamp per delegate POST (2 POSTs → 2 distinct stamps; 692 B base64 = the 517-B stamp encoded). Code: `GsaLoginChain` now mints ONE cpd per login run (the cpd dict, the POST headers, and the exposed `lastLoginAnisette` — Client-Time included — share it) and `GsaDelegateLoginClient`'s prefpane branch reuses a supplied Client-Time instead of re-minting; imessage `register()` presents the login run's set at the prefpane. The 2FA-validate and postdata paths keep their fresh-per-call mints for now (the live 434 wall of rev 26 contradicts the wire's reuse — untangle only if the prefpane fix leaves residue); registration tests green. - Rev 45 (2026-10-13, the vendored delegate sign-in request RE'd to FULL parity — the prefpane endpoint speaks a V1 protocol, and the 400 ICLOUD_INVALID_DEVICE_TOKEN survives both body shapes): rustpush is now PUBLIC (OpenBubbles/rustpush, 2026-10-12) and the vendored .so embeds it with symbols — `login_apple_delegates` disassembled (closure 0xc3a5d0): fresh `os_config.generate_validation_data()` per sign-in (the public byte-exact VEN chain), the account's ClearADI anisette (persisted provisioning, OTP per call), the recorded header family (UA com.apple.iCloudHelper/282, X-Mme-Client-Info, X-Mme-Nas-Qualify, X-Apple-ADSID), Basic `username:PET`, optional `Cookie: termsAccepted=true` (terms flow only) — **and the body is the V1 kebab-case `AuthRequest {apple-id, client-id (plain v4, no uppercase), delegates, password = PET}` at `/setup/prefpane/login`** (`MacOSConfig::get_login_url` returns prefpane in the vendored build; the .so carries ZERO V2AuthRequest/protocolVersion/userInfo strings — the V1 shape public rustpush has commented out is what ships; the V2 wrapper stays recorded for `/setup/signin/v2/login`). Ladder (all on-device, same account, real C58): prefpane + V1 body WITHOUT Basic → 401 UNAUTHORIZED (the server reads Basic even on the V1 shape); prefpane + V1 + Basic + fresh PET → 400 ICLOUD_INVALID_DEVICE_TOKEN — the SAME verdict as V2 + Basic + fresh PET, so the body shape is not the variable and the request is at parity. User intel: the C58 Mac may have been REMOVED from the account — the 400 reads as Apple failing to tie the validation stamp's machine identity to a device record the account still has (the vendored client's last success 2026-10-10 predates any removal); next attempt is gated on confirming/re-verifying the Mac (or a fresh C58 from a currently-registered Mac). Code: `GsaDelegateLoginClient` V1 branch on PREFPANE_ENDPOINT; logs `captures/c56k/register-ladder-log-2026-10-13b.txt`. - 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`). diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt index 02fc906..7f82956 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt @@ -127,6 +127,10 @@ class GsaCpd( anisette[AnisetteHeaderProvider.DEVICE_ID]?.let { put(AnisetteHeaderProvider.DEVICE_ID, it) } } + /** The set's `X-Apple-I-Client-Time` value (this mint's frozen time) — the delegate + * sign-in reuses it (the accepted wire carries one Client-Time session-wide). */ + fun clientTime(): String? = anisette[AnisetteHeaderProvider.CLIENT_TIME] + companion object { /** The anisette header names the §1.5 cpd carries as keys (values from the provider). */ val ANISETTE_KEYS = listOf( diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt index cd32b19..cd8c8fd 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -164,7 +164,7 @@ class GsaDelegateLoginClient( // endpoint's verdict is ICLOUD_INVALID_DEVICE_TOKEN — the fabricated sha256(keychain-id) // form is the prime suspect for the 400. GSA keeps its recorded set (rev 15). val anisette = if (prefpane) { - request.anisette + val mapped = request.anisette .filterKeys { !it.equals("X-Apple-I-MD-LU", true) } // The accepted prefpane request's exact wire casing (live capture 2026-10-14, // captures/c56k/mint-repro/ob-req-002.bin): real AuthKit sends mixed-case @@ -176,9 +176,12 @@ class GsaDelegateLoginClient( mapOf( "X-Apple-Locale" to "en_US", "X-Apple-I-Timezone" to "UTC", - // fresh per request, the get_headers Z form (capture 2026-10-14) - "X-Apple-I-Client-Time" to iso8601Z(System.currentTimeMillis()), ) + // Client-Time rides the supplied set when it carries one — the LOGIN RUN's + // frozen mint (the accepted wire reuses ONE anisette set session-wide, + // 2026-10-15 decode); fresh only when the caller minted none. + if (mapped.keys.any { it.equals("X-Apple-I-Client-Time", true) }) mapped + else mapped + ("X-Apple-I-Client-Time" to iso8601Z(System.currentTimeMillis())) } else { request.anisette } diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt index d2da623..9f0a548 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt @@ -207,6 +207,8 @@ class GsaLoginClient( "A2k" to a2k, "cpd" to cpd, "o" to OPERATION_INIT, + // Wire-proven 2026-10-15: `ps` is REQUIRED — omitting it (the trapped-binary + // decode's "no ps" reading) answers ec=-80001 "Protocols are invalid" live. "ps" to protocols, "u" to username, ), diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt index fc0b2f7..bf3ee3f 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -150,9 +150,16 @@ class GsaLoginChain( private val delegateClient = GsaDelegateLoginClient(http, endpoint = delegateEndpoint, logEvent = logEvent) private val postdataClient = GsaPostdataClient(http, logEvent = logEvent) - /** §1.5: the filtered anisette header set + the akd-variant headers, one per login. */ - private suspend fun gsaHeaders(): Map = - headerConfig?.let { cpd().headersFor() + it.toHeaders() } ?: cpd().headersFor() + /** + * The anisette header set of the last completed login run — the same set its GSA hops + * presented, `X-Apple-I-Client-Time` included (frozen at the run's mint). The delegate + * sign-in must present THIS set: the accepted wire (trapped binary, 2026-10-15) reuses + * ONE minted set across the entire session — GSA init/complete, 2FA validate, postdata, + * the prefpane POSTs — so the PET is paired with the OTP window it was issued under. + * Empty until the first login run. + */ + var lastLoginAnisette: Map = emptyMap() + private set /** * The §1.5 postdata liveness event — the account device-record registration the reference @@ -200,8 +207,16 @@ class GsaLoginChain( */ suspend fun loginWithPasswordHash(username: String, passwordHash: ByteArray): GsaLoginResult { val requestUuid = newRequestUuid() - val cpdDict = cpd().dictFor(requestUuid) - val gsaHeaders = gsaHeaders() + // ONE anisette mint per login run: the cpd dict and the POST headers carry the same + // set (the accepted wire reuses one set across the whole session, 2026-10-15), and + // the run's set is what the delegate sign-in must present (lastLoginAnisette). + val cpdSet = cpd() + val anisetteHeaders = cpdSet.headersFor() + lastLoginAnisette = cpdSet.clientTime() + ?.let { anisetteHeaders + (AnisetteHeaderProvider.CLIENT_TIME to it) } + ?: anisetteHeaders + val cpdDict = cpdSet.dictFor(requestUuid) + val gsaHeaders = headerConfig?.let { anisetteHeaders + it.toHeaders() } ?: anisetteHeaders val srp = GsaSrpClient(random) val a2k = srp.begin() val init = loginClient.init( @@ -342,12 +357,16 @@ class GsaLoginChain( * the §1.1 blob (the chunk-C exchange mints it — caller-supplied here). [timezone] defaults * to the recorded value (§1.5 C59); [clientInfo] defaults to the recorded bracketed AOSKit * form when the device config is wired ([GsaHeaderConfig.signInClientInfo]), falling back to - * the bare recorded AOSKit inner component. [anisette] must be FRESHLY minted for this - * call — the one-time OTP headers spent on the GSA hops are refused here (observed live; - * the reference client mints per request). [validationData] must be a FRESH mint per - * sign-in (minted at sign-in time, never a login-start blob carried across the 2FA wait) — - * a consumed blob answers HTTP 409, the stamp REPLAY refusal, not a blob rejection - * (rev 26, §1.4's replay guard). + * the bare recorded AOSKit inner component. [anisette] is the LOGIN RUN's set + * ([lastLoginAnisette] — the same set the GSA hops of the PET's session presented, + * Client-Time frozen at that mint): the accepted wire (trapped binary, 2026-10-15) + * reuses ONE anisette set across the whole session, so the prefpane POST pairs the + * PET with the OTP window it was issued under. The earlier "freshly minted here" + * reading (rev 25/26) predates the loginDelegates endpoint fix and is confounded + * with the stamp replay-guard refusals of that era. [validationData] must be a FRESH + * mint per sign-in (minted at sign-in time, never a login-start blob carried across + * the 2FA wait) — a consumed blob answers HTTP 409, the stamp REPLAY refusal, not a + * blob rejection (rev 26, §1.4's replay guard). */ suspend fun idsDelegateCredentials( username: String,