diff --git a/README.md b/README.md index bbeca98..e58c637 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Validated: - The registration chain ran live against Apple (2026-10-02→04, spec rev 26): Albert activation, the packed APNs courier, the full GSA sign-in (SRP, trusted-device 2FA, PET delivery, postdata), then IDS authenticate and register — both green, `mailto:` handle active. The earlier 409 refusals turned out to be Apple's validation-stamp replay guard (one mint = one device record), documented in the spec. - Two live unauthenticated probes against Apple succeeded: both bag endpoints return plists this stack parses, and certificate pinning validates Apple's real TLS chains. A drift check ships with the repo (`:engine:runProbe`). - The courier's auth model is settled: the packed path needs no client certificate; the legacy path answers the handshake with a TLS 1.3 post-handshake `certificate_required` alert. -- The ADI anisette chain is reimplemented natively, no emulation or helper binary: the white-box OTP ciphers, the GSA provisioning exchange, the full spim/cpim/ptm wire framing, and the validation-stamp ("NAC") mint — pear white-box cipher, blob assembly, and the 16-byte OTS signature (`NacSign`) — are transcribed clean-room from analysis of a compiled ADI implementation, verified byte-exact against oracle vectors and live captures (three genuine provisioning runs plus validation-stamp captures under an instrumented debugger). Every byte group of the exchange and of the 517-byte stamp is identified and reproduced; the full stamp computes byte-exact in Kotlin from its inputs (the earlier python reference model is retired to dev capture notes; the Kotlin port is pinned by synthetic-vector tests against it). 213 tests green, spec rev 35. A minimal public facade (`NacMint`) exposes the mint for consumers. All five wire-input mappings from the validation exchange (§1.1 C56) are now pinned: the step-2 request blob (C56-a) was closed by direct in-process replay of the reference client's validation context — the full 338-byte recipe (constant header, the RSA-OAEP of the pear key + an HMAC key, a fresh 16-byte slot, the length marker, the substituted-and-encrypted 32-byte field, the HMAC-SHA1 tail) is implemented in Kotlin (`NacRequestBlob`) and verified byte-exact on two independent runs. The last gate before a live validation mint is the hardware-identity serialization's field values (C56-c). No anisette server, no Unicorn, no Mac relay. +- The ADI anisette chain is reimplemented natively, no emulation or helper binary: the white-box OTP ciphers, the GSA provisioning exchange, the full spim/cpim/ptm wire framing, and the validation-stamp ("NAC") mint — pear white-box cipher, blob assembly, and the 16-byte OTS signature (`NacSign`) — are transcribed clean-room from analysis of a compiled ADI implementation, verified byte-exact against oracle vectors and live captures (three genuine provisioning runs plus validation-stamp captures under an instrumented debugger). Every byte group of the exchange and of the 517-byte stamp is identified and reproduced; the full stamp computes byte-exact in Kotlin from its inputs (the earlier python reference model is retired to dev capture notes; the Kotlin port is pinned by synthetic-vector tests against it). 213 tests green, spec rev 35. A minimal public facade (`NacMint`) exposes the mint for consumers. All five wire-input mappings from the validation exchange (§1.1 C56) are now pinned and implemented: the step-2 request blob (C56-a, `NacRequestBlob` — closed by direct in-process replay of the reference client's validation context, byte-exact on two independent runs) and the hardware-identity serialization (C56-c, `HardwareConfiguration.toBin` — the closed `to_bin` grammar, keyed off the C58 Mac-Hardware-Info export identity and round-trip-verified byte-exact against a live 250-B capture). Every §1.1 mint input mapping is established; a first live step-1/step-2 smoke against Apple's real endpoints accepted the step-1 cert fetch but the step-2 POST currently returns Apple's `5003 EM` error — the blob construction re-verified correct against the capture, so the residue is outside the blob bytes (see spec rev 36). 217 tests green. No anisette server, no Unicorn, no Mac relay. Not validated yet: diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index eb6f769..3bddfe1 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 35. 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 36. 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. @@ -21,7 +21,7 @@ - Current shipped flow (OpenBubbles, 2026): the Mac-side helper (`Mac-Hardware-Info` 1.2.0, notarized) only *reads* the machine identifiers — serial, board ID, model, etc. — via public IORegistry/sysctl APIs (docs: "High Sierra and above", no upper macOS version bound) and exports them as a shareable code (≤ 20 users per code, optional one-time-use/48-hour mode); it never invokes an Apple private framework [OB-MHI], [OB-FAQ]. The client then performs the three-step exchange above itself, in software, keyed with those identifiers and paired with an Apple-Account login — the [JJ] emulation path with an official identifier-export helper in front of it. `TODO(capture):` **C56** — the client-side generation procedure behind the three-step exchange: what the client computes with the `id-initialize-validation` session info (the key-establishment math and blob assembly), how it interacts with the Apple-Account/GSA login and the anisette header set (C6) at activation time, and which pieces are fixed protocol constants vs per-account/per-session state. — 2026-09-10: steps 1–2 of C56 are recorded (bullet above); step 3 — key establishment + signing — is closed source: the public OpenAbsinthe-Stub repo is an official mock whose methods are stubs (its README states the real repository is closed source), so the spec records only the API contract: context creation consumes the certificate chain + hardware configuration and outputs the session-info-request blob; key establishment consumes the server response; signing produces the final blob with no further inputs (all state retained in the context) [RP:macos.rs], [RP:activation.rs], [RP:auth.rs], [OA]. Remaining: wire-verification of the blob against one live activation; the enc-variant derivation rides C57. — 2026-09-11 (rev 14 extraction): the five obfuscation labels are now MAPPED to their fields (previously only listed together): serial → `Gq3489ugfi`, platform UUID → `Fyp98tpgj`, root-disk UUID → `kbjfrfpoJU`, ROM → `oycqAZloTNDm`, MLB → `abKPld1EcMni` [OA:nac.rs]; the derivation producing each `_enc` byte variant is still closed source. The hardware-configuration field set is recorded in full: product name (Apple product-type form, e.g. `Macmini9,1`), MAC address (exactly 6 raw bytes), platform serial, platform UUID, root-disk UUID, board ID, OS build number, ROM (raw bytes), MLB, plus the five `_enc` raw-byte variants [OA:nac.rs]. Step 3's closure is confirmed at the source level: the crate holding the key-establishment math ships as a closed-source stub whose mock methods panic; only the API contract is observable [OA:README.md], [OA:nac.rs]. - The helper's export-code format (2026-09-11, rev 15 — **C58 closed** by facts-only extraction from the helper's public upstream repo, which is unlicensed — facts only): the payload is a 4-byte ASCII magic `OABS`, a one-byte flag (1 = "prevent sharing" mode, 0 otherwise), then a raw protobuf message (no length prefix; protobuf starts at offset 5). Protobuf field map: outer — 1 nested identifier message, 2 macOS product version (sysctl `kern.osproductversion`), 3 protocol version (hardcoded `1640`), 4 device id (the IOPlatformUUID again), 5 a hardcoded iCloudHelper user-agent string, 6 a hardcoded AOSKit version string; nested identifier message — 1 product name (e.g. `MacBookPro18,3`), 2 MAC address (6 raw bytes), 3 platform serial, 4 platform UUID, 5 root-disk UUID, 6 board ID (Apple Silicon: literal prefix `Mac-` + lowercase hex of the device-tree board-id bytes), 7 OS build number (sysctl `kern.osversion`), 8/9/10/12/14 the five `_enc` obfuscated blobs read from the `IOPower:/` registry under Apple's key names (`Gq3489ugfi` serial, `Fyp98tpgj` platform UUID, `kbjfrfpoJU` root-disk UUID, `oycqAZloTNDm` ROM, `abKPld1EcMni` MLB — matching §1.1's label mapping), 11 ROM (Intel: the device-tree options ROM value; Apple Silicon: last 6 bytes of SHA-256 of `/chosen` `unique-chip-id`), 13 MLB string. Two delivery modes: plain = standard base64 of the payload (clipboard text, or the raw payload bytes as a QR code at medium error correction); restricted = AES-256-CBC/PKCS7 over the payload, ciphertext prefixed with ASCII `Salted__` + 8-byte random salt (legacy OpenSSL `enc` container), key/IV derived by the legacy EVP_BytesToKey KDF (repeated MD5 of previous-hash ‖ password ‖ salt to 48 bytes: first 32 = key, next 16 = IV) keyed by a human activation code (`MB` + 4×4 chars from the alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ123456789`), then base64. The restricted mode additionally uploads the blob (JSON `data` + `id` = lowercase hex SHA-256 of the activation code) to the vendor's server (`hw.openbubbles.app/code`) — the ≤20-user/one-time/48-hour mechanics live SERVER-side; nothing is embedded in the payload (no checksum, signature, compression, or expiry). No public parser exists (the client apps are closed-source); the construction above is the parse specification [MHG:ViewController.swift], [MHG:HwIdentifiers.swift], [MHG:mac_hw_info.pb.swift]. - The validation blob is submitted as the `validation-data` field of the IDS register body and, base64, in the `X-Mme-Nas-Qualify` header of the iCloud account login [RP:ids/user.rs], [RP:auth.rs]. `TODO(capture):` C5 — exact byte shape of the blob a genuine Mac produces, and the current exchange over the two validation bag URLs. — 2026-10-04 (rev 26, live): **REPLAY GUARD — one mint = one device record.** Apple keys a validation blob's consumption server-side: re-submitting an already-consumed blob at the sign-in/register pair answers HTTP 409, which is a REPLAY refusal, NOT a blob rejection — a fresh blob under the same device identity succeeds where the reused one failed, and a captured blob of external provenance succeeds outright (session 17: a captured stamp replaced the local mint and the 409 vanished). Client consequences: mint or fetch a fresh blob per registration attempt (the 15-minute validity above makes stale reuse doubly fatal), and mint the sign-in's blob AT SIGN-IN TIME — never a login-start blob carried across the 2FA wait. A caller-injected blob from any source (local mint, relay Mac, staged capture) is a legitimate stamp; the injection points are the register body's `validation-data` and the sign-in's `X-Mme-Nas-Qualify` header. -- Hardware-related anisette headers seen in activation flows: `X-Apple-I-SRL-NO` (serial), `X-Apple-I-MLB` (logic-board serial), `X-Apple-I-ROM` (hex ROM, lowercase) [RP:relay.rs], [APA:anisette]. `TODO(capture):` C6 — the full anisette header set a current client actually sends, including which optional hardware headers appear. — 2026-09-10: the header-set half of C6 is closed by facts-only extraction: per-request anisette headers `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-Locale`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, `X-Apple-I-MD` (base64 OTP), `X-Apple-I-MD-M` (base64 machine ID), plus per-platform hardware headers — macOS: `X-Apple-I-MLB`, `X-Apple-I-ROM` (lowercase hex), `X-Apple-I-SRL-NO` [APA:omnisette], [APA:icloud-auth], [RP:relay.rs]. They are produced by an ADI provisioning exchange with gsa.apple.com (start/finish provisioning returning machine tokens), then a local OTP [APA:omnisette]. GSA context (cross-referenced with C6): the Albert activation itself needs NO Apple-Account login — only the FairPlay-signed activation-info plist (§1.2); the GSA login (full Apple-ID password, SRP over gsa.apple.com/grandslam/GsService2, s2k/s2k_fo PBKDF2, 2FA-capable) feeds the delegate logins that yield the IDS `auth-token`/`profile-id` and the PET token [RP:auth.rs], [APA:icloud-auth]. The extraction confirms the validation blob rides base64 in the `X-Mme-Nas-Qualify` header of the iCloud sign-in POST (recorded above) [RP:auth.rs]. — 2026-09-11 (rev 14 extraction round over the anisette crates): the value-format and provisioning halves of C6 are closed. Per-request value forms: `X-Apple-I-Client-Time` = UTC now truncated to whole seconds, RFC 3339/ISO-8601 with the offset suffix replaced by `Z` (provisioning requests send the raw offset form without the Z — a recorded inconsistency between code paths); `X-Apple-I-TimeZone` = literal `UTC` (not the device zone); `X-Apple-Locale` = `en_US` hardcoded (a companion `X-Apple-I-Locale: en_US` variant exists on the browser-style header set); `X-Apple-I-MD-RINFO` = verbatim passthrough of the string returned in the finish-provisioning response; `X-Mme-Device-Id` = lowercase hyphenated UUID string of 16 locally random bytes (`keychain_identifier`); `X-Apple-I-MD-M` = base64 (standard, padded) of the 60-byte `mid`; `X-Apple-I-MD-LU` = lowercase hex (no dashes) SHA-256 over the same 16 bytes (real macOS instead uses AuthKit's local-user UUID) — present in some header sets, omitted in others (disagreement — C57). Header sets are cached 60 s; a fresh OTP is generated per cache miss. The ADI provisioning flow: `GET https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist whose `urls` keys `midStartProvisioning`/`midFinishProvisioning` give the endpoint URLs (never hardcoded); POST to the start URL with an XML plist body of two empty top-level dicts `Header` and `Request` → response `Response` → `spim` (base64); a local session is built from `spim` (+ empty hostuuid, DSID `-2`, flavor `Mac`=1 / `IOS`=3, flavor chosen by whether the client-info string contains `iPhone OS`) yielding `cpim` (base64); POST to the finish URL with `Request` = {`cpim`} → response `Response` → `ptm`, `tk` (both base64) and `X-Apple-I-MD-RINFO`; finishing the session with the decoded `tk`/`ptm` yields the machine state: `client_secret` (32 bytes), `mid` (60 bytes, Apple-issued at provisioning — reused for every OTP until re-provisioning, which happens only on a not-provisioned failure, e.g. ADI error -45061), `metadata` (variable blob). Provisioning HTTP specifics: `Content-Type: application/x-www-form-urlencoded` despite the XML-plist body (intentional), title-cased header names forced, HTTP/1.1, Apple root CA pinned (embedded DER), and a fixed companion header set on all three requests: akd `User-Agent`, `X-Apple-Baa-E: -10000`, `X-Apple-Baa-Avail: 2`, `X-Apple-Baa-UE: AKAuthenticationError:-7066|com.apple.devicecheck.error.baa:-10000`, `X-Apple-Host-Baa-E: -7066`, `X-Apple-Client-App-Name: akd`, `Accept: */*`, `Accept-Language: en-US,en;q=0.9`, plus `X-Apple-I-MD-LU`, `X-Mme-Device-Id`, `X-Mme-Client-Info`, `X-Apple-I-Client-Time` and any caller-supplied hardware headers. State persists as an XML plist with `keychain_identifier` (data) + the provisioned fields (`client_secret`, `mid`, `metadata`, `rinfo`, `flavor`) [APA:omnisette]. CLOSED-SOURCE RESIDUE: the OTP (`X-Apple-I-MD`) and trusted-device 2FA-code derivations live inside a closed-source crate — its public repo is a stub with `todo!()` methods; recorded is only the call contract: inputs (32-byte `client_secret`, 60-byte `mid`, `metadata`, flavor) → raw OTP bytes (base64 into the header) / 32-bit 2FA code [OA-stub], [APA:omnisette]. — rides C57/C56 residue; the seam stays injectable in the code. — 2026-10-07 (rev 27, clean-room RE of the OpenBubbles ADI library — unstripped Linux `.so`, oracle-verified; the derivation is no longer closed residue): the OTP is fully local and pure. `gen = unix_seconds / 30`; dispatch by OTP flavor byte: 1 (IOS) → `gen_otp` (0x17fba60), 0 (Mac) → `gen_otp_ios` (0x1801d90) — the binary's naming is inverted relative to the flavor (a distinct numbering from the §1.1 session flavor `Mac`=1/`IOS`=3). Cipher inputs: `one` = the machine's `client_info` (required ≥ 0x190 B IOS / ≥ 0xd0 B Mac, length-checked) and `two` = the 32-byte `mid`; output 16 bytes. The wire `X-Apple-I-MD` frame is 28 B: `u32be(5)` ‖ `u32be(16)` ‖ sig16 ‖ `u32be(flavor)`. The 2FA code is `SHA-1(sig16 ‖ secret60) mod 1_000_000` over the machine's full 60-byte secret slice (`machine[0x38..0x74]`; two standard SHA-1 blocks — an earlier report's "48-byte" reading is wrong, 0x18013a2/0x18013bb/0x18013ef). Provisioning framing, asm-pinned: `spim` = `field1` u32 BE @0 ‖ payload-length u32 BE @4 ‖ payload @8 (payload must exceed 59 B, 0x17efb33; more than 23 trailing bytes rejected with error 0x41); `cpim` = 9 fields (u32be(32) ‖ `encsec(rng2)` 32 B ‖ u32be(32) ‖ rng1 32 B ‖ 60 B blob = caller-extra ‖ rng3 pad ‖ u64 BE of arg9 = −2 ‖ u32 BE of the byte-swapped LE dword at rng3[8..12] ‖ two 256-bit tail fields fed by `encrypt(session, rng16, data)` + `spim_map_signature` — the tail's exact wire assembly is `TODO(capture)`); `ptm` is lenient (u32 BE then the whole tail — every candidate shape accepted, error 0); finish requires `tk` exactly 16 B (error 0x8, 0x17f1f11), key16 = `encrypt(session, NULL iv, tk)` (0x17f1eac), decrypt block count = payload.size/16 (0x17f2548), yielding the 0x78-byte provisioned struct (+0x00 16 B, +0x10 u64, +0x18 u64, +0x20 16 B, +0x30..0x70 64 B metadata, +0x70 u32, +0x74 u8 flavor — which bytes are `client_secret` vs `mid` vs `metadata` is `TODO(capture)`). Kotlin: `registration` module `ClearAdiOtp`/`ClearAdiProvision`/`ClearAdiFraming`/`ClearAnisetteProvider`, 192 tests green incl. byte-exact oracle vectors. — 2026-10-07 (rev 28, live capture: one real OpenBubbles provisioning run under gdb, `registration/dev/clearadi/captures/WIRE-NOTES.md`; the framer is corrected): `spim` trailing is NOT capped at 23 B — the live spim carries 99 trailing bytes and parses fine (the earlier 0x41 pin was a probe-harness artifact); the trailing is server material the client relays. `cpim` = an OUTER 276-B message `[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]` whose 160-B encrypt plaintext = `u32be(32) ‖ encsec(rng2) ‖ u32be(32) ‖ rng1 ‖ 60 B blob (caller-extra ‖ rng3 pad) ‖ u64 BE arg9 = −2 (live-confirmed) ‖ u32 BE unix-seconds ‖ u32be(1) ‖ 12 B (source TODO)`; the iv is 16 plain RNG bytes — the earlier 'IV assembled from the Spim's trailing fields' reading was actually the thread-RNG pool (words at r14+0x10, cursor at r14+0x110). `spim_map_signature` (0x17fe2a0) args are (rdi = in, rsi = out): the live call signs a constant 16-B block `e955a4f0…` → `f877c30e…`, and NEITHER that signature nor the SHA-256(encout) digest appears anywhere in the captured wire — the §6 'two 256-bit tail fields' reading is falsified. The `[u32be(52)][u32be(4)][Y 52]` group is the spim's trailing[16:76] relayed verbatim; X (32 B) = `TODO(capture)`. `ptm` = `[u32 BE 4][seed 16][u32 BE payloadLen][payload][tail]` (live 503 B: seed = ptm[4:20] feeds decryptPTM as its chain seed via rcx; block count = payloadLen/16 = 28); key16 = `encrypt(session, NULL iv, tk)` confirmed byte-exact against the live call. ProvisionedData = the 0x78-B Rust struct `{metadata: Vec (400 B live = decrypt out[4:404], length u32 BE at out[0]), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8 (0x01 on a live Mac run — distinct from the OTP flavor numbering)}`; the finish gate is the decrypt output's **out[408:440] == rng1 echo** (NOT out[0:32]); the OTP-path report's ProvisionedMachine note (mid[32]@+0x18, secret[60]@+0x38) had the two swapped. Kotlin `ClearAdiFraming`/`ClearAnisetteProvider` restructured to the live shapes; 194 tests green. Remaining `TODO(capture)`: the X32 source, the 12-B plaintext tail, trailing[0:16]'s role. — 2026-10-07 (rev 29, continuation capture — two further live provisioning runs, `gdb_prov8/9.gdb`, byte-exact on both): the three remaining cpim TODOs are CLOSED. X = SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ a fixed 24-byte filler constant `3795020c05f77009cbfeaa78a5bcb3175bad60b9deaaaa8d` (.so .rodata) — the digest is written to the wire byte-exact as the `[X 32]` group, so the rev-28 "sig16 and the digest appear nowhere on the wire" note resolves: both are on the wire transitively (sig16 as the hash input, the digest as X). The signature's INPUT is NOT a constant block — the "constant e955a4f0…" seen in the morning runs was that session's trailing[0:16]; the trailing head is server material that differs between provisioning sessions, and the signature model still reproduces the live output exactly. The 12-B plaintext tail = 12 fresh RNG bytes (low bytes of 12 thread-RNG pool words drawn immediately before the IV's 16 — the same construction as the IV, so the framer draws 12). Kotlin: the pinned production seal (`CpimShaTailSeal` — SHA-256 via the platform, sig via the ported `spimMapSignature`) is the provider default; 195 tests green. — 2026-10-08 (rev 30, clean-room RE of the NAC mint — the validation blob "C5" — over the same unstripped Linux `.so`; gdb oracle quadruples first, then python model, then Kotlin; method + findings in `registration/dev/clearadi/captures/NAC-NOTES.md`): the mint is local and pure, and every byte of the 517-B validation stamp is now derived EXCEPT one residue (below). The stamp = `0x02 ‖ rand16 (RNG) ‖ sig16 ‖ u32be(0x1e0) ‖ blob480`; blob480 = `tag 0x05 ‖ u32be(1) ‖ u32be(0x180) ‖ scramble_body(session250, rng_pad130) ‖ u64be(79) ‖ cert79`, where cert79 is the first 79 B of the ctx cert chain and session250 the 250-B session slice — E2E the full stamp computes byte-exact from (cert79, session250, rng_pad130, body576, rand16). The crypto layers, all byte-exact vs three independent live runs: **pear_aes** — a white-box AES variant (`create_rounds` = AES-128-style expansion with pluggable sbox/rcon; `init_key` = S1-scramble → schedule; `establish_key` = schedule + T-transform of blocks 1–9 only, blocks 0/10 raw; `encrypt_cbc` = whitening + **9 rounds** + final `SE[st9[p]]^rk11` with p=[12,10,4,3,15,5,2,8,14,1,9,7,0,13,11,6]; `decrypt_cbc` = backward rounds with the key xor fused into the table index, final `perm(SDI[st9^rk1])^chain^rk0`); **domergestep2** — the white-box core of `scramble_body` (KP key schedule over 80 B, 4 blocks of the `0x51·m` constant with state[0] xored on EVERY block, final `TC[x]+k`, out = state ⊕ in1; 21/21 live captures); **key_establishment** — the EstablishKeyResponse wire is `tag ‖ key16@0x01 ‖ u32be bodyLen@0x11 ‖ body@0x15 (0x250) ‖ trailing (85 B, unused by the crypto)`. **SignState::sign** (the 16-B OTS signature over the blob): structure fully decoded — scatter → polynomial chain → flag table → u32 permutations → divisor table → key map → keygen → matrix/phases B–F → 16 position rounds → tail (fold chains + threshold cipher + staging + final formula); stages 1–9 are hand-transcribed and byte-exact vs ground truth, the tail's threshold cipher (AES-like, 9 rounds × 30 generator runs) is structurally decoded with 270 checkpointed rounds, and the signature the model produces is byte-exact vs the live stamp. CLOSED (same day, follow-up): the capture-free pure-code transcription of the tail cipher and the pre-init region — the full 517-B stamp now computes byte-exact from (cert79, session250, rng_pad130, body576, rand16) in a pure-Python model (no emulator, no captures; `sig_hand.py verify` 20/20) with the complete constant inventory recorded for the Kotlin port (see WORKLOG session 20). Kotlin: `registration` `PearTablesData`/`PearTables` (generated base64 table image, ClearAdiTablesData pattern), `PearAes`, `NacMerge`, `NacValidation` (`mint` + `keyEstablishment`; sig16 a parameter — no placeholder signature), 206 tests green (synthetic vectors, no Apple bytes). — 2026-10-08 (rev 31, Kotlin port of the sign model — the sig16 residue above is CLOSED): `SignState::sign` is transcribed to `registration` `NacSign` (full pipeline: scatter → u32 flag table → perms → divisor/key map → keygen → matrix/phases B–E → 16 position rounds, then the 30-run tail — RS32/rec0/r70ab init, run-start feedback, soup57/soup80, the 9-round threshold cipher, staging from the generator soup, final formula, chain1 fold), with its .rodata tables embedded as a generated base64 image (`NacSignTablesData`/`NacSignTables`, PearTablesData pattern; the 17-loop byte tables are in-range slices of the T2_BIG span). `NacValidation.mint(cert79, session250, rngPad130, rand16, body576)` now computes the full 517-B stamp including sig16 internally (the sig16-parameter assembly step remains as `mintWithSig`); Kotlin sign == python model on LCG-seeded synthetic vectors (incl. zero-rand16 and zero-blob edges), full-mint e2e byte-exact, 208 tests green. — 2026-10-08 (rev 32, three instrumented live runs of the same OpenBubbles app under gdb — the mint's INPUT mappings (C56-b/c/d/e + cert79) are now wire-pinned, and a live-input divergence in the rev-30/31 sign model is discovered; dumps + method in `registration/dev/clearadi/captures/NAC-NOTES.md` session-21 continuation): the five mint inputs map from the exchange as follows — **C56-b pearKey16** = 16 fresh client RNG bytes created inside `ValidationCtx::new` (rand-crate `[u8;16]` sample; wrapped to the server inside the step-2 request blob, which is why it never appears in the session-info response); **C56-c session250** = the 250-B `HardwareConfig::to_bin` output — the client's hardware-identity serialization (model identifier, serial, platform-UUID + second UUID, board-id `Mac-…`, OS build, MLB — ASCII, len-prefixed), held at ctx+0x20 and NOT a slice of any server response; **C56-d rngPad130** = 130 fresh thread-RNG bytes filling the 0x180-B scramble buffer after `u32be(len) ‖ data` (pad content is irrelevant filler, drawn fresh per mint); **C56-e body576** = the SDAT-mapped decrypt of the session-info response's `body@0x15` (0x250 B) region `[2:578]` — i.e. ctx[0x40:0x280]; and **cert79 is NOT the step-1 certificate** (the rev-30 reading is falsified): it is the first 0x4f bytes of the response's 85-B trailing Vec (ctx+0x00), server material relayed into the stamp — wire-verified ×3 (stamp[438:517] == trailing[0:79]). The step-2 request blob itself is now wire-captured: 338 B = constant header `01 08 4b2c91481d9b7da0` + 328 B per-session material (RSA-OAEP under the step-1 cert's public key + digests, built inside `ValidationCtx::new`, held at ctx+0x2c0) — its exact field map and the HardwareConfig serialization grammar remain `TODO(capture)` (C56-a/C56-c). **Live-input divergence (new residue):** with ALL FOUR `sign::sign` args byte-verified against the captured call (rsi=body576, rdx=rand16, rcx=blob480), the rev-30/31 pure model produces a WRONG sig16 on two independent fresh runs (e.g. `7a9555e5…`/`b2c72389…` vs live `0160f19c…`) while still reproducing the session-20 capture byte-exact — the empirical tail-cipher/initial-carry fit contains an input-dependent path that the session-20 data never exercised; a live mint is blocked on closing this, and the rev-30/31 "byte-exact" claims hold only for the session-20 capture and the synthetic vectors. Kotlin: `NacValidation.keyEstablishmentFull` (payload + trailing), `NacMint` facade updated with the pinned mappings; 209 registration tests green. — 2026-10-09 (rev 33, the sign-model fresh-input divergence is CLOSED — the live-mint blocker from rev 32 is gone; method + dumps in `registration/dev/clearadi/captures/NAC-NOTES.md` session-23 continuation): the divergence was two more session-20-frozen constants inside `SignState::sign`, both found by rebuilding a fresh-input round-state checkpoint harness (`gdb_c56d.gdb`, 270 round dumps in `captures/c56d/`) and diffing stage-by-stage against a unicorn execution of the same input (the emulator reproduces the live sig16 byte-exact, so the fit — not the emulator — was wrong). (1) **staging i==10 ecx2**: the staging maze's i==10 branch multiplies in `perm2u[st[0x6fa2]]` (rsp+0x28, loaded at 0x17a36c0 from state+0x6fa2); the fitted `perm2u[0x40]` was the session-20 snapshot (s20 st[0x6fa2]==0x40 — a no-op there, silently wrong elsewhere). (2) **the P region (state+0x6898, 7×256) is INPUT-DEPENDENT** — the "runtime constant, byte-identical across base/mod/body-flip" reading (rev 30) is falsified: the SSE builder at 0x17a1381-0x17a20b5 parameterizes five of the seven 256-byte tables on five body-derived bytes (`st[0x288a]^st[0x287a]`, `st[0x287f]^st[0x267f]`, `st[0x2683]^st[0x2753]`, `st[0x287d]^st[0x288d]`, `st[0x274b]^st[0x288b]`); tables 2/5 are the only true constants. The builder's instruction stream is serialized and re-executed lane-wise (generated `NacPTable.kt`, 1833 ints + 46 const vectors), verified byte-exact vs the emulated builder on the fresh input, two random states and the session-20 capture. Also pinned while re-diffing: the region-0x6f98 sel bytes, 0x6fd8 selectors (base-7 digits of `body[0xe0+i]^body[0xf0+i]`), 0x6fe8/0x6fec masks/flags, the scatter N and the map-key cursor are all body-derived (already fixed in-session in the python model; now ported), and the cipher's round "RC constants" are the state's own 0x25d8 words. Verification: the pure model + Kotlin `NacSign` reproduce the FRESH live sig16 `307d7151…` and the FULL fresh 517-B stamp byte-exact (`captures/c56d/fresh_stamp.bin`), the session-20 stamp still byte-exact, `sig_hand.py verify` 20/20; Kotlin: `NacSign` re-ported (region6f98/masks/sel/N/cursor/scatter-mutated/RC now computed; the four former state-embedded constants dropped from `NacSignTablesData`), 209 registration tests green (vectors regenerated), imessage `:server:testDebugUnitTest` green. Remaining before a live attempt: C56-a (request-blob field map) + C56-c (HardwareConfig grammar) — both `TODO(capture)`, C58 OABS parser. — 2026-10-09 (rev 34, C56-a request-blob field map ~85% pinned — four further instrumented runs, `gdb_c56e..h.gdb`, dumps in `captures/c56e..h/`, write-up in NAC-NOTES.md session-24 continuation): the 338-B step-2 request blob = constant header `01 08 4b2c91481d9b7da0` ‖ [10:266] = 256-B **RSA-2048-OAEP-SHA1** ciphertext under the step-1 cert's public key (no sha2 crate linked in the helper binary; a single `Oaep::new` monomorphization; output 0x100 checked in the disasm), plaintext = **pearKey16 ‖ hmacKey20** (36 B — the encrypt's r9=0x24; hmacKey byte-verified at plaintext offset 16 on two runs) ‖ [266:286] = 20 B, UNPINNED (ends with `u32be(32)`, a length marker for the next field; assembly source = struct+0x450 ← [rsp+0x260] + r15d) ‖ [286:318] = 32 B = the serialize-time content of the `encrypt_cbc` output Vec (the serializer reads the Vec the encrypt_cbc sret was moved into; offline pear-AES reproduction from (plaintext32, iv, rk176) is open — the plaintext carries a 0x72-fill pattern suggesting a reused/padded scratch struct) ‖ [318:338] = **HMAC-SHA1(hmacKey20, the 32-B field)** — VERIFIED byte-exact: the two digest finalizes are the inner/outer of a standard HMAC-SHA1 (inner = sha1(key⊕ipad ‖ msg), outer = sha1(key⊕opad ‖ inner); the 20-B key = u1[0:20]^0x36 == u3[0:20]^0x5c). Role model: the server decrypts the OAEP → (pear16, hmacKey), derives the PAESKey from pear16 (`init_key` input confirmed = pear16), decrypts the 32-B field, and verifies the HMAC — a possession-of-pear16 proof bound to the blob. Also pinned: the blob Vec at ctx+0x2c0 is already moved-out by key_establishment time — capture it at the serializer return (0x1794998, sret {tag=6, cap, ptr@[0xc8], len@[0xd0]=0x152}) instead. Remaining for C56-a closure: the 20-B slot's source and the 32-B field's derivation. — 2026-10-12 (rev 35, C56-a CLOSED — the step-2 `session-info-request` blob fully derived by direct in-process gdb replay of `ValidationCtx::new` (two byte-verified runs) after the natural launch trigger stopped reproducing): the 338-B blob = constant header `01 08 4b2c91481d9b7da0` ‖ [10:266] RSA-2048-OAEP-SHA1(step-1 cert pubkey, **pearKey16 ‖ hmacKey20**; pearKey16 = the client's FIRST RNG16 draw — byte-equal to the `init_key` input — hmacKey20 the second material) ‖ [266:282] a third fresh RNG16 ‖ [282:286] `u32be(32)` (the serializer's BE length marker for the next field) ‖ [286:318] **field32 = mapA[encrypt_cbc(plain32, iv16, init_key(pearKey16))]** where mapA is a 256-B byte→byte substitution table at .so 0x2249f9 (the post-encrypt loop is `table[byte]`, NOT xor) and `plain32 = mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros(10)]` with mapB the substitution table at 0x224af9 (S2[0]=0x72 — the '0x72-fill' explained) over the 21-B serializer output `u8 tag=2 ‖ u32be version=1 ‖ innerKey16` zero-padded to 32 (innerKey16 = a second, independent RNG16 — opaque to the server until field32 is decrypted); the encrypt_cbc iv rides an in/out 16-B block (entry = the fresh-RNG iv, return = the last ciphertext block) ‖ [318:338] HMAC-SHA1(hmacKey20, field32) (re-verified). Role model unchanged: possession-of-pearKey16 bound to the blob. Kotlin `registration` `NacRequestBlob` (+ the two tables appended to `PearTablesData`), 213 registration tests green; imessage `ClearNacValidationSource` unstubbed at C56-a. Remaining live-mint gate: C56-c only (session250 HardwareConfig values). +- Hardware-related anisette headers seen in activation flows: `X-Apple-I-SRL-NO` (serial), `X-Apple-I-MLB` (logic-board serial), `X-Apple-I-ROM` (hex ROM, lowercase) [RP:relay.rs], [APA:anisette]. `TODO(capture):` C6 — the full anisette header set a current client actually sends, including which optional hardware headers appear. — 2026-09-10: the header-set half of C6 is closed by facts-only extraction: per-request anisette headers `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-Locale`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, `X-Apple-I-MD` (base64 OTP), `X-Apple-I-MD-M` (base64 machine ID), plus per-platform hardware headers — macOS: `X-Apple-I-MLB`, `X-Apple-I-ROM` (lowercase hex), `X-Apple-I-SRL-NO` [APA:omnisette], [APA:icloud-auth], [RP:relay.rs]. They are produced by an ADI provisioning exchange with gsa.apple.com (start/finish provisioning returning machine tokens), then a local OTP [APA:omnisette]. GSA context (cross-referenced with C6): the Albert activation itself needs NO Apple-Account login — only the FairPlay-signed activation-info plist (§1.2); the GSA login (full Apple-ID password, SRP over gsa.apple.com/grandslam/GsService2, s2k/s2k_fo PBKDF2, 2FA-capable) feeds the delegate logins that yield the IDS `auth-token`/`profile-id` and the PET token [RP:auth.rs], [APA:icloud-auth]. The extraction confirms the validation blob rides base64 in the `X-Mme-Nas-Qualify` header of the iCloud sign-in POST (recorded above) [RP:auth.rs]. — 2026-09-11 (rev 14 extraction round over the anisette crates): the value-format and provisioning halves of C6 are closed. Per-request value forms: `X-Apple-I-Client-Time` = UTC now truncated to whole seconds, RFC 3339/ISO-8601 with the offset suffix replaced by `Z` (provisioning requests send the raw offset form without the Z — a recorded inconsistency between code paths); `X-Apple-I-TimeZone` = literal `UTC` (not the device zone); `X-Apple-Locale` = `en_US` hardcoded (a companion `X-Apple-I-Locale: en_US` variant exists on the browser-style header set); `X-Apple-I-MD-RINFO` = verbatim passthrough of the string returned in the finish-provisioning response; `X-Mme-Device-Id` = lowercase hyphenated UUID string of 16 locally random bytes (`keychain_identifier`); `X-Apple-I-MD-M` = base64 (standard, padded) of the 60-byte `mid`; `X-Apple-I-MD-LU` = lowercase hex (no dashes) SHA-256 over the same 16 bytes (real macOS instead uses AuthKit's local-user UUID) — present in some header sets, omitted in others (disagreement — C57). Header sets are cached 60 s; a fresh OTP is generated per cache miss. The ADI provisioning flow: `GET https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist whose `urls` keys `midStartProvisioning`/`midFinishProvisioning` give the endpoint URLs (never hardcoded); POST to the start URL with an XML plist body of two empty top-level dicts `Header` and `Request` → response `Response` → `spim` (base64); a local session is built from `spim` (+ empty hostuuid, DSID `-2`, flavor `Mac`=1 / `IOS`=3, flavor chosen by whether the client-info string contains `iPhone OS`) yielding `cpim` (base64); POST to the finish URL with `Request` = {`cpim`} → response `Response` → `ptm`, `tk` (both base64) and `X-Apple-I-MD-RINFO`; finishing the session with the decoded `tk`/`ptm` yields the machine state: `client_secret` (32 bytes), `mid` (60 bytes, Apple-issued at provisioning — reused for every OTP until re-provisioning, which happens only on a not-provisioned failure, e.g. ADI error -45061), `metadata` (variable blob). Provisioning HTTP specifics: `Content-Type: application/x-www-form-urlencoded` despite the XML-plist body (intentional), title-cased header names forced, HTTP/1.1, Apple root CA pinned (embedded DER), and a fixed companion header set on all three requests: akd `User-Agent`, `X-Apple-Baa-E: -10000`, `X-Apple-Baa-Avail: 2`, `X-Apple-Baa-UE: AKAuthenticationError:-7066|com.apple.devicecheck.error.baa:-10000`, `X-Apple-Host-Baa-E: -7066`, `X-Apple-Client-App-Name: akd`, `Accept: */*`, `Accept-Language: en-US,en;q=0.9`, plus `X-Apple-I-MD-LU`, `X-Mme-Device-Id`, `X-Mme-Client-Info`, `X-Apple-I-Client-Time` and any caller-supplied hardware headers. State persists as an XML plist with `keychain_identifier` (data) + the provisioned fields (`client_secret`, `mid`, `metadata`, `rinfo`, `flavor`) [APA:omnisette]. CLOSED-SOURCE RESIDUE: the OTP (`X-Apple-I-MD`) and trusted-device 2FA-code derivations live inside a closed-source crate — its public repo is a stub with `todo!()` methods; recorded is only the call contract: inputs (32-byte `client_secret`, 60-byte `mid`, `metadata`, flavor) → raw OTP bytes (base64 into the header) / 32-bit 2FA code [OA-stub], [APA:omnisette]. — rides C57/C56 residue; the seam stays injectable in the code. — 2026-10-07 (rev 27, clean-room RE of the OpenBubbles ADI library — unstripped Linux `.so`, oracle-verified; the derivation is no longer closed residue): the OTP is fully local and pure. `gen = unix_seconds / 30`; dispatch by OTP flavor byte: 1 (IOS) → `gen_otp` (0x17fba60), 0 (Mac) → `gen_otp_ios` (0x1801d90) — the binary's naming is inverted relative to the flavor (a distinct numbering from the §1.1 session flavor `Mac`=1/`IOS`=3). Cipher inputs: `one` = the machine's `client_info` (required ≥ 0x190 B IOS / ≥ 0xd0 B Mac, length-checked) and `two` = the 32-byte `mid`; output 16 bytes. The wire `X-Apple-I-MD` frame is 28 B: `u32be(5)` ‖ `u32be(16)` ‖ sig16 ‖ `u32be(flavor)`. The 2FA code is `SHA-1(sig16 ‖ secret60) mod 1_000_000` over the machine's full 60-byte secret slice (`machine[0x38..0x74]`; two standard SHA-1 blocks — an earlier report's "48-byte" reading is wrong, 0x18013a2/0x18013bb/0x18013ef). Provisioning framing, asm-pinned: `spim` = `field1` u32 BE @0 ‖ payload-length u32 BE @4 ‖ payload @8 (payload must exceed 59 B, 0x17efb33; more than 23 trailing bytes rejected with error 0x41); `cpim` = 9 fields (u32be(32) ‖ `encsec(rng2)` 32 B ‖ u32be(32) ‖ rng1 32 B ‖ 60 B blob = caller-extra ‖ rng3 pad ‖ u64 BE of arg9 = −2 ‖ u32 BE of the byte-swapped LE dword at rng3[8..12] ‖ two 256-bit tail fields fed by `encrypt(session, rng16, data)` + `spim_map_signature` — the tail's exact wire assembly is `TODO(capture)`); `ptm` is lenient (u32 BE then the whole tail — every candidate shape accepted, error 0); finish requires `tk` exactly 16 B (error 0x8, 0x17f1f11), key16 = `encrypt(session, NULL iv, tk)` (0x17f1eac), decrypt block count = payload.size/16 (0x17f2548), yielding the 0x78-byte provisioned struct (+0x00 16 B, +0x10 u64, +0x18 u64, +0x20 16 B, +0x30..0x70 64 B metadata, +0x70 u32, +0x74 u8 flavor — which bytes are `client_secret` vs `mid` vs `metadata` is `TODO(capture)`). Kotlin: `registration` module `ClearAdiOtp`/`ClearAdiProvision`/`ClearAdiFraming`/`ClearAnisetteProvider`, 192 tests green incl. byte-exact oracle vectors. — 2026-10-07 (rev 28, live capture: one real OpenBubbles provisioning run under gdb, `registration/dev/clearadi/captures/WIRE-NOTES.md`; the framer is corrected): `spim` trailing is NOT capped at 23 B — the live spim carries 99 trailing bytes and parses fine (the earlier 0x41 pin was a probe-harness artifact); the trailing is server material the client relays. `cpim` = an OUTER 276-B message `[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]` whose 160-B encrypt plaintext = `u32be(32) ‖ encsec(rng2) ‖ u32be(32) ‖ rng1 ‖ 60 B blob (caller-extra ‖ rng3 pad) ‖ u64 BE arg9 = −2 (live-confirmed) ‖ u32 BE unix-seconds ‖ u32be(1) ‖ 12 B (source TODO)`; the iv is 16 plain RNG bytes — the earlier 'IV assembled from the Spim's trailing fields' reading was actually the thread-RNG pool (words at r14+0x10, cursor at r14+0x110). `spim_map_signature` (0x17fe2a0) args are (rdi = in, rsi = out): the live call signs a constant 16-B block `e955a4f0…` → `f877c30e…`, and NEITHER that signature nor the SHA-256(encout) digest appears anywhere in the captured wire — the §6 'two 256-bit tail fields' reading is falsified. The `[u32be(52)][u32be(4)][Y 52]` group is the spim's trailing[16:76] relayed verbatim; X (32 B) = `TODO(capture)`. `ptm` = `[u32 BE 4][seed 16][u32 BE payloadLen][payload][tail]` (live 503 B: seed = ptm[4:20] feeds decryptPTM as its chain seed via rcx; block count = payloadLen/16 = 28); key16 = `encrypt(session, NULL iv, tk)` confirmed byte-exact against the live call. ProvisionedData = the 0x78-B Rust struct `{metadata: Vec (400 B live = decrypt out[4:404], length u32 BE at out[0]), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8 (0x01 on a live Mac run — distinct from the OTP flavor numbering)}`; the finish gate is the decrypt output's **out[408:440] == rng1 echo** (NOT out[0:32]); the OTP-path report's ProvisionedMachine note (mid[32]@+0x18, secret[60]@+0x38) had the two swapped. Kotlin `ClearAdiFraming`/`ClearAnisetteProvider` restructured to the live shapes; 194 tests green. Remaining `TODO(capture)`: the X32 source, the 12-B plaintext tail, trailing[0:16]'s role. — 2026-10-07 (rev 29, continuation capture — two further live provisioning runs, `gdb_prov8/9.gdb`, byte-exact on both): the three remaining cpim TODOs are CLOSED. X = SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ a fixed 24-byte filler constant `3795020c05f77009cbfeaa78a5bcb3175bad60b9deaaaa8d` (.so .rodata) — the digest is written to the wire byte-exact as the `[X 32]` group, so the rev-28 "sig16 and the digest appear nowhere on the wire" note resolves: both are on the wire transitively (sig16 as the hash input, the digest as X). The signature's INPUT is NOT a constant block — the "constant e955a4f0…" seen in the morning runs was that session's trailing[0:16]; the trailing head is server material that differs between provisioning sessions, and the signature model still reproduces the live output exactly. The 12-B plaintext tail = 12 fresh RNG bytes (low bytes of 12 thread-RNG pool words drawn immediately before the IV's 16 — the same construction as the IV, so the framer draws 12). Kotlin: the pinned production seal (`CpimShaTailSeal` — SHA-256 via the platform, sig via the ported `spimMapSignature`) is the provider default; 195 tests green. — 2026-10-08 (rev 30, clean-room RE of the NAC mint — the validation blob "C5" — over the same unstripped Linux `.so`; gdb oracle quadruples first, then python model, then Kotlin; method + findings in `registration/dev/clearadi/captures/NAC-NOTES.md`): the mint is local and pure, and every byte of the 517-B validation stamp is now derived EXCEPT one residue (below). The stamp = `0x02 ‖ rand16 (RNG) ‖ sig16 ‖ u32be(0x1e0) ‖ blob480`; blob480 = `tag 0x05 ‖ u32be(1) ‖ u32be(0x180) ‖ scramble_body(session250, rng_pad130) ‖ u64be(79) ‖ cert79`, where cert79 is the first 79 B of the ctx cert chain and session250 the 250-B session slice — E2E the full stamp computes byte-exact from (cert79, session250, rng_pad130, body576, rand16). The crypto layers, all byte-exact vs three independent live runs: **pear_aes** — a white-box AES variant (`create_rounds` = AES-128-style expansion with pluggable sbox/rcon; `init_key` = S1-scramble → schedule; `establish_key` = schedule + T-transform of blocks 1–9 only, blocks 0/10 raw; `encrypt_cbc` = whitening + **9 rounds** + final `SE[st9[p]]^rk11` with p=[12,10,4,3,15,5,2,8,14,1,9,7,0,13,11,6]; `decrypt_cbc` = backward rounds with the key xor fused into the table index, final `perm(SDI[st9^rk1])^chain^rk0`); **domergestep2** — the white-box core of `scramble_body` (KP key schedule over 80 B, 4 blocks of the `0x51·m` constant with state[0] xored on EVERY block, final `TC[x]+k`, out = state ⊕ in1; 21/21 live captures); **key_establishment** — the EstablishKeyResponse wire is `tag ‖ key16@0x01 ‖ u32be bodyLen@0x11 ‖ body@0x15 (0x250) ‖ trailing (85 B, unused by the crypto)`. **SignState::sign** (the 16-B OTS signature over the blob): structure fully decoded — scatter → polynomial chain → flag table → u32 permutations → divisor table → key map → keygen → matrix/phases B–F → 16 position rounds → tail (fold chains + threshold cipher + staging + final formula); stages 1–9 are hand-transcribed and byte-exact vs ground truth, the tail's threshold cipher (AES-like, 9 rounds × 30 generator runs) is structurally decoded with 270 checkpointed rounds, and the signature the model produces is byte-exact vs the live stamp. CLOSED (same day, follow-up): the capture-free pure-code transcription of the tail cipher and the pre-init region — the full 517-B stamp now computes byte-exact from (cert79, session250, rng_pad130, body576, rand16) in a pure-Python model (no emulator, no captures; `sig_hand.py verify` 20/20) with the complete constant inventory recorded for the Kotlin port (see WORKLOG session 20). Kotlin: `registration` `PearTablesData`/`PearTables` (generated base64 table image, ClearAdiTablesData pattern), `PearAes`, `NacMerge`, `NacValidation` (`mint` + `keyEstablishment`; sig16 a parameter — no placeholder signature), 206 tests green (synthetic vectors, no Apple bytes). — 2026-10-08 (rev 31, Kotlin port of the sign model — the sig16 residue above is CLOSED): `SignState::sign` is transcribed to `registration` `NacSign` (full pipeline: scatter → u32 flag table → perms → divisor/key map → keygen → matrix/phases B–E → 16 position rounds, then the 30-run tail — RS32/rec0/r70ab init, run-start feedback, soup57/soup80, the 9-round threshold cipher, staging from the generator soup, final formula, chain1 fold), with its .rodata tables embedded as a generated base64 image (`NacSignTablesData`/`NacSignTables`, PearTablesData pattern; the 17-loop byte tables are in-range slices of the T2_BIG span). `NacValidation.mint(cert79, session250, rngPad130, rand16, body576)` now computes the full 517-B stamp including sig16 internally (the sig16-parameter assembly step remains as `mintWithSig`); Kotlin sign == python model on LCG-seeded synthetic vectors (incl. zero-rand16 and zero-blob edges), full-mint e2e byte-exact, 208 tests green. — 2026-10-08 (rev 32, three instrumented live runs of the same OpenBubbles app under gdb — the mint's INPUT mappings (C56-b/c/d/e + cert79) are now wire-pinned, and a live-input divergence in the rev-30/31 sign model is discovered; dumps + method in `registration/dev/clearadi/captures/NAC-NOTES.md` session-21 continuation): the five mint inputs map from the exchange as follows — **C56-b pearKey16** = 16 fresh client RNG bytes created inside `ValidationCtx::new` (rand-crate `[u8;16]` sample; wrapped to the server inside the step-2 request blob, which is why it never appears in the session-info response); **C56-c session250** = the 250-B `HardwareConfig::to_bin` output — the client's hardware-identity serialization (model identifier, serial, platform-UUID + second UUID, board-id `Mac-…`, OS build, MLB — ASCII, len-prefixed), held at ctx+0x20 and NOT a slice of any server response; **C56-d rngPad130** = 130 fresh thread-RNG bytes filling the 0x180-B scramble buffer after `u32be(len) ‖ data` (pad content is irrelevant filler, drawn fresh per mint); **C56-e body576** = the SDAT-mapped decrypt of the session-info response's `body@0x15` (0x250 B) region `[2:578]` — i.e. ctx[0x40:0x280]; and **cert79 is NOT the step-1 certificate** (the rev-30 reading is falsified): it is the first 0x4f bytes of the response's 85-B trailing Vec (ctx+0x00), server material relayed into the stamp — wire-verified ×3 (stamp[438:517] == trailing[0:79]). The step-2 request blob itself is now wire-captured: 338 B = constant header `01 08 4b2c91481d9b7da0` + 328 B per-session material (RSA-OAEP under the step-1 cert's public key + digests, built inside `ValidationCtx::new`, held at ctx+0x2c0) — its exact field map and the HardwareConfig serialization grammar remain `TODO(capture)` (C56-a/C56-c). **Live-input divergence (new residue):** with ALL FOUR `sign::sign` args byte-verified against the captured call (rsi=body576, rdx=rand16, rcx=blob480), the rev-30/31 pure model produces a WRONG sig16 on two independent fresh runs (e.g. `7a9555e5…`/`b2c72389…` vs live `0160f19c…`) while still reproducing the session-20 capture byte-exact — the empirical tail-cipher/initial-carry fit contains an input-dependent path that the session-20 data never exercised; a live mint is blocked on closing this, and the rev-30/31 "byte-exact" claims hold only for the session-20 capture and the synthetic vectors. Kotlin: `NacValidation.keyEstablishmentFull` (payload + trailing), `NacMint` facade updated with the pinned mappings; 209 registration tests green. — 2026-10-09 (rev 33, the sign-model fresh-input divergence is CLOSED — the live-mint blocker from rev 32 is gone; method + dumps in `registration/dev/clearadi/captures/NAC-NOTES.md` session-23 continuation): the divergence was two more session-20-frozen constants inside `SignState::sign`, both found by rebuilding a fresh-input round-state checkpoint harness (`gdb_c56d.gdb`, 270 round dumps in `captures/c56d/`) and diffing stage-by-stage against a unicorn execution of the same input (the emulator reproduces the live sig16 byte-exact, so the fit — not the emulator — was wrong). (1) **staging i==10 ecx2**: the staging maze's i==10 branch multiplies in `perm2u[st[0x6fa2]]` (rsp+0x28, loaded at 0x17a36c0 from state+0x6fa2); the fitted `perm2u[0x40]` was the session-20 snapshot (s20 st[0x6fa2]==0x40 — a no-op there, silently wrong elsewhere). (2) **the P region (state+0x6898, 7×256) is INPUT-DEPENDENT** — the "runtime constant, byte-identical across base/mod/body-flip" reading (rev 30) is falsified: the SSE builder at 0x17a1381-0x17a20b5 parameterizes five of the seven 256-byte tables on five body-derived bytes (`st[0x288a]^st[0x287a]`, `st[0x287f]^st[0x267f]`, `st[0x2683]^st[0x2753]`, `st[0x287d]^st[0x288d]`, `st[0x274b]^st[0x288b]`); tables 2/5 are the only true constants. The builder's instruction stream is serialized and re-executed lane-wise (generated `NacPTable.kt`, 1833 ints + 46 const vectors), verified byte-exact vs the emulated builder on the fresh input, two random states and the session-20 capture. Also pinned while re-diffing: the region-0x6f98 sel bytes, 0x6fd8 selectors (base-7 digits of `body[0xe0+i]^body[0xf0+i]`), 0x6fe8/0x6fec masks/flags, the scatter N and the map-key cursor are all body-derived (already fixed in-session in the python model; now ported), and the cipher's round "RC constants" are the state's own 0x25d8 words. Verification: the pure model + Kotlin `NacSign` reproduce the FRESH live sig16 `307d7151…` and the FULL fresh 517-B stamp byte-exact (`captures/c56d/fresh_stamp.bin`), the session-20 stamp still byte-exact, `sig_hand.py verify` 20/20; Kotlin: `NacSign` re-ported (region6f98/masks/sel/N/cursor/scatter-mutated/RC now computed; the four former state-embedded constants dropped from `NacSignTablesData`), 209 registration tests green (vectors regenerated), imessage `:server:testDebugUnitTest` green. Remaining before a live attempt: C56-a (request-blob field map) + C56-c (HardwareConfig grammar) — both `TODO(capture)`, C58 OABS parser. — 2026-10-09 (rev 34, C56-a request-blob field map ~85% pinned — four further instrumented runs, `gdb_c56e..h.gdb`, dumps in `captures/c56e..h/`, write-up in NAC-NOTES.md session-24 continuation): the 338-B step-2 request blob = constant header `01 08 4b2c91481d9b7da0` ‖ [10:266] = 256-B **RSA-2048-OAEP-SHA1** ciphertext under the step-1 cert's public key (no sha2 crate linked in the helper binary; a single `Oaep::new` monomorphization; output 0x100 checked in the disasm), plaintext = **pearKey16 ‖ hmacKey20** (36 B — the encrypt's r9=0x24; hmacKey byte-verified at plaintext offset 16 on two runs) ‖ [266:286] = 20 B, UNPINNED (ends with `u32be(32)`, a length marker for the next field; assembly source = struct+0x450 ← [rsp+0x260] + r15d) ‖ [286:318] = 32 B = the serialize-time content of the `encrypt_cbc` output Vec (the serializer reads the Vec the encrypt_cbc sret was moved into; offline pear-AES reproduction from (plaintext32, iv, rk176) is open — the plaintext carries a 0x72-fill pattern suggesting a reused/padded scratch struct) ‖ [318:338] = **HMAC-SHA1(hmacKey20, the 32-B field)** — VERIFIED byte-exact: the two digest finalizes are the inner/outer of a standard HMAC-SHA1 (inner = sha1(key⊕ipad ‖ msg), outer = sha1(key⊕opad ‖ inner); the 20-B key = u1[0:20]^0x36 == u3[0:20]^0x5c). Role model: the server decrypts the OAEP → (pear16, hmacKey), derives the PAESKey from pear16 (`init_key` input confirmed = pear16), decrypts the 32-B field, and verifies the HMAC — a possession-of-pear16 proof bound to the blob. Also pinned: the blob Vec at ctx+0x2c0 is already moved-out by key_establishment time — capture it at the serializer return (0x1794998, sret {tag=6, cap, ptr@[0xc8], len@[0xd0]=0x152}) instead. Remaining for C56-a closure: the 20-B slot's source and the 32-B field's derivation. — 2026-10-12 (rev 35, C56-a CLOSED — the step-2 `session-info-request` blob fully derived by direct in-process gdb replay of `ValidationCtx::new` (two byte-verified runs) after the natural launch trigger stopped reproducing): the 338-B blob = constant header `01 08 4b2c91481d9b7da0` ‖ [10:266] RSA-2048-OAEP-SHA1(step-1 cert pubkey, **pearKey16 ‖ hmacKey20**; pearKey16 = the client's FIRST RNG16 draw — byte-equal to the `init_key` input — hmacKey20 the second material) ‖ [266:282] a third fresh RNG16 ‖ [282:286] `u32be(32)` (the serializer's BE length marker for the next field) ‖ [286:318] **field32 = mapA[encrypt_cbc(plain32, iv16, init_key(pearKey16))]** where mapA is a 256-B byte→byte substitution table at .so 0x2249f9 (the post-encrypt loop is `table[byte]`, NOT xor) and `plain32 = mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros(10)]` with mapB the substitution table at 0x224af9 (S2[0]=0x72 — the '0x72-fill' explained) over the 21-B serializer output `u8 tag=2 ‖ u32be version=1 ‖ innerKey16` zero-padded to 32 (innerKey16 = a second, independent RNG16 — opaque to the server until field32 is decrypted); the encrypt_cbc iv rides an in/out 16-B block (entry = the fresh-RNG iv, return = the last ciphertext block) ‖ [318:338] HMAC-SHA1(hmacKey20, field32) (re-verified). Role model unchanged: possession-of-pearKey16 bound to the blob. Kotlin `registration` `NacRequestBlob` (+ the two tables appended to `PearTablesData`), 213 registration tests green; imessage `ClearNacValidationSource` unstubbed at C56-a. Remaining live-mint gate: C56-c only (session250 HardwareConfig values). — 2026-10-12 (rev 36, C56-c CLOSED): `to_bin` is ported (`registration` `HardwareConfiguration.toBin()`) and keyed off the C58 export identity; the seven binary Vecs are exactly the export identifier fields 8–14 (serialEnc, platformUuidEnc, rootDiskUuidEnc, ROM, romEnc, MLB ASCII, mlbEnc — a 1:1 mapping, nothing unpinned), and the raw second field is the MAC address, NOT the ROM (the 6-B Vec item in the live capture carries the multicast bit — no unicast MAC does — while the inline `[u8;6]` at the stub's struct slot is MAC-shaped; rev-32/35's ROM label corrected). `toBin` round-trips the live 250-B capture byte-exactly; 217 registration tests green; imessage `ClearNacValidationSource.mint(oabsExport)` unstubbed — every §1.1 mint input mapping is now established, and 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. ### 1.2 Activation endpoints and certificates @@ -330,6 +330,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 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/NacMint.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/NacMint.kt index aaabec6..18a4073 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/NacMint.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/NacMint.kt @@ -10,19 +10,15 @@ package dev.fenn.imessage.registration * Input mappings from the live exchange (2026-10-08, three instrumented runs; §1.1): * - pearKey16 — 16 fresh client RNG bytes (created in ValidationCtx::new; wrapped to the * server inside the step-2 request blob). - * - rngPad130 — 130 fresh client RNG bytes (the scramble buffer's thread-RNG filler). + * - rngPad — the scramble buffer's thread-RNG filler: `0x17c − session250.size` fresh + * client RNG bytes (130 on the capture machine, whose session250 is 250 B). * - rand16 — 16 fresh client RNG bytes (the mint's own sample). * - body576 — keyEstablishment payload[2:578] (the EstablishKeyResponse payload region). * - cert79 — keyEstablishmentFull trailing[0:79] (server material from the response — * NOT the step-1 certificate). - * Still NOT established (§1.1 C56-a/C56-c residue): the step-2 request blob's field - * construction and the session250 hardware-config serialization — callers must not - * fabricate them; run the exchange and fail loudly at the first unestablished mapping. - * - * KNOWN LIVE DIVERGENCE (§1.1, 2026-10-08): the sig16 pipeline (NacSign) is byte-exact - * vs the session-20 captures and the python model, but produces a WRONG sig16 for - * fresh live inputs (all four sign::sign args byte-verified; two independent runs) — - * do not ship a live mint until §1.1's sign-model residue is closed. + * Established (§1.1 rev 33/35 + C56-c): the request blob ([NacRequestBlob]) and the + * session250 hardware-config serialization ([HardwareConfiguration.toBin]) — no input + * mapping remains open. */ object NacMint { @@ -30,10 +26,10 @@ object NacMint { fun mint( cert79: ByteArray, session250: ByteArray, - rngPad130: ByteArray, + rngPad: ByteArray, rand16: ByteArray, body576: ByteArray, - ): ByteArray = NacValidation.mint(cert79, session250, rngPad130, rand16, body576) + ): ByteArray = NacValidation.mint(cert79, session250, rngPad, rand16, body576) /** [NacValidation.keyEstablishment] — the SDAT-mapped EstablishKeyResponse wire. */ fun keyEstablishment(sessionInfo: ByteArray, pearKey16: ByteArray): ByteArray = diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/NacValidation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/NacValidation.kt index db112cd..38a79e7 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/NacValidation.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/NacValidation.kt @@ -46,11 +46,11 @@ internal object NacValidation { internal fun mint( cert79: ByteArray, session250: ByteArray, - rngPad130: ByteArray, + rngPad: ByteArray, rand16: ByteArray, body576: ByteArray, ): ByteArray { - val scrambled = NacMerge.scrambleBody(session250, rngPad130) + val scrambled = NacMerge.scrambleBody(session250, rngPad) val blob = bodyToBits(scrambled, cert79) val sig16 = NacSign.sign(body576, rand16, blob) return dataToBits(rand16, sig16, blob) @@ -60,11 +60,11 @@ internal object NacValidation { internal fun mintWithSig( cert79: ByteArray, session250: ByteArray, - rngPad130: ByteArray, + rngPad: ByteArray, rand16: ByteArray, sig16: ByteArray, ): ByteArray { - val scrambled = NacMerge.scrambleBody(session250, rngPad130) + val scrambled = NacMerge.scrambleBody(session250, rngPad) return dataToBits(rand16, sig16, bodyToBits(scrambled, cert79)) } diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt index a3d9c95..295a61e 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataExchange.kt @@ -115,6 +115,43 @@ class ValidationCertClient( } } +/** + * The step-1 `cert` data's framing (observed live 2026-10-12; byte-identical to the + * 2026-10-08 capture the replay fed `ValidationCtx::new`): `u8 0x01 ‖ u8 certCount ‖ + * per cert — u16be(0x0000) entry type ‖ u16be(len) ‖ DER`, len = the full DER byte + * count, leaf LAST (the live chain is ["Apple System Integration Certification + * Authority" (intermediate), "DRM Technologies A01" (session identity cert — the + * public key the step-2 blob's OAEP encrypts under)]). A bare DER (no framing) passes + * through as a single-cert chain — the OAEP then fails loudly on anything else. + */ +object ValidationCertChain { + + fun parse(data: ByteArray): List { + if (data.size > 4 && data[0] == 0x30.toByte() && data[1] == 0x82.toByte()) { + return listOf(data) + } + val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.BIG_ENDIAN) + require(buf.remaining() >= 2 && buf.get() == 0x01.toByte()) { + "step-1 cert data: missing the 0x01 framing version byte" + } + val count = buf.get().toInt() and 0xff + require(count > 0) { "step-1 cert data: zero certs in the framing" } + val certs = ArrayList(count) + repeat(count) { + require(buf.remaining() >= 4) { "step-1 cert data: truncated cert entry" } + buf.short // entry type — observed 0x0000 on every live cert + val len = buf.short.toInt() and 0xffff + require(len in 5..buf.remaining()) { + "step-1 cert data: cert entry length $len out of range" + } + val der = ByteArray(len) + buf.get(der) + certs.add(der) + } + return certs + } +} + /** One completed generation: the §1.1 validation blob and when it was minted. */ class ValidationData( /** The final blob — verbatim into the register body's `validation-data` field (§1.4). */ diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt index b20fcff..0f56983 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/ValidationDataGenerator.kt @@ -81,6 +81,40 @@ class HardwareConfiguration( EncLabel.MLB to mlbEnc, ) + /** + * The `session250` NAC-mint input (C56-c, CLOSED 2026-10-12): the closed + * `HardwareConfig::to_bin` serialization (NAC-NOTES.md "C56-c HardwareConfig grammar + * CLOSED" — static disasm byte-verified against a live 250-B capture). Wire grammar: + * `write_str(s)` = `u8(len+1) ‖ s.utf8 ‖ 00`, `write_item(b)` = `u8(len) ‖ b`; serial + * order — product name, MAC address (raw 6 B, no prefix), serial, platform UUID, + * root-disk UUID, board ID, OS build, the constant `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. + * + * The raw second field is the MAC address — the earlier "ROM @ struct+0x138" reading is + * corrected by the live capture: the 6-B Vec item carries the multicast bit (a MAC never + * does), and the stub's field types put the inline `[u8;6]` MAC at that slot. + */ + fun toBin(): ByteArray { + val out = java.io.ByteArrayOutputStream() + writeStr(out, productName) + out.write(macAddress) + writeStr(out, platformSerial) + writeStr(out, platformUuid) + writeStr(out, rootDiskUuid) + writeStr(out, boardId) + writeStr(out, osBuildNumber) + out.write(TO_BIN_CONSTANT) + writeItem(out, serialEnc) + writeItem(out, platformUuidEnc) + writeItem(out, rootDiskUuidEnc) + writeItem(out, rom) + writeItem(out, romEnc) + writeItem(out, mlb.toByteArray(Charsets.UTF_8)) + writeItem(out, mlbEnc) + return out.toByteArray() + } + /** The recorded §1.1 obfuscation labels, mapped to their fields. */ object EncLabel { const val SERIAL = "Gq3489ugfi" @@ -93,6 +127,34 @@ class HardwareConfiguration( companion object { const val MAC_ADDRESS_BYTES = 6 + /** The to_bin constant dword (rodata, stored LE — `00 03 0b 52` on the wire). */ + val TO_BIN_CONSTANT = byteArrayOf(0x00, 0x03, 0x0b, 0x52.toByte()) + + private fun writeStr(out: java.io.ByteArrayOutputStream, s: String) { + val bytes = s.toByteArray(Charsets.UTF_8) + writePrefixed(out, bytes, prefixLength = bytes.size + 1, what = "string") + out.write(0) + } + + private fun writeItem(out: java.io.ByteArrayOutputStream, bytes: ByteArray) { + writePrefixed(out, bytes, prefixLength = bytes.size, what = "binary item") + } + + private fun writePrefixed( + out: java.io.ByteArrayOutputStream, + bytes: ByteArray, + prefixLength: Int, + what: String, + ) { + if (prefixLength > 0xff) { + throw ValidationDataException( + "hardware-config $what of ${bytes.size} bytes exceeds the u8 length prefix (C56-c grammar)", + ) + } + out.write(prefixLength) + out.write(bytes) + } + /** Builds the configuration from a parsed export payload; loud on any §1.1-required absence. */ fun from(identifiers: MacHardwareIdentifiers): HardwareConfiguration = from( productName = identifiers.productName, diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/HardwareConfigToBinTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/HardwareConfigToBinTest.kt new file mode 100644 index 0000000..a92fd9c --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/HardwareConfigToBinTest.kt @@ -0,0 +1,106 @@ +package dev.fenn.imessage.registration + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The C56-c `session250` serialization ([HardwareConfiguration.toBin] — the closed + * `HardwareConfig::to_bin` grammar, NAC-NOTES.md): synthetic vectors only. + */ +class HardwareConfigToBinTest { + + private fun config( + serialEnc: ByteArray = byteArrayOf(1, 2, 3), + ) = HardwareConfiguration( + productName = "TestMac1,1", + osBuildNumber = "25A1", + platformSerial = "ABC123", + platformUuid = "UUID-1", + rootDiskUuid = "UUID-2", + boardId = "Mac-ZZ", + macAddress = byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55), + rom = byteArrayOf(0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f), + mlb = "MLB17CHARS000001", + serialEnc = serialEnc, + platformUuidEnc = byteArrayOf(4, 4), + rootDiskUuidEnc = byteArrayOf(5, 5, 5, 5, 5), + romEnc = byteArrayOf(6, 6, 6, 6, 6, 6, 6), + mlbEnc = byteArrayOf(0xfe.toByte(), 0xff.toByte()), + ) + + private fun str(s: String): ByteArray = + byteArrayOf((s.length + 1).toByte()) + s.toByteArray(Charsets.UTF_8) + byteArrayOf(0) + + private fun item(vararg bytes: Byte): ByteArray = + byteArrayOf(bytes.size.toByte()) + bytes + + @Test + fun toBinMatchesTheClosedGrammarByteForByte() { + val expected = str("TestMac1,1") + + // the MAC — raw 6 B, no length prefix + byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55) + + str("ABC123") + + str("UUID-1") + + str("UUID-2") + + str("Mac-ZZ") + + str("25A1") + + // the to_bin constant dword + byteArrayOf(0x00, 0x03, 0x0b, 0x52) + + // the seven binary items in Mac-Hardware-Info export field order (8..14); + // the MLB item carries the ASCII bytes with NO trailing NUL + item(1, 2, 3) + + item(4, 4) + + item(5, 5, 5, 5, 5) + + item(0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f) + + item(6, 6, 6, 6, 6, 6, 6) + + item(*"MLB17CHARS000001".toByteArray(Charsets.US_ASCII)) + + item(0xfe.toByte(), 0xff.toByte()) + assertContentEquals(expected, config().toBin()) + } + + @Test + fun aLongerMachineThanTheLiveCaptureSerializesAtItsOwnLength() { + // The 250-B name is that capture machine's size — the grammar is length-agnostic. + val bytes = config(serialEnc = ByteArray(200)).toBin() + val constIdx = bytes.indices.indexOfFirst { i -> + bytes.drop(i).take(4).toByteArray().contentEquals(HardwareConfiguration.TO_BIN_CONSTANT) + } + assertTrue(constIdx >= 0) + // The 200-byte serialEnc item follows the constant: u8(200) ‖ 200 zero bytes. + assertEquals(200.toByte(), bytes[constIdx + 4]) + // The remaining six items (3 + 6 + 7 + 8 + 17 + 3 bytes) close the buffer. + assertEquals(constIdx + 4 + 1 + 200 + 44, bytes.size) + } + + @Test + fun stringBeyondTheU8PrefixFailsLoudly() { + val long = HardwareConfiguration( + productName = "M".repeat(300), + osBuildNumber = "25A1", + platformSerial = "S", + platformUuid = "U", + rootDiskUuid = "R", + boardId = "Mac-ZZ", + macAddress = ByteArray(6), + rom = ByteArray(6), + mlb = "MLB", + serialEnc = ByteArray(1), + platformUuidEnc = ByteArray(1), + rootDiskUuidEnc = ByteArray(1), + romEnc = ByteArray(1), + mlbEnc = ByteArray(1), + ) + val error = assertFailsWith { long.toBin() } + assertTrue(error.message!!.contains("u8 length prefix")) + } + + @Test + fun binaryItemBeyondTheU8PrefixFailsLoudly() { + val config = config(serialEnc = ByteArray(256)) + val error = assertFailsWith { config.toBin() } + assertTrue(error.message!!.contains("u8 length prefix")) + } +}