Engine modules: :codec (binary+XML plists), :courier (APNs push client), :ids (identity lookup), :crypto (the encryption envelope), :registration (GSA login, albert activation, renewal), :engine (integration + live drift probes). 344 offline tests; capture-fixture known-answer vectors for the parsers; live unauthenticated probes validated against Apple. The bundled FairPlay identity is a synthetic placeholder — Apple-derived material is user-staged, never shipped. Apache-2.0.
170 KiB
iMessage protocol spec (clean-room)
Status: draft — revision 24. 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) reference/rustpush IS the OpenBubbles fork (origin = github.com/OpenBubbles/rustpush, i.e. the genuine client the capture plan names); its tip re-fetched 2026-09-11 equals the staged snapshot, 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, read-only agents, main-session merge): 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 by four sequential firewall-clean implementer components; 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.
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; reference/imessage-captures/, gitignored) — ground truth, post-PQ3 · [CAP-IDSTLS]/[CAP-BAGCT] direct unauthenticated probes from this machine (2026-09-10, reference/imessage-captures/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-certbag URL; POST a session-info request toid-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 singlecertdata field; step 2 POSTs a single-key bodysession-info-request(data) with no anisette or authentication headers — body only — and receivessession-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 isAccept-Language: en-US,en;q=0.9[RP:util.rs], [RP:macos.rs]. A header-less step-1 GET succeeds because theid-validation-certURL resolves to a CDN static object (a genuine device's capture of that same GET showsstatic.ess.apple.com, plain HTTP,X-Cache: hit-fresh, a 2021Last-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-Info1.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 theid-initialize-validationsession 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_encbyte 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_encraw-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 (sysctlkern.osproductversion), 3 protocol version (hardcoded1640), 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 prefixMac-+ lowercase hex of the device-tree board-id bytes), 7 OS build number (sysctlkern.osversion), 8/9/10/12/14 the five_encobfuscated blobs read from theIOPower:/registry under Apple's key names (Gq3489ugfiserial,Fyp98tpgjplatform UUID,kbjfrfpoJUroot-disk UUID,oycqAZloTNDmROM,abKPld1EcMniMLB — matching §1.1's label mapping), 11 ROM (Intel: the device-tree options ROM value; Apple Silicon: last 6 bytes of SHA-256 of/chosenunique-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 ASCIISalted__+ 8-byte random salt (legacy OpenSSLenccontainer), 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 alphabetABCDEFGHJKLMNPQRSTUVWXYZ123456789), then base64. The restricted mode additionally uploads the blob (JSONdata+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-datafield of the IDS register body and, base64, in theX-Mme-Nas-Qualifyheader 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. - 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 headersX-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 IDSauth-token/profile-idand the PET token [RP:auth.rs], [APA:icloud-auth]. The extraction confirms the validation blob rides base64 in theX-Mme-Nas-Qualifyheader 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 byZ(provisioning requests send the raw offset form without the Z — a recorded inconsistency between code paths);X-Apple-I-TimeZone= literalUTC(not the device zone);X-Apple-Locale=en_UShardcoded (a companionX-Apple-I-Locale: en_USvariant 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-bytemid;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 whoseurlskeysmidStartProvisioning/midFinishProvisioninggive the endpoint URLs (never hardcoded); POST to the start URL with an XML plist body of two empty top-level dictsHeaderandRequest→ responseResponse→spim(base64); a local session is built fromspim(+ empty hostuuid, DSID-2, flavorMac=1 /IOS=3, flavor chosen by whether the client-info string containsiPhone OS) yieldingcpim(base64); POST to the finish URL withRequest= {cpim} → responseResponse→ptm,tk(both base64) andX-Apple-I-MD-RINFO; finishing the session with the decodedtk/ptmyields 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-urlencodeddespite 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: akdUser-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, plusX-Apple-I-MD-LU,X-Mme-Device-Id,X-Mme-Client-Info,X-Apple-I-Client-Timeand any caller-supplied hardware headers. State persists as an XML plist withkeychain_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 withtodo!()methods; recorded is only the call contract: inputs (32-byteclient_secret, 60-bytemid,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.
1.2 Activation endpoints and certificates
-
Public write-ups name
albert.apple.comas the activation service that sets up the "push" credentials (including the certificate needed for APNs) [BM], [JJ], andidentity.ess.apple.comas 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 athttps://albert.apple.com/deviceservices/deviceActivation?device=MacOS[RP:activation.rs], [IG:pushcert]; IDS registration athttps://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=3atinit.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 atinit-p01st.push.apple.com/bagserves 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 singlebagkey 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 fromhttp://init-p01st.push.apple.com/bagand resolves integerAPNSCourierHostcountand stringAPNSCourierHostname[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 athttps://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber, dependent registrations at.../TDIdentityService.woa/wa/getDependentRegistrations, handles at.../VCProfileService.woa/wa/idsGetHandles, lookup athttps://query.ess.apple.com/WebObjects/QueryService.woa/wa/query(used only inside the APNs HTTP tunnel, never plain HTTPS), registration athttps://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 withsignature,certs, andbagkeys, the inner bag a flat dictionary of 405 entries (full response archived asreference/imessage-captures/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 scalarid-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 embeddedbagpayload 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(theVCPermsService.woaapp also serves the bag — [CAP-BAG] had usedVCInit.woa) returnedContent-Type: text/xml, 94,859 bytes, an XML plist, top-level keyssignature/certs/bag; the embeddedbagvalue 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) returnedContent-Type: application/x-apple-plistbut 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/xmlvsapplication/x-apple-plistwith XML bodies) and parsing goes by content, not label. Also [CAP-IDSTLS]:openssl s_clientwith bare-hostname SNI toinit.ess.apple.com:443andquery.ess.apple.com:443— init serves leafCN=init.ess.apple.com←Apple Server Authentication CA 2←Apple Root CA - G3; query serves leafCN=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 - G3andApple 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 staged in the worktree asserver/src/main/resources/imessage/AppleRootCA-G3.cerandAppleRootCA.cer(G3 from Apple's live CA page; the G1Apple Root CAis no longer published by Apple — sourced from the public CT log, crt.sh id 1160190, SHA-256B0: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-25663: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 keyactivation-infowhose value is an XML plist carryingActivationInfoComplete(true),ActivationInfoXML(a nested XML plist),FairPlayCertChain, andFairPlaySignature; 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), plusack-receivedandshow-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). -
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.comendpoints 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-idbag URL [RP:auth.rs] (pinned URL variant:profile.ess.apple.comauthenticateDS [IG:bag]); for phone-number users the realm id has the form "P:" againstid-authenticate-phone-number[RP:auth.rs] (pinned variant:identity.ess.apple.comauthenticatePhoneNumber [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 keysid-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 hasid-authenticate-multiple-usersfor 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-dataholdsauth-tokenfor the Apple/DS flow andpush-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 onlyuser-agentandx-protocol-version(Rust addscontent-encoding: gzip) [RP:auth.rs], [IG:authdevice.go]. Response keys:status,cert— flat, no per-user nesting (both); the multiple-users variant returns anauthentication_responsesarray with per-entrycert,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-256bc18fc4d…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 realcerts/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",albertsimlockapplePHP 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 — issuerApple 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'sDeviceCertificate, 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.plistby the invitation-registration daemon (user agentcom.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 acertblob 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 theid-validation-certbag 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-registerbag 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), aservicesarray, ausersarray, and thevalidation-datablob [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 sendscontent-encoding: gzipplusaccept-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 thedtinteger [RP:macos.rs:157-178], [IG:register.go:325-344]. The extraction confirms the full sub-key set as the eighteen keys above (apthroughv, including the four both sources model but omit) [IG:register]. The relay/phone profile setsmandsto "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
servicesarray registerscom.apple.madridwith asub-serviceslist 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 undercom.apple.essandcom.apple.private.alloy.facetime.*(capability name "Invitation") and Find My undercom.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 withec-version1 and NGM version 13, a signed prekey record, key-transparency data withkt-version5, plus boolean capability flags),kt-loggable-data,uris,user-id, and atagfor phone profiles [RP:ids/user.rs], [IG:register].tagis "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].urisentries are single-key dictionaries of handle URIs withtel:(E.164) ormailto: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-datais 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 areclient-data(dict),uris(array),user-id,kt-loggable-data(data; optional in Go, always set in Rust when publishing keys), plustagonly for phone-number users — valueSIM/SIM<N>(Go tags "SIM" when the user-id starts withP:). Register-requesturisentries are single-key dictionaries with keyuri(both).client-database 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 booleansupports-*feature flags andnicknames-version/optionally-receive-typing-indicators/prefers-sdr/show-peer-errorsrecorded 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-version1 [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(plusx-push-tokencarrying the base64 push token), per-user indexed setsx-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 anx-id-nonce/x-id-cert/x-id-sigvariant instead of per-user auth headers [IG:ids/sign.go:63-79]. Extraction: thex-auth-user-id-<N>value is the plain user-id string (the profile ID; phone users prefixedP:), 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
messagestring, 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-integernext-hbi(heartbeat interval in seconds; parsed as absent when missing, with no fallback at parse time [RP:ids/user.rs:1160-1180]), an optionalalert(title/body/button plus an action object with button/type/url) shown on certain errors, and a top-levelretry-intervalinteger 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 levelstatus,message,retry-interval,services— Go names all four; Rust reads onlystatusandservices(disagreement on which exist/are read —TODO(capture):, C57). Per-service entries nest underservices, each withservice,users,status; per-user keysuris,user-id,cert,status,alert; per-URI entriesuri,status; Rust additionally reads an optional per-usernext-hbi(absent in Go's model, recorded above).alertkeys (Go):body,button,title, nestedactionwithbutton,type,url; Rust models onlytitle,body, and optionalaction.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/GsService2withContent-Type: text/x-xml-plist; the body is an XML plist with top-levelHeader(onlyVersion=1.0.1) andRequest. 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 (underResponse):s(salt, data),B(server public value, data),i(iteration count, integer),c(challenge string), optionalsp(selected protocol, defaults2k),Status(dict with integerec, 0 = ok, and stringem). Complete request keys:M1(proof, data),c,cpd,o=complete,u. Complete response:M2(server proof, data),spd(encrypted plist data),Statuswhoseaustring selects secondary auth:secondaryAuth(SMS/trusted-phone) ortrustedDeviceSecondaryAuth[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_fothat digest is ASCII-lowercase-hex-encoded (64 chars) and those bytes feed PBKDF2, fors2kthe raw 32-byte digest feeds it; PBKDF2-HMAC-SHA256 with server saltsand server iteration counti, 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 asA2k; the receivedM2is verified against the computed M2 [APA:icloud-auth]. spddecryption: AES-256-CBC + PKCS7; key = HMAC-SHA256(key = K, message = ASCIIextra data key:), IV = first 16 bytes of HMAC-SHA256(key = K, message = ASCIIextra data iv:). Decrypted plist carries: a per-service token mapt(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-variantX-MMe-Client-Info/User-Agent andX-Apple-AK-Context-Type/X-Apple-Client-App-Name/X-Apple-I-Client-Bundle-Id. Thecpddictionary 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, optionalptkn(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 booleansX-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]. - 2FA:
au=secondaryAuth→GET https://gsa.apple.com/authreturns JSONAuthenticationExtras(trustedPhoneNumbers withnumberWithDialCode/lastTwoDigits/pushMode/id; HTTP 201 = SMS already sent); request a code with PUT.../auth/verify/phonebody{phoneNumber:{id}, mode:"sms"}; verify with POST.../auth/verify/phone/securitycode(same body +securityCode:{code}).au=trustedDeviceSecondaryAuth→ trigger the code push withGET .../auth/verify/trusteddevice, then submit:GET .../GsService2/validatewith the code in asecurity-coderequest header. All 2FA requests carryX-Apple-Identity-Token= base64 ofadsid:GsIdmsToken(fromspd) and a large browser-style fixed header set. Success returns tokens in RESPONSE HEADERS: repeatedX-Apple-GS-Token(one per GSA service),X-Apple-HB-Token,X-Apple-PE-Token— each base64 of colon-separatedID: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 inpakedata; the code arrives AES-128-GCM-encrypted over an SRP-key-derived channel withrecv->send/send->recvinfo labels) [RP:auth.rs], [APA:icloud-auth]. - PET: arrives as
X-Apple-PE-Token; stored under service idcom.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]. - Delegate logins: POST
https://setup.icloud.com/setup/signin/v2/login, XML plist body withdelegates(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: Basicusername:PET,X-Mme-Nas-Qualify= base64 validation blob (§1.1),X-Apple-ADSID= adsid, akd-flavorX-Mme-Client-Info,User-Agent=com.apple.iCloudHelper/282 …, full anisette set. Response plist:status(0 = ok), optionallocalizedError+description,delegatesdictionary with per-entrystatus,statusMessage,serviceData. The IDS delegate's serviceData (kebab-case) yieldsauthTokenandprofileId— exactly the IDSauth-token(sent asauthentication-data= {auth-token: token}) and theprofile-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]. - Not present in these sources: no
idmsa.apple.com, noscnt/session-token headers — the session role is played byX-Apple-Identity-Token(GSA requests) and Basicusername: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/*andGsService2/validaterequests): the anisette base headers filtered toX-Apple-I-MD-LU/X-Apple-I-MD-RINFO/X-Apple-I-MD-M/X-Apple-I-MD/X-Mme-Device-Id, plus fixed literalsX-Apple-Client-App-Name,X-Apple-I-Client-Bundle-Id, non-akdX-MMe-Client-Info,X-Apple-I-CDP-Circle-Status: false,X-Apple-I-ICSCREC: true, browserUser-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, andX-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= literalakd/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=imessageoricloudper flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs].spddetails:GsIdmsTokenis a TOP-LEVEL string key (not insidet);tentries carrytoken(string) plusexpiry(epoch-ms) orduration(seconds); account-name keysfn/ln(optional strings) [APA:client.rs]. Host split:postdataandteardowngo togsas.apple.com(with the S) withX-Apple-I-UrlSwitch-Info= base64(adsid:postdata/adsid:teardown); circle requests useX-Apple-Identity-Tokenwhen 2FA-authenticated,X-Apple-HB-Token= base64(adsid:hbtoken) otherwise; the app-token checksum is HMAC-SHA256 keyed with the session key overapptokens‖ 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_Yorkin the reference config);X-Mme-Client-Infowraps the AOSKit formcom.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 literallycom.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 browserUser-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-akdX-MMe-Client-Infofor the browser set = the same device-bracket form with inner componentcom.apple.akd/1.0 (com.apple.akd/1.0)(rustpush's variant wrapscom.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-inX-Mme-Client-Infois 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 duplicatedcom.apple.iCloudHelper/282 com.apple.iCloudHelper/282while the fullcom.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0string 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
urisarray to theid-querybag URL [RP:ids/user.rs] (pinned URL:query.ess.apple.comquery 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): insideh, Rust sendsx-id-self-uri,x-push-token,x-protocol-version(from the stored per-user protocol version),user-agent=com.apple.madrid-lookup <version-ua>, thex-id-nonce/x-id-cert/x-id-sigsignature set, conditionallyx-id-sub-service(only when the lookup topic differs from the main topic), andx-required-for-message: true/x-result-expected: true(opt-in flags, both true in most calls) [RP:ids/user.rs]; Go sendsX-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-secondsandsession-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-secondsbounds how long the cached identity stays valid,session-token-refresh-secondstriggers 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): theuriselements are PLAIN handle strings (baretel:/mailto:strings) in both repos — the contrast with the register request's single-keyuridictionaries (§1.4) is real. Response wire keys (extraction): top levelstatus+results, a map keyed by the plain uri string; per-handle keysidentities,kt-account-key,sender-correlation-identifier,status(Go names all four; Rust readsidentitiesandsender-correlation-identifieronly — 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) plussupports-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-levelstatusonly — 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-handlestatus, 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), andngm-public-identity/kt-loggable-datadecode 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-secondsfor every identity, with proactive refresh when any identity exceedssession-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-registrationsbag 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-aliasbag 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 anid-get-handlesbag 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.idstopic (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), andngm-public-identity/kt-loggable-datadecode 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.comwhere*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.comobserved 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 errorbad 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.comroot [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 tocourier.push.apple.comCOMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384, chain leaf ←apple public server ecc ca 12 - g1, anchored atAAA Certificate Services), and the server then sends a fatal TLS alertcertificate_requiredon 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-v1andapns-security-v3and switches to a compressed "packed" wire format when a pack protocol is negotiated [RP:aps.rs]; the other negotiates onlyapns-security-v3and 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 bothapns-security-v3andapns-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].) -
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 extensionreading 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_clientto2-courier.push.apple.com:5223with SNI set to the barecourier.push.apple.comcompletes 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: leafCN = 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 intermediateCN = Apple Public Server ECC CA 12 - G1(Apple Inc.), itself issued by the publicly-trusted Comodo rootCN = 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 atAAA 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.
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
0b1000001with an added0b100bit marking a root connection; Rust sends0b01000001— 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.madridis the iMessage topic [JJ], [RP:aps.rs], [IG:apns/topic.go]; registered sub-service topics includecom.apple.private.alloy.sms,com.apple.private.alloy.gelato,com.apple.private.alloy.biz,com.apple.private.alloy.gamecenter.imessage[RP:ids/user.rs], pluscom.apple.private.alloy.safetymonitor(and an "ownaccount" variant),com.apple.private.alloy.fitnessfriends.imessage, andcom.apple.private.alloy.askto[IG:apns/topic.go].com.apple.private.idscarries 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 specificallycom.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
commandvalue 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 modeE("pair" or "pair-ec", present when a body exists); ciphertextP; sender handlesP; destination handletP; message UUID bytesU; numeric message idi; send timestampein unix nanoseconds; sender's 32-byte push tokent; session tokensT; versionv(8 for sends); no-response-needed flagnr; expiration secondseX; trusted-sender flaghtu; user agentua; fanout chunk indexfcn(starting at 1); and adtlarray of per-target bundles [RP:identity_manager.rs], [IG:sendmessagepayload]. One source adds: retry countrc, original send timeoe(epoch nanos, on retries), queued-message flagsv, scheduled delivery timedmt, queue idqI, FaceTime relay-allocation fields, and a legacy unencrypted body fieldp[RP:identity_manager.rs], [RP:ids/mod.rs].TODO(capture):C26-adjacent — confirm the full current field set. - Each
dtlbundle carries: target handletP, a request-delivered booleanD, the session tokensTfrom the IDS lookup, the target's 32-byte push tokent, the encrypted payloadP(absent for payload-less messages), the encryption modeE(only for pair-ec), and an optional certified-delivery versioncdv[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
Eis always the legacy valuepairregardless of the per-recipient scheme actually used, and per-dtl-entryEis written only for entries encrypted with the EC scheme — legacy entries omitEentirely at the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neitherEnorPat 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 noEpass 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):
cinteger;Estring ("pair"/"pair-ec");Pdata;iinteger;Udata (raw 16-byte UUID);tdata (push token);einteger (unix nanos — Go-only key);sP/tPstrings;vinteger (8; Rust uses 1850 for the SMS topic, §5.8);nrboolean;fcninteger; anddtlis 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 insidedtlentries and omits a per-entryEwhen it is "pair" (present for "pair-ec"). Rev-12 key-attribution correction to the first bullet of this section: the extraction recordse,eX,htuas Go-only anduaas 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
oCafter 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'
cfield: 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
commandvalue 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); subjects; participantsp(full list including self, as URIs); rich XML bodyx(present when multipart, mentions, or styling); group GUIDgid; group versiongv= "8"; protocol versionv= "1"; properties versionpv(0 for DMs, 1 for groups, per one source); reply/thread referencetg(format "r::::"); screen-effect idiid; chat display namen; audio flagsa/e(audio / expiring audio); balloon fieldsbid,bp,bpdi; message-summary infomsi(content-type numberamc, source textams, balloon typeamb); a previous-message UUIDr[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;bpdicarries 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 (versionsgv/vare strings,tga formatted string) but the audio flags,pv, the UUID forms (U-style bytes vs GUID strings forr), and themsi/bpdisub-dict value types are unstated. The writer phase assumes booleans for the audio flags, bytes forr, and integers forpv/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
Dbundle 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 listsp(including self, no URI prefixes), target participant listtp(membership after change), current namen, new namenn, old nameold, and an avatar file-transfer recordtv[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-hbias "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); whennext-hbiis absent it falls back to seconds remaining until the identity certificate'snotAfterminus 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 earliestnotAfteramong 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
notAfteris 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 — realnext-hbivalues and whether Apple sends it at all, real identity-certificatenotBefore/notAfterper handle type (phone vs Apple ID), realsession-token-expires-seconds/-refresh-secondsvalues, 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-usernext-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-certnotAfter− 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 protectedNACKeyEstablishmentwith 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 in this workspace)NACKeyEstablishmentis PAC-protected and SIGABRTs when called [RP-issue-21], and beeper'smac-registration-provideris 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'smac-registration-provider; rustpush running on a Mac, which is what issue #21 breaks). OpenBubbles' shipped flow never callsNACKeyEstablishmenton 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.
-
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
Evalues (top-level and per-dtlentry) on outgoing and incoming messages; payload-less messages'Epresence; 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. -
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-v3vs packed variants) and therefore legacy vs packed frame format. — 2026-09-10:courier2.push.apple.comobserved 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; archivedreference/imessage-captures/apns-bag-2026-09-10.xml):APNSCourierHostname: courier.push.apple.com,APNSCourierHostcount: 50— the 1–50 range is confirmed (courier2is one host within it);APNSAllowTLS1_3: 1is consistent with the observed TLS 1.3 session. C17's hostname/count half is closed; C18 still needs client-side evidence. [CAP-COURIER] -
C1 + C3 — registration request shape. Second-extraction state: the bag fetch URL, nested-plist shape (
bagkey → 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 (allid-*→ 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. -
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.
-
C41 + C10 — lifetimes. Real
next-hbivalues (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), realsession-token-expires-seconds/-refresh-secondsvalues (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. -
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. -
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.
-
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).
-
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.
-
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.
-
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: 90with Min/MaxIntervalTrigger 30/50 andAPNSDelayedReconnectTLSInterval: 120,APNSWiFiKeepAliveEarlyFireConstantInterval: 120,ClientConnectionRetryAttempts: 100,APNSBagExpiry: 10800. These bound the pacing questions but do not replace observing a real client. -
C28 + C30 — plumbing. How often the inner gzip layer appears; MMCS command codes (150/151) and header versions in current traffic.
-
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.
-
C7 + C8 — activation edge. Carrier entitlement exchange per carrier (SIM-less phone auth); exact Albert activation-info field set and FairPlay certificate choice.
-
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).
-
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
bagpayload (§1.2); the lookup request's header wire names beyond thex-id-*set and the push-token name recorded in §1.4, and whether a lookup carries the push header set at all besidex-id-*(§2.1). C48: the element form of the lookupurisarray, and the lookup response's per-identity/result wire key names beyondidentities,status, and the twosession-token-*-secondsfields (§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; thetext/xmlvsapplication/x-apple-plistlabels do not change the parse. Remaining open here: the lookup header wire names, theuriselement 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-stringuriselement 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. -
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.
-
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 theservicesarray, the register response's per-service breakdown shape and where itsuserssit (top level vs inside it), the per-user identity-certificate and per-URI-status keys, thealertobject's keys, the "refresh credentials" marker (§6.1), and the value form carried byx-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, flatstatus/certresponse (§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. -
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
statusonly (5206 → split-in-half, 6005 → re-register + retry, 5040/5041 defined but unhandled); neither checks a per-handlestatus. The per-handle-status reading above (and the lookup-orchestration code built on it) is superseded; a capture still confirms the live wire. -
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-validationsession 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→ singlecertdata field;id-initialize-validationbody-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). -
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+ optional0b100root bit vs Rust0b01000001(§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-datavalue encodings: thepush-tokenandsigsentry 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/FairPlaySignaturerequest encodings andack-received/show-settingsresponse 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-256bc18fc4d…/03aca53e…, matching the recorded provenance); own-Mac extraction DEFERRED until needed.
-
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):
OABSmagic + 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). -
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-Infoforms, thet-map inner keys and top-levelGsIdmsToken,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. -
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-infoblob 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) andmessage(a server trace string of the formSED:3:VEN-PROD:<uuid>:<uuid>:<epoch>:<uuid>:<64-hex>; exact values not recorded), with NOsession-infokey. 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 (
status5003): default set; KA=X25519; sig=HMAC-SHA256; content-typeapplication/x-www-form-urlencoded; content-typeapplication/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-requestbody 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 (onlyAccept-Languagerides the client's default headers), sotext/x-xml-plistwas 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, thex-apple-baa-*set) under aninvitation-registrationuser-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.mitmand every plist inreference/imessage-captures/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,
status5003, same nine token classes. The live step-2 URL the bag serves ishttps://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-requestis 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 =IDSStatusBadRequestin the IDS status space [IG:imessage/direct/ids/types/idserrors.gen.go]; that family has dedicated body-level codes (ParseFailure1000,InvalidField1001,DecodeFailureBase64/Hex1004/1005,MissingRequiredKey5008) 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:reference/rustpushIS the OpenBubbles fork (origingithub.com/OpenBubbles/rustpush), re-fetched 2026-09-11 with a tip identical to the staged snapshot; its step-2 call remainsREQWEST.post(bag_url).body(xml_plist), no content-type,Accept-Language: en-US,en;q=0.9the 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'sid-initialize-validationbag value is byte-identical to the URL the live run posted to. (e) The genuine bag enablesabsintheattestation on this endpoint family (absinthe-baa-option-enabled,-ios13,absinthe-v3-enabledall 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 realsession-info-requestBLOB (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.