C56 input mappings pinned from three live runs (rev 32); sign-model fresh-input divergence recorded

keyEstablishmentFull (payload + trailing Established); NacMint facade
documents the wire-pinned mappings: pearKey16/rngPad130/rand16 = fresh
client RNG, session250 = HardwareConfig::to_bin blob, body576 =
established[2:578], cert79 = response trailing[0:79] (NOT the step-1
cert — falsified). C56-a field map + HardwareConfig grammar stay open.
NEW RESIDUE: the rev-30/31 sign model mis-signs fresh live inputs (all
four sign::sign args byte-verified) — live mint blocked on closing it.
209 registration tests green.
This commit is contained in:
fenleon committed 2026-10-08 21:19:13 +02:00
1 parent cfd6cd4e71
commit 075857e79e
4 files changed
+66 -16

No files matched your search

+3 -2
View File
@@ -1,6 +1,6 @@
# iMessage protocol spec (clean-room)
**Status:** draft — revision 31. 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 32. 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 purLine truncated
- 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 purLine truncated
### 1.2 Activation endpoints and certificates
@@ -331,6 +331,7 @@ Ordered by value. IDs match the inline `TODO(capture):` markers above.
## Revision history
- Rev 32 (2026-10-08, three instrumented live runs — the mint's input mappings C56-b/c/d/e + cert79 are wire-pinned; a live-input divergence in the rev-30/31 sign model is discovered and recorded as a new residue): pearKey16/rngPad130/rand16 = fresh client RNG draws; session250 = the HardwareConfig::to_bin hardware-identity blob; body576 = the established payload's [2:578]; cert79 = the session-info response's trailing[0:79] (NOT the step-1 cert — falsified); step-2 request blob wire-captured (338 B, constant header + OAEP + digests; field map open). Kotlin `keyEstablishmentFull`; 209 tests green. Live mint blocked on: C56-a field map, C56-c grammar, and the sign-model fresh-input divergence.
- Rev 31 (2026-10-08, Kotlin port of the sign model — the rev-30 sig16 residue is CLOSED; method in `registration/dev/clearadi/captures/NAC-NOTES.md`, constant inventory in `STAGE7-10-REPORT.md` session 6): `SignState::sign` is transcribed to Kotlin `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), byte-exact vs the pure-Python model on LCG-seeded synthetic vectors incl. edge inputs, with the .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, no duplicate embedding). `NacValidation.mint(cert79, session250, rngPad130, rand16, body576)` now computes the full 517-B stamp including sig16 internally (assembly-with-sig kept as `mintWithSig`); full-mint e2e test byte-exact vs the python-generated vector. 208 tests green (synthetic vectors only, no Apple bytes).
- Rev 30 (2026-10-08, clean-room RE of the NAC mint — the validation blob "C5" — gdb oracle + python model + Kotlin; method/findings in `registration/dev/clearadi/captures/NAC-NOTES.md`): the 517-B validation stamp is fully derived — stamp = `0x02 ‖ rand16 ‖ sig16 ‖ u32be(0x1e0) ‖ blob480`, blob480 = `05 ‖ u32be(1) ‖ u32be(0x180) ‖ scramble_body(session250, rng_pad130) ‖ u64be(79) ‖ cert79` — with pear_aes (white-box AES variant: 9-round CBC, custom whitening/final maps, pluggable sbox schedules, establish_key T-transform on blocks 1–9), domergestep2 (21/21 live captures) and key_establishment (EstablishKeyResponse wire `tag ‖ key16@1 ‖ u32be len@0x11 ‖ body@0x15 ‖ 85-B trailing`) byte-exact vs three independent live runs, and the full stamp computed byte-exact end-to-end from (cert79, session250, rng_pad130, body576, rand16). SignState::sign (the 16-B OTS) is structurally decoded with stages 1–9 hand-verified; the tail threshold cipher is checkpointed (270 rounds) and CLOSED the same day via an empirical fit against those checkpoints — the capture-free pure-Python model now computes the full 517-B stamp byte-exact from its inputs, with the constant inventory recorded for the Kotlin port (the Kotlin signature port is the next step). Kotlin `PearTables`/`PearAes`/`NacMerge`/`NacValidation`; 206 tests green.
- Rev 29 (2026-10-07, continuation capture — two further live OpenBubbles provisioning runs under gdb, `gdb_prov8/9.gdb`): the cpim's X group is fully pinned — X = SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ a fixed 24-byte .rodata filler constant, digest byte-exact on the wire — closing the X32, 12-B-plaintext-tail (12 fresh RNG bytes, same pool construction as the IV) and trailing[0:16] (the signature input) TODOs; the "constant sig block" reading is corrected (it was a stale session's trailing head) and "sig16/digest not on the wire" is resolved (both ride transitively through X). Kotlin `CpimShaTailSeal` wired as the production default; 195 tests green.
@@ -1,15 +1,28 @@
package dev.fenn.imessage.registration
/**
* Public facade over the internal NAC mint ([NacValidation], spec §1.1 rev 31 — the 517-B
* validation stamp, byte-exact vs the live captures): the consumer-visible mint surface for
* the §1.1 validation-data seam (:engine api-exposes :registration, but internal members
* stay module-private).
* Public facade over the internal NAC mint ([NacValidation], spec §1.1 — the 517-B
* validation stamp): the consumer-visible mint surface for the §1.1 validation-data seam
* (:engine api-exposes :registration, but internal members stay module-private).
*
* Pure math — no transport, no persistence. The mapping from the pinned §1.1 exchange to
* these inputs is NOT established (§1.1 C56 residue; NAC-NOTES.md closes only the math):
* callers must not fabricate cert79/session250/rngPad130/body576/pearKey16 — run the
* exchange and fail loudly at the first unestablished mapping instead.
* Pure math — no transport, no persistence.
*
* 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).
* - 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.
*/
object NacMint {
@@ -25,4 +38,16 @@ object NacMint {
/** [NacValidation.keyEstablishment] — the SDAT-mapped EstablishKeyResponse wire. */
fun keyEstablishment(sessionInfo: ByteArray, pearKey16: ByteArray): ByteArray =
NacValidation.keyEstablishment(sessionInfo, pearKey16)
/** [NacValidation.keyEstablishmentFull] — payload + the response's trailing Vec. */
fun keyEstablishmentFull(sessionInfo: ByteArray, pearKey16: ByteArray): Established =
NacValidation.keyEstablishmentFull(sessionInfo, pearKey16)
}
/** The parsed key-establishment outcome (the ctx fields the mint consumes). */
class Established(
/** The SDAT-mapped EstablishKeyResponse wire (592 B live). */
val payload: ByteArray,
/** The response's trailing Vec (85 B live) — cert79 = [0:0x4f]. */
val trailing: ByteArray,
)
@@ -39,7 +39,7 @@ internal object NacValidation {
/**
* ValidationCtx::sign: the full 517-byte validation stamp. body576 is ValidationCtx
* [0x40:0x2c0] (the sign() body input); sig16 = SignState::sign(body576, rand16, blob480)
* [0x40:0x280] (the sign() body input; wire-verified ×3 live); sig16 = SignState::sign(body576, rand16, blob480)
* with blob480 = the ValidationBody wire built here (NAC-NOTES.md; verified byte-exact,
* STAGE7-10-REPORT.md session 6).
*/
@@ -72,12 +72,14 @@ internal object NacValidation {
* ValidationCtx::key_establishment (0x1792fb0). EstablishResponse wire (698 B live,
* offsets confirmed against the captures): tag u8 | key16 @1 | u32be bodyLen @0x11 |
* body @0x15 (0x250 = 592 B live; decrypt_cbc consumes exactly bodyLen bytes) |
* trailing data Vec (copied verbatim by the binary, unused by the crypto). The iv and
* trailing data Vec (85 B live, copied verbatim into the ctx — NOT unused: its first
* 0x4f bytes are the mint's cert79, wire-verified across three live runs). The iv and
* body are mapped through SKEY (0x2247f6), decrypted with the established round keys,
* and the plaintext is mapped through SDAT (0x2246f6). Returns the SDAT-mapped
* EstablishKeyResponse wire (592 B live; its payload must parse to 0x240 bytes).
* and the plaintext is mapped through SDAT (0x2246f6). The plaintext (592 B live) is
* the EstablishKeyResponse wire: [2:2+0x240] = the payload that becomes the ctx
* body576 region ([0x40:0x280], wire-verified ×3 live); [0:2] = its header.
*/
internal fun keyEstablishment(sessionInfo: ByteArray, pearKey16: ByteArray): ByteArray {
internal fun keyEstablishmentFull(sessionInfo: ByteArray, pearKey16: ByteArray): Established {
require(pearKey16.size == 16) { "key_establishment: pear key must be 16 bytes" }
require(sessionInfo.size >= 21) { "key_establishment: truncated EstablishResponse" }
val key16 = sessionInfo.copyOfRange(1, 17)
@@ -90,9 +92,13 @@ internal object NacValidation {
val pt = PearAes.decryptCbc(ct, iv, rk)
val sdat = PearTables.sdat
for (i in pt.indices) pt[i] = sdat[pt[i].toInt() and 0xff]
return pt
val trailing = sessionInfo.copyOfRange(21 + bodyLen, sessionInfo.size)
return Established(pt, trailing)
}
internal fun keyEstablishment(sessionInfo: ByteArray, pearKey16: ByteArray): ByteArray =
keyEstablishmentFull(sessionInfo, pearKey16).payload
private fun u32be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xff) shl 24) or ((b[off + 1].toInt() and 0xff) shl 16) or
((b[off + 2].toInt() and 0xff) shl 8) or (b[off + 3].toInt() and 0xff)
@@ -85,4 +85,22 @@ class NacValidationTest {
assertContentEquals(expected, pt)
assertEquals(592, pt.size)
}
@Test
fun keyEstablishmentFullSplitsPayloadAndTrailing() {
// the full variant (2026-10-08 live-run mapping): payload = the SDAT-mapped
// EstablishKeyResponse wire; trailing = the response's verbatim tail (85 B live)
// whose first 0x4f bytes are the mint's cert79
val pearKey = hex("aebfc2884fd1e6ffdc60273be5756ff5")
val sessionInfo = hex("021b0e89cab86b89c2911da5bbf6f475e3000002502feb2054e4786cfb482f083fb085cb87e0906991e2003bb2a45b65ecf9764d2c39bafe46cdd93600af6d1af6f27999fb275eacc6b6663b075be0bee9fc04e4018899a0e028954cd0f74a51d3c353a1b711ec431974926847dc715d71918f43d424a0c8ea663034699e704306c3c25ab7fa76e3e99a8944a6c1916204bb923e41218232ccc2d4df2658f96bad8f1e4e8e41c20a159de8485e1ec398ea86d1ef9f036d3c92d103500f9be66718c503c18d6cf724909a7e29da398a6b360b56511c034a437483612b5ee1f1fcdb582e0e9a6de81e306542beac6e96c89faa1eb43324b7051598bac4c7e2542542a0bfd6704152a187fe61623b0fdd9ec67105b22190c4b06b68c66a1ddd972ef6e23b269e128a21d9f36c0f0ca1422f4ea90bb1b7e6c5f32d524bd38beaffe99af5ad573fe7a07f3f20094adb6aa281b95e3a20fd6b7489c8d231d94287165e425de90ce4bf0a21cf523146fb0e1dac3c942eb40f75db11b3edd853975f37851adcb8883eac8952d8ad8232d17910ffcd8524167d7c14034b19e3c4a28266d45569384c5d2591333b46a542981ef4ed16e57979170276e079efecf83ec517b12ffa8cf6968d0d7ffb4c15422a6ca034d93096311bfdb724ea37155a2b8923d2bd8eb270b378f528ffd694c0a822bfe1c3016853c6474bada51f519e440f68df4589b377e36f19f839b8fa4965836d75135a5fc7dde8e083652a692cf0e2236e23044e20703d46ac3535ad2e084289c94c4e46c2220fd53e72d5ecba1baab23063544b03c01fb37160e2f2b7878ca58e55944f879c8e5a3feb6ae035cd16a4ec555c28fd73f5aa154e8aa0ce47fb96a0bc18f382bf7101638d563bfe2bd7a7d7ebc4284087ad43556ee2b6ba3973ea044d300d79d2a96dafe22e5fd1b1cfbab9cc5ccc19cd653b689b3a409882eb3441654852a593e128262806")
val full = NacValidation.keyEstablishmentFull(sessionInfo, pearKey)
assertContentEquals(NacValidation.keyEstablishment(sessionInfo, pearKey), full.payload)
assertEquals(85, full.trailing.size)
assertContentEquals(
sessionInfo.copyOfRange(sessionInfo.size - 85, sessionInfo.size),
full.trailing,
)
// body576 = payload[2:578] (the ctx [0x40:0x280] region, wire-verified ×3 live)
assertEquals(592, full.payload.size)
}
}