127 KiB
iMessage protocol spec (clean-room)
Status: draft — revision 50 (2026-10-16). Revision history at the bottom. Rev 49 corrected rev 48's "register #2": the empty-services register is the client's DEREGISTER (the failure-path reset_state), not a register step — and the reference's own register is confirmed status 0 (§1.1). Rev 50 closes the reference survey: no implementation available on this machine can confirm a registration, so the reference's captured bytes are the only oracle (see the rev 50 entry in the revision history).
How to read this document. Each section opens with a one-line plain-English summary, then the facts. Facts carry citations (legend below). Where two reference implementations disagree on a wire detail, the disagreement is stated and tagged TODO(capture): — running mitmproxy captures arbitrate. All open capture items are collected in §7.
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), never as source expression. Facts derived from SSPL/AGPL sources are used facts-only; nothing from them is reproduced. Interop-required protocol constants that are not directly wire-observable (e.g. the pair-ec HKDF salt "LastPawn-MessageKeys", the fixed prekey-signature label "NGMPrekeySignature") are recorded because both independent sources agree on them and a wrong string fails decryption or signature validation. "NGM" (next-generation-message) is research terminology, not a wire string.
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] — OpenBubbles docs (topic keyed)
- [OB-MHI] — OpenBubbles Mac-Hardware-Info docs
- [CF] — Apple's publicly documented binary property-list container format (Core Foundation; public Apple material)
- [CAP-REG] / [CAP-COURIER] — own mitmproxy captures of a registered iPhone 14,4, iOS 26.1 (2026-09-10; captures on file, not published) — ground truth, post-PQ3
- [CAP-IDSTLS] / [CAP-BAGCT] — direct unauthenticated probes from this machine (2026-09-10: openssl TLS captures; byte-exact bag bodies with response headers)
- [RP:file] — rustpush source (SSPL, facts-only); bare [RP] = fact from that repo, source file not recorded in the extraction notes
- [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] — Mac-Hardware-Info public upstream repo (unlicensed — facts-only, no code reuse)
- [RP-issue-21] — rustpush GitHub issue #21
1. Registration & activation
Plain English: how a client becomes a registered iMessage device — the device-genuineness blob ("validation data"), the Apple endpoints involved, the account login chain, and the register request/response that creates the identity.
1.1 Validation data
A binary blob proving the client is a genuine Apple device; every registration submits a fresh one.
Properties
- Built from machine identifiers: serial number, board ID, model, disk UUID [JJ], [OB-FAQ], [OB-Renewal]. The signing context consumes product name, OS build number, platform serial, MLB, ROM [RP:macos.rs].
- Valid 15 minutes after generation [OB-Hosted]; no local enforcement visible in the sources — server-side if anywhere [RP:macos.rs], [OA].
- Submitted as the
validation-datafield of the IDS register body (§1.4) and, base64, as theX-Mme-Nas-Qualifyheader of the iCloud login (§1.5) [RP:ids/user.rs], [RP:auth.rs].
Replay guard (live 2026-10-04, rev 26) — one mint = one device record.
- Apple keys a blob's consumption server-side: re-submitting an already-consumed blob at the sign-in/register pair answers HTTP 409 — a REPLAY refusal, not a blob rejection. A fresh blob under the same device identity succeeds; a captured blob of external provenance succeeds outright (session 17).
- Consequences: mint or fetch a fresh blob per attempt (the 15-minute validity makes stale reuse doubly fatal), and mint the sign-in's blob AT SIGN-IN TIME — never a login-start blob carried across the 2FA wait. A caller-injected blob from any source (local mint, relay Mac, staged capture) is a legitimate stamp.
Acquisition paths (third-party clients)
- Emulation: reimplement Apple's obfuscated generator with device properties fed in from a plist [JJ].
- Relay: fetch a ready-made blob from a trusted real Mac over an authenticated HTTP bridge [RP:relay.rs].
- Provider: a "nacserv"-style service returns the blob plus device version metadata and a validity timestamp [IG:nacserv].
- Acceptance quality: account age and a score determine whether a non-genuine serial is accepted or rejected (the "customer code" error, Hackintosh-analogous) [JJ].
- VM-derived identifiers are explicitly discouraged — Apple can detect them and re-registering with them risks an account ban [OB-FAQ]. One client's defaults present as Mac mini hardware, macOS 13.5, build 22G74 [IG:config.go].
- Legitimate own-Mac extraction of per-device activation identity is undocumented (open research item, §1.2 FairPlay).
Shipped flow (OpenBubbles, 2026): the Mac-side helper (Mac-Hardware-Info 1.2.0, notarized) only READS the machine identifiers via public IORegistry/sysctl APIs (macOS ≥ High Sierra, no upper bound — a macOS 26 Mac suffices) and exports them as a shareable code; it never invokes an Apple private framework [OB-MHI], [OB-FAQ]. The client then performs the three-step exchange itself, in software, paired with an Apple-Account login. Export codes are shareable by ≤ 20 users; a "prevent sharing" mode makes them single-use, 48-hour-expiring, non-transferable [OB-FAQ]. (Contrast: helpers that call the framework themselves — beeper's archived mac-registration-provider, rustpush running on a Mac — are blocked by the macOS 26 PAC protection of NACKeyEstablishment [RP-issue-21].)
Three-step exchange (recorded from the extracted client [RP:macos.rs], [RP:auth.rs], [RP:util.rs]):
- GET the
id-validation-certbag URL → a plist with a singlecertdata field. The URL is a CDN static object (static.ess.apple.com, plain HTTP cache hit,X-Cache: hit-fresh, 3474 bytes,application/octet-streamin the genuine capture) — so step 1's header-less success is NOT evidence the endpoints accept header-less requests. Thecertvalue is Apple's own container, not a bare DER:01 02followed by two(4-byte BE length, DER cert)pairs (1046 and 1329 bytes live) [rev 22]. - POST the
id-initialize-validationbag URL — XML-plist body with a singlesession-info-requestkey (data), NO content-type header; the client's only default request header isAccept-Language: en-US,en;q=0.9. Response:session-info(data). TLS gate (rev 38, live): the endpoint is Akamai-fronted and fingerprints the TLS ClientHello — OpenSSL-shaped stacks (curl, JDK HttpURLConnection, python urllib, Conscrypt/BoringSSL on the Android emulator) are rejected with HTTP 200 + app-layerstatus 5003, mnemonicEM, regardless of headers or blob correctness; only a rustls-shaped handshake (rustls 0.21 + the vendored client's baked Apple private root DER) gets a realsession-info. Consequence: §1.1 step 2 requires a rustls-backed HTTP client (a rust helper behind UniFFI), not a platform TLS stack. - Key establishment with the returned session info, then sign → the validation blob (mint procedure below). The exchange's step-2/3 math was closed source; it is now fully reconstructed (next block).
Hardware configuration (the context's input field set [OA:nac.rs], [RP:macos.rs]): 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 pre-computed obfuscated enc raw-byte variants of serial/platform-UUID/root-disk-UUID/ROM/MLB keyed by five fixed label strings. The derivation producing the enc variants is closed source → C57.
| Label | Field |
|---|---|
Gq3489ugfi |
serial |
Fyp98tpgj |
platform UUID |
kbjfrfpoJU |
root-disk UUID |
oycqAZloTNDm |
ROM |
abKPld1EcMni |
MLB |
Mint procedure (C5/C56 — CLOSED by clean-room RE, revs 27–36; method + dumps in registration/dev/clearadi/captures/, Kotlin in registration) — the 517-byte validation stamp is computed entirely client-side, byte-exact against live runs:
- Stamp:
0x02 ‖ rand16 ‖ sig16 ‖ u32be(0x1e0) ‖ blob480 - blob480:
tag 0x05 ‖ u32be(1) ‖ u32be(0x180) ‖ scramble_body(session250, rng_pad130) ‖ u64be(79) ‖ cert79 - Input mappings (wire-pinned, revs 32/35/36):
rand16= fresh client RNG;sig16=SignState::sign(body576, rand16, blob480);session250=HardwareConfig::to_bin()(below);rng_pad130= 130 fresh RNG bytes (irrelevant filler);body576= the decrypted session-info response'sbody@0x15region[2:578];cert79= the response's 85-B trailing Vec's first 79 bytes (server material — NOT the step-1 certificate; the earlier reading is falsified). - Crypto layers, all byte-exact vs live runs: pear_aes (white-box AES variant: 9-round CBC, custom whitening/final maps, pluggable sbox/rcon schedules, T-transform on blocks 1–9); domergestep2 (the
scramble_bodycore); key_establishment (EstablishKeyResponse wire:tag ‖ key16@0x01 ‖ u32be bodyLen@0x11 ‖ body@0x15 (0x250) ‖ 85-B trailing); SignState::sign (the 16-byte one-time signature: scatter → polynomial chain → flag table → u32 perms → divisor/key map → keygen → matrix/phases → 16 position rounds → 30-run tail threshold cipher; all "constant" regions are input-dependent — the P region is parameterized on five body-derived byte XOR pairs, staged via a serialized SSE builder; ported to KotlinNacSignwith generated table images). - Kotlin:
PearAes,NacMerge,NacValidation,NacSign,NacRequestBlob,HardwareConfiguration.toBin()— 217+ registration tests green (synthetic vectors, no Apple bytes).
Step-2 request blob (C56-a — CLOSED, revs 35/42): 338 B =
01 08 4b2c91481d9b7da0 (constant header) ‖ [10:266] RSA-2048-OAEP-SHA1 under the step-1 cert's public key of pearKey16 ‖ hmacKey20 ‖ [266:282] slot16 (fresh RNG16) ‖ [282:286] u32be(32) (length marker) ‖ [286:318] field32 = mapA[encrypt_cbc(mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros(10)], iv16, init_key(pearKey16))] with iv16 = mapA⁻¹(slot16) (rev 42: the IV is DERIVED from the wire slot, never drawn independently — drawing it breaks the slot↔iv pairing the server checks) ‖ [318:338] HMAC-SHA1(hmacKey20, field32). mapA/mapB are byte-substitution tables at .so 0x2249f9 / 0x224af9 (table[byte], not xor). Server role model: a possession-of-pearKey16 proof bound to the blob.
HardwareConfig::to_bin (session250) grammar (C56-c — CLOSED, rev 36): write_str = u8(len+1) ‖ utf8 ‖ NUL; write_item = u8(len) ‖ data; field order: product name, MAC address (raw 6 B — not ROM; corrected rev 36 via the multicast bit), serial, platform UUID, root-disk UUID, board ID, OS build, the constant dword 00 03 0b 52, then the seven binary items in Mac-Hardware-Info export field order (serialEnc, platformUuidEnc, rootDiskUuidEnc, ROM, romEnc, MLB ASCII, mlbEnc — exactly the export's identifier fields 8–14). Round-trips the live 250-B capture byte-exactly.
Mac-Hardware-Info export code (C58 — CLOSED, rev 15; parse spec, facts-only [MHG:ViewController.swift], [MHG:HwIdentifiers.swift], [MHG:mac_hw_info.pb.swift]):
- Payload: 4-byte ASCII magic
OABS, 1 flag byte (1 = "prevent sharing"), then a raw protobuf message starting at offset 5 (no length prefix). - Outer protobuf: 1 nested identifier message; 2 macOS product version (
kern.osproductversion); 3 protocol version (hardcoded1640); 4 device id (IOPlatformUUID); 5 hardcoded iCloudHelper user-agent; 6 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:Mac-+ lowercase hex of device-tree board-id bytes); 7 OS build (kern.osversion); 8/9/10/12/14 the fiveencblobs read from theIOPower:/registry under Apple's label names (matching the table above); 11 ROM (Intel: device-tree options ROM; Apple Silicon: last 6 bytes of SHA-256 of/chosenunique-chip-id); 13 MLB string. - Delivery modes: plain = standard base64 of the payload (clipboard text, or raw payload bytes as a QR at medium error correction); restricted = AES-256-CBC/PKCS7 over the payload with ASCII
Salted__+ 8-byte salt prefix (legacy OpenSSLenccontainer), key/IV via EVP_BytesToKey (repeated MD5 of prev-hash ‖ password ‖ salt to 48 bytes: first 32 = key, next 16 = IV), keyed by the human activation code (MB+ 4×4 chars fromABCDEFGHJKLMNPQRSTUVWXYZ123456789), then base64. - Restricted mode additionally uploads the blob (JSON
data+id= lowercase hex SHA-256 of the activation code) tohw.openbubbles.app/code; the ≤20-user/one-time/48-hour mechanics live SERVER-side — nothing embedded in the payload. No public parser exists; the construction above is the parse specification.
Anisette headers (C6 — CLOSED, revs 14/27–29):
- Per-request headers:
X-Apple-I-Client-Time,X-Apple-I-TimeZone,X-Apple-Locale,X-Apple-I-MD-RINFO,X-Mme-Device-Id,X-Apple-I-MD(base64 OTP),X-Apple-I-MD-M(base64 machine ID); macOS hardware headersX-Apple-I-MLB,X-Apple-I-ROM(lowercase hex),X-Apple-I-SRL-NO; present in some sets, omitted in others (disagreement → C57):X-Apple-I-MD-LU(lowercase hex SHA-256 over the 16 local bytes). - Value formats:
X-Apple-I-Client-Time= UTC now, RFC 3339 with offset replaced byZ(provisioning requests send the raw offset form — a recorded inconsistency between code paths);X-Apple-I-TimeZone= literalUTC;X-Apple-Locale=en_UShardcoded;X-Apple-I-MD-RINFO= verbatim from the finish-provisioning response;X-Mme-Device-Id= lowercase hyphenated UUID of 16 random bytes;X-Apple-I-MD-M= base64 of the 60-bytemid. Header sets are cached 60 s; a fresh OTP per cache miss. - ADI provisioning flow:
- GET
https://gsa.apple.com/grandslam/GsService2/lookup→ XML plist;urls.midStartProvisioning/midFinishProvisioninggive the endpoint URLs (never hardcoded). Live (rev 37): the lookup 404s withoutX-Mme-Client-Infoand accepts only the angle-bracket form<Model> <macOS;ver;build> <com.apple.AuthKit/1 (...)>. - POST the start URL with an XML-plist body of two top-level dicts
Header(empty) andRequest→ responseResponse→spim(base64). Body keys are case-sensitive (rev 37: lowercase{header, request}silently returns HTTP 200 with an EMPTY body). - Build a local session from
spim(+ empty hostuuid, DSID-2, flavorMac=1 /IOS=3, chosen by whether the client-info string containsiPhone OS) yieldingcpim(base64). - POST the finish URL with
Request= {cpim} → responseResponse→ptm,tk(both base64) andX-Apple-I-MD-RINFO. - Finish the session with the decoded
tk/ptm→ machine state:client_secret(32 B),mid(60 B, Apple-issued; reused for every OTP until re-provisioning, which happens only on a not-provisioned failure, e.g. ADI error −45061),metadata(variable blob). State persists as an XML plist withkeychain_identifier(data) + the provisioned fields.
- GET
- 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); 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, plus the anisette headers,X-Mme-Client-Info, and any caller-supplied hardware headers. - OTP derivation (rev 27, clean-room RE of the ADI library — no longer closed residue): fully local and pure.
gen = unix_seconds / 30; dispatch by OTP flavor byte (1 =gen_otp, 0 =gen_otp_ios— the binary's naming is inverted relative to the flavor). Cipher inputs:one= the machine's provisioning metadata (~400 B live; rev 39 corrected from "client_info"),two= the 32-byteclient_secret(revs 39/40 corrected — NOT the mid; mid-as-twoyields a well-formed OTP with a wrong signature, ladder rung two). Output 16 bytes; wireX-Apple-I-MDframe =u32be(5) ‖ u32be(16) ‖ sig16 ‖ u32be(flavor). The 2FA code =SHA-1(sig16 ‖ secret60) mod 1_000_000over the machine's full 60-byte secret slice (machine[0x38..0x74]). - Provisioning framing (final shapes, revs 28–29 live-gdb-corrected):
spim:field1 u32be@0 ‖ payload-length u32be@4 ‖ payload@8 ‖ trailing(trailing is server material the client relays — 99 B live; the ≤23-B cap reading was a probe-harness artifact).trailing[0:16]is thespim_map_signatureinput;trailing[16:76]is relayed verbatim as the wire tail's Y group.cpim: an OUTER 276-B message[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]; the 160-B plaintext =u32be(32) ‖ encsec(rng2) ‖ u32be(32) ‖ rng1 ‖ 60 B blob (caller-extra ‖ rng3 pad) ‖ u64be(−2) ‖ u32be(unix-seconds) ‖ u32be(1) ‖ 12 fresh RNG bytes. The IV is 16 plain RNG bytes; X =SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ fixed 24-byte .rodata filler)— written to the wire byte-exact (rev 29 closes the X32/12-B-tail/trailing-head TODOs; the earlier "constant sig block" and "sig16 not on the wire" readings are corrected).ptm:[u32be(4)][seed 16][u32be(payloadLen)][payload][tail]; key16 =encrypt(session, NULL iv, tk); block count = payloadLen/16; the finish gate is the decrypt output'sout[408:440] == rng1echo. ProvisionedData ={metadata: Vec (400 B live = decrypt out[4:404], length u32be at out[0]), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8}.
Live-mint status (as of rev 49): the full chain runs live on-device — export-code parse, hardware serialization byte-identical to capture, Apple-ID login with 2FA, fresh-blob mint accepted at step 2 through the rustls transport, courier connected, Albert activation accepted. Confirmed on the reference (rev 49): its own §1.4 register returns status 0 — forced re-registration rotates every service's id_keypair certificate to notAfter = register_time + 45 d in id.plist, and rustpush aborts before recording any registration on a non-zero status, so 6004 is not a step in a healthy register. The blockers are therefore ours alone: the §1.4 register answers 6004 = ActionRetryWithNewAbsintheContext (the response's SED message is a server-side VEN session record, not a stamp echo), and the §1.2 authenticate with the stored delegate credentials answers 6008 (stale delegate token — dies within hours). Body parity (rev 48) stands — full NGM identity client-data + gzip request body; the remaining request-shape divergence is that the reference sends all four services with both handles while ours sends madrid with one. Rev 49 removed the register's bogus first step (rev 48's "register #2" is the client's deregister).
1.2 Activation endpoints and certificates
Plain English: which Apple hosts serve what, how endpoint URLs are resolved ("bags"), and the Albert activation exchange that issues the push certificate.
Services
albert.apple.com— device activation; sets up the "push" credentials including the APNs certificate [BM], [JJ]. Activation:https://albert.apple.com/deviceservices/deviceActivation?device=MacOS[RP:activation.rs], [IG:pushcert].identity.ess.apple.com— registers the account with iMessage and generates the "identity" (encryption) credentials [BM]. IDS registration:https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register[IG:register].
Endpoint resolution ("bags")
- IDS endpoints resolve at runtime from a plist bag: GET
https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3(VCPermsService.woaserves the same family), mapping 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]. Response body: a plist dictionary with a singlebagkey whose value is embedded plist data parsed into a flat key → URL dictionary. Cached for the process lifetime; no TTL/refresh; a missing key is its own error [RP:util.rs:57-81]. - A parallel APNs bag at
http://init-p01st.push.apple.com/bagserves push settings: integerAPNSCourierHostcount, stringAPNSCourierHostname[RP:util.rs:56], [RP:aps.rs:1223-1224], [IG:courier]. Plus client-side connection-policy constants observed live (rev 24):KeepAliveV2TimeDriftMaxAllowed: 30,APNSDelayedReconnectIntervalAll: 60,APNSDelayedReconnectMaxInterval: 90(Min/MaxIntervalTrigger 30/50),APNSDelayedReconnectTLSInterval: 120,APNSWiFiKeepAliveEarlyFireConstantInterval: 120,ClientConnectionRetryAttempts: 100,APNSBagExpiry: 10800,APNSAllowTLS1_3: 1, BAA-authentication flags. - Live bag facts [CAP-BAG] / [CAP-BAGCT] (2026-09-10): both bags fetch unauthenticated (no auth, no anisette). IDS bag: 405 entries, every pinned id-* URL matches — no drift; carries the scalar
id-query-refresh-queued-query-interval: 28800(8 h). APNs bag:APNSCourierHostname: courier.push.apple.com,APNSCourierHostcount: 50(C17's count half closed). Both bag responses and the embedded bag payload are XML plists in every observed case; content-type labels are unreliable (text/xmlvsapplication/x-apple-plistwith XML bodies) — parse by content. APNs bag byte-identical across same-day fetches; the IDS bag carries abag-expiry-timestamp(~17-day observed TTL; 403 entries vs 405 two weeks later — config dictionaries, not just URL maps, rev 24). - Second client design: all endpoint URLs compile-time pinned (bag fetch URL mentioned only as manual refresh source). Pinned URLs: DS-ID auth
https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS; phone-number authhttps://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber; dependent registrations.../TDIdentityService.woa/wa/getDependentRegistrations; handles.../VCProfileService.woa/wa/idsGetHandles; lookuphttps://query.ess.apple.com/WebObjects/QueryService.woa/wa/query(only inside the APNs tunnel, never plain HTTPS); registration as above [IG:ids/bag.go:19-28], [IG:ids/urls.go:19-23], [IG:lookup.go:519-527]. No support for alias-provisioning, spam-report, validation-certificate/init, or multi-user phone-auth endpoints. Bag-resolution vs pinned-URLs is a client-design disagreement, not a wire fact.
TLS pinning
- Both implementations use a custom trust pool of embedded Apple root certificates only, not the system store [RP:util.rs:208-242], [IG:util/appletls/util.go].
- Live [CAP-IDSTLS]:
init.ess.apple.comserves leaf ←Apple Server Authentication CA 2←Apple Root CA - G3;query.ess.apple.comserves leaf ←Apple Server Authentication CA←Apple Root CA. No root is served; the unanchored chain is rejected by the system store. Anchor set to embed:Apple Root CA - G3andApple Root CA— pin roots only, never the leaf. Both root DERs committed asids/src/main/resources/imessage/AppleRootCA-G3.cer(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) andAppleRootCA.cer(G1, 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); each captured intermediate openssl-verifies against its root. Note (rev 40, live):gsa.apple.comnow serves an all-Apple chain too (leaf ← Apple Server Authentication CA ← Apple Root CA) — but Albert chains to DigiCert Global Root G2 (rev 43), so the engine trust manager must be system store + Apple roots merged. - Apple-root pinning also applies to the GSA endpoints (rev 40): the JVM default store fails the handshake.
Albert activation request/response
- Request: form POST, single key
activation-info— an XML plist carryingActivationInfoComplete(true),ActivationInfoXML(a nested XML plist),FairPlayCertChain,FairPlaySignature; no timestamp [RP:activation.rs], [IG:activationinfo.go]. - Nested
ActivationInfoXMLkeys: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.", SHA-1WithRSA (2048 also works; a generator-version field is noted [IG:pushcert]). Live (rev 26):
DeviceCertRequestrides as plist<data>whose content is base64 of the PEM CSR TEXT — a DER-encoded CSR is rejected server-side (SIGNATURE_VERIFICATION_FAILED). - FairPlay signing covers the serialized ActivationInfo plist (SHA-1, RSA PKCS#1v15). Live (rev 26): the accepted
FairPlayCertChainis THREE DER certificates concatenated — leaf + FairPlay CA + Apple Root CA; leaf-only and leaf+CA were refused. The post carriesUser-Agent: ApplePushService/4.0 com.apple.iCloudHelper/282(rev 43). - Response: XML plist; the client extracts the
<Protocol>span (it may be wrapped inside a larger<Document>document whose UI XML breaks a whole-document parse) and parsesdevice-activation→activation-record→DeviceCertificate(PEM), plusack-receivedandshow-settings. Only the device certificate returns — the private key is client-generated and never sent. Refusals state their reason in wrapped-UI page text, not structured fields. - Courier link: the DeviceCertificate (with the client private key) becomes connect frame field 12 (§3.3); the connect nonce is signed with that key (§3.3).
FairPlay activation identity (provenance CLOSED, rev 17; facts-only):
- All known embedded copies across the reference repos are ONE identity, byte-identical (leaf cert SHA-256
bc18fc4d…): one 1024-bit RSA keypair + single-DER leaf certificate (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 does not validate this chain's expiry). FairPlaySignature= RSA-SHA1 PKCS#1v15 over the exact serialized ActivationInfoXML bytes with the embedded private key;FairPlayCertChain= that leaf alone (the server fills in the rest). No per-account binding, no refresh/rotation, unlimited activations per identity in the sources.- Provenance: a 2023 iCloud/SIM activation-bypass community release ("Albert iTunes Bug"); rustpush's "ten pairs" are ten copies of it (upstream gitignores the real certs directory). User decision (2026-09-11): bundle the community pair; staged as
resources/imessage/fairplay/leaf.crt+identity.key. The committed repo copy is a placeholder (Apple bytes cannot ship in a public repo —CN = open-madrid-placeholder); the real pair loads from an out-of-repo override (/data/local/tmp/imessage-fairplay/, rev 44 — placeholder yieldsSIGNATURE_VERIFICATION_FAILED, real pair accepted on-device).
Capture ground truth [CAP-REG]: on a registered iPhone (iOS 26.1), the single iMessage-relevant artifact visible to a plain HTTP(S)-proxy capture is a plain-HTTP fetch of http://static.ess.apple.com:80/identity/validation/cert-1.0.plist (UA com.apple.invitation-registration [iPhone OS,26.1,23B85,iPhone14,4]). The plist carries an MD5 in an XML comment (certificate-production.cer md5=…) — clients apparently MD5-check and cache — and a cert blob: 6-byte header (01 02 00 00 04 16) + a DER cert from "Apple System Integration Certification Authority", expired 2019, yet still served and fetched. Same family as id-validation-cert but not the validation data itself. The pinned hosts (identity.ess, albert) bypass the proxy — the remaining C1 questions fold into C3 (§7 item 2).
Account login — see §1.5 (the Apple-Account/GSA chain yielding the PET and the delegate tokens that feed registration).
IDS authentication certificates (C54 — CLOSED, rev 12):
- Apple-ID users: CSR is a fresh 2048-bit RSA key, CN = a hex STRING of the SHA-1 of the realm user id (uppercase [RP] vs lowercase [IG] — case disagreement → C57), submitted to the
id-authenticate-ds-idbag URL [RP:auth.rs], [IG:bag]. Phone-number users: realm idP:<number>againstid-authenticate-phone-number[RP:auth.rs], [IG:bag]. - SIM-less phone route (
id-authenticate-multiple-users): per-request carrier EAP-AKA signature via the carrier's entitlement server, with the APNs connect token as client nonce [RP:auth.rs], [RP:util.rs]. The per-carrier exchange shape varies → C7. - Request body keys (agreed):
authentication-data,csr,realm-user-id; the CSR is DER (both).authentication-dataholdsauth-token(Apple/DS flow) orpush-token+sigs(phone flow). 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 → C57.
- Signature header sets (§1.4) are NOT applied to authenticate; only
user-agent,x-protocol-version(Rust addscontent-encoding: gzip). - Response:
status,cert— flat, no per-user nesting (both); the multiple-users variant returns anauthentication_responsesarray with per-entrycert,user-id,status(Rust only). - The short-lived authentication token must be exchanged for a longer-lived authentication certificate; this certificate allows IDS registration but not key lookups [JJ]. With 2FA there are two retrofitted options: legacy (code appended to password) and GrandSlam (anisette proves device identity; yields a PET usable in place of password+2FA) [JJ].
1.3 Phone and email handles
Plain English: how phone numbers and emails become registered iMessage handles.
- Phone-number registration without an Apple ID works via 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 (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; all of a user's registered devices show an alert when a new device, phone number, or email is added [APS-iMessage].
1.4 IDS registration body and response
Plain English: the register request that creates the identity on IDS, its wire keys, and how it is signed.
Request (POST to the id-register bag URL [RP:ids/user.rs]; pinned URL [IG:register])
- Body: XML plist in both implementations; disagreement is compression only — one gzips with
content-encoding: gzip+accept-encoding: gzip[RP:ids/user.rs:1112-1115], the other sends raw XML with no content-type and no content-encoding header at all [IG:register.go:366-375]. One source recordsContent-Type: application/x-apple-plist, the other none [RP:ids/user.rs:1112], [IG:register.go:370-375]. → C3: does the live server accept both? - Exactly eight top-level keys (agreed):
device-name,hardware-version,language,os-version,software-version,private-device-data(dict),services(array),validation-data(data) [RP:ids/user.rs], [IG:register]. - Headers:
x-push-token(base64 of the 32-byte APNs connect token) [RP:ids/user.rs]; protocol version "1640" hardcoded [IG:ids/user.go:44] vs from config with "1660" in test fixtures [RP:test.rs:303] → C2; user agentcom.apple.invitation-registration [<name>,<version>,<build>,<hardware>](both) [RP:macos.rs:77-79], [IG:ids/config.go:89-92]. private-device-data(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 strings exceptdt[RP:macos.rs:157-178], [IG:register.go:325-344]. The relay/phone profile setsmandsto "1" [RP:relay.rs:189-225]. Both sources also model four further keys (c,ec,ktf,ktv) but deliberately omit them from the built request.services: registerscom.apple.madridwith asub-serviceslist and one capability record named "Messenger" with version 1. Flags DISAGREE: 17 [RP:imessage/aps_client.rs:74] vs 1 [IG:register.go:347]. Sub-service lists 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] → C3. One source additionally registers FaceTime undercom.apple.ess+com.apple.private.alloy.facetime.*(capability "Invitation") and Find My undercom.apple.private.alloy.multiplex1(capability "com.apple.private.alloy") [RP:facetime.rs:26-56], [RP:findmy.rs:26-45].- Services-entry keys:
capabilities(array),service,sub-services,users; capability-record keys:flags(int),name(string),version(int; both set 1). - Per-user entry keys (C53 — agreed):
client-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 — valueSIMfor the first phone user andSIM2,SIM3, … for subsequent ones [RP:ids/user.rs:1078-1081]; Go tags any phone-profile user "SIM" [IG:register.go:233-236].urisentries are single-key dictionaries with keyuriholding handle URIs (tel:E.164 ormailto:) — and neither implementation constructs them locally: both fetch the registered handle list from the server first and echo it back [IG:util/uri/uri.go:61-64,177-191], [RP:ids/user.rs:839-843,1040-1042]. 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-errors[RP:ids/user.rs], [IG:register].- Capability booleans (wire keys, 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]. 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. Second source only:supports-askto,supports-emoji-stickers,supports-findmy-plugin-messages,supports-sticker-editing,supports-recurring-payment-bubbles. Type discrepancy: one encodes versions as plist reals, the other as integers → C57. - Identity keys: 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].
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].
Request signature (C4 structure — source-agreed)
- One signature with the push key, one per user with that user's auth key, using indexed header names [RP:auth.rs], [IG:sign].
- Nonce: 17 bytes — 1 type byte (0x01 HTTPS; 0x00 APNs), 8-byte big-endian unix-milliseconds, 8 random bytes [RP:auth.rs:685-700], [IG:util/nonce/nonce.go:25-42].
- Signing payload: nonce ‖ 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].
- Signature: RSA-SHA1-PKCS1v1.5 over SHA-1 of (nonce ‖ payload), prefixed with bytes
01 01, 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; per-user indexed setsx-auth-user-id-<N>/x-auth-nonce-<N>/x-auth-cert-<N>/x-auth-sig-<N>from N=0 [RP:ids/user.rs:1108-1121], [IG:ids/sign.go:48-96].x-auth-user-id-<N>= the plain user-id string (profile ID; phone users prefixedP:), register-only (agreed). Lookups usex-id-nonce/x-id-cert/x-id-siginstead (§2.1). → C4: confirm against a real device.
Response (C53 — CLOSED)
- Top level:
status(0 = ok),message,retry-interval(modeled by Go, never read),services— Go names all four; Rust reads onlystatusandservices(disagreement → C57). - Per-service entries under
services:service,users,status. Per-user keys:uris,user-id,cert(X.509 identity certificate, DER, to store),status,alert; per-URI entries:uri,status. Optional per-usernext-hbi(unsigned int; heartbeat interval seconds; Rust parses, absent → no fallback). alert(Go):body,button,title, nestedactionwithbutton,type,url; Rust models onlytitle,body, optionalaction.url.- 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].
- A "refresh credentials" status marks that auth pair for refresh [IG:register] — the marker's exact name is capture-bound → C53/C57.
Credentials stay local. Certificates and authentication keys are generated and stored 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].
Hosted/relay model (OpenBubbles): 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 (§1.1).
1.5 The Apple-Account (GSA) login chain
Plain English: signing in with an Apple ID — the SRP password exchange, 2FA, the PET token, and the iCloud delegate sign-in that yields the IDS auth token and profile id.
SRP endpoints (both steps POST https://gsa.apple.com/grandslam/GsService2, Content-Type: text/x-xml-plist; body = XML plist with top-level Header (only Version = 1.0.1) and Request [APA:icloud-auth]):
- Init request keys:
A2k(client SRP public value, data),cpd(client-provided-data dict, below),o=init,ps= [s2k,s2k_fo],u= username. - Init response (under
Response):s(salt, data),B(server public value, data),i(iteration count, integer),c(challenge string), optionalsp(selected protocol, defaults2k),Status(dict: integerec, 0 = ok; 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.
SRP-6a variant (byte-exact, C59 — CLOSED)
- Group: RFC 5054 2048-bit MODP, g = 2; SHA-256 throughout [APA:groups.rs], [RP:auth.rs]. (The Circle PAKE uses the RFC 5054 3072-bit group, g = 5.)
- 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 iteration counti, output 32 bytes = the SRP password. - Identity-hash quirk (GSA-specific): the identity hash uses an EMPTY username — SHA-256(empty ‖ ":" ‖ password-buffer) — while M1's H(username) term uses the REAL username.
- x = SHA-256(salt ‖ identity-hash); u = SHA-256(A ‖ B); k = SHA-256(N ‖ g padded to N's length); premaster = (B − k·g^x)^(a + u·x) mod N; K = SHA-256(premaster big-endian); M1 = SHA-256(XOR(H(N), H(pad(g))) ‖ H(username) ‖ salt ‖ A ‖ B ‖ K); M2 = SHA-256(A ‖ M1 ‖ K). A is sent as
A2k; receivedM2is verified.
spd decryption: AES-256-CBC + PKCS7; key = HMAC-SHA256(key = K, message = ASCII extra data key:); IV = first 16 bytes of HMAC-SHA256(key = K, message = ASCII extra data iv:). Decrypted plist carries: a per-service token map t (service id → token + expiry in epoch-ms or duration in seconds; GsIdmsToken is a TOP-LEVEL string key, not inside t), acname, DsPrsId, adsid, optional account-name keys fn/ln [APA:icloud-auth], [APA:client.rs].
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. akdrequest values:User-Agent= literalakd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0;X-MMe-ClientInfo(akd form) =<HardwareModel> <OSname;OSversion;OSbuild> <com.apple.AuthKit/1 (com.apple.akd/1.0)>;X-Apple-AK-Context-Type=imessageoricloudper flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs].- The
cpddictionary carries anisette values as KEYS inside the body: the six anisette headers above plus optionalptkn(APNs token, uppercase hex), and 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, 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]. For the Messages flavor:capp=Messages,cbid=com.apple.MobileSMS,svct=imessage(AppleIDSettings flavor:icloud/com.apple.systempreferences.AppleIDSettings) [APA:anisette.rs], [RP:lib.rs]. - Freshness (live, corrected over time): anisette is fresh per SRP RUN (a stale
X-Apple-I-MDon an SRP init answersec−22421, rev 26) and fresh per 2FA call (a frozen set answers HTTP 434, rev 26). Rev 46's full trapped-login decode CORRECTED the per-request reading: ONE anisette set serves the ENTIRE login session — oneX-Apple-I-MD, oneX-Apple-I-MD-M, oneX-Apple-I-Client-Timefrozen at the session's mint — byte-identical across GsService2 init/complete, circle, 2FA validate, postdata ×5, CloudKit/escrowproxy, and both prefpaneloginDelegatesPOSTs. The rev-25/26 observations predate the loginDelegates endpoint fix and are confounded with the stamp replay-guard refusals. The 2FA-validate and postdata paths keep their fresh-per-call mints until untangled (rev 46 note).
2FA
au=secondaryAuth→ GEThttps://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 with GET.../auth/verify/trusteddevice, then submit: GET.../GsService2/validatewith the code in asecurity-coderequest header. Live (rev 25): the trigger must run BEFORE prompting the user; a second trigger before the same validate invalidates the outstanding code and re-prompts every device.aurides INSIDE the complete response'sStatusdict, not at theResponselevel (reading it at the wrong level silently turns a 2FA login into a "logged in, no PET" dead end).- All 2FA requests carry
X-Apple-Identity-Token= base64 ofadsid:GsIdmsToken(fromspd) and a large browser-style fixed header set. Live (rev 26): the trigger and validate GETs carryContent-Type: text/x-xml-plist+Accept: text/x-xml-plist; a failed verify's body is a plist whoseStatus.ec/em(or top-levelec/em) name the real failure — the HTTP status alone is opaque. - The 2FA browser header set (all
/auth/verify/*andGsService2/validaterequests, C59 — recorded with literal values [APA:anisette.rs]): the anisette base headers filtered as above, 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=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)(static; NO Safari/Version token),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 value is the FULL plist wrapper (<?xml …+ Apple DOCTYPE +<plist version="1.0">+<array/>+</plist>+ trailing newline, 182 bytes), not a bare<array/>. - Success returns tokens in RESPONSE HEADERS: repeated
X-Apple-GS-Token(one per GSA service),X-Apple-HB-Token,X-Apple-PE-Token— each base64 of colon-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. - On-device variant (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]. - Live error ladder (rev 39): −22421 = structurally invalid OTP; −29004 "environment mismatch" = well-formed OTP whose signature payload is wrong (a debugger breakpoint on the white-box ALSO perturbs the derivation — rev 40; verify oracles uninstrumented). −20101 = credential rejection (environment check passed, wrong credentials). A persisted-password-hash re-login was rejected once with −22406 after four full logins in 30 minutes — server-side velocity throttling (rev 25).
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 iCloud requests [APA:icloud-auth], [RP:auth.rs]. - Second live-confirmed delivery channel (rev 25): the post-2FA re-login's decrypted
spdtoken maptcarries the PET plus the full per-service token set (com.apple.gs.idms.pet,com.apple.gs.icloud.auth,com.apple.gs.idms.hb,com.apple.gs.pb.auth,com.apple.gs.gpk.auth, … 21 observed in total); a no-2FA password login'sspdcarries onlycom.apple.gs.appleid.authand NO PET — 2FA is mandatory for the PET on this account class.
Delegate sign-in (iCloud)
- Classic endpoint: 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} [RP:auth.rs], [RP:macos.rs]. - Shipped protocol is V1 (rev 45 — RE'd from the vendored client, now public rustpush): the prefpane endpoint
/setup/prefpane/loginspeaks a V1 kebab-caseAuthRequest {apple-id, client-id (plain v4), delegates, password = PET}; the vendored build contains zero V2 strings. The V2 wrapper stays recorded for/setup/signin/v2/login. The server reads HTTP Basic even on the V1 shape (V1 without Basic → 401 UNAUTHORIZED). - Headers: Basic
username:PET,X-Mme-Nas-Qualify= base64 validation blob (§1.1 — minted at sign-in time),X-Apple-ADSID= adsid, akd-flavorX-Mme-Client-Info,User-Agent=com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0(static config constants; the composed sign-in UA in the reference literally duplicates the first token — treat the full static string as the recorded form), full anisette set. OptionalCookie: termsAccepted=true(terms flow only).userInfo.timezone= a hardcoded IANA name.client-idrides the kebab-case wire keyclient-id(live-confirmed rev 26). - Response plist:
status(0 = ok), optionallocalizedError+description,delegatesdictionary with per-entrystatus,statusMessage,serviceData. Live (rev 26): Apple's live response is KEBAB-CASE —status/service-data/account-exists; the camelCase recordings survive as a fallback reading. A 409 carriesprotocolVersion2,ErrorID,Header,Body,DialogType,DefaultButton*,AlternateButton*,HelpLink. - The IDS delegate's serviceData yields
authTokenandprofileId— exactly the IDSauth-token(sent asauthentication-data= {auth-token: token}) and theprofile-id(realm user id) feeding §1.2/§1.4. The MobileMe delegate's serviceData yields a token map (incl.mmeAuthToken) and service configs; its stored refresh timestamp is lazily refreshed when older than 7 days (604800 s) — client-side policy, not a measured server TTL [RP:auth.rs:204-212]. - Live verdicts on this endpoint: HTTP 409
ErrorID=ICLOUD_UNSUPPORTED_DEVICE("Cannot create another iCloud account with this device") — root cause (rev 26) was the validation-stamp replay guard; after the stamp fix the chain completed (rev 26). Rev 45:400 ICLOUD_INVALID_DEVICE_TOKEN— same verdict across V1/V2 shapes; the current open blocker (§1.1 live-mint status).
GSA postdata liveness event (rev 26, live):
- POST
https://gsas.apple.com/grandslam/GsService2/postdata(gsas, with the S — host split:postdata/teardowngo to gsas) withContent-Type: text/x-xml-plist; body = two top-level dictsHeader(empty) andRequest, thelivenessevent declaring servicesicloud,imessage,facetime(recovery-key generation on). - Account-binding headers:
X-Apple-I-UrlSwitch-Info= base64(adsid:postdata) andX-Apple-HB-Token= base64(adsid:hb-token) from the post-2FA spd map'scom.apple.gs.idms.hb. Anisette set = the postdata-filtered machine/device subset (serial-bearing headers ride; locale and request-uuid dropped); akd fixed headers ride. - The APNs push token rides the body as
ptkn(uppercase hex) when minted — a dead or absent token answersec−800012 "Push token is invalid": the Albert device certificate + courier connect token must be minted BEFORE the event, and the courier connection must stay OPEN across the registration (the connect-response token identifies the CONNECTION). - Order: the reference program runs the delegate sign-in FIRST and postdata AFTER it — a post-sign-in liveness declaration, not a sign-in precondition.
Not in these sources: no idmsa.apple.com, no scnt/session-token headers — the session role is played by X-Apple-Identity-Token (GSA requests) and Basic username:PET (iCloud requests); the idmsa-based flow in [IG] citations is a different client's route [APA:icloud-auth], [RP:auth.rs].
2. IDS (identity services)
Plain English: IDS is the keyserver — clients upload public keys at registration and look up other handles' device identities before sending.
2.1 Purpose and lookup
- IDS acts as the keyserver for iMessage (and FaceTime): registration uploads public encryption/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" — unofficial [JJ].
Lookup request
- Architecture fact (extraction, supersedes the earlier either-transport framing): IDS lookups ride the APNs tunnel, not plain HTTPS. The 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 HTTP-over-APNs command type is 96) [RP:ids/user.rs], [IG:lookup]. Tunnel mechanics: 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]; 1-minute timeout [IG:lookup]. → C12: which transport real devices use today; tunnel field-id residue rides C57. - A POST of a plist with a
urisarray to theid-querybag URL [RP:ids/user.rs] (pinned URL [IG:lookup]); theuriselements are PLAIN handle strings (baretel:/mailto:) — not the register request's single-keyuridictionaries. - Header wire names (extraction, C47/C48 — closed): inside
h, 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[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. - Signature payload matches §1.4: nonce ‖ bag name ("id-query") ‖ empty query string ‖ body ‖ APNs push token, RSA PKCS1v15-SHA1 with the
01 01prefix (both) [RP:auth.rs], [IG:sign]. - Batch cap per request: 18 handles [RP:identity_manager.rs] vs 20 URIs [IG:lookup]. A "response too large" error (5206) splits the batch in half recursively [RP:ids/user.rs], [IG:lookup] → C13.
Lookup response
- Gzipped plist; top level
status+results, a map keyed by the plain uri string. Per-handle keys:identities,kt-account-key,sender-correlation-identifier,status(Go names all four; Rust readsidentitiesandsender-correlation-identifieronly — disagreement → C57). The sender-correlation identifier's semantics are unexplained → C11. - Per-identity keys (identical six in both):
client-data,kt-loggable-data,push-token(32 B),session-token,session-token-expires-seconds,session-token-refresh-seconds[RP:ids/user.rs:694-704], [IG:lookup.go:107-109]. Both parse the token timing fields; only the first consults them (§2.2). client-data keys read:public-message-identity-key,public-message-identity-ngm-version,ngm-public-identity(both) plussupports-certified-delivery-v1(Rust); Go keeps unrecognized keys in a catch-all, Rust drops them. - Error statuses are read from the top-level
statusonly (C55 — narrowed): 5206 → split the batch in half (both), 6005 → full re-register then retry (Go at most once per hour), 5040/5041 defined but unhandled in lookup; neither checks a per-handlestatus. - No PQ3/PQ-typed keys appear in these results in either source — the identity key is EC P-256 signing + RSA encryption; the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and
ngm-public-identity/kt-loggable-datadecode to a compressed EC key → C16 evidence (§2.3).
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]; 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, 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 SHA-1 over (id certificate, current APNs token) — either change invalidates everything [RP:identity_manager.rs:25-96,212-240]. The other never reads the server token fields: refreshes positive results after 1 day, negative after 1 hour, hard-expires after 7 days [IG:lookup.go:242-248,339-352] → C14. - Send-side error codes for token problems: 5040 (bad session token), 5041 (expired); neither source implements a 5041 recovery path [IG:ids/types/idserrors.gen.go:104-105].
- 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], [IG:bag]; 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 viaid-get-handles[RP:ids/user.rs], [IG:bag]. A spam-report endpoint (id-report-spam) submits per-message metadata [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 [RP:identity_manager.rs]: 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). The other implementation receives but ignores that topic [IG:apns] → C15. - 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: one source re-registers and retries (≤ once per hour) [IG:lookup]; the other treats 6005 as bad authentication and forces a full re-login [RP:identity_manager.rs].
- With PQ3, each device registers two public encryption keys with IDS and replaces them regularly: 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. → C16: whether current lookup results carry PQ3 keys or the legacy client-data (live extraction evidence: no PQ3 keys in current-source results; what a real Apple device returns still needs the capture).
2.4 Contact Key Verification — OPEN
- Open item — not yet captured. CKV 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 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. No capture ID assigned yet; scheduled as its own window.
3. APNs courier
Plain English: the always-on bidirectional push connection every iMessage message travels over, its TLS setup, frame formats, and commands.
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:
*-courier.push.apple.comwhere*is 1–50 (APNSCourierHostcount: 50, live bag — C17 closed). The client connects to "<random 1..count>-" but sets TLS SNI to the bare hostname [RP:aps.rs], [RP:util.rs].courier2.push.apple.comobserved live [CAP-COURIER]. - Transport: TCP port 5223 (and 443 — a registered iPhone alternated between them in a ~5–15 s reconnect loop under interception) with TLS [RP:aps.rs], [IG:apns/connection.go], [CAP-COURIER]. Forward-secret; metadata such as timestamps and APNs routing information is not encrypted [APS-Send]. A non-transparent HTTP-proxy capture sees no courier traffic — the courier bypasses the Wi-Fi HTTP proxy entirely [CAP-COURIER].
- Client certificate — settled server-side [PROBE-COURIER, 2026-09-28]: an unauthenticated handshake to
courier.push.apple.comCOMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384), and the server then sends a fatal TLS alertcertificate_requiredon the first read — before any APNs frame. 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 connect message (§3.3) additionally carries the certificate+nonce+signature fields — both layers are in play; the client-cert presentation is the transport gate. - ALPN — closed (rev 26, instrumented client): offering
apns-pack-v1:4096:4096(the exact suffixed form is required in the offer),apns-pack-v1, andapns-security-v3, the server selectsapns-pack-v1:<enc>:<dec>— observedapns-pack-v1:4096:4096on every courier host — and the packed frame format flows (§3.2). With the pack-v1 ALPN the chain is leaf ←Apple Server Authentication CA← Apple Root CA (the RSA chain) and NO TLS client certificate is demanded — the push certificate rides only in the connect frame (field 12); thecertificate_requiredalert belongs to the no-ALPN legacy path. - Chain ground truth [CAP-COURIERTLS]:
openssl s_clientto2-courier.push.apple.com:5223with bare-hostname SNI completes. Server presents leafCN = courier.push.apple.com(notBefore 2026-09-02, notAfter 2026-12-02 — ~90-day rotation; pin the root, never the leaf) ← intermediateApple Public Server ECC CA 12 - G1← the publicly-trusted Comodo rootAAA Certificate Services(root not served). A trimmed system store rejects the chain; a pinned-root pool must anchor atAAA Certificate Services(or Apple's own pool) — distinct from the IDS pool (§1.2). - Handshake ground truth [CAP-COURIER]: the live session is structurally standard TLS 1.3 (0x0304, TLS_AES_256_GCM_SHA384) but the ClientHello carries Apple-private negotiation: private supported-version 0xfafa, private elliptic-curve group 0x9a9a (alongside the standard curves and the X25519+ML-KEM-768 hybrid 0x11EC; the ~1263-B key share is consistent with ML-KEM-768), two private extension types (0x5A5A empty; 0x1A1A one byte), plus standard status-request/SCT/signature-algorithms/PSK/compress-certificate extensions. Refinement of the earlier
bad extensionreading: the courier is TLS 1.3 plus Apple-private groups/extensions that conventional intercepting stacks do not offer or echo. The transport negotiates a post-quantum hybrid key exchange even though message payloads ride the legacy envelope. - Hosts observed pinned in the same capture window:
courier2.push.apple.com,gateway.icloud.com,configuration.apple.com,query.ess.apple.com; with Apple hosts passed through, delivery works normally [CAP-COURIER].
3.2 Frame format
- Legacy frames: 1-byte command, 4-byte big-endian total length (covers the whole field block after the command byte — C45 closed), then fields, each: 1-byte id, 2-byte big-endian length, value [RP:aps.rs], [IG:apns/binary.go]. Per-command field-id tables: §3.3.
- Packed format (C19 CLOSED — full wire shape from the live connection, rev 26): a frame is a 1-byte command, the attribute-block length as a tagged value of width 8, then the attributes.
- Tagged value: a 1-byte tag whose low
widthbits carry an inline value while they are not all ones; at the all-ones marker the remainder follows as a 7-bit continuation varint (always present — even a zero remainder writes one 0x00 byte). - Attribute: a 1-byte key tag — a single-byte key (< 0x10) rides inline as key|flags; longer keys go through the rolling key cache (
0x20|flags|indexreference;0x10|flags+ key-length−1 as a tagged width-4 value, then the key bytes) — then the value: inline integer (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), data (tagged width 6), string (width 5, |0x40). - Cache flags: 0x40 = first occurrence (full value, enters the rolling value cache), 0x80 = reference (tagged width-8 index into it). Both caches index from the newest entry; the key cache holds 32 entries, the value cache ~4 KiB (the ALPN's
4096:4096suffixes match the cache sizing). - Post-connect sequence (live): connect → connect-response (token) → set-state (20) → filter (9) with the connect-response token plus the SHA-1 topic hashes as enabled topics — unfiltered connections are dropped within seconds, which invalidates the connect token before the login can use it.
- Tagged value: a 1-byte tag whose low
3.3 Commands
- Connect (7): fields for the device/push token, a state byte (1), connection flags, the client certificate, a nonce, and a signature [RP:aps.rs], [IG:apns/commands.go]. Field-id table (agreed): 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; the 0x10 field (integer 9, a hardcoded version identifier) is Rust-only → C57 for its position. Flags DISAGREE: Go base
0b1000001+ optional0b100root bit vs Rust0b01000001→ C57. Nonce (AGREED — the rev-2 "disagreement" was a wrapper-function misreading): the same structured 17-byte nonce as the HTTPS signature (§1.4) with the type byte 0x00 (APNs), signed ALONE — SHA-1 of the raw nonce, RSA-SHA1-PKCS1,01 01prefix [RP:aps.rs:1005-1006 + RP:auth.rs:685-714], [IG:apns/connection.go:205-219]. (Go's recorded 20-byte-random-nonzeroed reading contradicts the agreement → C57.) → C20: confirm against a real client's connect frame. - Connect response (8): status byte (0 = ok; 2 = invalid cert — on 2, one client regenerates its push key/cert via Albert once and resets all IDS registrations [IG:connector]) and a 32-byte connect token (possibly new), stored and used in later messages [RP:aps.rs], [IG:apns/commands.go]. Field ids: 1 status, 3 token — Rust parses only 1 and 3; Go-only extras: 4 max message size (~4 KiB), 5, 6 capabilities, 8 large-message size (~15 KiB), 10 server timestamp (unix ms) → C57 for the extras' shapes → C23 for real advertised sizes (Apple's docs: APNs relays 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 as the SHA-1 of its topic string [RP:aps.rs]; the other 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: Go sends 1 token, 2 topics; Rust sends 1 token, 2 enabled, 3 ignored, 4 opportunistic, 5 paused → C57.
- Send message (10): outgoing field ids (agreed): 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 5 expiration (u32; observed 0xffffffff), 6 timestamp (u64 unix ns), 7 (observed 0x02) [IG:apns/commands.go]; Rust also parses an optional field 29 as a channel payload (Rust-only).
- 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 acks every received send message and reports 0x80 = "sent too early" (retried), 0x03 = too large [IG:apns/connection.go]. Field ids: 1 token, 4 message id, 8 status; Go additionally reads field 6 (commented as a possible error) → C57.
- Keepalive (12) / keepalive ack (13): no fields (both) [RP:aps.rs], [IG:apns/commands.go].
- Command 14 DISAGREES: Go labels it "filter-topics-ack" (uncertain; field 1 = token); Rust decodes a no-storage marker with no fields → C57 / C22.
- 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): one source only — fields 1 index, 2 message (protobuf), 3 token; response carries status field 4 [RP:aps.rs] → C24.
3.4 Keepalive, reconnection, topics
- Keepalive: one source pings every 60 s and waits for the pong, capping response waits at 15 s (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: exponential backoff capped at 30 s, indefinitely [RP:aps.rs]; vs wait 3 s after a failed connect, 6 s if the previous connection lasted under 90 s, otherwise reconnect immediately [IG:connector] → C21. Live capture: 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 [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,.gelato,.biz,.gamecenter.imessage[RP:ids/user.rs], plus.safetymonitor(and an "ownaccount" variant),.fitnessfriends.imessage,.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 "cache-flush command (160) on connect" framing is withdrawn; the command-14 no-storage-marker behavior stands. - Packet-capture ground truth [CAP-COURIER]: a 1-minute capture of an active courier session shows a single long-lived TCP session carrying send, receive, edit, and tapback traffic — no per-message connections. Outbound messages appear as 2.3–2.8 KB bursts; inbound frames ≈115 B and ≈520 B. Frame format and ALPN are not observable through the pinned TLS (settled later by the instrumented client, §3.1/§3.2).
4. Payload format
Plain English: what sits inside a command-10 payload — the outer envelope keys, the two legacy encryption envelopes ("pair" and "pair-ec"), attachments, and Apple's current PQ3 protocol (no third-party implementation exists).
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]. - Key attribution (rev-12 correction): 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 keysrc,oe,sv,dmt,qI,qr,qgid,qgmc,qai,qat,qv,qids,ua. - Each
dtlbundle carries: target handletP, 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: when any encrypted body exists, the top-level
Eis always the legacy valuepairregardless of the per-recipient scheme actually used; per-dtl-entryEis written only for entries encrypted with the EC scheme — legacy entries omitEat the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neitherEnorPat all; incoming messages with noEpass through without decryption [IG:connector.go:1743-1751], [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 — 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]. → C25: which schemes current Apple clients emit. - Value types (C49 — agreed):
cinteger;Estring ("pair"/"pair-ec");Pdata;iinteger;Udata (raw 16-byte UUID);tdata (push token);einteger (unix nanos);sP/tPstrings;vinteger (8; Rust uses 1850 for the SMS topic, §5.8);nrboolean;fcninteger;dtlis 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. - The inner plaintext is a binary plist, optionally gzipped [RP:messages.rs], [IG:connector]. Gzip placement: Go gzips a converted binary plist of the message before encryption; the Rust gzip location was not traced (→ C57).
- Binary plist container format ([CF] — Apple's publicly documented container format; the encoding of any plist that travels in binary form):
- File = 8-byte magic
bplist00, then the object area, then an offset table (one fixed-width entry per object: the byte offset of that object from the start of the file), then a 32-byte trailer: 5 unused bytes, 1 byte sort version (written 0), 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 is big-endian. - Widths: the minimal 1/2/4/8-byte width that holds the largest value present (object-reference width is the minimal width whose 2^(8w) ≥ the object count, so every reference 0..count−1 is representable; offset-table width must hold the final file offset).
- Objects: 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 width and negative values only in the 8-byte form. Reals are 0x2n, IEEE 754 big-endian, 2^n bytes. 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).
- Variable-length markers: 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 inline 0x1n integer 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. Dictionary keys may in principle be any object; the plists in this protocol use string keys.
- File = 8-byte magic
4.2 Encryption envelope — legacy "pair"
The original per-recipient encryption: AES-128-CTR under a key derived from randomness plus a plaintext-verifying HMAC, key transport via RSA-OAEP, ECDSA signature.
- 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): AES-128-CTR under a 128-bit key built from 11 random bytes plus the first 5 bytes of an HMAC-SHA-256 keyed with those same 11 bytes over (plaintext ‖ 0x02 ‖ sender-identity-hash ‖ recipient-identity-hash) — the HMAC verifies the decrypted plaintext and is never placed in the envelope [APS-Send], [RP:ids/user.rs], [IG:encryptsign_pair]. An identity hash = SHA-256 of the concatenated DER-encoded signing + encryption key blobs. Fixed IV = 15 zero bytes followed by 0x01. A message encrypted with an entirely random key fails to decrypt on newer devices [JJ].
- Key transport (agreed): the AES key plus the first min(100, len) ciphertext bytes are encrypted with RSA-OAEP (SHA-1, MGF1-SHA1, empty label) under the recipient's 1280-bit RSA encryption key; the RSA block is 160 bytes, placed at the start of the body, with the remaining ciphertext appended in cleartext [RP:ids/user.rs], [IG:encryptsign_pair], [APS-Send]. From iOS 13 / iPadOS 13.1 devices may use ECIES 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): SHA-1 over (RSA block + remaining ciphertext) — i.e. the exact body bytes — signed with ECDSA over P-256 (SHA1withECDSA, DER/ASN.1-encoded), verified against the sender's published identity key [RP:ids/user.rs], [IG:encryptsign_pair], [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 with a 2-byte type prefix [RP:ids/user.rs], [IG:identity].
4.3 Encryption envelope — "pair-ec"
The newer EC envelope using each device's published prekey; protobuf-framed, ECDH + HKDF, AES-256-CTR.
- Scheme selection (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 "pair-ec" [RP:ids/user.rs:525-531], [IG:ids/encryptsign.go:31-66]. One source gates pair-ec sending behind a config flag defaulting off [IG:config/config.go:57]; 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.
- Wire form (agreed): a protobuf message — field 1 payload, 2 key (the raw compressed P-256 ephemeral point), 3 signature, 99 validator (C52 — agreed); the inner message carries field 1 message (the gzipped plist), 2 counter (optional uint32), 3 key-transparency gossip (bytes), 99 debug info (bytes) — both sources always send 3 and 99 empty; what Apple devices put there is unrecoverable from the sources.
- 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) [RP:ids/user.rs], [IG:encryptsign_pair_ec]. - Encryption (agreed): 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: ciphertext = inner protobuf ‖ random padding to the next multiple of 16 (none if already aligned) ‖ the 4-byte LE uint32 padding length, under the derived IV (IV not prepended; big-endian counter increment).
- 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).
- Validator (agreed): the 2-byte prefixes of (sender device key, recipient device key, recipient prekey) plus a final byte, agreed to be the literal value 0x0c (rev-2's "0xc0-ish" reading was wrong); 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 [RP:ids/user.rs:585-590], [IG:ids/encryptsign_pair_ec.go:96-103,231-274]. → C26: confirm Apple's live validator bytes (and whether the final byte ever moves off 0x0c). 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.
- Inner counter (agreed on persistence, disagreed on keying): a per-sender-pair monotonically increasing value, persisted between sessions, started at 0 (rev-12 correction superseding the "starts at 1 / per-URI / possibly memory-only" reading); Rust keys it by a hash of own device key ‖ peer device key ‖ peer prekey (so prekey rotation resets it), Go keys it per destination store [RP:ids/user.rs:533-547], [RP:identity_manager.rs:175-181], [IG:database/outgoingcounter.go:31-44]. Whether receivers enforce it is unknown to both sources, and neither verifies the received counter → C27.
- Published prekey record (agreed): the 33-byte compressed prekey (extraction records 32 bytes against the 33-byte reading — TODO(capture)), a 64-byte signature by the device key, and a timestamp (double unix seconds); the signature is over SHA-256 of the 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.
4.4 Attachments (MMCS) and size limits
Plain English: oversized content is encrypted and uploaded to Apple's cloud storage; the message carries a pointer and the key.
- Oversized text or attachments are encrypted with AES-CTR under a random 256-bit key and uploaded to Apple's cloud storage; the message carries the attachment's AES key, its URI, and an SHA-1 hash of the encrypted form [APS-Send].
- Upload (one source): random 32-byte key, AES-256-CTR (zero nonce) streaming; authorization via an APNs-tunneled request (command 150) yielding a storage domain and object id; PUT to a CDN URL. The message references the SHA-1 hex signature, owner/object id, URL, and the decryption key (hex, prefixed 0x00), 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 → C30.
- 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 ~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] → C29.
- 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] → C36.
4.5 PQ3 (current Apple protocol; no third-party implementation exists)
Plain English: Apple's post-quantum replacement for pair/pair-ec, from Apple's own research post. All facts below are from [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).
- Three ratchets: a symmetric ratchet (per-message key derived from a ratcheted session key; message keys deleted after decryption; 256-bit keys, HKDF-SHA384); an ECDH ratchet (NIST P-256, 32 bytes per message); a conditionally executed Kyber KEM ratchet (Kyber-768 in-band, adding over 2 KB, amortized).
- Rekeying: 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.
- Per-message crypto: 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, 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.
- 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.
- Rollout: PQ3 rolled out with iOS/iPadOS 17.4, watchOS 10.4, macOS 14.4; conversations "automatically ramp up" to it; Apple stated (2024) PQ3 "will fully replace the existing protocol within all supported conversations" — no published completion date. Apple never names the wire envelope type PQ3 messages use, and the post does not mention the "pair"/"pair-ec" strings at all. Neither reference client implements PQ3 — an incoming message whose envelope is neither "pair" nor decodable as the EC outer message simply fails to decrypt [repo-wide]. → C31 (PQ3 wire format) / C25 (whether current Apple-to-Apple traffic uses PQ3 exclusively — 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
Plain English: what the different command-100-family payloads mean — plain messages, reactions, edits, typing, receipts, group events, and the SMS relay.
5.1 Command numbers
Agreed core table for the madrid/sms topics' c field [RP:messages.rs], [RP:aps_client.rs], [IG:imessage.go], [IG:sendmessagepayload]:
| Command | Meaning |
|---|---|
| 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 (60001 global read receipts, 70000 profile sharing) |
| 181 | chat delete (recoverable metadata under 182) |
| 190 | group events |
| 255 | send-time ack (both [RP:identity_manager.rs], [IG:ackwaiter.go]) |
| 96/97 | APNs-tunnel HTTP commands (both) |
Single-source entries to verify (→ C36): 103 unschedule [RP]; 104 async large payload [IG]; 113 notify-anyways [RP]; 120 error report [RP]; 122 sticker/extension update [RP]; 131 share profile [RP]; 138 transcript background change [RP]; 142 SMS enable request [IG]; 146/149 SMS send confirm success/failure [RP]; 160 offline-message-pending/queue-flush as a payload command value inside command-10 plists, not a frame command (rev-12 correction) [IG]; 196 link-preview edit [IG].
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 = 1:1; more = group) [IG:decrypt].
- → C50: the plist value type of each body key is not recorded for most keys (versions
gv/vare strings,tga formatted string; the audio flags,pv, the UUID forms forr, and themsi/bpdisub-dict value types are unstated). The writer phase assumes booleans for the audio flags, bytes forr, integers forpv/amc; a capture must arbitrate.
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, extending 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]. Target string DISAGREED: "bp:" for balloons and "p:/" otherwise [IG:tapback.go] vs a raw target GUID (balloons) or "p:"-shaped variants [RP:messages.rs] → C32.
- 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] → C38. 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] → 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] → C33.
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). - 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] → C34.
- 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) → 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]. Status tables differ: 0 and 5008 delivered, 5032 "refresh keys and resend" [RP:identity_manager.rs] — vs the other's table: 5032 bad signature (same remedy: re-lookup and resend once), 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] → C39.
- 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]. → C37. - 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] → C40.
- An SMS enabling handshake exists: request (142) and toggle confirmation (145) with accepted/rejected/disabled states [IG:sendmessagepayload]; 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
Plain English: how registrations expire and get renewed, and what breaks and how.
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]. The exact renewal interval in days is not documented publicly [OB-Renewal].
- Client-side scheduling policies: one source schedules 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 safety margin, rechecking the wall clock every 60 seconds; 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 [RP:ids/user.rs:79-98,1171-1180], [RP:identity_manager.rs:434-443,491-525], [RP:util.rs:916-917,1116-1133]. The other recomputes its re-register ticker after every successful registration: earliestnotAfteramong all identity certs, capped at 45 days, clamped to max 1 day / min 30 minutes, minus a 2-minute buffer; forces re-registration whenever 45 days have elapsed since the last success; per-cert safety buffer tiered by approximate certificate lifetime (<8 h → re-register when <3 min remain; <1 day → 30 min; <7 days → 4 h; otherwise 24 h); pauses (ticker reset to 1 hour) when the validation-data provider is unreachable [IG:connector.go:178-224,301,317-334,238-243]. - Forced immediate re-registration: a push on the private IDS topic with command 32 (re-register) or 66 (handles changed) triggers it in the first source; command 34 (devices changed) only invalidates cached identities for own handles [RP:identity_manager.rs:758-797]. A lookup 6005 triggers re-register + retry (≤ once per hour, 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. An APNs command 130 received from self also forces re-registration in the second source [IG:connector.go:824-841] — but that source's receive path explicitly ignores the private IDS topic entirely, so server-pushed 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].
- "~45-day certificate" folklore — corrected: 45 days appears in the sources only as the second source's client-side cap on re-registration cadence and 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. → C41. - Mac-renewal arbitration (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 Mac online [OB-Renewal], [OB-FAQ]. Contrast: iPhone-relay renewal is an HTTP call to the relay, so the iPhone must be live at every renewal [RP:relay.rs]. Standing risk: macOS 26 PAC-protected
NACKeyEstablishment[RP-issue-21] — binds only framework-calling helpers; the shipped OpenBubbles flow (identifier export + in-software generation, §1.1) is unaffected, and any genuine Mac's identifiers work (they only need to be valid, not the capturing client's own hardware). - 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 → 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] → C42.
- 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] — the real key-replacement cadence folds into §7 item 1.
- 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
Open items only, ordered by value. Closed items are recorded in the revision history. IDs match the inline → C## markers above.
- C25 + C31 — 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. 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 pair-ec validator final byte; incoming inner counters and gossip fields. If PQ3 shows up: dump its container/envelope (Apple's post describes the cryptography, not the wire encoding; no third-party implementation exists to cross-check). - C3 — register request acceptance. Whether the server accepts both gzip-XML and plain-XML register bodies; the current sub-service list and Messenger capability flags (17 vs 1); the capability-boolean set the server expects. Requires an instrumented client (pinned hosts bypass every proxy tier tried; register requires authentication, and Apple throttles abusive registrations — no blind probing). C2 (protocol-version "1640" vs "1660") and C4 (signature construction — structure is source-agreed, §1.4) resolve from the same 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 + C13 + C14 — lifetimes and cadence. Real
next-hbivalues (and whether Apple sends it at all); real identity-certificate lifetimes per handle type (the "~45 days" figure is a client-side cap, §6.1); realsession-token-expires-seconds/-refresh-secondsvalues; real lookup batch cap; the effective renewal cadence and which renewal trigger fires in practice (timer, private-IDS push 32/66, 6005, handle change). All reachable via an instrumented client's debug log: 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. - C26 + C27 + C29 — pair-ec details & chunking. Apple's live validator bytes (and whether the final byte ever moves off 0x0c); receiver-side counter enforcement; which counter keying/reset behavior Apple uses (per-key-hash persisted from 0 vs per-store — §4.3); real send-chunking (~5000-byte packing vs ≤20 devices against 15 KiB).
- C35 + C36 + C37 + C38 + C39 + C40 + C50 — semantics sweep. Certified-delivery (109) emission in the wild; single-source command numbers (196, 104, 113, 120, 122, 131, 138, 103, 142, 146/149); group "vr" change type and real pv values; edit part-index −1 (subject); full ack status table; SMS body constants; command-100 body-plist value types (§5.2).
- C42 + C43 + C44 + C46 + C15 — 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.
- C20 + C22 + C23 + C24 — APNs message details. A real client's connect frame (and the fixed 0x10 field value, §3.3); 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.
- C21 — timing. Real keepalive pacing, silence-drop threshold, reconnect strategy (§3.4 — the APNs bag's client-side constants bound the questions but do not replace observing a real client).
- C30 + C12 + C28 — plumbing. MMCS command codes (150/151) and header versions in current traffic; which lookup transport real devices use today (§2.1); how often the inner gzip layer appears.
- C7 — carrier entitlement exchange per carrier (SIM-less phone auth; request/response shape varies by carrier).
- C16 — PQ3 keys in live lookup results (whether current lookup results carry PQ3 keys or the legacy/EC client-data — folds into item 1's observation plan).
- C11 — the lookup sender-correlation identifier's exact semantics.
- C53 residue — the register response's "refresh credentials" marker name (§1.4) — instrumented register capture; folds into item 2.
- C26 residue — the published prekey's wire length (32 vs 33 bytes, §4.3) — folds into item 5.
7.1 Extraction disagreements & closed-source residue (C57)
Each item names its arbitration path. Closed residue is noted inline.
- Authenticate 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's 20-byte random with first byte zeroed vs the source-agreed 17-byte structured nonce (§3.3) — 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 (§3.3) — same dump.
- Ack field 6 (Go-only) and connect-response Go-only fields 4/5/6/8/10 value shapes (§3.3) — same dump.
- Protocol-version pinning: "1640" hardcoded [IG] vs 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. - Pair-ec inner-body gzip location on the Rust side (§4.1) — instrumented encrypt/send round trip.
- Multi-topic filter-field encoding: one state field holding concatenated 20-byte topic hashes vs repeating fields (§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.
- APNs-tunnel framing residue (§2.1, rides C12): the frame field ids of tunnel commands 96/97 (field SET recorded, 1-byte ids not); whether the tunnel payload rides as framed fields at all; the
h-keyed dict's serialization on the tunnel; 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.
Revision history
-
Rev 50 (2026-10-16, session 36): reference survey closed — a negative result, recorded so it is not re-run. No implementation available on this machine can confirm a registration, so the reference's captured bytes are the only oracle.
open-absintheis the official stub (every bodytodo!(); "closed source ... no actual functionality"), so it cross-checks nothing — the [OA] legend entry is load-bearing rather than merely descriptive.imessagego(snapshot 2024-01-16) requires an external Mac / jailbroken-iPhone registration provider plus a relay and never registers standalone, so it evidences request shape only. Its back half matches ours: identicalauthenticateDSbody shape ({authentication-data.auth-token, csr, realm-user-id}), the same eight register keys, one servicecom.apple.madrid, and its own degenerate deregister (DeRegisterIDS— register with nil handles/cert pairs) confirming independently that the empty-services body is a deregister, not a register step. Its front half is a different Apple-ID world: no anisette (zeroX-Apple-I-MDhits), noGsService2, nospd/PET, no delegate — login is a single call toprofile.ess.apple.com/…/authenticateUser(plist{username, password}, 2FA code appended to the password), and validation data comes from an external NAC server. Consequence for our 6004: anisette cannot be its cause. Also recorded: the shipped reference binary is ClearADI-built (1026clearadi/ 0RemoteAnisetteProviderV3/ 78open_absinthesymbol hits), so a checkout whoseCargo.tomlenablesremote-anisette-v3is not the shipped build — trust the binary. -
Rev 49 (2026-10-16 late evening): rev 48's "register #2" is NOT a register — an empty-services body with no
x-auth-*headers is the client's DEREGISTER. rustpush emits an empty-services register in exactly one place:register(config, aps, &[], &mut [], identity)insidereset_state(state, reset_hw, logout)(/tmp/openbubbles/rust/src/api/api.rs:2424, "try deregistering from iMessage, but if it fails we don't really care"), reached fromrustpush_service.dart:348 markFailedToLogin()→:4621 Future reset()→api.resetState; both healthy register paths (api.rs:580 register_ids,identity_manager.rs:344) always send the four services. A captured FULL→EMPTY pair therefore means register succeeded, a later step failed, the app reset. The step is removed fromidsRegister. Also settled by measurement rather than by response capture: the reference's own register returns status 0 — forcing a re-register (host-side id.plist edit:registered_at_s = now − 7200plusheartbeat_interval_s = 600, a key the app deletes on write) rotates every serviceid_keypaircertificate tonotAfter = register_time + 45 dand renews theenv_hash/pseudonym set, whichregister()can only do after a zero status (it returnsErrbefore recording on any non-zero status). A register carries no anisette headers —SignedRequest::sign(rustpush/src/auth.rs:395+) appends onlyx-{auth|push|id}-nonce|sig|cert[-i]— so the absinthe context of a register is exactly itsvalidation-data. Capture limits worth recording: the register rides HTTP/2 (PRI preface + HPACK), so its headers are not recoverable from a plaintext write trap, and itsplist::from_bytes::<Value>response parse is LTO-inlined intoregister, so no breakpoint fires for the response body (needs a rustls read-path hook, e.g.ConnectionCore::process_new_packets). Reference body shape re-verified from frame-016: four services (com.apple.madrid17 flags/"Messenger" + sub-services sms/gelato/biz/gamecenter.imessage,com.apple.private.alloy.multiplex11/"com.apple.private.alloy",com.apple.private.alloy.facetime.multi1/"Invitation",com.apple.ess1/"Invitation"), one user each,uris= tel + mailto on every service,client-data52/11/13/9 keys, the 14private-device-datakeys, 517 B stamp. Our register still sends madrid only, with one handle. -
Rev 48 (2026-10-16 evening): the §1.4 register body was a STUB — now capture parity. The trapped
wa/registerframes decoded (frame-025/026 = the full user register, frame-051/052 = a second MACHINE register): the genuine body carries the full madrid NGM identity inclient-data(identity-key = DER SEQ{[1] u16be‖uncompressed P-256 signing point, [2] u16be‖RSA-1280 PKCS#1 DER} = 249 B; prekey-data = protobufPreKeyData{x32, ECDSA-SHA256 raw sig over "NGMPrekeySignature"‖x‖f64-le ts, ts}= 109 B;kt-loggable-data= protobuf{deviceIdentity{x32}, ngmVersion 13, ktVersion 5}= 40 B) plus the ~47 captured capability booleans. Ported asMadridIdentity(four persisted keys: EC signing, RSA-1280 enc, EC device key, EC prekey — the genuine's enc key IS RSA-1280,Rsa::generate(1280)in rustpush). The register request body is GZIPPED (content-encoding: gzip— C3 resolved by capture; the "un-gzipped lower-assumption" choice was wrong). The genuine wire's register #2 = machine-only register:services: [], NOx-auth-*headers (push-only signature), fresh stamp — implemented as the firstidsRegisterstep. [Corrected, rev 49: that frame is the client's DEREGISTER (reset_state), not a register step — the step was removed.] Live: both our machine register AND the full register answer6004— decoded via [IG:idserrors]: 6004 =ActionRetryWithNewAbsintheContext, withmessage= an SED server-session tokenSED:3:VEN-PROD:<uuid>:<uuid>:<mint-epoch>:<uuid>:<hash>:EM(all fields server-side: the hash ≠ sha256(stamp), UUIDs rotate per attempt). Separately: the §1.2 authenticate with the stored delegate credentials now answers 6008 (no cert) — the delegate token went stale hours after session 33's pass. Open questions: whether the reference's register #1 also 6004s (responses not captured — the trap hooks writes only), and what "new absinthe context" concretely means when the register carries no anisette (the stamp's VEN session is the only context it rides). -
Rev 47 (2026-10-16): §1.1 mint EXONERATED on live data — the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered inputs (pad recovered by inverting the scramble chain;
domergestep2is XOR-invertible). The session-32 "rng_pad130" verdict is corrected: the scramble pad is thread-RNG filled insidescramble_body(second code path, disasm 0x179654d). Transport (rustls ClientHello byte-equivalence), request shape, and staleness eliminated live. The prefpaneICLOUD_INVALID_DEVICE_TOKENblocker narrows to the machine's IDS registration freshness: the reference runs §1.4 register ×2 before the delegate sign-in, and a pre-prefpane register on our side answers 6004 transient-retry.
One line per revision, newest first. Details live in the sections above and in registration/dev/clearadi/captures/.
- Rev 47 (2026-10-16): formatting pass — citation legend, section summaries, and revision history restructured for human/machine legibility; no protocol facts changed.
- Rev 46 (2026-10-15): trapped-login decoded — anisette is SESSION-WIDE (one mint per login, §1.1/§1.5); VEN step-2 carries zero anisette; the "fresh per request" reading corrected.
- Rev 45 (2026-10-13): delegate sign-in RE'd to full parity — the prefpane endpoint speaks a V1 kebab-case AuthRequest at
/setup/prefpane/login;400 ICLOUD_INVALID_DEVICE_TOKENsurvives both body shapes → account/device-state gate. - Rev 44 (2026-10-13): albert GENERIC_ERROR root-caused — the public-repo port shipped a placeholder FairPlay identity; real pair accepted on-device; chain stalled at
409 ICLOUD_UNSUPPORTED_DEVICE. - Rev 43 (2026-10-13): REGISTER step-2 accepted on-device (DigiCert trust + two request-shape fixes, §1.2); new albert refusal opened.
- Rev 42 (2026-10-13): C56-a fully closed — the pear-AES IV is derived from the wire slot (
mapA⁻¹(slot16)), never drawn; fresh Kotlin blob accepted live. - Rev 41 (2026-10-13): first live REGISTER attempts — the step-2 rejection isolated to the blob slot [266:282]; export-code flow + Apple-ID login chain verified live end-to-end.
- Rev 40 (2026-10-13): the rev-39 divergence closed as a debugger artifact; OTP
twoinput =client_secret; host init A/B green;gsa.apple.comserves an all-Apple chain. - Rev 39 (2026-10-13): first live GSA login A/B + gdb OTP capture — OTP input mapping corrected; Kotlin
gen_otptranscription divergence found (later closed, rev 40). - Rev 38 (2026-10-12): §1.1 step-2
5003/EMgate closed — the VEN endpoint fingerprints the TLS ClientHello; rustls required (§1.1 step 2). - Rev 37 (2026-10-12): live provisioning wire facts — GSA lookup 404s without
X-Mme-Client-Info; provisioning POST body keys are case-sensitive (§1.1). - Rev 36 (2026-10-12): C56-c closed —
HardwareConfig::to_binported and byte-exact; first live step: step-2 answered5003/EM. - Rev 35 (2026-10-12): C56-a closed — the step-2 request-blob recipe fully derived (§1.1).
- Rev 34 (2026-10-09): C56-a ~85% pinned — request blob decomposed, HMAC chain verified byte-exact.
- Rev 33 (2026-10-09): sign-model fresh-input divergence closed — input-dependent regions found and ported; full fresh stamp byte-exact.
- Rev 32 (2026-10-08): the mint's input mappings wire-pinned (C56-b/c/d/e + cert79); a live-input sign-model divergence discovered (closed rev 33).
- Rev 31 (2026-10-08): sign model ported to Kotlin
NacSign— sig16 residue closed; 208 tests green. - Rev 30 (2026-10-08): NAC mint clean-room RE — the full 517-B stamp derived byte-exact from its inputs (§1.1).
- Rev 29 (2026-10-07): continuation capture — the cpim X group pinned (SHA-256 over encout ‖ signature ‖ filler); all cpim TODOs closed.
- Rev 28 (2026-10-07): live provisioning capture — the cpim/ptm/ProvisionedData framings corrected on every prior TODO (§1.1).
- Rev 27 (2026-10-07): clean-room RE of the ADI library — the OTP derivation is fully local and pure (§1.1).
- Rev 26 (2026-10-02→04): live registration green end-to-end (Albert, packed courier, GSA chain through 2FA, IDS authenticate + register) — replay guard found (§1.1); packed ALPN closed (§3.1); kebab-case sign-in response (§1.5); postdata recorded (§1.5); Albert refinements (§1.2).
- Rev 25 (2026-09-29): first live GSA login —
auplacement, PET's spd delivery channel, trigger-before-prompt ordering, per-run anisette freshness, delegate sign-in409 ICLOUD_UNSUPPORTED_DEVICE(§1.5). - Rev 24 (2026-09-28): first live probes — C18 settled (post-handshake client-cert alert, §3.1); bags are typed config dictionaries with a ~17-day expiry (§1.2).
- Rev 23 (2026-09-27): binary-plist container gaps closed from [CF] + empirical cross-check (§4.1).
- Rev 22 (2026-09-12): offline byte-audit of the live step-2 request — client-side encoding class closed; step-1
certcontainer recorded (§1.1). - Rev 21 (2026-09-11): step-2 refusal re-read — the rev-20 "body-independent" conclusion withdrawn as unproven; SED trace grammar recorded.
- Rev 20 (2026-09-11): live step-2 control experiment — step-2 URL recorded; envelope named the surviving suspect class.
- Rev 19 (2026-09-11): step-2 self-check — content-type default corrected (none); framing named the prime suspect.
- Rev 18 (2026-09-11): first live step-1 attempt + probe matrix — step-2
5003invariant under every flipped variable (later re-read, revs 19–21). - Rev 17 (2026-09-11): FairPlay provenance closed — all known copies are ONE community identity; expired leaf still accepted (§1.2).
- Rev 16 (2026-09-11): activation implementation round — GSA header literals pinned; C60 hypotheses registered (later mooted by the rev-30+ clean-room RE).
- Rev 15 (2026-09-11): C59 + C58 closed — GSA-login residue recorded (§1.5); Mac-Hardware-Info export format recorded (§1.1).
- Rev 14 (2026-09-11): activation extraction round — anisette value formats + ADI provisioning flow recorded (§1.1); §1.5 (GSA chain) added.
- Rev 13 (2026-09-11): implementer round — rev-12 fact sets wired into code; no new protocol facts; C57 gains six writer-pass gaps.
- Rev 12 (2026-09-10): big extraction round — C45/C47/C48/C49/C51/C52/C53/C54 + C8's wire half closed; HKDF salt +
dtlcorrections; "IDS lookups ride the APNs tunnel" recorded (§2.1); C57 created. - Rev 11 (2026-09-10): OpenBubbles-flow research — the Mac's role is identifier export only; C56 registered.
- Rev 10 (2026-09-10): C55 registered — lookup error-status locations (§2.1).
- Rev 9 (2026-09-10): C53/C54 registered — registration wire-name gaps.
- Rev 8 (2026-09-10): [CAP-IDSTLS]/[CAP-BAGCT] recorded — IDS TLS anchors + bag encodings (§1.2); C49–C52 registered.
- Rev 7 (2026-09-10): C47/C48 registered — IDS lookup/bag wire details.
- Rev 6 (2026-09-10): [CAP-COURIERTLS] chain ground truth recorded (§3.1); C45 registered.
- Rev 5 (2026-09-10): binary-plist container format added to §4.1 ([CF]).
- Rev 4 (2026-09-10): phase-1 review-gate pass — one leaked source identifier removed, citation-form legend entries added; no protocol changes.
- Rev 3 (2026-09-10): second targeted extraction round; corrects two rev-2 misreadings (§1.4 signature agreement; §4.3 validator 0x0c); ~45-day figure re-read as a client-side cap (§6.1).
- Rev 2 (2026-09-10): merged three phase-1 extractions plus first capture ground truth [CAP-REG]/[CAP-COURIER].
- Rev 1 (2026-09-10): initial fact set from the phase-1 extractions.