From c10551fc97d8e491add8173bef1fe988ce9f856e Mon Sep 17 00:00:00 2001 From: fenleon Date: Mon, 12 Oct 2026 18:36:29 +0200 Subject: [PATCH] Live-provisioning wire fixes (rev 37): capitalized Header/Request keys, lookup needs angle-format X-Mme-Client-Info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by running ClearAnisetteProvider against Apple's live endpoints (first live Kotlin provisioning run — succeeds after the fixes): - lowercase {header, request} on the start/finish POST gets a silent HTTP 200 with an EMPTY body; {Header, Request} gets the real Response.{spim,ptm,tk} - the GSA lookup GET 404s without X-Mme-Client-Info and only accepts the angle-bracket form Also records the session-26 step-2 EM A/B: the 5003/EM gate reproduces on a FRESH genuine blob (ValidationCtx::new under gdb, replay-state eliminated) and with the full anisette header set — anisette-on-step-2 falsified; the app's own-flow verdict is blocked on interactive IDS re-registration. --- docs/imessage-protocol-spec.md | 1 + .../imessage/registration/ClearAnisetteProvider.kt | 13 +++++++------ .../registration/ClearAnisetteProviderTest.kt | 6 +++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index 3bddfe1..cbdea73 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -331,6 +331,7 @@ Ordered by value. IDs match the inline `TODO(capture):` markers above. ## Revision history - Rev 36 (2026-10-12, C56-c CLOSED — the session250 hardware-identity serialization ported to Kotlin and keyed off the C58 export identity): `HardwareConfig::to_bin` (the 2026-10-08 static-disasm grammar, NAC-NOTES.md) is implemented as `registration` `HardwareConfiguration.toBin()` — `write_str` = `u8(len+1) ‖ utf8 ‖ NUL`, `write_item` = `u8(len) ‖ data`; serial order — product name, **MAC address** (raw 6 B, no prefix), serial, platform UUID, root-disk UUID, board ID, OS build, the constant dword `00 03 0b 52`, then the seven binary items in Mac-Hardware-Info export field order — serialEnc, platformUuidEnc, rootDiskUuidEnc, ROM, romEnc, MLB (ASCII bytes, no NUL), mlbEnc. Two corrections fall out of the export cross-check: (1) the raw second field is the MAC address, NOT the ROM — the rev-32/35 "u32le@+0x138 = ROM" label is corrected (the live capture's 6-B Vec item carries the multicast bit, which no unicast MAC does; the OA stub's field types put the inline `[u8;6]` `io_mac_address` at that struct slot, and ROM is a `Vec`); (2) the seven binary Vecs are exactly the export's identifier fields 8–14 (the five `_enc` blobs, ROM bytes, MLB ASCII) — a 1:1 mapping with nothing unpinned remaining. Verified: `toBin` round-trips the live 250-B capture byte-exactly (capture stays in the gitignored captures/ dir; tests are synthetic). Kotlin: 217 registration tests green; imessage `ClearNacValidationSource.mint(oabsExport)` unstubbed at C56-c — the mint chain is fully real offline (`ValidationDataSource.mint` now takes the parsed export; rngPad generalized to `0x17c − session250.size`). No §1.1 input mapping remains open; the next milestone is a live registration attempt. — First live step (2026-10-12, same day): a JVM smoke ran the real exchange against Apple — step 1 (bag + static cert, byte-identical to the 10-08 capture) OK; step 2 POST of the closed blob → HTTP 200 error plist `status 5003`, mnemonic `EM` (reproduced ~8x, both chain certs as the OAEP key, with/without default headers). The blob construction re-verified against the replay dumps while chasing it (init_key(pearKey16) via the captured rk176, mapA/mapB, field32 = mapA[ciphertext]); the residue is therefore outside the blob bytes (anisette-on-step-2 / transport fingerprinting / an unseen precondition — NAC-NOTES session-26), and is the live-mint gate. +- 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 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 34 (2026-10-09, C56-a request-blob field map ~85% pinned — four instrumented runs): the 338-B request blob decomposes as header ‖ RSA-2048-OAEP-SHA1(cert pubkey, pearKey16 ‖ hmacKey20) ‖ 20 B (unpinned, ends u32be(32)) ‖ 32 B pear-AES field (derivation open) ‖ HMAC-SHA1(hmacKey20, the 32-B field) — the HMAC chain verified byte-exact. Live mint still gated on the two unpinned C56-a sub-fields; C58 parser + §1.2/§1.4 activation chain wired in imessage (`register()`), tool UI inputs (export-code paste, courier push token) pending. diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/ClearAnisetteProvider.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/ClearAnisetteProvider.kt index a3df2a2..5d98294 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/ClearAnisetteProvider.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/ClearAnisetteProvider.kt @@ -155,9 +155,10 @@ data class ProvisionedAnisette( * * 1. `GET https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist → * `urls.{midStartProvisioning, midFinishProvisioning}` - * 2. `POST ` with body `{"header":{}, "request":{}}` (XML plist, - * `Content-Type: application/x-www-form-urlencoded` — "not a bug, it's how you *think - * different*", reference) → `Response.spim` (base64) + * 2. `POST ` with body `{"Header":{}, "Request":{}}` (XML plist — the keys are + * CASED: lowercase `header`/`request` gets a silent HTTP 200 with an EMPTY body, + * live-probed 2026-10-12; `Content-Type: application/x-www-form-urlencoded` — + * "not a bug, it's how you *think different*", reference) → `Response.spim` (base64) * 3. [Spim.parse] the spim → `payload240` → [ClearAdiProvision.initSession] → session context * 4. [Cpim.build] with 124 RNG bytes (32/32/60) + the 16-B IV, an empty extra blob and the GSA * arg −2 → base64 into `request.cpim` → `POST ` → `Response.{ptm, tk, X-Apple-I-MD-RINFO}` @@ -189,7 +190,7 @@ class ClearAnisetteProvider( suspend fun provision(): ClearAdiProvisionResult { val urls = lookupUrls() - val startBody = XmlPlist.encode(mapOf("header" to emptyMap(), "request" to emptyMap())) + val startBody = XmlPlist.encode(mapOf("Header" to emptyMap(), "Request" to emptyMap())) val startResponse = exchange("start-provisioning", urls.startProvisioning, startBody) val spimB64 = responseString(startResponse, "Response", "spim") val spim = Spim.parse(Base64.getDecoder().decode(spimB64.trim())) @@ -208,8 +209,8 @@ class ClearAnisetteProvider( ) val finishBody = XmlPlist.encode( mapOf( - "header" to emptyMap(), - "request" to mapOf("cpim" to Base64.getEncoder().encodeToString(built.wire)), + "Header" to emptyMap(), + "Request" to mapOf("cpim" to Base64.getEncoder().encodeToString(built.wire)), ), ) val finishResponse = exchange("finish-provisioning", urls.finishProvisioning, finishBody) diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ClearAnisetteProviderTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ClearAnisetteProviderTest.kt index 4101d9f..09e8d27 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ClearAnisetteProviderTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ClearAnisetteProviderTest.kt @@ -348,7 +348,7 @@ class ClearAnisetteProviderTest { } else { // the cpim must be the base64 request body field @Suppress("UNCHECKED_CAST") - val request = (bodyPlist as Map)["request"] as Map + val request = (bodyPlist as Map)["Request"] as Map assertTrue(request.containsKey("cpim")) val cpim = Base64.getDecoder().decode(request["cpim"]!!) // outer shape: [u32 BE 5][iv 16][u32 BE 160][encout 160][tail 88] @@ -375,8 +375,8 @@ class ClearAnisetteProviderTest { private fun assertBodyHasEmptyDicts(body: Any?) { @Suppress("UNCHECKED_CAST") val m = body as Map - assertEquals(0, (m["header"] as Map<*, *>).size) - assertEquals(0, (m["request"] as Map<*, *>).size) + assertEquals(0, (m["Header"] as Map<*, *>).size) + assertEquals(0, (m["Request"] as Map<*, *>).size) } }