Files
open-madrid/docs/imessage-protocol-spec.md
T
fenleon 543178fc2a Spec rev 38: the step-2 5003/EM gate is a TLS-stack fingerprint (rustls accepted, OpenSSL rejected)
The user's 2FA re-login re-fired the app's own IDS registration; gdb traps
showed the full mint chain ACCEPTED live (key_establishment + sign after a
successful step-2 parse, x3). Fresh-genuine-blob transport matrix: reqwest
0.11 + rustls 0.21 (+ the vendored baked Apple root DER) gets a real
session-info response on the identical XML-plist envelope; curl-OpenSSL over
h2 and h1.1, and curl with UA/Accept stripped, gets 5003/EM on the same
blobs. HTTP version and headers are irrelevant — the VEN endpoint
fingerprints the TLS ClientHello.

Kotlin blob/framing fully vindicated; the tool's :server needs a
rustls-shaped handshake (implementation decision open).
2026-10-12 21:37:40 +02:00

217 KiB
Raw Blame History

iMessage protocol spec (clean-room)

Status: draft — revision 36. The full revision history is at the bottom (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.

Citation legend: [APS-iMessage] Apple Platform Security, iMessage security overview · [APS-CKV] Apple Platform Security, Contact Key Verification · [APS-Send] Apple Platform Security, How iMessage sends and receives messages securely · [PQ3] Apple Security Research, iMessage with PQ3 · [JJ] jjtech.dev, iMessage explained · [BM] Beeper blog, How Beeper Mini Works · [BA] Beeper blog, How Beeper Android Works · [OB-FAQ]/[OB-Renewal]/[OB-Hosted]/[OB-PNR]/[OB-QS]/[OB-MHI] OpenBubbles docs · [CF] Apple's publicly documented binary property-list container format (Core Foundation; public Apple material — not a restricted source) · [CAP-REG]/[CAP-COURIER] own mitmproxy captures of a registered iPhone 14,4, iOS 26.1 (2026-09-10; captures on file, not published) — ground truth, post-PQ3 · [CAP-IDSTLS]/[CAP-BAGCT] direct unauthenticated probes from this machine (2026-09-10: openssl s_client TLS captures; byte-exact bag bodies with response headers) · [RP:file] rustpush source (SSPL, facts-only) · [APA:file] apple-private-apis source (MPL 2.0, facts-only) · [IG:file] imessagego source (AGPL, facts-only) · [OA] OpenAbsinthe-Stub public API surface (official mock of a closed-source implementation; call sites in rustpush) — facts-only · [MHG:file] the Mac-Hardware-Info helper's public upstream repo (github.com/OpenBubbles/Mac-Hardware-Info; unlicensed upstream — facts-only, no code reuse). Bare [RP]/[IG] = fact from that repo with the source file not recorded in the extraction notes. [RP-issue-21] = rustpush GitHub issue #21.

Provenance notes: "NGM" (next-generation-message) is research terminology, not a wire string. Two §4.3 strings — the HKDF salt "LastPawn-MessageKeys" (rev-12 correction: it is the HKDF salt, with empty info — not an info string, as earlier recorded) and the fixed ASCII prekey-signature label — are interop-required protocol constants that are not directly wire-observable; both independent sources agree on them, and capture arbitration is trivial (a wrong string fails decryption or signature validation). APNs-bag constant names (§3, §7 C18/C20) and Apple private-framework symbol names (e.g. NACKeyEstablishment) are Apple-published/observable data, not source expression.

1. Registration & activation

1.1 What validation data is and what it requires

  • Registration with IDS requires a binary blob, "validation data", that proves the client is a genuine Apple device; it is produced from device information including serial number, model, and disk UUID [JJ], also described as machine identifiers: serial number, board ID, and model [OB-FAQ], [OB-Renewal]. The signing context for producing it needs product name, OS build number, platform serial number, logic-board serial (MLB), and a ROM value [RP:macos.rs].
  • Validation data is only valid for 15 minutes after generation [OB-Hosted]. No local 15-minute-validity enforcement is visible in the extraction sources — if the bound is enforced anywhere, it is server-side [RP:macos.rs], [OA].
  • Apple's own generator binary is heavily obfuscated; third-party clients sidestep it by emulating the binary with device properties (serial number etc.) fed in from a plist [JJ], or by fetching a ready-made blob from a trusted real Mac over an authenticated HTTP bridge [RP:relay.rs] or from a "nacserv"-style provider that returns the blob plus device version metadata and a validity timestamp [IG:nacserv].
  • Not all validation data is equivalent: account age and a score determine whether a non-genuine serial is accepted or rejected with the "customer code" error (Hackintosh-analogous) [JJ].
  • Using identifiers derived from a virtual machine is explicitly discouraged: Apple can detect VM-derived identifiers and re-registering with them risks an account ban [OB-FAQ]. One third-party client's defaults present as Mac mini hardware, macOS 13.5, build 22G74 [IG:config.go].
  • For the real-Mac path, validation data is produced by a three-step exchange with Apple: download a certificate from the id-validation-cert bag URL; POST a session-info request to id-initialize-validation; then run key establishment with the returned session info and produce a signed blob [RP:macos.rs]. Extraction detail (2026-09-10): step 1's response is a plist with a single cert data field; step 2 POSTs a single-key body session-info-request (data) with no anisette or authentication headers — body only — and receives session-info (data) back [RP:macos.rs], [RP:auth.rs]. — 2026-09-11 (rev 19): the step-2 request construction is recorded at the same level of detail — the body is serialized as an XML plist and posted with NO content-type header, from an HTTP client whose only default request header is Accept-Language: en-US,en;q=0.9 [RP:util.rs], [RP:macos.rs]. A header-less step-1 GET succeeds because the id-validation-cert URL resolves to a CDN static object (a genuine device's capture of that same GET shows static.ess.apple.com, plain HTTP, X-Cache: hit-fresh, a 2021 Last-Modified, application/octet-stream, 3474 bytes), so step 1's header-less success is NOT evidence that the endpoints accept header-less requests (§7 item 24). The hardware configuration the context consumes carries product name, OS build number, platform serial number, platform UUID, root-disk UUID, board ID, MAC address (6 bytes), ROM (bytes), and MLB (string), plus pre-computed obfuscated "enc" variants of serial/platform-UUID/root-disk-UUID/ROM/MLB keyed by five fixed label strings (Gq3489ugfi, Fyp98tpgj, kbjfrfpoJU, oycqAZloTNDm, abKPld1EcMni); the derivation producing the enc variants is closed source — TODO(capture): → C57. The blob is parseable back into the hardware fields (round-trip constructor in the mock's API surface) [OA], [RP:macos.rs].
  • 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). — 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

  • Public write-ups name albert.apple.com as the activation service that sets up the "push" credentials (including the certificate needed for APNs) [BM], [JJ], and identity.ess.apple.com as the service that registers the account with iMessage and generates the "identity" credentials used to encrypt/decrypt [BM]. Exact paths in the sources: device activation at https://albert.apple.com/deviceservices/deviceActivation?device=MacOS [RP:activation.rs], [IG:pushcert]; IDS registration at https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register [IG:register]. All IDS endpoint URLs are resolved from a plist "bag" fetched at runtime (getBag?ix=3 at init.ess.apple.com), which maps bag keys (id-register, id-query, id-authenticate-ds-id, id-authenticate-phone-number, id-authenticate-multiple-users, id-validation-cert, id-initialize-validation, …) to URLs [RP:util.rs]; a parallel bag at init-p01st.push.apple.com/bag serves APNs settings [RP:util.rs], [IG:courier].

  • Bag shape and fetching (second-extraction facts): the live bag is fetched over GET from https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3; the response body is a plist dictionary with a single bag key whose value is embedded plist data — those bytes are themselves a plist parsed into a flat bag-key → URL dictionary [RP:util.rs:57-81]. The fetch is cached for the process lifetime keyed by bag URL; there is no TTL or refresh, and a missing key inside the parsed bag is its own error [RP:util.rs:59-81]. The APNs bag is fetched over plain HTTP from http://init-p01st.push.apple.com/bag and resolves integer APNSCourierHostcount and string APNSCourierHostname [RP:util.rs:56], [RP:aps.rs:1223-1224].

  • The second implementation never fetches a bag at runtime: all endpoint URLs are compile-time pinned, and the bag fetch URL is only mentioned as the manual refresh source. Its pinned URLs: DS-ID authentication at https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS, phone-number authentication at https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber, dependent registrations at .../TDIdentityService.woa/wa/getDependentRegistrations, handles at .../VCProfileService.woa/wa/idsGetHandles, lookup at https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query (used only inside the APNs HTTP tunnel, never plain HTTPS), registration at https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register [IG:ids/bag.go:19-28], [IG:ids/urls.go:19-23], [IG:lookup.go:519-527]. It has no support at all for the alias-provisioning, spam-report, validation-certificate, validation-initialization, or multi-user phone-auth endpoints [IG:ids/bag.go, repo-wide].

  • IDS HTTPS requests in both implementations use a custom TLS trust pool containing only embedded Apple root certificates, not the system store [RP:util.rs:208-242], [IG:util/appletls/util.go]. — 2026-09-10 [CAP-BAG]: the live bag was fetched directly with an unauthenticated GET https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3 — no auth, no anisette; the response is a plist with signature, certs, and bag keys, the inner bag a flat dictionary of 405 entries (full response archived on file as ids-bag-2026-09-10.xml). This closes the live-bag-contents half of C1 without an instrumented client. Notably the endpoint presented a TLS chain the local system store rejected (consistent with the sources' Apple-root-only pinning). Every id-* URL that either source pins (id-register, id-query, id-authenticate-ds-id, id-authenticate-phone-number, id-authenticate-multiple-users, id-get-dependent-registrations, id-get-handles, id-validation-cert, id-initialize-validation, …) matches the live bag — no drift. The bag also carries the scalar id-query-refresh-queued-query-interval: 28800 (8 h, queued-refresh interval). TODO(capture): C47 — the concrete plist encoding of both bag responses, and of the embedded bag payload those responses carry, is not stated in this fact set (the archives are named .xml, and that is the whole of the evidence here); neither is the response content type. — 2026-09-10 [CAP-BAGCT]: the encoding half of C47 is settled. GET https://init.ess.apple.com/WebObjects/VCPermsService.woa/wa/getBag?ix=3 (the VCPermsService.woa app also serves the bag — [CAP-BAG] had used VCInit.woa) returned Content-Type: text/xml, 94,859 bytes, an XML plist, top-level keys signature/certs/bag; the embedded bag value is base64 of an XML plist (decodes to <?xml version="1.0"…<plist version="1.0"><dict>…). The APNs bag (GET http://init-p01st.push.apple.com/bag) returned Content-Type: application/x-apple-plist but the body is still an XML plist, 10,675 bytes, byte-identical across two same-day fetches. Verdict: both bag responses and the embedded bag payload are XML plists in every observed case — no binary plist on the bag wire; the content-type labels are unreliable (text/xml vs application/x-apple-plist with XML bodies) and parsing goes by content, not label. Also [CAP-IDSTLS]: openssl s_client with bare-hostname SNI to init.ess.apple.com:443 and query.ess.apple.com:443 — init serves leaf CN=init.ess.apple.com ← Apple Server Authentication CA 2 ← Apple Root CA - G3; query serves leaf CN=query.ess.apple.com ← Apple Server Authentication CA (the older CA) ← Apple Root CA; neither root is served, and the unanchored chain is rejected by the system store (OpenSSL verify code 20; curl error 60 with the system bundle) — matching the Apple-root-only pool of the sources. Anchor set to embed: Apple Root CA - G3 and Apple Root CA — Apple roots, distinct from the publicly-trusted Comodo/AAA root the courier chain needs ([CAP-COURIERTLS], §3.1); pin roots only, never the leaf. Both root DERs are committed as ids/src/main/resources/imessage/AppleRootCA-G3.cer and AppleRootCA.cer (G3 from Apple's live CA page; the G1 Apple Root CA is no longer published by Apple — sourced from the public CT log, crt.sh id 1160190, SHA-256 B0:B1:73:0E:CB:C7:FF:45:05:14:2C:49:F1:29:5E:6E:DA:6B:CA:ED:7E:2C:68:C5:BE:91:B5:A1:10:01:F0:24; G3 SHA-256 63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79) and each captured intermediate openssl-verifies against its root.

  • The Albert activation request is a form post containing an activation-info plist (activation randomness, activation state, build version, a DER certificate request, device class "MacOS", product type, product version, serial number, and a unique device ID) plus a FairPlay certificate chain and a FairPlay signature over the activation-info bytes [RP:activation.rs]. The certificate request is generated over a fresh 1024-bit RSA key with a "Client Push Certificate" common name [RP:activation.rs]; a second implementation agrees on the 1024-bit RSA push key (2048 also works) and notes a generator-version field [IG:pushcert]. The response embeds a plist whose activation record carries the device certificate (plus chain) [RP:activation.rs], [IG:pushcert]. TODO(capture): C8 — the exact activation-info field set and FairPlay certificate choice a genuine current client sends (one source reports signing with one of ten bundled FairPlay keys [RP:activation.rs]). — 2026-09-10: the wire half of C8 is closed by facts-only extraction (both source repos). The request is a POST (form-urlencoded) with a single form key activation-info whose value is an XML plist carrying ActivationInfoComplete (true), ActivationInfoXML (a nested XML plist), FairPlayCertChain, and FairPlaySignature; no timestamp is sent in the request [RP:activation.rs], [IG:activationinfo.go]. Nested activation-info keys: ActivationRandomness (uppercase UUIDv4), ActivationState ("Unactivated"), BuildVersion, DeviceCertRequest (PEM CSR), DeviceClass, ProductType, ProductVersion, SerialNumber, UniqueDeviceID. The CSR is 1024-bit RSA, CN "Client Push Certificate", OU "iPhone", O "Apple Inc.", signed SHA-1WithRSA. FairPlay signing covers the serialized ActivationInfo plist (SHA-1, RSA PKCS1v15) and yields the (chain, signature) pair placed into the activation body; both sources embed FairPlay identities (Go: two embedded files; Rust: ten certificate/key pairs, one chosen at random per activation — provenance unrecorded, → C57) [IG:fairplay.go], [RP:activation.rs]. The response's outer XML <Protocol> block is plist-parsed: device-activation → activation-record → DeviceCertificate (PEM), plus ack-received and show-settings — only the device certificate returns; the private key is client-generated and never sent, and no device token appears in the response [IG:pushcert], [RP:activation.rs]. Courier link: the DeviceCertificate (with the client private key) becomes connect field 12 (§3.3), and the connect nonce is signed with that key — Go: a 20-byte random nonce with the first byte zeroed; Rust: 0x00 ‖ big-endian unix-milliseconds ‖ 8 random bytes; signature = 0x01 0x01 ‖ RSA-SHA1(nonce) (both) [IG:connection.go], [RP:aps.rs] (nonce-shape tension with §3.3's recorded agreement — C57). — 2026-10-02/03 (rev 26, live): three request/response refinements. (1) DeviceCertRequest rides as a plist <data> whose content is base64 of the PEM CSR TEXT bytes — a DER-encoded CSR is rejected server-side (the SIGNATURE_VERIFICATION_FAILED page; the earlier "DER <data>" reading was dead, session 12). (2) The accepted FairPlayCertChain is THREE DER certificates concatenated — leaf + FairPlay CA + Apple Root CA (the two CA links captured in the accepted Android request); leaf-only and leaf+CA were refused. (3) The activation response may wrap the <Protocol> block inside a larger document (<Document><Protocol>…</Protocol><ScrollView>… whose UI XML breaks a whole-document parse, live 2026-10-02) — a client extracts the <Protocol> span and parses it alone; a bare <Protocol> document parses as before. Refusals (no DeviceCertificate) carry the reason in wrapped-UI page text, not in a structured field.

  • Account login requires an authentication token from Apple ID username and password. With 2FA there are two retrofitted options: legacy (2FA code appended to the password) and GrandSlam, where "anisette data" proves device identity so no repeated 2FA entry is needed; the result is a Password Equivalent Token (PET) usable in place of password+2FA [JJ]. GrandSlam login uses an SRP-6a variant (3072-bit group) [APA:client], [IG:appleid]; the Apple ID login side uses idmsa.apple.com endpoints with session, scnt, and client-info headers [IG:appleid]. The iCloud setup login (setup.icloud.com) posts a delegates map and yields per-delegate service data; the IDS delegate yields an auth token and a profile id [RP:auth.rs].

  • The short-lived authentication token must be exchanged for a longer-lived authentication certificate; this certificate allows IDS registration but is not sufficient for key lookups [JJ].

  • IDS authentication certificates: for Apple-ID users the CSR is a fresh 2048-bit RSA key whose common name is a SHA-1 of the realm user id, submitted to the id-authenticate-ds-id bag URL [RP:auth.rs] (pinned URL variant: profile.ess.apple.com authenticateDS [IG:bag]); for phone-number users the realm id has the form "P:" against id-authenticate-phone-number [RP:auth.rs] (pinned variant: identity.ess.apple.com authenticatePhoneNumber [IG:bag]). A SIM-less phone route (id-authenticate-multiple-users) carries a per-request carrier EAP-AKA signature produced through the carrier's entitlement server, with the APNs connect token as client nonce [RP:auth.rs], [RP:util.rs]. TODO(capture): C7 — the carrier entitlement exchange per carrier (request/response shape varies by carrier). TODO(capture): C54 — the authenticate exchange's unrecorded wire shape: the request body's plist keys, the CSR's encoding ("CSR" is the recorded term; nothing states PKCS#10 or another form) and its self-signature algorithm, the encoding of the CN's SHA-1 (raw digest vs hex vs base64), whether the §1.4 signature header sets are carried, and the response's wire keys (the short-lived token, the longer-lived authentication certificate, and their relationship). — 2026-09-10: C54 is closed by facts-only extraction (both source repos), with three disagreements tagged for capture (C57). Endpoints: bag keys id-authenticate-ds-id / id-authenticate-phone-number, one authentication per user; Rust resolves them from the bag, Go pins the full URLs — a recorded client-design disagreement (bag resolution vs pinned URLs), not a wire fact; Rust also has id-authenticate-multiple-users for the SMS-less flow (recorded above) [RP:ids/user.rs], [IG:bag.go]. Request body keys (agreed): authentication-data, csr, realm-user-id; the CSR is DER (both). authentication-data holds auth-token for the Apple/DS flow and push-token + sigs (array) for the phone flow. The SMS-less variant body (Rust only): csr, sig, tag, user-id. Self-signature over the CSR DISAGREES: Rust SHA-1 with RSA/PKCS1, Go SHA256WithRSA — TODO(capture): (C57). CN encoding: a hex STRING of the SHA-1 digest of the realm-user-id (not raw, not base64); Rust uppercases the hex, Go lowercases — case-only disagreement, TODO(capture): (C57). The §1.4 signature header sets are NOT applied to authenticate in either repo; the headers sent are only user-agent and x-protocol-version (Rust adds content-encoding: gzip) [RP:auth.rs], [IG:authdevice.go]. Response keys: status, cert — flat, no per-user nesting (both); the multiple-users variant returns an authentication_responses array with per-entry cert, user-id, status (Rust only) [RP:auth.rs], [IG:authdevice.go].

  • FairPlay activation material (2026-09-11, rev 17 — facts-only metadata extraction over the reference repos plus one public community file): both implementations embed the SAME single identity — imessagego's two files and rustpush's certs/legacy-fairplay/ are byte-identical (SHA-256 bc18fc4d… for the leaf cert; same RSA modulus for both key containers), and rustpush's recorded "ten pairs, one chosen at random" are an illusion: upstream gitignores the real certs/fairplay/ directory and its CI fabricates the ten names by copying one "legacy" pair. All copies trace to a 2023 iCloud/SIM activation-bypass community release ("Albert iTunes Bug", albertsimlockapple PHP file, claimed MIT license) — the material was NOT extracted by either project's authors. The identity: one 1024-bit RSA keypair + its leaf certificate (single DER cert, not a chain — issuer Apple iPhone Device CA, subject CN a UUID-form donor-device identifier, validity 2021-10-11 → 2024-10-11, EXPIRED yet activations still succeed → albert evidently does not validate this chain's expiry). FairPlaySignature = RSA-SHA1 PKCS#1v15 over the exact serialized ActivationInfoXML bytes, signed by the embedded private key; FairPlayCertChain = that leaf cert alone (Apple's server fills in the rest). No per-account binding, no refresh/rotation path, unlimited activations per identity in the sources; rustpush ships an unmerged "dummy-fairplay" escape hatch for contributors lacking the material. NO source documents a legitimate own-Mac extraction path — where macOS stores the per-device FairPlay activation identity is an OPEN research item (distinct from the activation response's DeviceCertificate, which is the client-generated push-key cert, not FairPlay input).

  • Capture ground truth [CAP-REG]: on a registered iPhone (iOS 26.1), the single iMessage-relevant artifact visible to a plain HTTP(S)-proxy capture is a plain-HTTP fetch of http://static.ess.apple.com:80/identity/validation/cert-1.0.plist by the invitation-registration daemon (user agent com.apple.invitation-registration [iPhone OS,26.1,23B85,iPhone14,4]). The plist carries an MD5 in an XML comment (certificate-production.cer md5=f6e148c97bfbc9867e19c2eeadbab534) — clients apparently MD5-check the payload and cache it — and a cert blob consisting of a 6-byte header (01 02 00 00 04 16) followed by a DER certificate: an "Apple System Integration Certification Authority" intermediate issued by Apple Root CA, valid 2011-01-26 to 2019-01-26 — expired, yet still served and still fetched by current iOS. This is the same validation-certificate family as the id-validation-cert bag key above, but it is not the validation data itself (§1.1). The pinned hosts (identity.ess, albert) produce no flows at all through the proxy — their daemons bypass it — so C1 stays open, and the validation-data exchange (C5/C6) remains uncaptured.

1.3 Phone and email handles

  • Phone-number registration without an Apple ID works via an SMS to Apple's "Gateway" service; the carrier-specific gateway responds by SMS and the response contents are submitted to Apple to register the number as iMessage-capable [BM]. An Apple ID login is optional for phone-number-only operation [BM]. At the Apple-account level, phone numbers are verified via the carrier network and SIM, on some networks by SMS with a confirmation dialog [APS-iMessage]. Registering an Apple ID and a phone number "together in the same call" links the handles so all devices on the Apple ID can use both [BM]. Additional email addresses are added by verifying a confirmation link, and all of the user's registered devices show an alert when a new device, phone number, or email address is added [APS-iMessage].

1.4 IDS registration body and response

  • Registration POSTs to the id-register bag URL [RP:ids/user.rs] (pinned URL: identity.ess register path [IG:register]). The body carries: device name, hardware version, language, OS version, software version, private-device-data (dictionary), a services array, a users array, and the validation-data blob [RP:ids/user.rs], [IG:register]. Encoding (second-extraction refinement): both implementations serialize the body as an XML plist; the disagreement is compression only — one gzips the XML and sends content-encoding: gzip plus accept-encoding: gzip [RP:ids/user.rs:1112-1115], the other sends the raw XML with no content-type and no content-encoding header at all [IG:register.go:366-375]. TODO(capture): C3 — whether the live server accepts both encodings or one is stale; current sub-service list and capability flags (below). Wire key names (extraction, agreed — the request has exactly eight top-level keys, none beyond): device-name, hardware-version, language, os-version, software-version, private-device-data (dict), services (array), validation-data (data) [RP:ids/user.rs], [IG:register]. Services-entry keys: capabilities (array), service, sub-services, users; capability-record keys: flags (int), name (string), version (int; both sources set 1 — Go's record is flags 1, name "Messenger") [RP:ids/user.rs], [IG:register].
  • private-device-data (second-extraction refinement — exact wire keys, agreed): ap "0", d = seconds since the Apple epoch (2001-01-01) as a decimal string, dt = integer 1, gt "0", h "1", m "0", p "0", pb = OS build string, pn = platform name, pv = OS version, s "0", t "0", u = device UUID (uppercase string), v "1"; all values are strings except the dt integer [RP:macos.rs:157-178], [IG:register.go:325-344]. The extraction confirms the full sub-key set as the eighteen keys above (ap through v, including the four both sources model but omit) [IG:register]. The relay/phone profile sets m and s to "1" instead [RP:relay.rs:189-225]. Both sources also model four further keys (c, ec, ktf, ktv) but deliberately omit them from the built request [RP:macos.rs:159-177], [IG:register.go:57-82,331-332].
  • The services array registers com.apple.madrid with a sub-services list and one capability record named "Messenger" with version 1 — but the flags value is disagreed: 17 [RP:imessage/aps_client.rs:74] vs 1 [IG:register.go:347]. The sub-service lists also disagree: four topics (com.apple.private.alloy.sms, .gelato, .biz, .gamecenter.imessage) [RP:imessage/aps_client.rs:19-24] vs seven (those four plus .safetymonitor, .safetymonitor.ownaccount, .askto) [IG:register.go:352-360], [IG:apns/topic.go:24-33]. The first source additionally registers non-iMessage services in the same array when enabled: FaceTime under com.apple.ess and com.apple.private.alloy.facetime.* (capability name "Invitation") and Find My under com.apple.private.alloy.multiplex1 (capability name "com.apple.private.alloy") [RP:facetime.rs:26-56], [RP:findmy.rs:26-45]. TODO(capture): C3 — current sub-service list and flags.
  • Per user entry: client-data (public identity key structure with version 2, optional NGM/EC key data with ec-version 1 and NGM version 13, a signed prekey record, key-transparency data with kt-version 5, plus boolean capability flags), kt-loggable-data, uris, user-id, and a tag for phone profiles [RP:ids/user.rs], [IG:register]. tag is "SIM" for the first phone user and "SIM2", "SIM3", … for subsequent ones [RP:ids/user.rs:1078-1081]; the other source tags any phone-profile user "SIM" [IG:register.go:233-236]. uris entries are single-key dictionaries of handle URIs with tel: (E.164) or mailto: schemes — and neither implementation constructs them locally: both fetch the registered handle list from the server first and echo it back into the register body [IG:util/uri/uri.go:61-64,177-191], [RP:ids/user.rs:839-843,1040-1042], [IG:ids/user.go:132-163]. kt-loggable-data is a protobuf blob carrying the compressed NGM device key and the NGM (13) and key-transparency (5) versions [RP:ids/user.rs:1048-1054], [IG:ids/user.go:279-292]. C53 wire names (extraction, agreed): per-user keys are client-data (dict), uris (array), user-id, kt-loggable-data (data; optional in Go, always set in Rust when publishing keys), plus tag only for phone-number users — value SIM/SIM<N> (Go tags "SIM" when the user-id starts with P:). Register-request uris entries are single-key dictionaries with key uri (both). client-data base wire keys (Rust): public-message-identity-key, public-message-identity-version, ec-version, public-message-identity-ngm-version, public-message-ngm-device-prekey-data-key, kt-version, plus per-service extras; Go adds the same six plus the boolean supports-* feature flags and nicknames-version/optionally-receive-typing-indicators/prefers-sdr/show-peer-errors recorded above [RP:ids/user.rs], [IG:register].
  • The capability booleans in client-data (wire key names; the union across both sources, all true unless noted): optionally-receive-typing-indicators, show-peer-errors, supports-ack-v1, supports-audio-messaging-v2, supports-autoloopvideo-v1, supports-be-v1, supports-ca-v1, supports-cross-platform-sharing, supports-fsm-v1/v2/v3, supports-ii-v1, supports-impact-v1, supports-inline-attachments, supports-keep-receipts, supports-location-sharing, supports-media-v2, supports-original-timestamp-v1, supports-people-request-messages (+ -v2, -v3), supports-photos-extension-v1/v2, supports-protobuf-payload-data-v2, supports-rem, supports-sa-v1, supports-st-v1, supports-update-attachments-v1, supports-hdr, supports-heif, supports-dq-nr, prefers-sdr (false), nicknames-version 1 [RP:imessage/aps_client.rs:25-73], [IG:register.go:240-299]. Each source sends a further set the other omits — first source only: is-c2k-equipment, supports-activity-sharing-v1, supports-shared-exp, supports-family-invite-message-bubble, supports-live-delivery, supports-stick-moji-backs, supports-emoji-tapbacks, supports-send-later-messages, supports-certified-delivery-v1, supports-transcript-backgrounds, supports-gti, supports-polls [RP:imessage/aps_client.rs:26-72]; second source only: supports-askto, supports-emoji-stickers, supports-findmy-plugin-messages, supports-sticker-editing, supports-recurring-payment-bubbles [IG:register.go:247-274]. Type discrepancy: the first source encodes the versions as plist real values, the second as plist integers [RP:imessage/aps_client.rs:51-52], [IG:register.go:241,309].
  • The identity keys themselves: an RSA-1280 encryption key plus a P-256 signing key for the legacy scheme, plus a P-256 device key and a P-256 pre-key for the newer EC scheme [RP:ids/user.rs].
  • The request is signed once with the push key and once per user with that user's auth key, using indexed header names [RP:auth.rs], [IG:sign]. Second-extraction correction: the two sources agree on the construction (rev 2 recorded a disagreement based on a misreading of a wrapper function's name — the "wrap" is plain concatenation, verified in source): the nonce is 17 bytes — one type byte (0x01 for HTTPS requests; 0x00 exists for APNs use), an 8-byte big-endian unix-milliseconds timestamp, 8 random bytes [RP:auth.rs:685-700], [IG:util/nonce/nonce.go:25-42]; the signing payload is that nonce followed by a big-endian u32 length-prefixed concatenation of (bag key string, query string — empty for register, request body, raw push token) [RP:auth.rs:702-708], [IG:ids/sign.go:35-46]; the signature is RSA-SHA1-PKCS1v1.5 over SHA-1 of (nonce ‖ payload), output prefixed with the two bytes 0x01 0x01, then base64 [RP:auth.rs:710-714], [IG:ids/sign.go:54-59,89-95]. Header wire names: push set x-push-nonce/x-push-cert/x-push-sig (plus x-push-token carrying the base64 push token), per-user indexed sets x-auth-user-id-<N>/x-auth-nonce-<N>/x-auth-cert-<N>/x-auth-sig-<N> starting at N=0 [RP:ids/user.rs:1108-1121], [IG:ids/sign.go:48-96]; lookups use an x-id-nonce/x-id-cert/x-id-sig variant instead of per-user auth headers [IG:ids/sign.go:63-79]. Extraction: the x-auth-user-id-<N> value is the plain user-id string (the profile ID; phone users prefixed P:), N is the zero-based user index, and the header is register-only (agreed) [RP:ids/user.rs], [IG:sign]. TODO(capture): C4 — confirm server acceptance of this construction against a real device (source-side structure is settled).
  • The request carries a push-token header (base64 of the 32-byte APNs connect token) [RP:ids/user.rs]. Protocol-version header values: "1640" hardcoded in one source [IG:ids/user.go:44]; the other takes it from config, with "1660" in its test fixtures [RP:test.rs:303]. User agent: com.apple.invitation-registration [<name>,<version>,<build>,<hardware>] in both [RP:macos.rs:77-79], [IG:ids/config.go:89-92]. Content type on register: application/x-apple-plist (one source; the other sends none) [RP:ids/user.rs:1112], [IG:register.go:370-375]. TODO(capture): C2 — current protocol-version value.
  • The response contains a top-level status (0 = ok) and message string, a per-service breakdown (status, users), and per user: status, the X.509 identity certificate (DER) to store, the registered URIs with per-URI statuses, an optional unsigned-integer next-hbi (heartbeat interval in seconds; parsed as absent when missing, with no fallback at parse time [RP:ids/user.rs:1160-1180]), an optional alert (title/body/button plus an action object with button/type/url) shown on certain errors, and a top-level retry-interval integer that the second source models but never reads [RP:ids/user.rs:1127-1183], [IG:register.go:110-144,429-450]. Notable status codes: 6001 incompatible (Contact Key Verification or Advanced Data Protection enabled), 6004 transient retry, 6005 bad authentication, 6009 iMessage access disabled, 5052 alias removed [RP:ids/user.rs], [RP:identity_manager]. Response wire keys (extraction): top level status, message, retry-interval, services — Go names all four; Rust reads only status and services (disagreement on which exist/are read — TODO(capture):, C57). Per-service entries nest under services, each with service, users, status; per-user keys uris, user-id, cert, status, alert; per-URI entries uri, status; Rust additionally reads an optional per-user next-hbi (absent in Go's model, recorded above). alert keys (Go): body, button, title, nested action with button, type, url; Rust models only title, body, and optional action.url. — 2026-09-10: C53 closed by facts-only extraction (both source repos); the "refresh credentials" marker name (§6.1) remains the one capture-bound half, and the two disagreements above join C57.
  • Certificates and authentication keys are generated and stored locally on the client and never touch third-party relay servers; with Apple ID login the third-party app appears as a trusted device on the account [OB-Hosted].
  • OpenBubbles' hosted/relay model: one-time access to a Mac (or an always-online iPhone) generates activation codes via a QR generator; the Mac need not stay online afterward and never receives the Apple Account keys [OB-QS], [OB-FAQ]. One Mac's identifiers can be shared with up to 20 users; a "prevent sharing" mode makes codes single-use, 48-hour-expiring, and non-transferable [OB-FAQ].

1.5 The Apple-Account (GSA) login chain

  • Facts-only extraction (2026-09-11, rev 14) over the GSA-auth crate and the Mac client's auth flow. Endpoints: the two SRP steps both POST https://gsa.apple.com/grandslam/GsService2 with Content-Type: text/x-xml-plist; the body is an XML plist with top-level Header (only Version = 1.0.1) and Request. Init request keys: A2k (client SRP public value, plist data), cpd (a client-provided-data dictionary, below), o = init, ps = [s2k, s2k_fo], u = username. Init response (under Response): s (salt, data), B (server public value, data), i (iteration count, integer), c (challenge string), optional sp (selected protocol, default s2k), Status (dict with integer ec, 0 = ok, and string em). Complete request keys: M1 (proof, data), c, cpd, o = complete, u. Complete response: M2 (server proof, data), spd (encrypted plist data), Status whose au string selects secondary auth: secondaryAuth (SMS/trusted-phone) or trustedDeviceSecondaryAuth [APA:icloud-auth].
  • SRP-6a variant (byte-exact): 2048-bit group, g = 2, SHA-256 throughout. Password key: SHA-256 of the raw password; for s2k_fo that digest is ASCII-lowercase-hex-encoded (64 chars) and those bytes feed PBKDF2, for s2k the raw 32-byte digest feeds it; PBKDF2-HMAC-SHA256 with server salt s and server iteration count i, output 32 bytes = the SRP password. Identity hash quirk (GSA-specific, must match): the identity hash uses an EMPTY username — SHA-256(empty ‖ ":" ‖ password-buffer) — while M1's H(username) term uses the REAL username. x = SHA-256(salt ‖ identity-hash); u = SHA-256(A ‖ B); k = SHA-256(N ‖ g padded to N's length); premaster = (B − k·g^x)^(a + u·x) mod N; K = SHA-256(premaster big-endian); M1 = SHA-256(XOR(H(N), H(pad(g))) ‖ H(username) ‖ salt ‖ A ‖ B ‖ K); M2 = SHA-256(A ‖ M1 ‖ K). A is sent as A2k; the received M2 is verified against the computed M2 [APA:icloud-auth].
  • spd decryption: AES-256-CBC + PKCS7; key = HMAC-SHA256(key = K, message = ASCII extra data key:), IV = first 16 bytes of HMAC-SHA256(key = K, message = ASCII extra data iv:). Decrypted plist carries: a per-service token map t (service id → token + expiry in epoch-ms or duration in seconds, e.g. GsIdmsToken), acname, DsPrsId, adsid, first/last name [APA:icloud-auth].
  • Anisette at login: the anisette base headers (§1.1) ride the POSTs filtered to X-Apple-I-MD-LU, X-Apple-I-MD-RINFO, X-Apple-I-MD-M, X-Apple-I-MD, X-Mme-Device-Id, plus akd-variant X-MMe-Client-Info/User-Agent and X-Apple-AK-Context-Type/X-Apple-Client-App-Name/X-Apple-I-Client-Bundle-Id. The cpd dictionary carries anisette values as KEYS inside the body: X-Apple-I-Client-Time, X-Apple-I-MD, X-Apple-I-MD-LU, X-Apple-I-MD-M, X-Apple-I-MD-RINFO, X-Mme-Device-Id, optional ptkn (APNs token), plus fixed values: X-Apple-I-Device-Configuration-Mode = 0, X-Apple-I-Request-UUID (uppercase UUIDv4, one per login, reused across both steps), X-Apple-Requested-Partition = 0, X-Apple-Security-Upgrade-Context = com.apple.authkit.generic, capp, cbid, cou = US, loc = en_US, svct (e.g. imessage), and booleans X-Apple-Offer-Security-Upgrade=true, at=0, bootstrap=true, ckgen=true, fcd=true, icdrsDisabled=false, icscrec=true, pbe=false, prkgen=true, webAccessEnabled=false [APA:icloud-auth]. — 2026-10-04 (rev 26, live): anisette is fresh PER SRP RUN, not per login flow — GSA rejects an SRP init whose X-Apple-I-MD was minted minutes earlier (error ec −22421), including the post-2FA re-run of the same login; the reference client mints a new set for every login attempt.
  • 2FA: au = secondaryAuth → GET https://gsa.apple.com/auth returns JSON AuthenticationExtras (trustedPhoneNumbers with numberWithDialCode/lastTwoDigits/pushMode/id; HTTP 201 = SMS already sent); request a code with PUT .../auth/verify/phone body {phoneNumber:{id}, mode:"sms"}; verify with POST .../auth/verify/phone/securitycode (same body + securityCode:{code}). au = trustedDeviceSecondaryAuth → trigger the code push with GET .../auth/verify/trusteddevice, then submit: GET .../GsService2/validate with the code in a security-code request header. All 2FA requests carry X-Apple-Identity-Token = base64 of adsid:GsIdmsToken (from spd) and a large browser-style fixed header set. Success returns tokens in RESPONSE HEADERS: repeated X-Apple-GS-Token (one per GSA service), X-Apple-HB-Token, X-Apple-PE-Token — each base64 of colon-separated ID:TOKEN[:...] with inconsistent duration/expiration trailer forms (epoch-ms vs seconds, heuristically parsed) [APA:icloud-auth]. After a successful 2FA, the full SRP login is run a SECOND time, which then completes as logged-in [APA:icloud-auth]. An on-device variant exists (Circle PAKE over .../GsService2/circle: SRP-6a over the 3072-bit group with the DSID as username and the zero-padded 6-digit code as password, 16-byte random salt, DER/ASN.1 step payloads base64 in pakedata; the code arrives AES-128-GCM-encrypted over an SRP-key-derived channel with recv->send/send->recv info labels) [RP:auth.rs], [APA:icloud-auth]. — 2026-09-29 (rev 25, live): the trusted-device trigger is what makes the code APPEAR on the devices — it must run BEFORE prompting the user, and a SECOND trigger before the same validate invalidates the outstanding code and re-prompts every device (both observed live); au is returned inside the complete response's Status dict, not at the Response level. — 2026-10-04 (rev 26, live): the anisette riding the 2FA calls is fresh PER CALL (trigger and validate each re-mint; a set frozen at login start answers HTTP 434 — the frozen-OTP wall of session 16), the trusted-device trigger and the validate GET both carry Content-Type: text/x-xml-plist + Accept: text/x-xml-plist, and a failed verify's body is a plist whose Status.ec/em (or top-level ec/em) name the real failure — the HTTP status alone is opaque.
  • PET: arrives as X-Apple-PE-Token; stored under service id com.apple.gs.idms.pet; default expiry 300 s when no trailer, otherwise parsed seconds/epoch-ms; refreshed by silently re-running the SRP login with the persisted password hash. Used as the HTTP Basic password (username:PET) on the iCloud requests below [APA:icloud-auth], [RP:auth.rs]. — 2026-09-29 (rev 25, live): the post-2FA SRP re-login's decrypted spd token map t is an ALTERNATIVE delivery channel carrying the PET plus the full per-service token set under the same ID keys (com.apple.gs.idms.pet, com.apple.gs.icloud.auth, com.apple.gs.idms.hb, com.apple.gs.pb.auth, com.apple.gs.gpk.auth, …21 observed in total); a no-2FA password login's spd carries only com.apple.gs.appleid.auth and NO PET — the header route on the 2FA-verification response and the re-login spd map are the two live-confirmed PET paths.
  • Delegate logins: POST https://setup.icloud.com/setup/signin/v2/login, XML plist body with delegates (dictionary keyed by bundle id: com.apple.private.ids → {protocol-version: 4}; com.apple.mobileme → {}), protocolVersion = 1.0, userInfo = {clientId (uppercase UUIDv4), language = en-US, timezone}. Headers: Basic username:PET, X-Mme-Nas-Qualify = base64 validation blob (§1.1), X-Apple-ADSID = adsid, akd-flavor X-Mme-Client-Info, User-Agent = com.apple.iCloudHelper/282 …, full anisette set. Response plist: status (0 = ok), optional localizedError + description, delegates dictionary with per-entry status, statusMessage, serviceData. The IDS delegate's serviceData (kebab-case) yields authToken and profileId — exactly the IDS auth-token (sent as authentication-data = {auth-token: token}) and the profile-id (realm user id) that feed the authenticate exchange (§1.2) and registration (§1.4). The MobileMe delegate's serviceData yields a token map (incl. mmeAuthToken) and service configs [RP:auth.rs], [RP:macos.rs]. — 2026-09-29 (rev 25, live): the anisette headers minted at login start are SPENT by the GSA hops — the sign-in POST needs a FRESH set (a reused set was refused; the reference client mints per request); and the endpoint answered HTTP 409 with ErrorID=ICLOUD_UNSUPPORTED_DEVICE ("This Apple Account is valid but is not an iCloud account" / "Cannot create another iCloud account with this device") — a device-identity-bound iCloud-activation refusal (response plist carries protocolVersion 2, ErrorID, Header, Body, DialogType, DefaultButton*, AlternateButton*, HelpLink), consistent with the per-Mac-identity account cap the upstream docs put at ~20 accounts. — 2026-10-04 (rev 26, live): the 409's true root cause is the validation-stamp REPLAY guard (§1.1), not the request shape or a blob rejection — a captured stamp minted elsewhere passed the sign-in with HTTP 200 and CREATED the iCloud device record, and the full chain then completed: §1.2 authenticate + §1.4 register both green against live Apple, with a mailto: handle coming back ACTIVE (§1.3/§1.4). Two response/request refinements: (1) Apple's live sign-in response is KEBAB-CASE — the delegate entries carry status/service-data/account-exists (delegate keys observed live), i.e. service-data and status-message, NOT the extraction's camelCase serviceData/statusMessage (which survive as a fallback reading of the recording); (2) the sign-in body's client id rides the kebab-case wire key client-id in userInfo — the one body delta that survived until the live capture.
  • The GSA postdata liveness event (rev 26, live 2026-10-03; gsas.apple.com per the host split above): a POST .../grandslam/GsService2/postdata with Content-Type: text/x-xml-plist and an XML-plist body of two top-level dictionaries — Header (empty) and Request — the liveness event declaring the device's services icloud, imessage, facetime (with recovery-key generation on), and two account-binding headers: X-Apple-I-UrlSwitch-Info = base64(adsid:postdata) and X-Apple-HB-Token = base64(adsid:hb-token) with the hb token from the post-2FA spd token map's com.apple.gs.idms.hb entry (§1.5 PET). The anisette set is the postdata-filtered machine/device subset (serial-bearing headers, locale and the request uuid are dropped); the akd-variant fixed headers ride. The APNs push token rides the body as ptkn (uppercase hex) when minted — a dead or absent token answers ec −800012 "Push token is invalid", so the Albert device certificate + courier connect token must be minted BEFORE the event, and the courier connection must stay OPEN across the whole registration: the connect-response token identifies the CONNECTION, and closing the socket between the mint and the postdata hop drew −800012 on an otherwise valid token. ORDER (correcting the earlier session note): the reference program runs the delegate sign-in FIRST and postdata AFTER it (the sign-in mints its own Nas-Qualify blob at sign-in time, §1.1) — the hop is a post-sign-in liveness declaration, not a sign-in precondition.
  • Not present in these sources: no idmsa.apple.com, no scnt/session-token headers — the session role is played by X-Apple-Identity-Token (GSA requests) and Basic username:PET (iCloud requests); the idmsa-based flow recorded in §1.2's [IG] citations is a different client's route (C57 note) [APA:icloud-auth], [RP:auth.rs].
  • — 2026-09-11 (rev 15 extraction: C59 closed). Group constants: the GSA login uses the RFC 5054 2048-bit MODP group, g = 2 (the standard Appendix-A prime); the Circle PAKE uses the RFC 5054 3072-bit group with g = 5 [APA:groups.rs], [RP:auth.rs]. The 2FA browser-style header set (all /auth/verify/* and GsService2/validate requests): the anisette base headers filtered to X-Apple-I-MD-LU/X-Apple-I-MD-RINFO/X-Apple-I-MD-M/X-Apple-I-MD/X-Mme-Device-Id, plus fixed literals X-Apple-Client-App-Name, X-Apple-I-Client-Bundle-Id, non-akd X-MMe-Client-Info, X-Apple-I-CDP-Circle-Status: false, X-Apple-I-ICSCREC: true, browser User-Agent, Sec-Fetch-Site: same-origin, X-Apple-Requested-Partition: 0, X-Apple-I-DeviceUserMode: 0, X-Apple-I-Locale: en_US, X-Apple-Security-Upgrade-Context: com.apple.authkit.generic, Accept-Language: en-US,en;q=0.9, X-Apple-I-PRK-Gen: true, Sec-Fetch-Mode: cors, X-Apple-I-TimeZone: UTC, X-Apple-I-OT-Status: false, X-Apple-I-TimeZone-Offset: 0, X-MMe-Country: US, X-Apple-I-CDP-Status: false, X-Apple-I-Device-Configuration-Mode: 0, Sec-Fetch-Dest: empty, X-Apple-AK-Context-Type, and X-Apple-I-CFU-State — a hardcoded base64 whose decoded XML plist is just an empty <array/> [APA:anisette.rs]. cpd values: capp = client app name, cbid = bundle id, svct = the AK context type — for the Messages flavor: capp = Messages, cbid = com.apple.MobileSMS, svct = imessage (AppleIDSettings flavor: icloud / com.apple.systempreferences.AppleIDSettings); cou = US, loc = en_US; ptkn = the APS push token as uppercase hex [APA:anisette.rs], [RP:lib.rs]. akd request values: User-Agent = literal akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0; X-MMe-Client-Info (akd form) = <HardwareModel> <OSname;OSversion;OSbuild> <com.apple.AuthKit/1 (com.apple.akd/1.0)> (observed <iMac13,1> <macOS;13.6.4;22G513> … and <iPhone7,2> <iPhone OS;12.5.5;16H62> …); X-Apple-AK-Context-Type = imessage or icloud per flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs]. spd details: GsIdmsToken is a TOP-LEVEL string key (not inside t); t entries carry token (string) plus expiry (epoch-ms) or duration (seconds); account-name keys fn/ln (optional strings) [APA:client.rs]. Host split: postdata and teardown go to gsas.apple.com (with the S) with X-Apple-I-UrlSwitch-Info = base64(adsid:postdata/adsid:teardown); circle requests use X-Apple-Identity-Token when 2FA-authenticated, X-Apple-HB-Token = base64(adsid:hbtoken) otherwise; the app-token checksum is HMAC-SHA256 keyed with the session key over apptokens ‖ dsid ‖ app name [APA:client.rs]. Sign-in request values: User-Agent = com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0 (static config constants); userInfo.timezone = a hardcoded IANA name (America/New_York in the reference config); X-Mme-Client-Info wraps the AOSKit form com.apple.AOSKit/282 (com.apple.accountsd/113) [RP:auth.rs], [RP:test.rs]. HTTP client notes: Apple root CA pinned, HTTP/1.1 title-cased headers forced, cookie store in use; a parser quirk makes the reference's composed sign-in UA literally com.apple.iCloudHelper/282 com.apple.iCloudHelper/282 (duplicated token — treat the full static string as the recorded form) [RP:relay.rs]. — 2026-09-11 (small follow-up: the three remaining literals): the 2FA browser User-Agent = Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) — static in every source, notably with NO Safari/Version token; the non-akd X-MMe-Client-Info for the browser set = the same device-bracket form with inner component com.apple.akd/1.0 (com.apple.akd/1.0) (rustpush's variant wraps com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)); X-Apple-I-CFU-State's hardcoded base64 decodes to the FULL plist wrapper — <?xml version="1.0" encoding="UTF-8"?> + Apple DOCTYPE + <plist version="1.0"> + <array/> + </plist> + trailing newline (182 bytes), NOT a bare <array/>; the sign-in X-Mme-Client-Info is bracketed like the others: <HardwareModel> <macOS;13.6.4;22G513> <com.apple.AOSKit/282 (com.apple.accountsd/113)>; and the sign-in UA quirk is confirmed — the composed header is the duplicated com.apple.iCloudHelper/282 com.apple.iCloudHelper/282 while the full com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0 string is the config literal other endpoints draw from [APA:anisette.rs], [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs], [RP:test.rs], [RP:macos.rs].

2. IDS (identity services)

2.1 Purpose and lookup

  • IDS acts as the keyserver for iMessage (and FaceTime): registration uploads the public encryption and signing keys plus "client data" describing device capabilities; a lookup takes target handles and returns one identity per registered device, each with the device's public key, push token, and session token [JJ]. Apple's guide confirms the device contacts IDS to retrieve public keys and APNs addresses for all devices of an addressee [APS-Send]. "IDS" is believed to stand for "IDentity Services" but this is unofficial [JJ].
  • Lookup is a POST of a plist with a uris array to the id-query bag URL [RP:ids/user.rs] (pinned URL: query.ess.apple.com query path [IG:lookup]), with headers including the sender's own handle, the push token, and the protocol version, a lookup-specific user agent, an optional sub-service topic header, and opt-in flags requesting messages/results [RP:ids/user.rs]; the second source agrees on the self-URI and protocol-version headers [IG:lookup]. Header wire names (extraction, closing C47): inside h, Rust sends x-id-self-uri, x-push-token, x-protocol-version (from the stored per-user protocol version), user-agent = com.apple.madrid-lookup <version-ua>, the x-id-nonce/x-id-cert/x-id-sig signature set, conditionally x-id-sub-service (only when the lookup topic differs from the main topic), and x-required-for-message: true / x-result-expected: true (opt-in flags, both true in most calls) [RP:ids/user.rs]; Go sends X-ID-Self-URI, X-Protocol-Version (pinned "1640"), X-ID-Nonce, X-ID-Cert, X-Push-Token, X-ID-Sig — no user-agent/sub-service/opt-in headers (disagreement on the optional set — C57) [IG:lookup]. No push header set (x-push-*) rides lookups in either repo — that C47 half closes negatively. The signature payload matches §1.4: nonce ‖ bag name ("id-query") ‖ empty query string ‖ body ‖ APNs push token, RSA PKCS1v15-SHA1 with the 0x01 0x01 prefix (both) [RP:auth.rs], [IG:sign]. — 2026-09-10: C47's header half closed by facts-only extraction; nothing capture-bound remains here (the optional-set disagreement rides C57).
  • Batches are capped per request: 18 handles [RP:identity_manager.rs] vs 20 URIs [IG:lookup]. A "response too large" error (5206) causes the batch to be split in half recursively [RP:ids/user.rs], [IG:lookup]. TODO(capture): C13 — real batch cap.
  • Architecture fact (extraction, 2026-09-10 — supersedes the earlier either-transport framing): IDS lookups ride the APNs tunnel, not plain HTTPS. The lookup request and its headers travel inside a tunnel payload whose header dictionary is keyed h; the tunnel-level response carries a status field (Rust checks it; Go's constant for the HTTP-over-APNs request type is 96) [RP:ids/user.rs], [IG:lookup]. The tunnel mechanics stand as recorded: command 96 carries URL, headers, content type, body, and a 16-byte request UUID; the reply carries the same request UUID and body on the same topic (command 97 with a status field, per Go) [RP:auth.rs], [RP:ids/user.rs], [IG:lookup]; a 1-minute timeout applies [IG:lookup]. TODO(capture): C12 — which transport real devices use today.
  • The response (gzipped plist) maps each handle to a result containing an identities array (one per device), optionally a sender-correlation identifier and a key-transparency account key, and a numeric status [RP:ids/user.rs], [IG:lookup]. Each identity carries the client-data dictionary, a 32-byte push token, a session token, server-declared session-token-expires-seconds and session-token-refresh-seconds, and optional key-transparency data [RP:ids/user.rs:694-704], [IG:lookup.go:107-109]. Both sources parse the two token timing fields, but only the first consults them: session-token-expires-seconds bounds how long the cached identity stays valid, session-token-refresh-seconds triggers a proactive re-lookup [RP:identity_manager.rs:52-72]; the second ignores both and uses its own fixed TTLs (§2.2) [IG:lookup.go:242-248]. The semantics of the sender-correlation identifier are not explained by either source. TODO(capture): C11 — its exact semantics; C10 — real token TTL values (an instrumented client logs both fields per identity). Element form (extraction): the uris elements are PLAIN handle strings (bare tel:/mailto: strings) in both repos — the contrast with the register request's single-key uri dictionaries (§1.4) is real. Response wire keys (extraction): top level status + results, a map keyed by the plain uri string; per-handle keys identities, kt-account-key, sender-correlation-identifier, status (Go names all four; Rust reads identities and sender-correlation-identifier only — disagreement, C57); per-identity keys, identical six in both: client-data, kt-loggable-data, push-token, session-token, session-token-expires-seconds, session-token-refresh-seconds; client-data keys read: public-message-identity-key, public-message-ngm-device-prekey-data-key, ngm-public-identity (both) plus supports-certified-delivery-v1 (Rust); Go keeps unrecognized client-data keys in a catch-all, Rust drops them [RP:ids/user.rs], [IG:lookup]. — 2026-09-10: C48 closed by facts-only extraction (both source repos). C55 narrowed: both repos read lookup error statuses from the top-level status only — 5206 splits the batch in half (both), 6005 triggers a full re-register then retry (Go at most once per hour), 5040/5041 are defined but unhandled in lookup — and neither checks a per-handle status, superseding the per-handle reading that follows and §7 item 19. C16 evidence (§2.3): no PQ3/PQ-typed keys appear anywhere in these results; the identity key is EC P-256 signing + RSA encryption; the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and ngm-public-identity/kt-loggable-data decode to a compressed EC key [RP:identity_manager.rs], [IG:lookup]. The lookup-orchestration code reads per-handle result statuses only (the one numeric-status location this fact set records) and treats a whole-batch 5206 the same as a per-handle one.
  • Session tokens prove a recent lookup, are required to send to a device, expire, and cannot be shared — only the account that performed the lookup can use them [JJ]; they are passed verbatim in each outgoing message bundle [RP:identity_manager.rs]. This account binding is an edge case for relay architectures where one account performs lookups for others [JJ].
  • After registering, the client receives an "identity keypair" used to perform lookups [JJ].

2.2 Key caching and multi-device

  • Cache policy is client-side and the two sources differ: one keeps an entry with zero identities stale after 1 hour (hardcoded fallback [RP:identity_manager.rs:25]), otherwise valid while elapsed time is under session-token-expires-seconds for every identity, with proactive refresh when any identity exceeds session-token-refresh-seconds, a 60-second minimum spacing between forced refreshes, and the cache keyed by a SHA-1 over (id certificate, current APNs token) so either change invalidates everything [RP:identity_manager.rs:27-28,52-96,212-240]. The other never reads the server-provided token fields: it refreshes positive results after 1 day, negative results after 1 hour, and hard-expires entries after 7 days [IG:lookup.go:242-248,339-352]. The send-side error codes for token problems are 5040 (bad session token) and 5041 (expired session token); neither source implements a recovery path for 5041 [IG:ids/types/idserrors.gen.go:104-105]. TODO(capture): C14 — observe real lookup cadence to arbitrate.
  • Each device on an account generates its own key set; private keys are never exported; messages go to all of the sender's other devices and all recipient devices as pair-wise sessions, each encrypted with keys uniquely derived per session [PQ3]. The sender encrypts one payload per target identity (per receiving device) and addresses identities by push token [RP:identity_manager.rs]; devices without a published identity key are skipped and duplicate push tokens are deduplicated except when destinations differ [IG:connector].
  • Other registrations of the same account (own devices: UUID, device name, push token, identities, sub-services) are retrievable via the id-get-dependent-registrations bag key (GET) [RP:ids/user.rs] (pinned URL: getDependentRegistrations path [IG:bag]); this maps own-device push tokens to UUIDs, used to address specific own devices [RP:identity_manager.rs].
  • Pseudonym "aliases" can be created/deleted via an id-provision-alias bag URL with attributes (allowed services, expiry, feature id); aliases are cached with expiry and pruned [RP:ids/user.rs], [RP:identity_manager.rs]. Registered handles can be re-fetched via an id-get-handles bag key [RP:ids/user.rs] (pinned URL: idsGetHandles path [IG:bag]).
  • A spam-report endpoint (id-report-spam) submits per-message metadata for suspected spam [RP:ids/user.rs].

2.3 Server-initiated control

  • Pushes arrive on the com.apple.private.ids topic (hashed on the wire) with a small JSON body carrying a command count: c=32 forces re-registration; c=66 means handles changed (client re-fetches handles and re-registers if they differ); c=34 means devices changed (client invalidates its key cache for all handle pairs) [RP:identity_manager.rs]. The other implementation receives but ignores that topic [IG:apns]. TODO(capture): C15 — which of these actually occur on a real account.
  • Command 130 ("peer cache invalidate") tells receivers to drop the sender's cached identity; received for self, it triggers a full re-register; sending it with token-only bundles is how a client broadcasts a new own handle as resolvable [RP:imessage/aps_client.rs], [IG:connector].
  • An error 6005 on lookup triggers an IDS re-register and retry, at most once per hour [IG:lookup]; the other source treats 6005 as bad authentication and forces a full re-login [RP:identity_manager.rs]. TODO(capture): C39-adjacent — observe real 6005 handling.
  • With PQ3, each device registers two public encryption keys with IDS and replaces them regularly with fresh ones: a Kyber-1024 key-encapsulation key and a P-256 key-agreement key, signed (with a validity timestamp) by a P-256 authentication key generated inside the Secure Enclave, itself signed by the Contact Key Verification account key together with attributes such as the supported protocol version [PQ3]. Neither reference client implements PQ3; both register only the legacy/EC key sets [repo-wide]. TODO(capture): C16 — whether current lookup results carry PQ3 keys or the legacy client-data. Extraction evidence (2026-09-10): current-source lookup results carry no PQ3/PQ-typed keys — the identity key is EC P-256 signing + RSA encryption, the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and ngm-public-identity/kt-loggable-data decode to a compressed EC key (§2.1) [RP:identity_manager.rs], [IG:lookup]; what a real Apple device returns still needs the capture.

2.4 Contact Key Verification — OPEN

  • Open item — not yet captured. Contact Key Verification is an opt-in iOS 17.4+ feature: the verifier pins each contact's devices by comparing per-device keys against a verification code shown in the contact card and against Apple's Key Transparency log [APS-CKV]. Its effect is already visible indirectly in IDS registration: status 6001 ("incompatible") is returned when a target has CKV or Advanced Data Protection enabled [RP:ids/user.rs], [RP:identity_manager].
  • Observation plan: enable CKV on the capture device and run a full-interception window. CKV hosts are expected to be pinned (like the courier), so the expected yield is SNI and timing evidence only, not payload. No capture ID assigned yet; scheduled as its own window.

3. APNs courier

3.1 Connection

  • APNs is bidirectional: devices both receive pushes and send data; on connect the device receives a push token used to route notifications to it, distinct from the per-app token of the standard iOS API [JJ]. All iMessage traffic flows over APNs in both directions; a persistent connection is required for real-time delivery [BM].

  • Servers are *-courier.push.apple.com where * is a number in 1–30 [BM] vs 1–50 [IG:courier]; the host count and hostname come from the APNs bag, and the client connects to "<random 1..count>-" but sets TLS SNI to the bare hostname [RP:aps.rs], [RP:util.rs]. TODO(capture): C17 — current bag hostname/count and the real range. (2026-09-10: courier2.push.apple.com observed live [CAP-COURIER]; bag hostname/count and full range still open.)

  • Capture ground truth [CAP-COURIER]: a registered iPhone (iOS 26.1) connected to courier2.push.apple.com, with both ports 5223 and 443 in use — under interception the client alternated between them in a ~5–15 s reconnect loop. The courier pins against interception even with a trusted interception CA installed: the upstream TLS handshake is rejected with the OpenSSL error bad extension, i.e. the courier TLS stack is not vanilla TLS — consistent with a custom client-certificate/extension design, which arbitrates nothing between the [RP:aps.rs] (no client cert in handshake) and [IG:connection.go] (mutual TLS) readings; C18 stays open. Hosts observed pinned in the same window: courier2.push.apple.com, gateway.icloud.com, configuration.apple.com, query.ess.apple.com; with Apple hosts passed through, delivery works normally. A non-transparent HTTP-proxy capture sees no courier traffic at all — the courier bypasses the Wi-Fi HTTP proxy entirely.

  • Transport is TCP port 5223 with TLS [RP:aps.rs], [IG:apns/connection.go]; the channel is forward-secret, and metadata such as timestamps and APNs routing information is not encrypted [APS-Send]. The client pins Apple's own root certificate(s) rather than system roots (a profileidentity.ess.apple.com root [RP:aps.rs]; a custom Apple root pool [IG:connection.go]).

  • Client certificate: public write-ups say connecting requires a client certificate issued by the Albert activation server [JJ], [BM]; one source states the client certificate is NOT used in the TLS handshake and authentication happens in the application-layer connect message instead [RP:aps.rs]; the other configures mutual TLS with the Albert-issued certificate [IG:connection.go]. TODO(capture): C18 — whether the handshake presents a client certificate. — 2026-09-28 [PROBE-COURIER] (unauthenticated direct probe from this machine, no client certificate configured): the handshake to courier.push.apple.com COMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384, chain leaf ← apple public server ecc ca 12 - g1, anchored at AAA Certificate Services), and the server then sends a fatal TLS alert certificate_required on the first read — before any APNs frame can flow. C18 is settled by the server side: the courier uses TLS 1.3 post-handshake client-certificate authentication with the Albert-issued certificate; an application-layer-auth-only client would never get past the alert. The application-layer connect message (below) additionally carries the certificate+nonce+signature fields — both layers are in play; the client-cert presentation is the transport gate.

  • ALPN: one source offers apns-pack-v1 and apns-security-v3 and switches to a compressed "packed" wire format when a pack protocol is negotiated [RP:aps.rs]; the other negotiates only apns-security-v3 and speaks only the legacy frame format [IG:connection.go]. TODO(capture): C19 — negotiated ALPN and frame format in real traffic. (2026-09-10: the live ClientHello offers both apns-security-v3 and apns-pack-v1; the ServerHello selects no ALPN protocol at all — so the "switch when a pack protocol is negotiated" trigger never fires in the observed session, and the legacy frame format is the default assumption [CAP-COURIER].) — 2026-10-03 (rev 26, live, instrumented client): C19 CLOSED. Offering apns-pack-v1:4096:4096 (the suffixed form), apns-pack-v1, and apns-security-v3, the server selects apns-pack-v1:<enc>:<dec> — observed apns-pack-v1:4096:4096 on every courier host — and the packed frame format flows; the offer must include the exact suffixed form (a TLS stack that rejects a selection outside the offer otherwise refuses the handshake). With the pack-v1 ALPN the server's chain is leaf ← Apple Server Authentication CA ← Apple Root CA (the RSA chain; the working client pins the intermediate itself, distinct from the no-ALPN ECC chain of [CAP-COURIERTLS] anchored at AAA Certificate Services), and NO TLS client certificate is demanded on this path — the push certificate rides only in the connect frame (§3.3 field 12); the C18 certificate_required alert belongs to the no-ALPN legacy path.

  • Handshake ground truth [CAP-COURIER] (pass-through pcap, 2026-09-10): the live client↔courier session is structurally standard TLS 1.3 — the server selects version 0x0304 with cipher TLS_AES_256_GCM_SHA384 — but the ClientHello carries Apple-private negotiation: a private supported-version value 0xfafa offered alongside TLS 1.3, a private elliptic-curve group 0x9a9a offered alongside the standard curves and the IANA X25519+ML-KEM-768 hybrid group (0x11EC; the offered key share, ~1263 B, is consistent with an ML-KEM-768 share), and two private extension types (0x5A5A, empty; 0x1A1A, one byte). Standard status-request, SCT, signature-algorithms, PSK-mode, and compress-certificate extensions are also offered. This refines the bad extension reading above: the courier is not a custom TLS protocol — it is TLS 1.3 plus Apple-private groups/extensions that conventional intercepting stacks do not offer or echo. Because TLS 1.3 encrypts all post-ServerHello handshake messages, whether the handshake presents the Albert client certificate (C18) is unobservable from a passive capture; that question now needs client-side evidence, not a better network vantage. (Note: the transport already negotiates a post-quantum hybrid key exchange even though message payloads ride the legacy envelope — PQ appears at the TLS layer today.)

  • Chain ground truth [CAP-COURIERTLS] (direct uninstrumented TLS connect from this machine, 2026-09-10): openssl s_client to 2-courier.push.apple.com:5223 with SNI set to the bare courier.push.apple.com completes the TCP+TLS layer — the shard-prefixed host naming and bare-hostname SNI of §3.1 work against the live server. The server presented two certificates: leaf CN = courier.push.apple.com (Apple Inc.; notBefore 2026-09-02, notAfter 2026-12-02 — ~90-day rotation, so pin the root, never the leaf) and intermediate CN = Apple Public Server ECC CA 12 - G1 (Apple Inc.), itself issued by the publicly-trusted Comodo root CN = AAA Certificate Services — the root itself was not served. A trimmed system store rejected the chain (missing anchor), consistent with the client-side pinning of §3.1; a pinned-root pool must anchor at AAA Certificate Services (or Apple's own pool).

3.2 Frame format

  • Legacy frames: a 1-byte command, a 4-byte big-endian total length, then fields, each a 1-byte id, 2-byte big-endian length, and value [RP:aps.rs], [IG:apns/binary.go]. TODO(capture): C45 — the scope of the length prefix (fields-only, as in the documented APNs provider framing, vs the whole frame) and the per-command field-id tables are not captured in this spec (§7 item 15); the transport layer is buildable now, command builders are not. — 2026-09-10: C45 closed by facts-only extraction (both source repos): the 4-byte big-endian length covers the whole field block after the command byte (fields-only scope, agreed), and the per-command field-id tables are recorded in §3.3. The tagged table disagreements (connect flags, filter fields, command 14) and the Go-only extra fields ride C57; command builders are now writable from the spec, with captures arbitrating the tagged items.
  • The packed format (single source) replaces the length/value fields with tag-based attributes with 4-bit value fields, extension bytes, and rolling 32-entry key/value caches; only a fixed list of short attribute keys is value-cached [RP:aps.rs]. TODO(capture): C19 — confirm packed format use and cache-size suffixes. — 2026-10-03 (rev 26, live): C19 CLOSED — the packed format's full wire shape is now recorded from the live connection (it flowed with the negotiated apns-pack-v1 ALPN, §3.1). A frame is a 1-byte command, the attribute-block length as a tagged value of width 8, then the attributes. Tagged value: a 1-byte tag whose low width bits carry an inline value while they are not all ones; at the all-ones marker the remainder follows as a 7-bit continuation varint (always present — even a zero remainder writes one 0x00 byte). Attribute: a 1-byte key tag — a single-byte key (< 0x10) rides inline as key|flags; longer keys go through the rolling key cache (0x20|flags|index reference; 0x10|flags + key-length−1 as a tagged width-4 value, then the key bytes) — then the value: inline integer (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), data (tagged width 6), string (width 5, |0x40). Cache flags: 0x40 = first occurrence (full value, enters the rolling value cache), 0x80 = reference (tagged width-8 index into it). Both caches index from the newest entry; the key cache holds 32 entries, the value cache ~4 KiB of entries (the ALPN's 4096:4096 suffixes match the cache sizing). Post-connect sequence (live): connect → connect-response (token) → set-state (20) → filter (9) with the connect-response token plus the SHA-1 topic hashes as enabled topics — unfiltered connections are dropped within seconds, which invalidates the connect token before the login can use it.

3.3 Commands

  • Connect (7): fields for the device/push token, a state byte (1), connection flags (base 0x41; an extra bit 0x04 marks a root connection), the client certificate, a nonce, and a signature [RP:aps.rs], [IG:apns/commands.go]. Second-extraction correction: the two sources agree on the nonce (rev 2 recorded a disagreement that does not hold in source): it is the same structured 17-byte nonce as the HTTPS signature (§1.4) but with the type byte 0x00 (APNs), and it is signed alone — SHA-1 of the raw nonce, RSA-SHA1-PKCS1, 0x01 0x01 prefix [RP:aps.rs:1005-1006 with RP:auth.rs:685-714], [IG:apns/connection.go:205-219]. One source also sends a fixed value 9 in a field with id 0x10 [RP:aps.rs]. Field-id table (extraction, agreed unless noted): 1 device token, 2 state (value 1), 5 flags, 12 push certificate, 13 nonce, 14 signature — sent in the order token/state/flags/cert/nonce/signature [RP:aps.rs], [IG:apns/commands.go]; the 0x10 field is Rust-only (integer 9, a hardcoded version identifier). Flags disagreement: Go's base is 0b1000001 with an added 0b100 bit marking a root connection; Rust sends 0b01000001 — the recorded flag-bit sets disagree — TODO(capture): (C57). The C8 extraction pass also records the Go connect nonce as a 20-byte random value with the first byte zeroed, against the structured 17-byte agreement above — TODO(capture): (C57). TODO(capture): C20 — confirm against a real client's connect frame; the meaning of the 0x10 field.
  • Connect response (8): a status byte (0 = ok; 2 = invalid cert), and a 32-byte connect token (possibly new) which the client stores and uses in later messages [RP:aps.rs], [IG:apns/commands.go]. The other source adds: advertised maximum message size (usually 4 KiB), capabilities, a large-message size (usually 15 KiB), and a server timestamp in unix ms [IG:apns/commands.go]. On status 2, one client regenerates its push key/cert via Albert once and resets all IDS registrations [IG:connector]. Field-id table (extraction): 1 status (0 success; Go comments 2 = error), 3 token — Rust parses only 1 and 3; Go-only extras: 4 maximum message size (~4 KiB), 5, 6 capabilities, 8 large-message size (~15 KiB), 10 server timestamp (unix ms) [RP:aps.rs], [IG:apns/commands.go]. TODO(capture): C23 — real advertised sizes (Apple's docs say APNs can relay messages up to 4 KB or 16 KB depending on OS version [APS-Send]).
  • Filter (9): sent after connect and whenever the topic set changes, carrying the token and per-topic states — enabled, ignored, opportunistic, paused — each topic appearing as the SHA-1 of its topic string [RP:aps.rs]; the other source confirms SHA-1 topic hashes and subscribes to madrid, the SMS alloy topic, and the IDS topic [IG:apns/connection.go]. Field-id sets disagree (extraction): Go sends 1 token, 2 topics (SHA-1-hashed topic list); Rust sends 1 token, 2 enabled, 3 ignored, 4 opportunistic, 5 paused (Go uses only field 2) — TODO(capture): (C57) [RP:aps.rs], [IG:apns/commands.go].
  • Send message (10): field ids (extraction, agreed, outgoing): 1 topic, 2 token, 3 payload (a plist), 4 message id (u32 BE; random in 1..2^31−1) [RP:aps.rs], [IG:apns/commands.go]. Incoming adds field 5 expiration (u32; observed 0xffffffff), field 6 timestamp (u64 unix ns), field 7 (observed 0x02) [IG:apns/commands.go]. Rust also parses an optional field 29 as a channel payload (Rust-only) [RP:aps.rs]. TODO(capture): C26-adjacent — confirm incoming-only fields.
  • Send ack (11): token, the message id being acknowledged, and a status (0 = accepted); incoming notifications are auto-acked with status 0 [RP:aps.rs]; the other source acks every received send message and reports status 0x80 = "sent too early" (retried) and 0x03 = too large [IG:apns/connection.go]. Field ids (extraction): 1 token, 4 message id, 8 status byte; Go additionally reads field 6 (commented as a possible error) — Go-only, TODO(capture): (C57) [RP:aps.rs], [IG:apns/commands.go].
  • Keepalive (12) / keepalive ack (13): no fields (both) [RP:aps.rs], [IG:apns/commands.go]. Command 14 disagrees (extraction): Go labels it "filter-topics-ack" (uncertain; field 1 = token); Rust decodes it as a no-storage marker carrying no fields — TODO(capture): (C57) [RP:aps.rs], [IG:apns/commands.go]. TODO(capture): C22 — real meaning of command 14.
  • Set state (20): field 1 state (u8), field 2 interval, constant 0x7fffffff (both) [RP:aps.rs], [IG:apns/connection.go]. Command 29 (0x1d) — subscribe-to-channels — exists in one source only, with fields 1 index, 2 message (protobuf), 3 token, and a response carrying status field 4 [RP:aps.rs]. TODO(capture): C24 — whether real clients use channel subscribe.

3.4 Keepalive, reconnection, topics

  • Keepalive: one source pings every 60 seconds and waits for the pong, capping response waits at 15 seconds (a timeout forces a connection reload) [RP:aps.rs]; the other pings every 60 s with the first at 5 s after connect, suppresses pings when traffic arrives, and drops the connection after three silent intervals (180 s) [IG:apns/connection.go]. Reconnect: one source uses exponential backoff capped at 30 seconds, indefinitely [RP:aps.rs]; the other waits 3 s after a failed connect, 6 s if the previous connection lasted under 90 s, otherwise reconnects immediately [IG:connector]. TODO(capture): C21 — real keepalive pacing and reconnect behavior of Apple clients. (2026-09-10: see the packet-capture note below — a 2.0 s client activity cadence was observed, not 60 s pings [CAP-COURIER].)
  • Topics: com.apple.madrid is the iMessage topic [JJ], [RP:aps.rs], [IG:apns/topic.go]; registered sub-service topics include com.apple.private.alloy.sms, com.apple.private.alloy.gelato, com.apple.private.alloy.biz, com.apple.private.alloy.gamecenter.imessage [RP:ids/user.rs], plus com.apple.private.alloy.safetymonitor (and an "ownaccount" variant), com.apple.private.alloy.fitnessfriends.imessage, and com.apple.private.alloy.askto [IG:apns/topic.go]. com.apple.private.ids carries server-initiated control [RP:identity_manager.rs], [IG:apns/topic.go]. "ESS" is also used as an alternate name for IDS, but the APNs topic FaceTime uses is specifically com.apple.ess [JJ].
  • Push credentials (the APNs connection) and identity credentials (encryption keys) are separated by design, so push credentials can be delegated to a notification relay without exposing message content or the ability to send [BM], [BA].
  • iMessage pushes are deleted from APNs on delivery but, unlike normal pushes, are queued for offline devices — stored on Apple servers for up to 30 days [APS-Send]. Rev-12 correction: 160 is not a courier frame command in either repo — it is a payload command value inside the command-10 plist message type, meaning offline-message-pending/queue-flush (recorded in Go; absent in Rust) [IG:apns/commands.go]. The earlier "sends a cache-flush command (160) on connect" framing is withdrawn; the related no-storage-marker behavior (command 14, §3.3) stands.
  • Packet-capture ground truth [CAP-COURIER]: a 1-minute capture of an active courier session (post-PQ3 device) shows a single long-lived TCP session carrying send, receive, edit, and tapback traffic — no per-message connections. While active, the client emits ~40-byte records on a hard 2.0-second cadence — an activity timer, not the 60-second idle keepalive either implementation models; idle behavior and reconnect pacing remain uncaptured (C21). Outbound messages appear as 2.3–2.8 KB bursts; inbound frames are ≈115 B and ≈520 B. Frame format and ALPN are not observable through the pinned TLS (C19 stays open).

4. Payload format

4.1 Outer envelope

  • An iMessage send is one APNs notification on the topic (e.g. madrid or the SMS alloy topic) whose plist payload contains, agreed by both sources: command number c; encryption mode E ("pair" or "pair-ec", present when a body exists); ciphertext P; sender handle sP; destination handle tP; message UUID bytes U; numeric message id i; send timestamp e in unix nanoseconds; sender's 32-byte push token t; session token sT; version v (8 for sends); no-response-needed flag nr; expiration seconds eX; trusted-sender flag htu; user agent ua; fanout chunk index fcn (starting at 1); and a dtl array of per-target bundles [RP:identity_manager.rs], [IG:sendmessagepayload]. One source adds: retry count rc, original send time oe (epoch nanos, on retries), queued-message flag sv, scheduled delivery time dmt, queue id qI, FaceTime relay-allocation fields, and a legacy unencrypted body field p [RP:identity_manager.rs], [RP:ids/mod.rs]. TODO(capture): C26-adjacent — confirm the full current field set.
  • Each dtl bundle carries: target handle tP, a request-delivered boolean D, the session token sT from the IDS lookup, the target's 32-byte push token t, the encrypted payload P (absent for payload-less messages), the encryption mode E (only for pair-ec), and an optional certified-delivery version cdv [RP:identity_manager.rs], [IG:sendmessagepayload]. The delivery-request flag is false for self [IG:connector]; one client excludes its own push token from targets unless a queue id is present [RP:identity_manager.rs].
  • Encryption-mode placement (second-extraction facts): when any encrypted body exists, the top-level E is always the legacy value pair regardless of the per-recipient scheme actually used, and per-dtl-entry E is written only for entries encrypted with the EC scheme — legacy entries omit E entirely at the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neither E nor P at all — in the second source's receipt path the encryption routine is never invoked [RP:identity_manager.rs:1124-1135], [IG:connector.go:1743-1751]; incoming messages with no E pass through without decryption [RP:identity_manager.rs:814-838]. One source's fan-out builder keeps duplicate entries for the same destination push token when the destination's identities support different schemes, with a note that Apple senders are observed sending the same destination both ways — i.e. mixed per-device schemes within one send appear in real traffic [IG:connector.go:1605-1619]. TODO(capture): C25 — settle which schemes current Apple clients emit (§4.5, §7 item 1).
  • Value types (extraction, agreed, closing C49): c integer; E string ("pair"/"pair-ec"); P data; i integer; U data (raw 16-byte UUID); t data (push token); e integer (unix nanos — Go-only key); sP/tP strings; v integer (8; Rust uses 1850 for the SMS topic, §5.8); nr boolean; fcn integer; and dtl is an ARRAY OF DICTIONARIES — nested message dicts, not nested data plists (both agree) [RP:ids/user.rs], [IG:sendmessagepayload]. Placement: the per-recipient fields (tP, t, sT, P, E, D, cdv) sit top-level in Go for normal sends; Rust always nests them inside dtl entries and omits a per-entry E when it is "pair" (present for "pair-ec"). Rev-12 key-attribution correction to the first bullet of this section: the extraction records e, eX, htu as Go-only and ua as Rust-only. Go-only keys: e, oC, htu, eX, cdr, plus HTTP (u, b, cT, h, hs, hr, s), MMCS (mO, mS, mL, mA, mR, mU), content (cV, cH, cB), and failure (aDI, fR, fU, fM, fRM, fFS, fS) groups; Rust-only keys: rc, oe, sv, dmt, qI, qr, qgid, qgmc, qai, qat, qv, qids, ua. — 2026-09-10: C49 closed by facts-only extraction (both source repos); any residual per-key value-type doubt is capture-bound (§7 item 17).
  • The inner plaintext is a binary plist, optionally gzipped [RP:messages.rs], [IG:connector]. Gzip placement (extraction): Go gzips a converted binary plist of the message before encryption; the Rust gzip location was not traced (unresolved — C57) [IG:connector].
  • Binary plist container format (the encoding of any plist above that travels in binary form — inner plaintexts, APNs-tunneled requests, archived balloon payloads; this is Apple's publicly documented container format, not something extracted from the restricted sources): a file is an 8-byte magic bplist00, then the object area, then an offset table with one fixed-width entry per object (each the byte offset of that object from the start of the file), then a 32-byte trailer: 5 unused bytes, 1 byte sort version, 1 byte offset-table integer width, 1 byte object-reference width, then three 8-byte big-endian integers — object count, top-object index (the offset-table index of the root object), and the offset table's byte offset. Every multi-byte integer in the format is big-endian. Widths are the minimal 1/2/4/8-byte width that holds the largest value present (object-reference width is the minimal 1/2/4/8 whose 2^(8w) ≥ the object count, so every reference 0..count−1 is representable; offset-table width must hold the final file offset). The trailer's sort-version byte is written as 0.
  • Binary plist object encoding (continuing [CF]): each object starts with a marker byte — high nibble type, low nibble info. Simple values: 0x00 null, 0x08 false, 0x09 true. Integers are marker 0x1n and occupy 2^n bytes; the 1-, 2-, and 4-byte forms are unsigned (zero-extended on decode), the 8-byte form is two's-complement signed. Encoders emit non-negative values in the minimal 1/2/4/8-byte width that holds them and negative values only in the 8-byte form. Reals are 0x2n, IEEE 754 big-endian, 2^n bytes (4- or 8-byte). Dates are marker 0x33 plus an 8-byte big-endian double of seconds since 2001-01-01T00:00:00Z. Data 0x4n; ASCII strings 0x5n with a byte count; UTF-16BE strings 0x6n with a length counted in UTF-16 code units (not bytes); arrays 0xAn; dictionaries 0xDn, encoded as key references followed by value references. UIDs are marker 0x8n occupying 1<<n bytes (n = 0..3). For every variable-length marker, a count/length < 15 sits in the low nibble; at 15 or more the low nibble is 0xF and the count follows immediately as an integer (an inline 0x1n encoding, not an object-table entry). Collections store their children as object references — indices into the offset table, each of the trailer's reference width. A dictionary's keys may in principle be any object; the plists in this protocol use string keys.

4.2 Encryption envelope — legacy "pair"

  • Envelope framing (agreed): a 0x02 tag byte, a 2-byte big-endian body length, the body, a 1-byte signature length, then the signature [RP:ids/user.rs], [IG:encryptsign_pair].
  • Key derivation (agreed by all three source families): the message is encrypted with AES-128-CTR under a 128-bit key built from an 88-bit random value and a 40-bit value derived via HMAC-SHA-256 from the plaintext and the sender/receiver public keys; the 40-bit part verifies integrity of the decrypted plaintext [APS-Send]. Both implementations spell out the same construction: 11 random bytes plus the first 5 bytes of an HMAC-SHA-256 over (plaintext, the 0x02 marker, the sender's identity hash, the recipient's identity hash), where an identity hash is SHA-256 over the concatenation of the two marshalled keys [RP:ids/user.rs], [IG:encryptsign_pair]. The cipher's fixed IV is 15 zero bytes followed by 0x01 [RP:ids/user.rs], [IG:encryptsign_pair]. A message encrypted with an entirely random key fails to decrypt on newer devices [JJ].
  • Key transport (agreed): the AES key plus the first 100 bytes of the ciphertext are encrypted with RSA-OAEP (SHA-1) under the recipient's 1280-bit RSA encryption key (the RSA block is 160 bytes, so a decryptable body must be at least that long); the remaining ciphertext is appended in cleartext [RP:ids/user.rs], [IG:encryptsign_pair]. Apple's guide confirms the per-message AES key is encrypted with RSA-OAEP to the receiver's public key [APS-Send]. From iOS 13 / iPadOS 13.1 devices may use ECIES for the key encryption instead of RSA [APS-Send]; Apple switched the protocol from RSA to ECC in 2019 with keys protected by the Secure Enclave and a periodic rekey mechanism for self-healing [PQ3].
  • Signature (agreed): the SHA-1 of (RSA block + remaining ciphertext) is signed with ECDSA over P-256 using the sender's signing key, verified by the receiver against the sender's published identity key [RP:ids/user.rs], [IG:encryptsign_pair]; Apple's guide confirms the encrypted text and key are hashed with SHA-1 and signed with ECDSA [APS-Send]. Third-party clients may skip signature verification but it is recommended [JJ].
  • Published identity key structure (agreed): an ASN.1-style blob holding an uncompressed P-256 signing point and a PKCS#1 RSA-1280 encryption key, each sub-blob carrying a 2-byte type prefix [RP:ids/user.rs], [IG:identity].
  • — 2026-09-10: C51 closed by facts-only extraction (both source repos); all three open details above are settled. The HMAC is keyed with the 11 random bytes themselves and computed over plaintext ‖ 0x02 ‖ sender-identity-hash ‖ recipient-identity-hash; only its first 5 bytes are used, appended to the 11-byte seed to form the AES-128 key — and the HMAC is never placed in the envelope. An identity hash is SHA-256 of the concatenated DER-encoded signing + encryption key blobs. The RSA-OAEP block is SHA-1 (MGF1-SHA1, empty label) under the recipient's RSA encryption key and encrypts the 16-byte AES key plus the first min(100, len) ciphertext bytes, placed at the start of the body — so a shorter ciphertext transports only what exists. The signature is SHA1withECDSA (P-256) over the exact body bytes, DER/ASN.1-encoded [RP:ids/user.rs], [IG:encryptsign_pair].

4.3 Encryption envelope — "pair-ec"

  • Used when both sides advertise the newer key set. The choice is made per recipient device at send time: the sender falls back to "pair" if its own registration lacks the NGM device key or prekey, or if the recipient's lookup result lacks a public prekey or an NGM identity (in client data or key-transparency data); otherwise it uses "pair-ec" [RP:ids/user.rs:525-531], [IG:ids/encryptsign.go:31-66]. One source gates pair-ec sending behind a configuration flag that defaults to off — it sends only "pair" unless opted in [IG:config/config.go:57], [IG:example-config.yaml:84-85]; the other always prefers pair-ec when the keys exist. A recipient supporting only one scheme gets that scheme without failing the send. Prekey-signature validation failure only warns [RP:ids/user.rs], [IG:encryptsign.go:57-59].
  • Wire form (agreed): a protobuf message with fields for the ciphertext, a 33-byte compressed ephemeral P-256 public key, a 64-byte raw signature, and a 7-byte key validator; the inner message carries the plaintext (the gzipped plist), a counter, and optional key-transparency gossip [RP:ids/user.rs], [IG:idsproto]. Field numbers (extraction, agreed): payload = 1, key = 2, signature = 3, validator = 99; the field-2 ephemeral key is the raw compressed P-256 point (this extraction records 32 bytes against the 33-byte reading above — TODO(capture):) [IG:idsproto], [RP:ids/user.rs].
  • Key agreement (agreed): a fresh ephemeral P-256 key is ECDH'd with the recipient's published prekey; HKDF-SHA256 with SALT = the ASCII string "LastPawn-MessageKeys" and EMPTY info yields 48 bytes — the first 32 the AES-256 key, the next 16 the CTR IV (rev-12 correction: the string is the HKDF salt, not an info string — both sources agree) [RP:ids/user.rs], [IG:encryptsign_pair_ec]. The plaintext is padded with random bytes to a 16-byte multiple plus a 4-byte little-endian padding-length trailer, then encrypted with AES-256-CTR: the ciphertext block is the inner protobuf ‖ random padding to the next multiple of 16 (none if already aligned) ‖ the 4-byte little-endian uint32 padding length, under the derived IV (IV not prepended; big-endian counter increment) [RP:ids/user.rs], [IG:encryptsign_pair_ec].
  • Signature (agreed): a raw 64-byte ECDSA-P256-SHA256 signature over (shared secret, recipient prekey bytes, sender ephemeral key bytes, recipient device-key bytes, ciphertext) [RP:ids/user.rs], [IG:encryptsign_pair_ec].
  • The validator is the 2-byte prefixes of (sender device key, recipient device key, recipient prekey) plus a final version byte. Second-extraction correction: the final byte is agreed to be the literal value 0x0c (rev 2's "0xc0-ish" reading was wrong) [RP:ids/user.rs:585-590]; the other source treats it as a version indicator ("appears to be the NGM version"), logs it on verification failure, and only verifies the first six bytes [IG:ids/encryptsign_pair_ec.go:96-103,231-274]. The outer protobuf carries the validator in field number 99 [IG:ids/idsproto/ids.proto:24]. TODO(capture): C26 — confirm Apple's live validator bytes (and watch whether the final byte ever moves off 0x0c).
  • The inner counter is a per-sender-pair monotonically increasing value; whether receivers enforce it is unknown to both sources, and neither verifies the received counter [RP:ids/user.rs:520], [IG:ids/encryptsign_pair_ec.go:218]. The sources disagree on keying and persistence: one keys it on a hash of the three public keys (sender device, recipient device, recipient prekey), persists it to disk, starts at 0, and so implicitly resets it whenever the recipient rotates the prekey or the sender re-registers [RP:ids/user.rs:533-547], [RP:identity_manager.rs:175-181]; the other keys it on the recipient URI only (so prekey rotation does not reset it), starts at 1, and persists it either in memory (resetting on process restart) or in a per-recipient database row [IG:ids/encryptsign_pair_ec.go:44-68], [IG:database/outgoingcounter.go:31-44]. Rev-12 extraction correction: both sources persist the counter between sessions and start it at 0; Rust keys it by a hash of own device key ‖ peer device key ‖ peer prekey, Go keys it per destination store — the "starts at 1 / per-URI / possibly memory-only" reading above is superseded [RP:ids/user.rs], [IG:encryptsign_pair_ec]. TODO(capture): C27 — receiver-side counter/replay enforcement, and which keying/reset behavior Apple clients actually use.
  • The inner message has two further optional fields beyond the payload and counter — key-transparency gossip data and debug info — that both sources always send empty; what Apple devices put there is not recoverable from either source [RP:ids/user.rs:549-554], [IG:ids/idsproto/ids.proto:24-30]. Inner-message field numbers (extraction, agreed, closing C50's protobuf half): message = 1 (bytes), counter = 2 (optional uint32), key-transparency gossip = 3 (bytes), debug info = 99 (bytes) — identical in both [RP:ids/user.rs], [IG:idsproto]. On the receive path, "signature failed but validator matched" is a distinct expected error condition, evidence that key mismatch after a rekey happens in practice [IG:ids/encryptsign_pair_ec.go:236-282].
  • The published prekey record (agreed) holds the 33-byte compressed prekey, a 64-byte signature by the device key, and a timestamp (double unix seconds); the signature is over SHA-256 of a fixed ASCII label ("NGMPrekeySignature") followed by the compressed prekey and the little-endian timestamp bytes [RP:ids/user.rs:240-277,451-469], [IG:ids/user.go:240-277]. Receivers verify it against the sender's device key [RP:ids/user.rs:531], [IG:ids/user.go:250].
  • — 2026-09-10: C52 closed by facts-only extraction (both source repos): the outer field numbers (payload 1, key 2, signature 3, validator 99) and the inner field numbers (§4.3 above) are recorded, and the HKDF input is settled as a salt, not an info string. Remaining pair-ec question: where the Rust side applies the inner-body gzip (C57).

4.4 Attachments (MMCS) and size limits

  • Oversized text or attachments are encrypted with AES-CTR under a random 256-bit key and uploaded to Apple's cloud storage; the message then carries the attachment's AES key, its URI, and an SHA-1 hash of the encrypted form [APS-Send].
  • Upload (implementation detail, one source): the client generates a random 32-byte key, AES-256-CTR-encrypts (zero nonce) while streaming, authorizes via an APNs-tunneled request (command 150) yielding a storage domain and object id, then PUTs to a CDN URL; the message references the SHA-1 hex signature, owner/object id, URL, and the decryption key (hex, prefixed with a 0x00 byte), plus file size [RP:messages.rs]. Download uses command 151 with protocol/plist-version headers; requests carry a client-info header and an "IMTransferAgent" user agent with the Messenger data class [RP:messages.rs]. TODO(capture): C30 — confirm command codes and header versions in real traffic.
  • App "balloon" payloads ride inline up to 1024 bytes; anything larger is uploaded to MMCS and referenced instead [RP:messages.rs].
  • Sizes and chunking are disagreed: one source packs bundles so encrypted payload bytes stay under about 5000 per APNs message, splitting across batches [RP:identity_manager.rs]; the other uses the connect-ack-advertised 4 KiB normal / 15 KiB large limits and chunks fanout at max 20 devices per chunk, recomputing chunk size against the large-message limit [IG:connector]. TODO(capture): C29 — real limits and chunking strategy.
  • Very large payloads can also arrive as command 104 ("async") pointing at an MMCS object, reprocessed under the original command stored in field oC after download [IG:decrypt]. TODO(capture): C36 — confirm command 104.

4.5 PQ3 (current Apple protocol; no third-party implementation exists)

  • PQ3 initial key establishment: the sender combines an ECDH result (sender ephemeral key with the receiver's registered P-256 key) with a Kyber encapsulation against the receiver's post-quantum key; both are entropy-extracted via HKDF-SHA384-Extract (twice), then a third Extract call combines the 48-byte secret with a domain-separation string and session information (user identifiers, public keys used, encapsulated secret) [PQ3].
  • PQ3 uses three ratchets: a symmetric ratchet (per-message key derived from the session key, which is itself ratcheted forward; message keys deleted after decryption; 256-bit keys, HKDF-SHA384), an ECDH ratchet (NIST P-256, 32 bytes per message), and a conditionally executed Kyber KEM ratchet (Kyber-768 in-band, adding over 2 KB, amortized) [PQ3].
  • Rekeying at launch: post-quantum rekey roughly every 50 messages, guaranteed at least every 7 days, adaptive on message count, elapsed time, and connectivity. Completing a ratchet requires a send+receive, so iMessage uses encrypted delivery receipts to complete ratchets without a user reply [PQ3].
  • PQ3 messages are padded before encryption with the Padmé heuristic (≤12% overhead), encrypted with AES-CTR under a 256-bit key with an IV derived from the message key, and individually signed with ECDSA over P-256 using the Secure Enclave-protected device authentication key. The signature covers unique user identifiers, push tokens, the encrypted payload, authenticated data, a ratchet-derived message-key indicator, and any public key information in the protocol [PQ3].
  • PQ3 authentication remains classical: sender identity is verified by mapping the sender's identifier to the public key; with Contact Key Verification enabled, device keys are checked against a Key Transparency log [PQ3].
  • Neither reference client implements PQ3; everything above is from Apple's own research post. Second-extraction facts: a repo-wide search finds zero PQ3/post-quantum handling, mention, or fallback in either client — an incoming message whose envelope is neither "pair" nor decodable as the EC outer message simply fails to decrypt [repo-wide]. Apple's post states PQ3 rolled out with iOS/iPadOS 17.4, watchOS 10.4, macOS 14.4, that conversations between PQ3-capable devices "automatically ramp up" to it, and that PQ3 "will fully replace the existing protocol within all supported conversations" — stated in 2024, with no published completion date; Apple never names the wire envelope type PQ3 messages use, and the PQ3 post does not mention the "pair"/"pair-ec" strings at all [PQ3]. TODO(capture): C31 — the PQ3 wire format as actually sent (container, field names, envelope) and C25 — whether current Apple-to-Apple traffic uses PQ3 exclusively, making pair/pair-ec legacy-only; the cleanest observable signal is whether current lookup results still carry the legacy/EC key sets next to the PQ3-registered Kyber-1024 + P-256 keys.

5. Message semantics

5.1 Command numbers

  • Both sources agree on the core table for the madrid/sms topics' c field: 100 = iMessage payload (text, reactions, typing, effects); 101 = delivered; 102 = read; 109 = certified-delivery receipt; 111 = mark unread; 118 = edit and unsend; 130 = peer cache invalidate / new-device notice; 140/141 = incoming SMS/MMS forwarded from a real phone; 143/144 = SMS/MMS sent by this client; 145 = SMS relay enable/toggle confirm; 147 = read-on-device (SMS variant of 102); 180 = profile/settings changes (including a settings code 60001 for global read receipts and 70000 for profile sharing); 181 = chat delete (with recoverable metadata under 182); 190 = group events [RP:messages.rs], [RP:aps_client.rs], [IG:imessage.go], [IG:sendmessagepayload].
  • Single-source entries to verify: 113 = notify-anyways [RP]; 120 = error report [RP]; 122 = sticker/extension update [RP]; 131 = share profile [RP]; 138 = transcript background change [RP]; 160 = offline-message-pending/queue-flush as a payload command value inside command-10 plists, not a frame command (rev-12 correction) [IG]; 103 = unschedule [RP]; 196 = link-preview edit [IG]; 104 = async large payload [IG]; 142 = SMS enable request [IG]; 146/149 = SMS send confirm success/failure [RP]; 255 = send-time ack (both [RP:identity_manager.rs], [IG:ackwaiter.go]); APNs-tunnel HTTP commands 96/97 (both). TODO(capture): C36 — confirm the single-source command numbers against a real capture.

5.2 Normal message body (command 100)

  • Agreed fields: plain text t (concatenation of parts); subject s; participants p (full list including self, as URIs); rich XML body x (present when multipart, mentions, or styling); group GUID gid; group version gv = "8"; protocol version v = "1"; properties version pv (0 for DMs, 1 for groups, per one source); reply/thread reference tg (format "r::::"); screen-effect id iid; chat display name n; audio flags a/e (audio / expiring audio); balloon fields bid, bp, bpdi; message-summary info msi (content-type number amc, source text ams, balloon type amb); a previous-message UUID r [RP:rawmessages.rs], [IG:imessage.go].
  • The XML body's children are either FILE elements (attachments with MMCS download info: owner, URL, hex signature, decryption key, inline-attachment references) or span elements (text parts with part-index attributes and mention markup) [IG:decrypt], [RP:rawmessages.rs]. Text-part positions are counted in UTF-16 code units; part index/start/length is the same indexing scheme used for tapback targets and edit parts [IG:imessage.go].
  • Screen effects use "com.apple.messages.effect." names (echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration, shooting star); bubble effects use "com.apple.MobileSMS.expressivesend." names (impact, loud, gentle, invisible ink) [IG:imessage.go].
  • Balloon payloads (bp) are optionally gzipped and either a raw keyed-archiver plist or a wrapper with payload and attachments; bpdi carries MMCS download info (key, size, owner, URL, signature) [IG:imessage.go], [RP:rawmessages.rs]. Rich links live in the URL balloon provider's keyed-archiver payload with URL, title, summary, and icon/image metadata [RP:messages.rs], [IG:balloonparse]. A location-share fallback exists where the text is exactly U+FFFD and the XML carries a breadcrumb-text attribute [IG:decrypt].
  • 1:1 vs group is distinguished by participant count (2 participants = 1:1; more = group) [IG:decrypt].
  • TODO(capture): C50 — the plist value type of each body key is not recorded for most keys; the spec's own quoting settles some (versions gv/v are strings, tg a formatted string) but the audio flags, pv, the UUID forms (U-style bytes vs GUID strings for r), and the msi/bpdi sub-dict value types are unstated. The writer phase assumes booleans for the audio flags, bytes for r, and integers for pv/amc; a capture must arbitrate. — 2026-09-10: C50's protobuf-numbering half is closed by facts-only extraction (§4.3); the body-plist value-type half stays capture-bound.

5.3 Tapbacks / reactions

  • Agreed core: reactions ride on command 100 payloads; six reaction types as integers 2000–2005 (love, like, dislike, laugh, emphasize, question); removal adds 1000 (3000–3999); the reaction carries no UUID of its own and references its target by a target string plus start-index/length fields [RP:messages.rs], [IG:tapback.go].
  • Disagreements/extensions: one source describes the action selector as 2000–2999 for add and 3000–3999 for remove with the offset giving the index, and extends the set with 6 = custom emoji (carrying the emoji string) and 7 = sticker, plus app-extension reaction codes 2/4000/1000 [RP:messages.rs]; the other documents only 2000–2005 [IG:tapback.go]. The target string is disagreed: "bp:" for balloons and "p:/" otherwise [IG:tapback.go] vs a raw target GUID (for balloons) or "p:"-shaped variants [RP:messages.rs]. TODO(capture): C32 — capture real reaction codes and target-string formats.
  • The emoji mapping used by clients to display reactions: heart, thumbs up/down, laugh, exclamation, question marks [IG:tapback.go].

5.4 Edits and unsends

  • Agreed: both are command 118 with a fresh message UUID; body fields are the target message GUID (emg), part index (epi), edit type (et, 1 = edit, 2 = unsend), a boolean unsend flag (rs), version "1", and for edits the new XML body (epb) plus new plain text [RP:rawmessages.rs], [IG:imessage.go]. The part index can be −1 when unsending the subject part [IG:imessage.go]. TODO(capture): C38 — confirm the −1 case. No edit history is transmitted — only the current content of the edited part; the edit XML is an HTML document with one span per edited part, with visible text recovered by stripping tags [IG:editparse].
  • A link-preview edit is a separate command 196 whose body decodes like a normal message and only contributes rich-link metadata [IG:decrypt]. TODO(capture): C36.

5.5 Typing indicators

  • Agreed start: a command 100 message with the expiration-seconds field = 0, no encrypted payload, trusted-sender and no-response-needed flags set [RP:messages.rs], [IG:connector]. Registration may advertise a receive-typing-indicators capability [IG:register].
  • Disagreed stop: one source stops typing by sending a body where the typing flag is absent and a version field is "1" [RP:messages.rs]; the other signals stop by an empty (zero-length) encrypted payload with participants and version fields set [IG:connector]. TODO(capture): C33 — capture a real start/stop pair.

5.6 Delivery and read receipts

  • Agreed: commands 101 (delivered) and 102 (read) exist, are addressed to other accounts' handles (own devices excluded) [RP:messages.rs], and the D bundle flag requests a receipt (1:1 messages request it; group messages do not, reactions always do) [RP:messages.rs].
  • Disagreed body: one source says 101/102 carry no payload [RP:messages.rs]; the other says 102 is sent unencrypted with the timestamp of the read event and the message UUID set to the "read up to" GUID, fanned out to all participants [IG:connector]. TODO(capture): C34 — capture a real 102.
  • Certified delivery (agreed): a delivered message can carry a certified-delivery version plus an opaque receipt; the receiving client acknowledges via command 109 on madrid with the receipt, a new APNs id, the original UUID, the sender's token, version 8, no-response-needed, and optionally the target/sender handles and a notify flag [RP:identity_manager.rs], [IG:connector]. Whether real Apple clients emit these receipts is unknown (one implementation disables the capability). TODO(capture): C35.
  • Send-time acks (agreed): command 255 echoes the message UUID with a per-device push token and a numeric status; an empty token means a server-level error for the whole send [IG:ackwaiter.go]; one source reports status 0 and 5008 as delivered and 5032 as "refresh keys and resend" [RP:identity_manager.rs], while the other's status table reads 5032 as bad signature (same remedy: re-lookup and resend once) plus 1002 message removed, 1003 message inhibited, 5006 bad push token, 5040/5041 bad/expired session token, 6005 refresh credentials, 6006 refresh registration, 7000 rate limited, 5000 unauthenticated [IG:errors.json]. TODO(capture): C39 — confirm the status table.
  • Retry policy is client-side and differs: up to 5 retries waiting 60 s × (retry+1) [RP:identity_manager.rs] vs 1 s + up to 19 s of ack aggregation [IG:connector]. A special error string (ec-com.apple.messageprotection-802) means "undecryptable", prompting a key-cache refresh [RP:ids/mod.rs].

5.7 Group events

  • Agreed (command 190): body carries group GUID, group version, properties version pv (a monotonic counter that must be tracked), change type, full participant list sp (including self, no URI prefixes), target participant list tp (membership after change), current name n, new name nn, old name old, and an avatar file-transfer record tv [RP:rawmessages.rs], [IG:imessage.go]. Change types: name = "n", avatar = "v", participants = "p"; an additional "vr" is seen but not understood [IG:imessage.go]. Participant add/remove is expressed purely by the sp/tp diff; avatar removal is change type "v" with no file transfer [IG:decrypt], [RP:rawmessages.rs]. TODO(capture): C37 — observe "vr" and real pv values.
  • Icon changes carry MMCS transfer metadata (created date, filename, embedded MMCS reference, transfer and message GUIDs) [RP:rawmessages.rs].

5.8 SMS fallback (relay)

  • Incoming SMS/MMS relayed by the user's own iPhone arrive as commands 140/141; outgoing "reflected" SMS/MMS as 143/144. Long messages (>160 chars), a subject or XHTML body, or multiple recipients switch reflected-SMS to reflected-MMS [IG:connector], [RP:rawmessages.rs]. Outgoing messages use the SMS sub-service topic with version field 1850 [RP:identity_manager.rs].
  • Outgoing body (agreed): chat style ("im" DM / "chat" group), recipient records, and message data (GUID, handle, plain body, subject, XHTML, reply-to GUID, service "SMS" or "RCS", version "1") [RP:rawmessages.rs], [IG:imessage.go]. Incoming body (agreed): receiver, sender, UUID, timestamp, content chunk list (MIME type + bytes, with MMS content-location/content-id), MMS subject/type/recipients, and constants including message type "sms"/"mms", a constant "244" field, and service version "1" [RP:rawmessages.rs], [IG:imessage.go]. TODO(capture): C40 — confirm constants.
  • An SMS enabling handshake exists: request (142) and toggle confirmation (145) with accepted/rejected/disabled states [IG:sendmessagepayload]; one source also reports read-on-device as 147 on the SMS topic addressed only to the own phone token [RP:aps_client.rs], [IG:connector].
  • Keeping a phone number registered via relay apps requires keeping the device on, awake, and connected to Wi-Fi [OB-PNR]. "Verification Failed" on a received message means sender identity could not be verified at receive time — causes include no connectivity to Apple's keyserver, rate-limiting, or (unlikely) an attacker [OB-FAQ].

6. Renewal & edge cases

6.1 Re-registration cadence

  • Apple requires periodic renewal of the registration, submitting new validation data each time; clients surface the next scheduled renewal, and Apple can force a re-registration at any time [OB-Renewal], [OB-Hosted]. With an iPhone-relay registration the iPhone and relay must stay online at all times; Mac-based registration can renew indefinitely with no Mac online [OB-Renewal]. The exact renewal interval in days is not documented publicly [OB-Renewal].
  • Client-side scheduling policies (second-extraction refinement): the first source reads next-hbi as "seconds from registration time until the client should re-register" and schedules the next re-registration at the minimum, across all users, of (registration time + next-hbi); when next-hbi is absent it falls back to seconds remaining until the identity certificate's notAfter minus a 300-second (5-minute) safety margin, and rechecks the wall clock every 60 seconds [RP:ids/user.rs:79-98,1171-1180], [RP:identity_manager.rs:491-525]. Failed registrations retry with exponential backoff — minimum 5 minutes, maximum 1 day, indefinitely — and a triggered refresh is a no-op if one succeeded within the last 15 seconds, with callers waiting at most 30 seconds for the running attempt [RP:identity_manager.rs:434-443], [RP:util.rs:916-917,1116-1133]. The second source recomputes its re-register ticker after every successful registration: it starts from the earliest notAfter among all identity certs but caps the interval at 45 days, clamps to a maximum of 1 day and a minimum of 30 minutes, then subtracts a 2-minute buffer [IG:connector.go:178-224]. It also forces re-registration whenever 45 days have elapsed since the last successful registration [IG:connector.go:301]. Its per-cert safety buffer is tiered by approximate certificate lifetime (registered-time → notAfter): under 8 hours → re-register when less than 3 minutes remain (fresh phone-number certs are described as short-lived); under 1 day → 30 minutes; under 7 days → 4 hours; otherwise 24 hours [IG:connector.go:317-334]. When its validation-data provider is unreachable it pauses by resetting the ticker to 1 hour [IG:connector.go:238-243].
  • Forced immediate re-registration (second-extraction refinement): a push on the private IDS topic with command 32 (re-register) or 66 (handles changed — re-fetch handles, re-register if they differ) triggers it in the first source; command 34 (devices changed) only invalidates the cached identities for own handles [RP:identity_manager.rs:758-797]. A lookup returning 6005 triggers a re-register and retry, at most once per hour with a two-strike guard, in the second source [IG:lookup.go:447-471]; the first treats a 6005 from registration itself as permanent ("do not retry") pending re-login [RP:identity_manager.rs:369-380]. An APNs command 130 received from self also forces re-registration in the second source [IG:connector.go:824-841]. Disagreement: the second source's receive path explicitly ignores the private IDS topic entirely, so server-pushed commands 32/66/34 do nothing there [IG:connector.go:810-813]. A stored client-data hash differing from what the client would now send triggers immediate re-registration (first source) [RP:identity_manager.rs:400-412].
  • Second-extraction correction on the "~45-day certificate" folklore: 45 days appears in the sources only as the second source's client-side cap on re-registration cadence and on time-since-last-registration; neither source records an observed server-issued certificate validity, and the first source assumes no certificate lifetime at all (the certificate's own notAfter is authoritative) [IG:connector.go:181,208,301], [RP:ids/user.rs:79-88]. Treat 45 days as a client policy constant, not a measured Apple certificate TTL. TODO(capture): C41 — real next-hbi values and whether Apple sends it at all, real identity-certificate notBefore/notAfter per handle type (phone vs Apple ID), real session-token-expires-seconds/-refresh-seconds values, and which renewal trigger fires in practice. An instrumented client closes this with a debug log of: the raw register response plist (top-level keys, per-user next-hbi, cert DER), cert validity tagged by handle type, the computed next-renewal deadline and the trigger that fired, and the lookup identity dicts' two token fields.
  • Mac-renewal arbitration (2026-09-10 research; Phase 0 go/no-go input): renewal never needs the Mac again — every re-registration submits freshly generated validation data, produced on-device by the client's own NAC key-establishment session keyed with the cached Mac hardware identifiers [RP:ids/user.rs], [RP:macos.rs]. OpenBubbles documents Mac-based registration as renewing indefinitely with no connection to the Mac, and never requiring the Mac online after setup [OB-Renewal], [OB-FAQ]; corten-matrix likewise generates validation data locally from the hardware key extracted from the Mac once (the extraction produces an already-complete key, so no Mac round-trip is needed later). Contrast: in the iPhone-relay configuration each renewal is an HTTP call to the relay, so the iPhone must be live at every renewal [RP:relay.rs]. Client-side schedule confirmed: earliest of (registration time + next-hbi) or the identity-cert notAfter − 5 min; failure retries back off 5 min–24 h; re-registration also fires reactively on APS re-register commands and identity errors. IDS status 6005 is a server-side logout needing an Apple-ID relogin (not a Mac/validation-data problem). No public source states the actual day count — treat the ~45-day max-certificate figure as approximate. Standing risk: macOS 26 protected NACKeyEstablishment with PAC (rustpush issue #21) — an Apple-side protocol change can break local generation; watch rustpush/corten releases. — 2026-09-10: the standing risk fired — on macOS 26.6.3 (confirmed live on a test machine) NACKeyEstablishment is PAC-protected and SIGABRTs when called [RP-issue-21], and beeper's mac-registration-provider is archived (Apr 2026) with support only up to macOS 14.3. Minting validation data currently requires a Mac on macOS ≤ 14.3; the issue is open with no upstream fix or comments. rustpush's own fallback note stands: any genuine Mac's validation data works — the identifiers only need to be valid, not the capturing client's own hardware. — 2026-09-10 correction (OpenBubbles-flow research): the ≤ 14.3 conclusion above is narrowed — it binds only helpers that call the framework themselves (beeper's mac-registration-provider; rustpush running on a Mac, which is what issue #21 breaks). OpenBubbles' shipped flow never calls NACKeyEstablishment on the Mac at all: the helper exports identifiers only (public APIs, any macOS ≥ High Sierra including 26.x), and the client performs the generation in software (§1.1, C56). A macOS 26 Mac therefore suffices as the identifier source; the remaining C5/C6 work is client-side software (the generation procedure + Apple-Account login), not hardware access.
  • A status of "refresh credentials" in the register response marks that auth pair for refresh [IG:register]; error 6005 forces a full re-login [RP:identity_manager.rs] (see §2.3 for the lookup-side reading).

6.2 Expiry and failure modes

  • Expired-validation-data symptoms beyond the deregistered state, and certificate-expiry behavior after renewal failure, are covered by no source. TODO(capture): C43.
  • Deregistration: neither reference client implements client-initiated un-registration; deregistration is handled by Apple (e.g. when the account is used elsewhere) [RP:identity_manager.rs]. Operationally: reset the app and delete the device from the Apple Account page; an already-registered number can be deregistered via Apple's selfsolve deregistration page [OB-Renewal]. The MobileMe delegate token state is refreshed when its stored refresh timestamp is older than 7 days (604800 s) — a lazy refresh at fetch time, client-side policy, not a measured server TTL [RP:auth.rs:204-212]. TODO(capture): C42 — observe the deregistration flow end-to-end.
  • Failure modes for send/receive breakage: stale identity cache (clearable in developer tools), deregistration, enabled Advanced Data Protection, enabled Contact Key Verification (both must be disabled for relay use — matching status 6001), and Apple-side throttling/banning of the account or hardware [OB-FAQ], [RP:ids/user.rs].
  • Apple throttles new or abusive registrations: freshly registered devices may see temporary limits; texting many people can require waiting days or weeks; mass spam from shared hardware identifiers gets the device blocked. Account blocks are usually temporary (a day or two); permanent blocks are typically liftable via Apple support [OB-FAQ].
  • Key rollover: the 2019 protocol added a periodic rekey mechanism providing cryptographic self-healing [PQ3]; under PQ3 the two IDS-registered encryption keys are replaced regularly with fresh, timestamped signatures that senders validate for expiry [PQ3]. TODO(capture): C45 — real key-replacement cadence.
  • A known sync edge case: messages sent from a relayed client may not appear on the account's other Apple devices for up to about 24 hours after linking the Apple Account [OB-FAQ].
  • Partial group delivery: because every payload is per-device, a group send can partially fail; failures surface per-target via ack statuses (bad push token, expired session token, rate limited, etc.) [RP:identity_manager.rs], [IG:connector]. Aliases and session tokens expire and must be re-provisioned/re-looked-up [RP:identity_manager.rs], [JJ].
  • On connect-ack "invalid certificate", one client regenerates its push certificate once and resets all IDS registrations and keys [IG:connector].

7. Capture checklist

Ordered by value. IDs match the inline TODO(capture): markers above.

  1. C25 — pair vs pair-ec vs PQ3 in current traffic. The single most load-bearing question: do today's Apple clients still send "pair"/"pair-ec" envelopes, or has PQ3 replaced them entirely? Determines whether §4.2–4.3 are sufficient for receive compatibility. Second-extraction sharpening: Apple stated in 2024 that PQ3 "will fully replace the existing protocol within all supported conversations", but neither source repo has any post-2024 evidence, and one repo's fan-out code notes Apple senders shipping the same destination both with and without EC keys in one send (mixed schemes observed pre-PQ3) [IG:connector.go]. Payloads are end-to-end encrypted, so network captures can never settle this — the only path is an instrumented client. Observe: per-recipient E values (top-level and per-dtl entry) on outgoing and incoming messages; payload-less messages' E presence; whether current lookup results still carry the legacy/EC key sets beside the PQ3-registered Kyber-1024 + P-256 keys; the incoming validator final byte; incoming inner counters and gossip fields.

  2. C17 + C18 + C19 — APNs transport stack. Courier hostname/count from the APNs bag (range 1–30 vs 1–50); whether the TLS handshake presents the Albert client certificate (mutual TLS) or auth happens only in the connect message; negotiated ALPN (apns-security-v3 vs packed variants) and therefore legacy vs packed frame format. — 2026-09-10: courier2.push.apple.com observed live, 5223↔443 both used; vanilla-TLS interception rejected (bad extension) — Apple-private TLS negotiation confirmed (see §3.1). 2026-09-10 pcap mining: live session is TLS 1.3 (server picks 0x0304 / TLS_AES_256_GCM_SHA384) with Apple-private version 0xfafa, group 0x9a9a, extensions 0x5A5A/0x1A1A, and the X25519+ML-KEM-768 hybrid group offered; client offers both ALPNs, server selects none → legacy frame format assumed. C18 is unobservable by passive capture under TLS 1.3 — needs client-side evidence. — 2026-09-10 [CAP-APNSBAG]: the APNs bag was fetched directly (GET http://init-p01st.push.apple.com/bag, unauthenticated plain HTTP; archived on file as apns-bag-2026-09-10.xml): APNSCourierHostname: courier.push.apple.com, APNSCourierHostcount: 50 — the 1–50 range is confirmed (courier2 is one host within it); APNSAllowTLS1_3: 1 is consistent with the observed TLS 1.3 session. C17's hostname/count half is closed; C18 still needs client-side evidence. [CAP-COURIER] — 2026-10-03 (rev 26): C19 CLOSED — with apns-pack-v1 offered the server selects apns-pack-v1:4096:4096 and the packed frame format flows; the packed chain anchors at the Apple Server Authentication CA intermediate (§3.1/§3.2), and no TLS client certificate is demanded on the packed path (refining C18: the post-handshake client-cert alert belongs to the no-ALPN legacy path only).

  3. C1 + C3 — registration request shape. Second-extraction state: the bag fetch URL, nested-plist shape (bag key → embedded plist), one-per-process caching, and both clients' full endpoint sets are now known (§1.2) — rustpush resolves 11 bag keys at runtime with no fallbacks [RP]; the other pins everything and never fetches. What remains needs an instrumented client (pinned hosts bypass every proxy tier tried): the live bag contents (all id-* → URL mappings as served today), the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies. — 2026-09-10: the bag contents are now observed directly — both bags fetched unauthenticated, no drift in the pinned URLs ([CAP-BAG]/[CAP-APNSBAG], §1.2). What remains C1/C3-bound to an instrumented client: the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies — these live in the register request/response pair, which does require authentication (and Apple throttles abusive registrations, so no blind probing). C2 (protocol-version: "1640" vs "1660") and C4 (signature construction — structure now source-agreed, §1.4) also resolve from the same instrumented-client capture.

  4. C32 + C33 + C34 — semantic wire forms. Tapback action codes (2006/2007/4000/1000/2 extensions) and target-string formats ("bp:" vs raw GUID vs "p:"-variants); typing start/stop encoding (empty payload vs body-field stop); whether command 102 carries a body (timestamp + read-up-to UUID) or none.

  5. C41 + C10 — lifetimes. Real next-hbi values (and whether Apple sends it at all — one source parses it, the other doesn't read it), real identity-certificate lifetimes per handle type (the "~45 days" figure is now known to be a client-side cap, not an observed cert TTL — §6.1), real session-token-expires-seconds/-refresh-seconds values (one source consults them, the other ignores them), the effective renewal cadence, and which renewal trigger fires in practice (timer, private-IDS push 32/66, 6005, handle change). All reachable only via an instrumented client's debug log — the exact logging list is in §6.1.

  6. C5 + C6 — validation data & anisette. Exact validation-data blob shape/inputs on a genuine Mac (and the two validation bag-URL exchanges); the full anisette header set current clients send (including optional hardware headers and X-Mme-Nas-Qualify). — 2026-09-11 (rev 14): the anisette halves are closed — header set, per-request value formats, the ADI provisioning flow (§1.1), and the GSA login chain end-to-end (§1.5) are recorded. Remaining open here: the validation blob itself (C5), the OTP/2FA-code derivation (closed-source crate — contract recorded, §1.1), and wire-verification of the whole chain against one live activation.

  7. C20 + C22 + C23 + C24 — APNs message details. Second-extraction narrowing: C20's connect nonce is now source-agreed (structured 17-byte nonce with type byte 0x00, signed alone — §3.3); what remains is confirming a real client's connect frame and the fixed 0x10 field value. Still open: the meaning of command 14; connect-ack advertised max/large message sizes (4/15 KiB [IG] vs Apple-doc 4/16 KB [APS-Send]); whether channel subscribe (0x1d) is used.

  8. C26 + C27 + C29 — pair-ec details & chunking. Second-extraction narrowing: C26's validator final byte is source-agreed at 0x0c (§4.3) — capture only confirms Apple's live value and whether it ever differs. Still open: receiver-side counter enforcement, which counter keying/reset behavior Apple uses (per-key-hash persisted from 0 vs per-URI from 1 — §4.3), and real send-chunking (≈5000-byte packing vs ≤20 devices against 15 KiB).

  9. C35 + C36 + C37 + C38 + C39 + C40 — semantics sweep. Certified-delivery (109) emission in the wild; single-source command numbers (196, 104, 113, 120, 122, 131, 138, 103, 142, 146/149 — §3 table, tagged per source; 160 withdrawn as a frame command, §3.3); group "vr" change type and real pv values; edit part-index −1 (subject); full ack status table; SMS body constants.

  10. C42 + C43 + C44 + C46 — lifecycle edges. Server-initiated ids-topic commands (c=32/34/66) in practice; deregistration flow; expired-validation-data/renewal-failure behavior; session-token account binding as seen in relay scenarios.

  11. C21 — timing. Real keepalive pacing (60 s confirm), silence-drop threshold (15 s ack timeout vs 180 s), and reconnect strategy (backoff cap 30 s vs 3/6 s rules). — 2026-09-10: 2.0 s client activity-record cadence observed while active; idle keepalive and reconnect pacing still open [CAP-COURIER]. — 2026-09-10 [CAP-APNSBAG]: Apple's client-side connection-policy constants observed in the APNs bag (client policy, not wire measurement): KeepAliveV2TimeDriftMaxAllowed: 30, APNSDelayedReconnectIntervalAll: 60, APNSDelayedReconnectMaxInterval: 90 with Min/MaxIntervalTrigger 30/50 and APNSDelayedReconnectTLSInterval: 120, APNSWiFiKeepAliveEarlyFireConstantInterval: 120, ClientConnectionRetryAttempts: 100, APNSBagExpiry: 10800. These bound the pacing questions but do not replace observing a real client.

  12. C28 + C30 — plumbing. How often the inner gzip layer appears; MMCS command codes (150/151) and header versions in current traffic.

  13. C31 — PQ3 wire format. If captures show PQ3, dump its container/envelope; Apple's post describes the cryptography but not the wire encoding, and no third-party implementation exists to cross-check.

  14. C7 + C8 — activation edge. Carrier entitlement exchange per carrier (SIM-less phone auth); exact Albert activation-info field set and FairPlay certificate choice.

  15. C45 — APNs legacy framing gaps (blocks command construction). The §3.2 legacy frame's per-field 1-byte ids for every command (connect 7: token/state/flags/cert/nonce/signature; connect-response 8: status/token; filter 9; send 10; ack 11; keepalive 12/13; set-state 20; cache-flush 160), plus the length-prefix scope question (§3.2). Frames are TLS-encrypted, so passive captures see nothing — needs an instrumented client or apsd introspection. Until closed, the courier transport (TLS, frames, keepalive, reconnect) is implementable but no command builders can be written. Related: which Apple root certificate(s) to pin for the courier trust pool (§3.1). — 2026-09-10: closed by facts-only extraction (both source repos) — the length-prefix scope is settled (covers the whole field block after the command byte) and the field-id tables are recorded (§3.2, §3.3). What remains here is only the tagged disagreement set — connect flags bits, filter field set, command-14 semantics, ack field 6, connect-response Go-only fields, connect-nonce shape — now tracked under C57 (item 21); the "cache-flush 160" entry above is withdrawn (payload value, not a frame command — §3.3, §5.1).

  16. C47 + C48 — IDS endpoint encodings and the lookup wire details the phase-1 set never captured. C47: the plist encoding of the bag responses and of the embedded bag payload (§1.2); the lookup request's header wire names beyond the x-id-* set and the push-token name recorded in §1.4, and whether a lookup carries the push header set at all beside x-id-* (§2.1). C48: the element form of the lookup uris array, and the lookup response's per-identity/result wire key names beyond identities, status, and the two session-token-*-seconds fields (§2.1). Phase-2 code takes each of these as configuration instead of guessing, so a wrong name fails loudly rather than silently. The three source repos are closed to the writer phase; these close by capture (an instrumented client's lookup request/response pair, or a bag fetch archived with its content type and byte-exact body) or by a deliberate post-Phase-2 extraction. — 2026-09-10 [CAP-BAGCT]: the C47 encoding half is closed (§1.2) — both bag responses and the embedded bag payload are XML plists; the text/xml vs application/x-apple-plist labels do not change the parse. Remaining open here: the lookup header wire names, the uris element form, and the lookup response key names. — 2026-09-10: closed by facts-only extraction (both source repos) — the header wire names (§2.1), the plain-string uris element form, and the response key names (§2.1) are all recorded, and no push header set rides lookups. Nothing capture-bound remains for C47/C48; the optional-header-set and per-handle-key-set disagreements ride C57.

  17. C49 + C50 + C51 + C52 — codec assumptions (Phase-2 component 4). The value-type assumptions of the outer-envelope/body codecs (C49 §4.1, C50 §5.2), the pair envelope's HMAC key and signature encoding (C51 §4.2), and the pair-ec protobuf field numbers (validator 99 excepted) + HKDF salt (C52 §4.3). All are closed by an instrumented client's encrypt/send round trip; until then the code fails loudly rather than guessing. — 2026-09-10: C49, C51, and C52 closed by facts-only extraction (§4.1, §4.2, §4.3), and C50's protobuf-numbering half is closed (§4.3). What remains: the command-100 body-plist value types (§5.2) and the Rust-side inner-body gzip location (C57) — both capture-bound.

  18. C53 + C54 — registration wire names (Phase-2 component 5). C53 (§1.4): the register request's top-level keys beyond private-device-data/services/users/validation-data (device name, hardware version, language, OS version, software version), the service-entry and capability-record key names inside the services array, the register response's per-service breakdown shape and where its users sit (top level vs inside it), the per-user identity-certificate and per-URI-status keys, the alert object's keys, the "refresh credentials" marker (§6.1), and the value form carried by x-auth-user-id-<N>. C54 (§1.2): the authenticate request/response pair — body wire keys, CSR encoding and self-signature algorithm, the CN's encoding of the SHA-1 realm-user-id hash, whether the §1.4 signature header sets apply, and the response's token/certificate wire keys. The component-5 code takes every one of these as caller-supplied configuration or keeps it in a raw map, failing loudly rather than guessing; both close with an instrumented client's authenticate+register capture. — 2026-09-10: closed by facts-only extraction (both source repos) — request/response wire keys, CSR = DER, CN = hex SHA-1 string, no §1.4 signature headers on authenticate, flat status/cert response (§1.2, §1.4). Remaining: the "refresh credentials" marker name (C53 half, §6.1) stays capture-bound, and the capture-arbitrated disagreements (self-signature hash, CN hex case, bag-vs-pinned endpoint resolution) ride C57.

  19. C55 — lookup error-status locations (Phase 2 lookup-orchestration component). Where the lookup response carries its error statuses — 5206 ("response too large", the §2.1 split-in-half trigger), 6005 (bad authentication, §2.3), and the session-token codes 5040/5041 (§2.2 records them as send-side codes; nothing states whether a lookup can also return them): top-level response status, per-handle result status, or both. The §2.1 response shape records only a per-handle numeric status, so the lookup-orchestration code reads per-handle statuses only; a capture of one real lookup response's full plist (top-level keys and per-handle status values) closes this alongside C48's response-key half. — 2026-09-10: narrowed by facts-only extraction — both clients read lookup error statuses from the top-level status only (5206 → split-in-half, 6005 → re-register + retry, 5040/5041 defined but unhandled); neither checks a per-handle status. The per-handle-status reading above (and the lookup-orchestration code built on it) is superseded; a capture still confirms the live wire.

  20. C56 — client-side validation-data generation (§1.1). The procedure a client runs in software to produce the validation blob: the key establishment over the id-initialize-validation session info (inputs, algorithm, blob assembly), its interaction 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. The shipped OpenBubbles flow proves the procedure runs entirely client-side from exported Mac identifiers; closing C56 needs a facts-only extraction from a current client's implementation, then wire-verification against one live activation. — 2026-09-10: steps 1–2 recorded by facts-only extraction (id-validation-cert → single cert data field; id-initialize-validation body-only POST → session-info; §1.1); step 3's math is closed source — OpenAbsinthe-Stub is an official mock whose methods are stubs, so only the API contract is recorded (§1.1). The GSA login and the full anisette header set are recorded (§1.1). What remains: wire-verification against one live activation, and the enc-variant derivation for the five obfuscation labels (closed source — C57).

  21. C57 — extraction disagreements & closed-source residue (rev 12). Gaps that survived the rev-12 extraction round, each with its arbitration path:

    • CN hex case: uppercase [RP] vs lowercase [IG] hex of the SHA-1 realm-user-id (§1.2) — one instrumented authenticate capture.
    • Authenticate self-signature hash: SHA-1 RSA/PKCS1 [RP] vs SHA256WithRSA [IG] (§1.2) — same capture.
    • Connect flags bits: Go base 0b1000001 + optional 0b100 root bit vs Rust 0b01000001 (§3.3) — client-side connect-frame dump.
    • Connect nonce shape: Go 20-byte random with first byte zeroed vs the recorded 17-byte structured agreement (§3.3/§1.2) — same dump.
    • Filter field set: Go 1/2 vs Rust 1/2/3/4/5 (§3.3) — client-side filter-frame dump.
    • Command-14 semantics: Go "filter-topics-ack" (field 1) vs Rust no-storage marker with no fields (§3.3) — same dump.
    • Ack field 6 (Go-only, §3.3) and connect-response Go-only fields 4/5/6/8/10 (§3.3) — same dump.
    • Protocol-version pinning: "1640" hardcoded [IG] vs taken from the stored per-user protocol version [RP] (§1.4, §2.1) — instrumented-client header log.
    • The "enc"-variant derivation for the five obfuscation labels (§1.1) — closed source; needs a new facts-only source or clean-room reconstruction checked against one live device.
    • The pair-ec inner-body gzip location on the Rust side (§4.1) — instrumented encrypt/send round trip.
    • Multi-topic filter-field encoding: whether one state field holds concatenated 20-byte topic hashes or the fields repeat (§3.3) — client-side filter-frame dump.
    • Position of the Rust-only connect field 0x10 (value 9) within the connect field order (§3.3) — same dump.
    • Connect-response fields 5 and 6 (capabilities) value shapes (§3.3) — same dump.
    • APNs-tunnel framing residue (§2.1, rides C12): the frame field ids of tunnel commands 96/97 (the field SET is recorded, the 1-byte ids are not); whether the tunnel payload rides as framed fields at all, and the headers-field encoding (an h-keyed dict is recorded; its serialization on the tunnel is not); the tunnel-level status value table beyond 0 = ok — one instrumented tunnel capture.
    • Phone-flow authentication-data value encodings: the push-token and sigs entry shapes inside the authenticate request body (§1.2; key names recorded, value encodings not) — one instrumented phone-user authenticate capture.
    • Albert wire value types: FairPlayCertChain / FairPlaySignature request encodings and ack-received / show-settings response encodings (§1.2; names recorded, value types not) — same activation capture.
    • FairPlay identity provenance/licensing: ten embedded certificate/key pairs with unrecorded provenance (§1.2) — a policy decision, not a wire fact; resolve before any distribution. — 2026-09-11 (rev 17): the provenance half is CLOSED by metadata extraction (§1.2): all known copies are ONE identity (1024-bit RSA + leaf cert), byte-identical across repos, descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies of it; the cert expired 2024-10 yet activations succeed. What remains: the own-Mac extraction method (open research — nothing documents where macOS stores the per-device FairPlay identity), and the distribution decision (bundling the community pair in a public APK is the provenance problem; BYO-identity is the sane architecture but needs the extraction method first). — 2026-09-11 USER DECISION: bundle the community pair (same one every third-party client already activates with; per-user extraction only if Apple ever starts validating) — staged as resources/imessage/fairplay/leaf.crt + identity.key (SHA-256 bc18fc4d… / 03aca53e…, matching the recorded provenance); own-Mac extraction DEFERRED until needed.
  22. C58 — the Mac-Hardware-Info export-code format (the decided identifier source). — 2026-09-11 (rev 15): CLOSED by facts-only extraction from the helper's public upstream repo (unlicensed — facts only): OABS magic + flag byte + raw protobuf with the full field map, plain-base64 or OpenSSL-Salted__ AES-256-CBC delivery modes, sharing mechanics server-side; §1.1 records the parse specification. Residue: the vendor-server upload endpoint (hw.openbubbles.app/code) is only relevant to the restricted mode's sharing mechanics, which our app does not need (we parse, not mint).

  23. C59 — GSA login residue (writer-pass gaps from the §1.5 implementation). — 2026-09-11 (rev 15): CLOSED by facts-only extraction — the SRP modulus (RFC 5054 2048-bit, g=2; Circle 3072-bit, g=5), the full 2FA browser header set with literal values, the cpd values per flavor, the akd UA/X-MMe-Client-Info forms, the t-map inner keys and top-level GsIdmsToken, fn/ln, and the sign-in UA/timezone forms are all recorded in §1.5; the implementation converts its caller-supplied configuration to the recorded constants.

  24. C60 — validation-data step-3 reconstruction hypotheses. The step-3 math is closed source (confirmed at source level, §1.1), so the implementation reconstructs it from the recorded contract with every non-recorded choice explicit. Each hypothesis below is arbitrable ONLY by one live activation (or an instrumented-Mac capture of the exchange); a wrong choice fails the server side loudly:

    • Key agreement: ECDH P-256 (default; Apple's recorded crypto is NIST-curve-heavy) vs X25519 as the implemented alternative.
    • KDF: HKDF-SHA256 with EMPTY salt and info (both unrecorded).
    • Blob signature: ECDSA P-256/SHA-256 with a context-generated keypair (public key rides the blobs) vs HMAC-SHA256 keyed by the session key (symmetric hypothesis).
    • Session-info interpretation: the whole session-info blob is the server's raw public point (SEC1 33/65-byte for P-256, 32 raw for X25519).
    • Blob framing: TLV v1 (1 tag byte + 2-byte BE length; tags 0x01–0x0E hardware fields in §1.1 order, 0x0F cert, 0x10/0x11 public keys, 0x20 signature last) — an invented framing, nothing recorded.
    • Step-2 content-type: text/x-xml-plist (inferred from the GSA plist posts; §1.1 records body-only). All six live as parameters/strategies in the code; capture or live-attempt results collapse them.
    • 2026-09-11 (first live step-1 attempt, rev 18): the DEFAULT hypothesis set (P-256 ECDH, HKDF-SHA256 empty salt/info, ECDSA-P256-SHA256, TLV v1, RAW_PUBLIC_POINT, text/x-xml-plist) was rejected at STEP 2 — the bag fetch and step-1 cert GET both succeeded (HTTP 200, TLS pinned to the embedded Apple roots, cert parsed), and the step-2 POST returned HTTP 200 with a plist dict of exactly two keys: status (integer 5003) and message (a server trace string of the form SED:3:VEN-PROD:<uuid>:<uuid>:<epoch>:<uuid>:<64-hex>; exact values not recorded), with NO session-info key. The exchange died before key establishment, so this data point does NOT yet arbitrate the crypto hypotheses — it shows the step-2 request itself was refused. The meaning of SED status 5003 is unrecorded: TODO(capture) the SED error-status table (candidate refusal causes: the body shape under a wrong C60 hypothesis, the C56 content-type, or a required-but-unrecorded step-2 header).
    • 2026-09-11 (probe matrix, same session): FIVE step-2 attempts returned the byte-identical 498-byte error shape (status 5003): default set; KA=X25519; sig=HMAC-SHA256; content-type application/x-www-form-urlencoded; content-type application/octet-stream. The refusal is INVARIANT under every flipped variable — the server rejects something common to all attempts. Eliminated as causes: the key-agreement scheme, the signature scheme, the step-2 content-type. Remaining suspects, ranked: (1) the invented TLV v1 framing itself (tags/lengths wrong → body unparsable → generic error), (2) the single-key plist wrapper's encoding (XML <data> vs base64-string value form), (3) a required-but-unrecorded step-2 header (protocol drift since the 2024-era recordings, e.g. an anisette set now enforced), (4) the exported Mac identifiers being stale/invalid server-side. Next lever is a wire capture of a genuine client performing the exchange, not more blind flips.
    • 2026-09-11 (rev 19, step-2 self-check round): the reconstruction was audited offline against the extracted client's own request construction and the archived genuine captures; the audit MOVES the ranking rather than adding a hypothesis. (a) Wrapper — CLOSED as a suspect: the extracted client serializes the session-info-request body with the plist XML writer (the same <data>-base64 XML form this implementation emits), so suspect 2 is retired. (b) Content-type — CORRECTED, not just probed: the extracted client sets NO content-type on the step-2 POST (only Accept-Language rides the client's default headers), so text/x-xml-plist was an unsupported inference carried in from the §1.5 GSA posts and the probe matrix's three content-types were all non-faithful; the code's constant is now absent (§1.1). (c) Invariance — CORRECTED: the five probe attempts did NOT send identical bodies (each mints fresh key-agreement key material, the X25519 and P-256 variants differ in that field's width, and the HMAC variant drops the signing-key field), so "byte-identical response" shows the refusal is independent of the body's CONTENT — which retires the key-agreement and signature values as variables at this step (step 2 precedes key establishment entirely) and leaves the body's FRAMING (the invented TLV v1 layout) and something outside the body as the surviving explanations. (d) Headers — WEAKENED as a suspect but not retired: the archived 2026-09-10 capture of a genuine device shows the validation endpoint family reached with the full anisette + BAA header set (X-Apple-I-MD, X-Apple-I-MD-M, X-Apple-I-MD-RINFO, X-Apple-I-Client-Time, X-Apple-I-TimeZone, X-Apple-I-Locale, x-apple-i-device-type, the x-apple-baa-* set) under an invitation-registration user-agent, while this implementation posts body-only; but that captured request is the CDN-served static step-1 cert, which is exactly why it succeeds header-less here, so the capture neither requires nor acquits a header set at step 2. No archived capture contains the step-2 POST (every capture on file was searched for the request's key). Next levers, in order: (1) live — one faithful step-2 POST (no content-type) plus ONE deliberate control POST whose body is not the session-info-request plist, to learn whether the endpoint examines the body at all (control identical to the 5003 shape ⇒ the refusal precedes the body, so framing is not the cause; control different ⇒ the body is parsed, so framing is the cause); (2) a genuine-client wire capture of the exchange, still the only way to READ the real framing — the Mac-side client performs it in software from exported identifiers, so the capture host is whatever machine runs a client that completes the exchange (the identifier-export helper alone never performs it).
    • 2026-09-11 (rev 20, live control experiment): the BODY half of this item is CLOSED. A deliberate control — 20 bytes that are not a plist at all, POSTed to the same live step-2 URL with the same (absent) content-type — was answered exactly like the reconstruction: HTTP 200, 498 bytes, status 5003, same nine token classes. The live step-2 URL the bag serves is https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation. Consequences: (1) the invented TLV framing, the wrapper encoding, and the step-2 content-type are all retired as causes — the endpoint refuses WITHOUT examining the body, so no further body flip can arbitrate anything; (2) the rev-18 "byte-identical under five variants" reading is corrected: the trace carries a live per-request epoch (the two rev-20 runs' epochs sit 28 s apart, matching the gap between them) and three per-request uuids, so those responses were shape-identical, not byte-identical — and the rev-19 inference built on that reading is superseded by this experiment; (3) the surviving suspect class is the request ENVELOPE: the header set a genuine client carries (the anisette + BAA set, §1.1/C6) or an identity/attestation the endpoint resolves before parsing; (4) next levers, ranked: a one-call envelope probe with a plausible client header set (a UA, X-Apple-I-Client-Time/TimeZone/Locale, X-Mme-Device-Id — none of which needs the OTP derivation that is still missing), then the genuine-client wire capture, which is now the only way to see the real envelope. Instrumentation note (dev-only, uncommitted): the live runner dumps the exact request bytes mode-0600 beside the blob and classifies the server trace token-by-token (shape plus boolean match against our own digests/identifiers), so every attempt is post-mortem-analyzable without spending another call.
    • 2026-09-11 (rev 21, offline re-read + cross-checks — this SUPERSEDES the rev-20 conclusion that the body half is closed): (a) the rev-20 experiment cannot prove the endpoint ignores the body. Both requests it compared were invalid — the 20-byte control is not a plist at all, and our reconstruction's session-info-request is the invented TLV v1 blob, emitted in place of the closed-source library's real output — so "one generic 5003 for both" is equally consistent with "the server parses each, rejects each, and reports a single generic code". Retiring the TLV framing is therefore withdrawn: framing is live again, and it is now the leading suspect, because the reference client's envelope is body-only (below). Content-type and the step-2 crypto values stay retired (they were flipped and changed nothing). (b) 5003 = IDSStatusBadRequest in the IDS status space [IG:imessage/direct/ids/types/idserrors.gen.go]; that family has dedicated body-level codes (ParseFailure 1000, InvalidField 1001, DecodeFailureBase64/Hex 1004/1005, MissingRequiredKey 5008) and none came back — weak evidence for a pre-body rejection, equally explicable by this endpoint simply not using the fine-grained codes; not decisive either way. (c) The model client is current and still body-only: the rustpush reference IS the OpenBubbles fork (origin github.com/OpenBubbles/rustpush), re-fetched 2026-09-11, unchanged from the snapshot on file; its step-2 call remains REQWEST.post(bag_url).body(xml_plist), no content-type, Accept-Language: en-US,en;q=0.9 the sole default header (src/macos.rs, src/util.rs). If that client completes the exchange today, headers are not the cause — and the two envelopes then differ only at the HTTP layer (client stack, protocol version, header casing). (d) Route confirmed against a genuine device: the archived 2026-09-10 capture's id-initialize-validation bag value is byte-identical to the URL the live run posted to. (e) The genuine bag enables absinthe attestation on this endpoint family (absinthe-baa-option-enabled, -ios13, absinthe-v3-enabled all true) — the one mechanism that could still demand an envelope this project cannot currently produce. (f) SED trace grammar from the two recorded responses: SED:3:VEN-PROD:<uuid>:<uuid>:<unix-epoch>:<uuid>:<64-hex>:EM; every uuid and the epoch differ between the runs (the epochs 28 s apart, matching the calls) and the 64-hex differs too and matches no digest of the recorded body, the empty string, or the step-2 URL — per-request state, not a hash of our input. (g) Ranked next levers: the genuine-client capture, now valued primarily for the real session-info-request BLOB (plus, in the same exchange, the real step-3 validation blob) rather than for the header set; then, only if the capture shows a richer envelope, the envelope probe. The capture needs a client that performs the exchange (an OpenBubbles app or a rustpush-based bridge — the macOS identifier-export helper never does); one is reported available.

Revision history

  • Rev 36 (2026-10-12, C56-c CLOSED — the session250 hardware-identity serialization ported to Kotlin and keyed off the C58 export identity): HardwareConfig::to_bin (the 2026-10-08 static-disasm grammar, NAC-NOTES.md) is implemented as registration HardwareConfiguration.toBin() — write_str = u8(len+1) ‖ utf8 ‖ NUL, write_item = u8(len) ‖ data; serial order — product name, MAC address (raw 6 B, no prefix), serial, platform UUID, root-disk UUID, board ID, OS build, the constant dword 00 03 0b 52, then the seven binary items in Mac-Hardware-Info export field order — serialEnc, platformUuidEnc, rootDiskUuidEnc, ROM, romEnc, MLB (ASCII bytes, no NUL), mlbEnc. Two corrections fall out of the export cross-check: (1) the raw second field is the MAC address, NOT the ROM — the rev-32/35 "u32le@+0x138 = ROM" label is corrected (the live capture's 6-B Vec item carries the multicast bit, which no unicast MAC does; the OA stub's field types put the inline [u8;6] io_mac_address at that struct slot, and ROM is a Vec); (2) the seven binary Vecs are exactly the export's identifier fields 8–14 (the five _enc blobs, ROM bytes, MLB ASCII) — a 1:1 mapping with nothing unpinned remaining. Verified: toBin round-trips the live 250-B capture byte-exactly (capture stays in the gitignored captures/ dir; tests are synthetic). Kotlin: 217 registration tests green; imessage ClearNacValidationSource.mint(oabsExport) unstubbed at C56-c — the mint chain is fully real offline (ValidationDataSource.mint now takes the parsed export; rngPad generalized to 0x17c − session250.size). No §1.1 input mapping remains open; the next milestone is a live registration attempt. — First live step (2026-10-12, same day): a JVM smoke ran the real exchange against Apple — step 1 (bag + static cert, byte-identical to the 10-08 capture) OK; step 2 POST of the closed blob → HTTP 200 error plist status 5003, mnemonic EM (reproduced ~8x, both chain certs as the OAEP key, with/without default headers). The blob construction re-verified against the replay dumps while chasing it (init_key(pearKey16) via the captured rk176, mapA/mapB, field32 = mapA[ciphertext]); the residue is therefore outside the blob bytes (anisette-on-step-2 / transport fingerprinting / an unseen precondition — NAC-NOTES session-26), and is the live-mint gate.

  • Rev 37 (2026-10-12, live-provisioning wire facts + the step-2 EM A/B): driving the Kotlin ClearAnisetteProvider against Apple's live endpoints for the first time (the session-26 EM A/B, NAC-NOTES session-26 addendum) surfaced two wire facts the offline scripted-HTTP tests could not catch: (1) the GSA lookup GET 404s without X-Mme-Client-Info, and accepts only the angle-bracket form <Model> <macOS;ver;build> <com.apple.AuthKit/1 (…)> — a bare com.apple.AuthKit/1 (com.apple.akd/1.0) value 404s (the app's real value: <MacBookPro18,3> <macOS;26.6.2;25G83> <com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)>; imessage's loginInfoFor/DEFAULT_LOGIN_INFO already use the angle form); (2) the start/finish provisioning POST body keys are case-sensitive: {header, request} (lowercase — what the Kotlin sent) gets a silent HTTP 200 with an EMPTY body, {Header, Request} (as §1.5/C6 records) gets the real Response.spim/ptm/tk — fixed in ClearAnisetteProvider (Kotlin now sends the capitalized keys; 217 registration tests green; first live Kotlin provisioning run succeeded: metadata 400 B / mid 60 B). The vendored anisette client's fixed companion headers (rev-14 set) were confirmed against a live 2026-10-03 capture of the app's own lookup. Step-2 gate A/B (all via the identical transport): the app's genuine 10-08 blob replay → 5003/EM; our minted blob → 5003/EM; a FRESH blob minted by the app's own unmodified ValidationCtx::new the same hour (replay-state eliminated) → 5003/EM; with the full anisette header set on step 2 (± hardware headers, both blobs) → 5003/EM ×4 — the anisette-on-step-2 hypothesis is falsified. Residue: transport fingerprinting vs a server-side gate for everyone; the app's own-flow verdict is blocked on interactive IDS re-registration (user Apple-ID login) — removing anisette_test/state.plist re-fires provisioning but never the mint (no ValidationCtx::new hit in a fully-trapped run), and the vendored anisette client no longer traverses an intercepting proxy (baked DER root, SSL_CERT_FILE ignored).

  • Rev 38 (2026-10-12 late, the §1.1 step-2 5003/EM gate CLOSED — it is a TLS-stack fingerprint, not a blob/server-gate problem): after the user's 2FA re-login re-fired the app's own IDS registration and the full mint chain was observed ACCEPTED live (vctx_new → step2_serialize → key_establishment → vctx_sign ×3, gdb traps), fresh genuine blobs minted per-test (gdb ValidationCtx::new) settled the transport matrix: reqwest 0.11 + rustls 0.21 (+ the vendored client's baked Apple root DER, extracted from the .so at 0x24afa9 — webpki- roots/Mozilla does not include this Apple private root) POSTing the identical XML-plist envelope gets a real session-info response; curl (OpenSSL 3) over BOTH HTTP/2 and HTTP/1.1, and curl with UA/Accept headers removed (Accept-Language only), gets 5003/EM on the same fresh blobs. HTTP version and request headers are irrelevant: the VEN endpoint (Akamai-fronted identity.ess.apple.com) fingerprints the TLS ClientHello and rejects OpenSSL-shaped handshakes with the app-layer error 5003/EM. Earlier probes (JDK HttpURLConnection, python urllib, both cert-verif-off) were rejected for the same reason. Kotlin NacRequestBlob and the §1.1 framing are fully vindicated — the blob and envelope were never wrong. Consequence: the imessage tool's §1.1 HTTP must present a rustls-shaped handshake — JVM/Android default stacks (conscrypt/BoringSSL, OpenSSL-shaped) are expected to be rejected (unverified on-device); Conscrypt (Android's default stack, BoringSSL) is ALSO rejected — verified with a plain HttpsURLConnection probe on the lightos AVD posting a fresh genuine blob (EM-REJECTED) — so the accepted shape so far is rustls specifically, and the platform TLS stacks cannot do §1.1 step 2 at all. Implementation: a rust helper (rustls) behind UniFFI in :server (the calls/ pattern); a fingerprint-shaped JVM stack is not viable given the BoringSSL result. Evidence + probe sources: registration/dev/clearadi/captures/c56j/tls-ab/ (NAC-NOTES session-26 EM-gate closure).

  • Rev 35 (2026-10-12, C56-a CLOSED — direct in-process replay of ValidationCtx::new, two byte-verified runs): the request blob's full recipe — header ‖ OAEP(pubkey, pearKey16 ‖ hmacKey20) ‖ RNG16 ‖ u32be(32) ‖ field32 ‖ HMAC-SHA1(hmacKey20, field32), with field32 = mapA[encrypt_cbc(mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros], iv, init_key(pearKey16))] and mapA/mapB the 0x2249f9/0x224af9 byte-substitution tables (table[byte], not xor). Kotlin NacRequestBlob; imessage unstubbed at C56-a; live-mint gate = C56-c only.

  • Rev 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.

  • Rev 33 (2026-10-09, the sign-model fresh-input divergence CLOSED — the last functional live-mint blocker on the sign path): two session-20-frozen constants inside SignState::sign are falsified and replaced by their input-dependent forms — the staging maze's i==10 ecx2 index (perm2u[st[0x6fa2]], was frozen perm2u[0x40]) and the ENTIRE P region state+0x6898 (7×256; five of seven tables are parameterized on five body-derived byte XOR pairs via the SSE builder at 0x17a1381-0x17a20b5 — the "byte-identical across base/mod/body-flip" claim held only because those captures shared a body). The builder stream is serialized into generated Kotlin (NacPTable) and re-executed lane-wise, byte-exact vs the emulated builder on fresh + random + s20 states. NacSign re-ported with all body-derived regions computed (region6f98/0x6fd8/0x6fe8/0x6fec/N/cursor/RC); fresh live sig16 + full 517-B stamp byte-exact, session-20 still byte-exact; 209 registration tests green, imessage :server:testDebugUnitTest green. Live mint still gated on C56-a + C56-c (capture) and C58 (OABS parser).

  • 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.

  • Rev 28 (2026-10-07, live capture — one real OpenBubbles provisioning run under gdb over the unstripped Linux .so; dumps + method in registration/dev/clearadi/captures/WIRE-NOTES.md): the §1.1 anisette framer is corrected on every point the previous session left TODO(capture). The cpim is an OUTER 276-B message [u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52] over a 160-B plaintext; the PTM is [u32 BE 4][seed 16][u32 BE payloadLen][payload] with the seed riding into decryptPTM as its chain seed; ProvisionedData = {metadata Vec (400 B), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8} with the finish gate = the decrypt output's out[408:440] == rng1 echo; the spim carries 99 trailing bytes (the ≤23-B cap was a probe artifact) and trailing[16:76] is relayed verbatim as the wire tail's Y group. Superseded readings recorded dead: the 'IV from Spim trailing fields' (it is the thread-RNG pool), the 'two 256-bit sig/sha tail fields' (neither the spim_map_signature output nor the SHA-256 digest appears on the wire), and the OTP-path ProvisionedMachine mid/secret ordering. Remaining TODO(capture): the X32 source, the 12-B plaintext tail, trailing[0:16]. Kotlin ClearAdiFraming/ClearAnisetteProvider restructured to the live shapes; 194 tests green.

  • Rev 26 (2026-10-02→04, live registration of this protocol family against real Apple endpoints — Albert activation, APNs courier (packed ALPN), the full GSA chain through 2FA, and IDS authenticate + register all green; user-gated with a real Apple ID, mailto: handle ACTIVE): six live-verified fact groups beyond the extraction record. (1) The 409's root cause is the validation-stamp REPLAY guard (§1.1) — one mint = one device record: a consumed blob re-submitted answers HTTP 409 ICLOUD_UNSUPPORTED_DEVICE, a fresh (or captured-elsewhere) blob succeeds; the earlier readings (request shape, blob provenance) are dead. §1.2 authenticate + §1.4 register completed green after the stamp route was fixed, with the mailto: handle active (§1.3/§1.4). (2) The courier's packed format is live (§3.1/§3.2, C19 CLOSED) — the server selects apns-pack-v1:4096:4096 and speaks the tag-based packed codec (full wire shape recorded from the wire); its chain anchors at the Apple Server Authentication CA intermediate, no TLS client certificate is demanded on that path, and the post-connect set-state + filter sequence is mandatory (unfiltered connections are dropped within seconds, invalidating the connect token). (3) Apple's live GSA sign-in response is kebab-case — delegate service-data/status-message (camelCase recordings become the fallback), and the sign-in body's client id rides client-id (§1.5). (4) Anisette freshness is per-run and per-call — fresh per SRP run incl. the post-2FA re-run (stale → ec −22421), fresh per 2FA call (frozen set → HTTP 434); the trigger/validate GETs carry the plist content type, and failed verifies name the real failure in the body plist's Status.ec/em (§1.5). (5) The GSA postdata liveness event is recorded in full (§1.5) — services icloud/imessage/facetime, X-Apple-I-UrlSwitch-Info/X-Apple-HB-Token binding, ptkn (−800012 with a dead push token — the courier connection must stay open across the registration), and the order sign-in FIRST, postdata AFTER (correcting the earlier reversed note). (6) Albert activation refinements (§1.2) — DeviceCertRequest is <data>(base64(PEM)), DER rejected; the accepted FairPlayCertChain is leaf + FairPlay CA + Apple Root CA concatenated; the response's <Protocol> block may arrive wrapped in a larger <Document> (parse the span); refusals state their reason in wrapped-UI page text. Code side: the packed courier codec + ALPN negotiation, GsaPostdata, the kebab-case parsing, the per-run/per-call anisette providers, the <Protocol>-span extraction, and the three-cert FairPlay chain all landed; the validation stamp stays caller-injected with the replay guard documented.

  • Rev 25 (2026-09-29, first LIVE GSA login of this protocol family — SRP init/complete, both 2FA steps, and the PET-bearing re-login against gsa.apple.com with a real Apple ID, user-gated; the delegate sign-in POST was attempted and refused): the §1.5 chain is live-confirmed end-to-end up to the delegate sign-in, with five behavioral facts beyond the extraction record. (1) au (trustedDeviceSecondaryAuth observed) rides INSIDE the complete response's Status dict — reading it at the Response level silently turns a 2FA login into a "logged in, no PET" dead end (§1.5 2FA). (2) The PET's second live-confirmed delivery channel: the post-2FA re-login's decrypted spd token map t (21 service tokens observed incl. com.apple.gs.idms.pet), while a no-2FA login's spd carries only com.apple.gs.appleid.auth and no PET — an earlier implementation inference that a no-2FA login suffices for the PET is dead; 2FA is mandatory for the PET on this account class (§1.5 PET). (3) The trusted-device trigger creates the code on the devices and must precede the prompt; triggering twice before one validate invalidates the outstanding code and re-prompts (§1.5 2FA). (4) Anisette is one-time per request: headers minted at flow start are spent by the GSA hops, and the delegate sign-in with a reused set is refused — mint fresh per POST, matching the reference client's per-request provider (§1.5 delegate logins). Post-login ADI behavior confirmed live: after an account login the machine re-provisions for the personal DSID (GetLoginCode → not-provisioned → full exchange succeeds), which is the personal-X-Apple-I-MD-M path, not a defect. (5) The delegate sign-in answered HTTP 409 ICLOUD_UNSUPPORTED_DEVICE with the recorded error-plist shape (§1.5 delegate logins) — a device-identity-bound refusal, the current open blocker. Velocity note: a persisted-password-hash re-login was rejected once with -22406 ("enter the correct password") after four full logins in 30 minutes, with the identical hash derivation succeeding twice earlier in the same window — treat as server-side velocity throttling, not a derivation defect.

  • Rev 24 (2026-09-28, first live probes of this implementation — two unauthenticated bag GETs + a no-client-cert courier TLS handshake, runner :engine:runProbe; nothing else touched): (1) C18 SETTLED — the courier demands the Albert client certificate via a fatal TLS 1.3 certificate_required alert after a successful handshake, on both ports (§3.1); (2) the bags are TYPED CONFIG DICTIONARIES, not URL maps — the live IDS bag carries ints/bools/floats/string-lists alongside 400+ URLs and a bag-expiry-timestamp (~17-day observed TTL; 403 entries live vs 405 on 2026-09-10, two new push-debounce keys in, four A/B-test keys out); the APNs bag is 65 stable entries incl. BAA-authentication flags (APNSEnableBAAAuthenticationForDevices etc. — C17/C20-relevant); (3) AppleTrust's embedded-root pinning validates the live chains of init.ess.apple.com and courier.push.apple.com (no root served — chain anchored at the embedded root); (4) rev-23's container-fact follow-ups: sort-version 0 and the ref-width rule are now capture/probe-consistent.

  • Rev 23 (2026-09-27, writer-pass follow-up, [CF] public material + host-decoder empirical cross-check only — no restricted source): three §4 container-format gaps found by the first writer session are closed — integer signedness (1/2/4-byte forms unsigned; the 8-byte form is two's-complement signed; non-negative values take the minimal width, negative values always the 8-byte form), the object-reference width rule (minimal width whose 2^(8w) ≥ object count, so every reference 0..count−1 is representable), and the sort-version byte (written as 0).

  • Rev 22 (2026-09-12, offline byte-audit of the saved live step-2 request — no network call, nothing committed): the CLIENT-SIDE ENCODING class is closed. The exact 3898 bytes the live runner sent (§7 item 24) parse as an XML plist with the single session-info-request <data> key, and its 2729-byte payload walks the invented TLV v1 to the last byte with ZERO slack, 17 distinct tags, no duplicates and no trailing bytes — our bytes are exactly what the code intends, so no writer bug hides there (the framing intent stays invented and unverified). By-product fact: the step-1 cert data field is NOT a bare DER cert — it is Apple's own container, 01 02 followed by two (4-byte BE length, DER cert) pairs (1046 and 1329 bytes; 2+4+1046+4+1329 = 2385 exactly), carried through verbatim by this implementation (§1.1) and cross-checking against the archived genuine step-1 response size. Apple's length convention there is 4-BYTE big-endian where our TLV v1 uses 2 — the only Apple framing ground truth in hand, and it weakens TLV v1 further.

  • Rev 21 (2026-09-11, step-2 refusal re-read + offline cross-checks — no new live calls): one correction and five facts. CORRECTION — the rev-20 "BODY-INDEPENDENT" conclusion is withdrawn as unproven: a 20-byte non-plist body and our 3898-byte reconstruction returning the same generic status 5003 establishes only that 5003 is this endpoint's generic request-level failure; it cannot separate "the body is never parsed" from "both bodies are parsed and both rejected identically", because the reconstruction is itself unverified — the session-info-request blob is minted by the closed-source library (§1.1 [OA]). Blob FRAMING is therefore live again beside the envelope. FACTS — (1) 5003 is named in the IDS status space: IDSStatusBadRequest [IG:imessage/direct/ids/types/idserrors.gen.go], a table auto-generated from web FaceTime's JavaScript, so it is the IDS family's space and not proven for this WebObjects service; that same table shows what a body-level rejection in this family looks like (ParseFailure 1000, InvalidField 1001, DecodeFailureBase64/Hex 1004/1005, MissingRequiredKey 5008) and the endpoint returned none of them. (2) the rustpush reference IS the OpenBubbles fork (github.com/OpenBubbles/rustpush), re-fetched 2026-09-11 and unchanged from the snapshot on file, and its step-2 call is still REQWEST.post(bag_url).body(xml_plist) with no content-type and one default header Accept-Language: en-US,en;q=0.9 — a current model client's envelope is body-only, which makes the header half the weakest suspect and the blob the strongest. (3) The route is confirmed against a GENUINE device: the archived 2026-09-10 capture's id-initialize-validation bag value is byte-identical to the URL the live run posted to. (4) That genuine bag enables attestation on this endpoint family — absinthe-baa-option-enabled/-ios13 and absinthe-v3-enabled all true, absinthe-baa-cert-ttl-minutes 144000 — the one mechanism that could still put the envelope back in front. (5) Both SED traces are now recorded as a grammar: SED:3:VEN-PROD:<uuid>:<uuid>:<unix-epoch>:<uuid>:<64-hex>:EM; between the two runs every uuid and the epoch differ (the epochs 28 s apart, matching the calls' spacing) and the 64-hex differs, so it is per-request, not a digest of any recorded input (checked against the body, the empty string and the step-2 URL). Next lever unchanged: the genuine-client capture — valued now for the BLOB it reveals, not the header set.

  • Rev 20 (2026-09-11, live step-2 control experiment): the step-2 refusal is BODY-INDEPENDENT — a 20-byte body that is not a plist at all and the full reconstructed 3898-byte request are answered identically (HTTP 200, the same 498-byte status 5003 plus a nine-token SED trace; only its per-request epoch and uuids differ between the two runs). The invented TLV framing, the wrapper encoding, and the step-2 content-type are therefore ALL retired as causes at this endpoint: nothing in the body is examined before the refusal. The rev-18 "byte-identical" reading is corrected — the trace carries a live per-request epoch (two runs 28 s apart) and three per-request uuids, so the responses were shape-identical, and the rev-19 inference chain from that reading is superseded by this direct experiment. Surviving suspect class: the request ENVELOPE (the header set a genuine client carries, or an identity the endpoint resolves before parsing); the live step-2 URL is now recorded (§1.1, §7 item 24).

  • Rev 19 (2026-09-11, step-2 self-check round): the step-2 request reconstruction was audited against the extracted client's own request construction (§7 item 24) — the XML-plist wrapper is confirmed, while our text/x-xml-plist default turned out to be an unsupported inference: the recorded client sets NO content-type on the step-2 POST (§1.1), so all three content-types of the rev-18 probe matrix were non-faithful (the constant is now absent in the code); the rev-18 "invariant under every flipped variable" claim is corrected to a body-content independence (the five attempts did NOT send identical bodies — fresh key material each time), which retires the step-2 crypto variables and leaves the invented TLV framing as the prime suspect; and an archived genuine-device capture shows the validation endpoint family reached with the full anisette/BAA header set while step 1 succeeds header-less because that URL is a CDN static object (§1.1) — next levers: one faithful (no content-type) step-2 POST beside a deliberate control POST, then a genuine-client capture.

  • Rev 18 (2026-09-11, first live step-1 attempt + probe matrix): the §1.1 validation exchange ran against the live Apple endpoints for the first time — bag + step-1 cert GET OK (pinned TLS, HTTP 200); FIVE step-2 POST variants (default C60 set, X25519, HMAC, three content-types) all answered HTTP 200 with the same 498-byte error plist (status 5003, SED trace message) and no session-info (§1.1, §7 item 24) — the refusal is invariant under every flipped variable, so the C60 crypto hypotheses remain open and the next lever is a genuine-client wire capture; the SED status table is a new TODO(capture).

  • Rev 17 (2026-09-11, FairPlay material metadata extraction): the provenance half of the FairPlay identity question is closed (§1.2, §7 item 21) — all known embedded copies across the reference repos are ONE 1024-bit RSA identity (leaf cert expired 2024, yet accepted), descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies; no own-Mac extraction path is documented anywhere (open research item); BYO-identity is recorded as the sane architecture.

  • Rev 16 (2026-09-11, activation implementation round): the three remaining GSA header literals are pinned (§1.5 — browser UA, non-akd X-MMe-Client-Info, the full-wrapper X-Apple-I-CFU-State plist, the bracketed sign-in client-info); the validation-data exchange is implemented — steps 1–2 concrete, step 3 as an explicit contract reconstruction with six named hypotheses (key agreement, KDF, signature, session-info interpretation, blob framing, step-2 content-type) collected as new C60 (§7 item 24) and arbitrable only by one live activation; the Mac-Hardware-Info export parser and the C59 constants are wired into code.

  • Rev 15 (2026-09-11, second activation extraction round — C59 + C58 closed): the GSA-login residue is recorded (§1.5 closure paragraph: RFC 5054 2048-bit SRP group with g=2, the full 2FA browser header set with literal values, per-flavor cpd values, akd UA/X-MMe-Client-Info forms, spd t-map inner keys + top-level GsIdmsToken + fn/ln, the gsas.apple.com postdata/teardown host split, the sign-in UA/timezone forms); C58 is closed — the Mac-Hardware-Info export-code format is fully recorded in §1.1 (OABS magic + flag byte + protobuf field map; plain-base64 or OpenSSL-Salted__ AES-256-CBC delivery; sharing mechanics server-side; upstream repo unlicensed — facts only). Citation legend gains [MHG].

  • Rev 14 (2026-09-11, activation extraction round — facts-only over the anisette/GSA crates and the Mac client, facts-only extraction): C6's value-format and provisioning halves are closed (§1.1 — per-request anisette value forms, the ADI provisioning flow with spim/cpim/ptm/tk, the 60-byte Apple-issued mid, the 16-byte local keychain_identifier, machine-state persistence); new §1.5 records the full GSA login chain end-to-end (SRP-6a 2048-bit with the GSA empty-username identity-hash quirk, s2k/s2k_fo, spd decryption, both 2FA variants + the Circle PAKE, PET semantics, delegate logins yielding the IDS authToken/profileId, the X-Mme-Nas-Qualify sign-in POST); the five obfuscation labels are mapped to their fields (§1.1); C56 step-3 and the OTP derivation are CONFIRMED closed at source (upstream ships stubs — contracts only).

  • Rev 13 (2026-09-11, implementer round — rev 12's closed fact sets wired into code; no new protocol facts): courier command builders + connect/auth + the engine's courier frame handler are concrete (§3.3, C45); lookups ride the APNs tunnel with the C47/C48 wire names, plain-string uris, and C55 top-level-status routing (§2.1); register/authenticate bodies and the Albert activation wire half are concrete (C53/C54/C8, §1.2/§1.4); envelope fixes landed (HKDF salt "LastPawn-MessageKeys" with empty info, pair HMAC keying, recorded pair-ec field numbers, dtl array-of-dicts regression-tested, §4). Nothing closed was re-opened. C57 (§7 item 21) gains six writer-pass gaps: filter multi-topic encoding, connect field-0x10 position, connect-response 5/6 shapes, tunnel 96/97 field ids + payload framing/encoding + status table, phone-flow authentication-data value encodings, Albert request/response value types.

  • Rev 12 (2026-09-10, facts-only extraction round over both source repos plus the OpenAbsinthe-Stub mock): closed C45 (courier field-id tables + length-prefix scope, §3.2–3.3), C47/C48 (lookup headers/keys, §2.1), C49/C51/C52 and C50's protobuf half (§4), C53 (register wire names, §1.4), C54 (authenticate exchange, §1.2), C8's wire half (Albert activation, §1.2), and C56's steps 1–2 with the GSA login + full anisette set (§1.1). Corrected: the HKDF "info string" is the salt (§4.3); dtl is an array of dicts; payload command 160 is not a frame command (§3.3, §5.1). Narrowed: C55 — both clients read lookup error statuses from the top-level status only (§2.1, §7 item 19). New first-class architecture fact: IDS lookups ride the APNs tunnel, never plain HTTPS (§2.1). New C57 (§7 item 21) collects extraction disagreements and closed-source residue. Legend gains [OA].

  • Rev 11 (2026-09-10, OpenBubbles-flow research — C5/C6 model corrected): the Mac's role in activation is identifier export only — the shipped OpenBubbles helper (Mac-Hardware-Info 1.2.0, notarized) reads serial/board-ID/model via public IORegistry/sysctl APIs on any macOS ≥ High Sierra including 26.x, and never calls an Apple private framework (§1.1, §6.1). The prior "minting requires a Mac on macOS ≤ 14.3" conclusion bound only framework-calling helpers (beeper's archived mac-registration-provider; rustpush running on a Mac); the remaining C5/C6 work is client-side software — the generation procedure, recorded as new C56 (§7 item 20). Legend gains [OB-MHI].

  • Rev 10 (2026-09-10, Phase 2 lookup-orchestration component): added C55 (§2.1) — the lookup response's error-status locations: whether 5206 ("response too large"), 6005, and the send-side 5040/5041 session-token codes appear as a top-level response status, per-handle result statuses, or both; the lookup flow reads per-handle result statuses only, the one numeric-status location the fact set records.

  • Rev 9 (2026-09-10, Phase 2 component 5): registered the registration wire-name gaps the component's writer pass hit — C53 (§1.4: register request/response key names beyond the recorded set, and the x-auth-user-id-<N> value form) and C54 (§1.2: the authenticate request/response pair — body wire keys, CSR encoding, CN encoding, self-signature algorithm, response keys) — plus §7 item 18. The activation code takes every unrecorded name as caller-supplied configuration and keeps every unrecorded response field in a raw map.

  • Rev 8 (2026-09-10, Phase 2 component 4): [CAP-IDSTLS] (§1.2) fixed the IDS TLS anchor set — Apple Root CA - G3 and Apple Root CA, roots not served, system store rejects the unanchored chain — and [CAP-BAGCT] (§1.2, §7 item 16) closed C47's encoding half: both bag responses and the embedded bag payload are XML plists on the wire, content-type labels notwithstanding. The component-4 writer pass added TODO(capture) C49–C52 and §7 item 17 (unrecorded plist/body value types, the §4.2 HMAC key + ECDSA wire encoding, §4.3 protobuf field numbers beyond validator=99 and the HKDF salt).

  • Rev 7 (2026-09-10, Phase 2 component 3): recorded the IDS lookup/bag wire details the phase-1 fact set never captured — endpoint plist encodings and the unrecorded lookup header/body/response key names (C47/C48, §1.2, §2.1, §7 item 16). The writer phase guesses none of them: they are configuration in the code until a capture closes them.

  • Rev 6 (2026-09-10, Phase 2 component 2): added [CAP-COURIERTLS] chain ground truth to §3.1 and the C45 framing-gaps TODO (§3.2, §7 item 15).

  • Rev 5 (2026-09-10, Phase-2 start): added the publicly documented binary-plist container format to §4.1 ([CF]) so the clean-room codec can be written from the spec alone.

  • Revision 2 merged three phase-1 extractions plus first capture ground truth (own mitmproxy captures of a registered iPhone 14,4 running iOS 26.1, 2026-09-10 — still the only post-PQ3 evidence here).

  • Revision 3 (2026-09-10) is a second targeted extraction round over both source repos on the three highest-value gaps (lifetimes C41/C10, envelope selection C25, bag/registration C1/C3); it corrects two rev-2 misreadings (the HTTP signature construction is agreed, not disagreed, §1.4; the pair-ec validator final byte is agreed 0x0c, not "0xc0-ish", §4.3) and re-reads the ~45-day certificate figure as a client-side cap (§6.1).

  • Revision 4 (2026-09-10) is the phase-1 review-gate pass: removed one leaked source identifier (registered_at, rewritten as prose), paraphrased the only reproduced source fragment, added citation-form legend entries and the protocol-constant provenance notes; no copied expression, code, or structural transcription found.