mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
The trapped wa/register frames (ob-trap-20261014-201103 frame-025/026/051/052) decode to two registers: the full user register (x-auth families) and a machine-only register (services: [], push-only signature, fresh stamp). Our body was a stub: client-data carried only client-data-version. MadridIdentity ports the NGM identity (EC signing + RSA-1280 enc + EC device key + EC prekey — the genuine enc key IS RSA-1280), the identity-key DER (249 B at 1280), the prekey-data protobuf (109 B), kt-loggable-data (40 B), and the ~47 captured capability booleans. The request body is gzipped (C3 resolved by capture). Live: both registers answer 6004 ActionRetryWithNewAbsintheContext (SED server-session record in the message); the stored delegate credentials now answer 6008 at §1.2 authenticate.
618 lines
122 KiB
Markdown
618 lines
122 KiB
Markdown
# iMessage protocol spec (clean-room)
|
||
|
||
**Status:** draft — revision 47 (2026-10-16). Revision history at the bottom. This revision updates the §1.1 live-mint status: the mint is exonerated on live data and the prefpane blocker moves to the machine's IDS registration freshness (details in §1.1).
|
||
|
||
**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-data` field of the IDS register body (§1.4) and, base64, as the `X-Mme-Nas-Qualify` header 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]):
|
||
|
||
1. GET the `id-validation-cert` bag URL → a plist with a single `cert` data field. The URL is a CDN static object (`static.ess.apple.com`, plain HTTP cache hit, `X-Cache: hit-fresh`, 3474 bytes, `application/octet-stream` in the genuine capture) — so step 1's header-less success is NOT evidence the endpoints accept header-less requests. The `cert` value is Apple's own container, not a bare DER: `01 02` followed by two `(4-byte BE length, DER cert)` pairs (1046 and 1329 bytes live) [rev 22].
|
||
2. POST the `id-initialize-validation` bag URL — XML-plist body with a single `session-info-request` key (data), NO content-type header; the client's only default request header is `Accept-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-layer `status 5003`, mnemonic `EM`, 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 real `session-info`. Consequence: §1.1 step 2 requires a rustls-backed HTTP client (a rust helper behind UniFFI), not a platform TLS stack.
|
||
3. 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's `body@0x15` region `[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_body` core); **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 Kotlin `NacSign` with 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 (hardcoded `1640`); 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 five `enc` blobs read from the `IOPower:/` 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 `/chosen` `unique-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 OpenSSL `enc` container), 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 from `ABCDEFGHJKLMNPQRSTUVWXYZ123456789`), then base64.
|
||
- Restricted mode additionally uploads the blob (JSON `data` + `id` = lowercase hex SHA-256 of the activation code) to `hw.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 headers `X-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 by `Z` (provisioning requests send the raw offset form — a recorded inconsistency between code paths); `X-Apple-I-TimeZone` = literal `UTC`; `X-Apple-Locale` = `en_US` hardcoded; `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-byte `mid`. Header sets are cached 60 s; a fresh OTP per cache miss.
|
||
- ADI provisioning flow:
|
||
1. GET `https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist; `urls.midStartProvisioning` / `midFinishProvisioning` give the endpoint URLs (never hardcoded). Live (rev 37): the lookup 404s without `X-Mme-Client-Info` and accepts only the angle-bracket form `<Model> <macOS;ver;build> <com.apple.AuthKit/1 (...)>`.
|
||
2. POST the start URL with an XML-plist body of two top-level dicts `Header` (empty) and `Request` → response `Response` → `spim` (base64). Body keys are case-sensitive (rev 37: lowercase `{header, request}` silently returns HTTP 200 with an EMPTY body).
|
||
3. Build a local session from `spim` (+ empty hostuuid, DSID `-2`, flavor `Mac`=1 / `IOS`=3, chosen by whether the client-info string contains `iPhone OS`) yielding `cpim` (base64).
|
||
4. POST the finish URL with `Request` = {`cpim`} → response `Response` → `ptm`, `tk` (both base64) and `X-Apple-I-MD-RINFO`.
|
||
5. 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 with `keychain_identifier` (data) + the provisioned fields.
|
||
- Provisioning HTTP specifics: `Content-Type: application/x-www-form-urlencoded` despite the XML-plist body (intentional); title-cased header names forced; HTTP/1.1; Apple root CA pinned (embedded DER); a fixed companion header set on all three requests: akd `User-Agent`, `X-Apple-Baa-E: -10000`, `X-Apple-Baa-Avail: 2`, `X-Apple-Baa-UE: AKAuthenticationError:-7066|com.apple.devicecheck.error.baa:-10000`, `X-Apple-Host-Baa-E: -7066`, `X-Apple-Client-App-Name: akd`, `Accept: */*`, `Accept-Language: en-US,en;q=0.9`, plus 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-byte `client_secret` (revs 39/40 corrected — NOT the mid; mid-as-`two` yields a well-formed OTP with a wrong signature, ladder rung two). Output 16 bytes; wire `X-Apple-I-MD` frame = `u32be(5) ‖ u32be(16) ‖ sig16 ‖ u32be(flavor)`. The 2FA code = `SHA-1(sig16 ‖ secret60) mod 1_000_000` over 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 the `spim_map_signature` input; `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's `out[408:440] == rng1` echo. 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 48):** 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. Current blockers (rev 48): the §1.4 register answers **6004 = `ActionRetryWithNewAbsintheContext`** (both the machine-only register, `services: []` + push-only signature per the trapped wire's register #2, and the full user register; 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 — the delegate token dies within hours). The §1.4 register body is now capture parity (rev 48): full NGM identity `client-data` + gzip request body; the register body was a stub before.
|
||
|
||
### 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.woa` serves 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 single `bag` key 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/bag` serves push settings: integer `APNSCourierHostcount`, string `APNSCourierHostname` [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/xml` vs `application/x-apple-plist` with XML bodies) — parse by content. APNs bag byte-identical across same-day fetches; the IDS bag carries a `bag-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 auth `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber`; dependent registrations `.../TDIdentityService.woa/wa/getDependentRegistrations`; handles `.../VCProfileService.woa/wa/idsGetHandles`; lookup `https://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.com` serves leaf ← `Apple Server Authentication CA 2` ← `Apple Root CA - G3`; `query.ess.apple.com` serves 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 - G3` and `Apple Root CA` — pin roots only, never the leaf. Both root DERs committed as `ids/src/main/resources/imessage/AppleRootCA-G3.cer` (G3 SHA-256 `63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79`) and `AppleRootCA.cer` (G1, no longer published by Apple — sourced from the public CT log, crt.sh id 1160190, SHA-256 `B0:B1:73:0E:CB:C7:FF:45:05:14:2C:49:F1:29:5E:6E:DA:6B:CA:ED:7E:2C:68:C5:BE:91:B5:A1:10:01:F0:24`); each captured intermediate openssl-verifies against its root. Note (rev 40, live): `gsa.apple.com` now 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 carrying `ActivationInfoComplete` (true), `ActivationInfoXML` (a nested XML plist), `FairPlayCertChain`, `FairPlaySignature`; no timestamp [RP:activation.rs], [IG:activationinfo.go].
|
||
- Nested `ActivationInfoXML` keys: `ActivationRandomness` (uppercase UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (PEM CSR), `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, `UniqueDeviceID`.
|
||
- The CSR is 1024-bit RSA, CN "Client Push Certificate", OU "iPhone", O "Apple Inc.", SHA-1WithRSA (2048 also works; a generator-version field is noted [IG:pushcert]). **Live (rev 26):** `DeviceCertRequest` rides 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 `FairPlayCertChain` is THREE DER certificates concatenated — leaf + FairPlay CA + Apple Root CA; leaf-only and leaf+CA were refused. The post carries `User-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 parses `device-activation` → `activation-record` → `DeviceCertificate` (PEM), plus `ack-received` and `show-settings`. Only the device certificate returns — the private key is client-generated and never sent. 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 (issuer `Apple iPhone Device CA`, subject CN a UUID-form donor-device identifier, validity 2021-10-11 → 2024-10-11 — EXPIRED, yet activations still succeed → albert 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 yields `SIGNATURE_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-id` bag URL [RP:auth.rs], [IG:bag]. Phone-number users: realm id `P:<number>` against `id-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-data` holds `auth-token` (Apple/DS flow) or `push-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 adds `content-encoding: gzip`).
|
||
- Response: `status`, `cert` — flat, no per-user nesting (both); the multiple-users variant returns an `authentication_responses` array with per-entry `cert`, `user-id`, `status` (Rust only).
|
||
- 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 records `Content-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 agent `com.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 except `dt` [RP:macos.rs:157-178], [IG:register.go:325-344]. The relay/phone profile sets `m` and `s` to "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`**: registers `com.apple.madrid` with a `sub-services` list 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 under `com.apple.ess` + `com.apple.private.alloy.facetime.*` (capability "Invitation") and Find My under `com.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), plus `tag` only for phone-number users — value `SIM` for the first phone user and `SIM2`, `SIM3`, … for subsequent ones [RP:ids/user.rs:1078-1081]; Go tags any phone-profile user "SIM" [IG:register.go:233-236]. `uris` entries are single-key dictionaries with key `uri` holding handle URIs (`tel:` E.164 or `mailto:`) — 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-data` base wire keys (Rust): `public-message-identity-key`, `public-message-identity-version`, `ec-version`, `public-message-identity-ngm-version`, `public-message-ngm-device-prekey-data-key`, `kt-version`, plus per-service extras; Go adds the same six plus the boolean `supports-*` feature flags and `nicknames-version`/`optionally-receive-typing-indicators`/`prefers-sdr`/`show-peer-errors` [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-version` 1 [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-data` is a protobuf blob carrying the compressed NGM device key and the NGM (13) and key-transparency (5) versions [RP:ids/user.rs:1048-1054], [IG:ids/user.go:279-292].
|
||
|
||
**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 sets `x-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 prefixed `P:`), register-only (agreed). Lookups use `x-id-nonce`/`x-id-cert`/`x-id-sig` instead (§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 only `status` and `services` (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-user `next-hbi` (unsigned int; heartbeat interval seconds; Rust parses, absent → no fallback).
|
||
- `alert` (Go): `body`, `button`, `title`, nested `action` with `button`, `type`, `url`; Rust models only `title`, `body`, optional `action.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), optional `sp` (selected protocol, default `s2k`), `Status` (dict: integer `ec`, 0 = ok; string `em`).
|
||
- Complete request keys: `M1` (proof, data), `c`, `cpd`, `o` = `complete`, `u`.
|
||
- Complete response: `M2` (server proof, data), `spd` (encrypted plist data), `Status` whose `au` string selects secondary auth: `secondaryAuth` (SMS/trusted-phone) or `trustedDeviceSecondaryAuth`.
|
||
|
||
**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_fo` that digest is ASCII-lowercase-hex-encoded (64 chars) and those bytes feed PBKDF2; for `s2k` the raw 32-byte digest feeds it. PBKDF2-HMAC-SHA256 with server salt `s` and iteration count `i`, 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`; received `M2` is 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-variant `X-MMe-Client-Info`/User-Agent and `X-Apple-AK-Context-Type`/`X-Apple-Client-App-Name`/`X-Apple-I-Client-Bundle-Id`.
|
||
- `akd` request values: `User-Agent` = literal `akd/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` = `imessage` or `icloud` per flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs].
|
||
- The `cpd` dictionary carries anisette values as KEYS inside the body: the six anisette headers above plus optional `ptkn` (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`, booleans `X-Apple-Offer-Security-Upgrade`=true, `at`=0, `bootstrap`=true, `ckgen`=true, `fcd`=true, `icdrsDisabled`=false, `icscrec`=true, `pbe`=false, `prkgen`=true, `webAccessEnabled`=false [APA:icloud-auth]. 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-MD` on an SRP init answers `ec` −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 — one `X-Apple-I-MD`, one `X-Apple-I-MD-M`, one `X-Apple-I-Client-Time` frozen at the session's mint — byte-identical across GsService2 init/complete, circle, 2FA validate, postdata ×5, CloudKit/escrowproxy, and both prefpane `loginDelegates` POSTs. 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` → GET `https://gsa.apple.com/auth` returns JSON `AuthenticationExtras` (trustedPhoneNumbers with `numberWithDialCode`/`lastTwoDigits`/`pushMode`/`id`; HTTP 201 = SMS already sent); request a code with PUT `.../auth/verify/phone` body `{phoneNumber:{id}, mode:"sms"}`; verify with POST `.../auth/verify/phone/securitycode` (same body + `securityCode:{code}`).
|
||
- `au` = `trustedDeviceSecondaryAuth` → trigger the code push with GET `.../auth/verify/trusteddevice`, then submit: GET `.../GsService2/validate` with the code in a `security-code` request header. 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. `au` rides INSIDE the complete response's `Status` dict, not at the `Response` level (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 of `adsid:GsIdmsToken` (from `spd`) and a large browser-style fixed header set. Live (rev 26): the trigger and validate GETs carry `Content-Type: text/x-xml-plist` + `Accept: text/x-xml-plist`; a failed verify's body is a plist whose `Status.ec`/`em` (or top-level `ec`/`em`) name the real failure — the HTTP status alone is opaque.
|
||
- The 2FA browser header set (all `/auth/verify/*` and `GsService2/validate` requests, C59 — recorded with literal values [APA:anisette.rs]): the anisette base headers filtered as above, plus fixed literals `X-Apple-Client-App-Name`, `X-Apple-I-Client-Bundle-Id`, non-akd `X-MMe-Client-Info`, `X-Apple-I-CDP-Circle-Status: false`, `X-Apple-I-ICSCREC: true`, browser `User-Agent` = `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`, and `X-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-separated `ID:TOKEN[:...]` with inconsistent duration/expiration trailer forms (epoch-ms vs seconds, heuristically parsed) [APA:icloud-auth]. After a successful 2FA the full SRP login is run a SECOND time, which then completes as logged-in.
|
||
- 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 in `pakedata`; the code arrives AES-128-GCM-encrypted over an SRP-key-derived channel with `recv->send`/`send->recv` info labels [RP:auth.rs], [APA:icloud-auth].
|
||
- 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 id `com.apple.gs.idms.pet`; default expiry 300 s when no trailer, otherwise parsed seconds/epoch-ms; refreshed by silently re-running the SRP login with the persisted password hash. Used as the HTTP Basic password (`username:PET`) on iCloud requests [APA:icloud-auth], [RP:auth.rs].
|
||
- Second live-confirmed delivery channel (rev 25): the post-2FA re-login's decrypted `spd` token map `t` carries 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's `spd` carries only `com.apple.gs.appleid.auth` and 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 with `delegates` (dictionary keyed by bundle id: `com.apple.private.ids` → {`protocol-version`: `4`}; `com.apple.mobileme` → {}), `protocolVersion` = `1.0`, `userInfo` = {`clientId` (uppercase UUIDv4), `language` = `en-US`, `timezone`} [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/login` speaks a V1 kebab-case `AuthRequest {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-flavor `X-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. Optional `Cookie: termsAccepted=true` (terms flow only). `userInfo.timezone` = a hardcoded IANA name. `client-id` rides the kebab-case wire key `client-id` (live-confirmed rev 26).
|
||
- Response plist: `status` (0 = ok), optional `localizedError` + `description`, `delegates` dictionary with per-entry `status`, `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 carries `protocolVersion` 2, `ErrorID`, `Header`, `Body`, `DialogType`, `DefaultButton*`, `AlternateButton*`, `HelpLink`.
|
||
- The IDS delegate's serviceData yields `authToken` and `profileId` — exactly the IDS `auth-token` (sent as `authentication-data` = {`auth-token`: token}) and the `profile-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`/`teardown` go to gsas) with `Content-Type: text/x-xml-plist`; body = two top-level dicts `Header` (empty) and `Request`, the `liveness` event declaring services `icloud`, `imessage`, `facetime` (recovery-key generation on).
|
||
- Account-binding headers: `X-Apple-I-UrlSwitch-Info` = base64(`adsid:postdata`) and `X-Apple-HB-Token` = base64(`adsid:hb-token`) from the post-2FA spd map's `com.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 answers `ec` −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 `uris` array to the `id-query` bag URL [RP:ids/user.rs] (pinned URL [IG:lookup]); the `uris` elements are PLAIN handle strings (bare `tel:`/`mailto:`) — not the register request's single-key `uri` dictionaries.
|
||
- Header wire names (extraction, C47/C48 — closed): inside `h`, Rust sends `x-id-self-uri`, `x-push-token`, `x-protocol-version` (from the stored per-user protocol version), `user-agent` = `com.apple.madrid-lookup <version-ua>`, the `x-id-nonce`/`x-id-cert`/`x-id-sig` signature set, conditionally `x-id-sub-service` (only when the lookup topic differs from the main topic), and `x-required-for-message: true` / `x-result-expected: true` [RP:ids/user.rs]; Go sends `X-ID-Self-URI`, `X-Protocol-Version` (pinned "1640"), `X-ID-Nonce`, `X-ID-Cert`, `X-Push-Token`, `X-ID-Sig` — no user-agent/sub-service/opt-in headers (disagreement on the optional set → C57) [IG:lookup]. No push header set (`x-push-*`) rides lookups in either repo.
|
||
- Signature payload matches §1.4: nonce ‖ bag name ("id-query") ‖ empty query string ‖ body ‖ APNs push token, RSA PKCS1v15-SHA1 with the `01 01` prefix (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 reads `identities` and `sender-correlation-identifier` only — 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) plus `supports-certified-delivery-v1` (Rust); Go keeps unrecognized keys in a catch-all, Rust drops them.
|
||
- Error statuses are read from the top-level `status` only (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-handle `status`.
|
||
- 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-data` decode 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-seconds` for EVERY identity, with proactive refresh when ANY identity exceeds `session-token-refresh-seconds`, a 60-second minimum spacing between forced refreshes, and the cache keyed by 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-registrations` bag 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-alias` bag URL with attributes (allowed services, expiry, feature id); aliases are cached with expiry and pruned [RP:ids/user.rs], [RP:identity_manager.rs]. Registered handles can be re-fetched via `id-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.ids` topic (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.com` where `*` is 1–50 (`APNSCourierHostcount: 50`, live bag — C17 closed). The client connects to "<random 1..count>-<hostname>" but sets TLS SNI to the bare hostname [RP:aps.rs], [RP:util.rs]. `courier2.push.apple.com` observed 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.com` COMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384), and the server then sends a fatal TLS alert `certificate_required` on 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`, and `apns-security-v3`, the server selects `apns-pack-v1:<enc>:<dec>` — observed `apns-pack-v1:4096:4096` on every courier host — and the packed frame format flows (§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); the `certificate_required` alert belongs to the no-ALPN legacy path.
|
||
- **Chain ground truth [CAP-COURIERTLS]:** `openssl s_client` to `2-courier.push.apple.com:5223` with bare-hostname SNI completes. Server presents leaf `CN = courier.push.apple.com` (notBefore 2026-09-02, notAfter 2026-12-02 — ~90-day rotation; pin the root, never the leaf) ← intermediate `Apple Public Server ECC CA 12 - G1` ← the publicly-trusted Comodo root `AAA Certificate Services` (root not served). A trimmed system store rejects the chain; a pinned-root pool must anchor at `AAA 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 extension` reading: 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 `width` bits carry an inline value while they are not all ones; at the all-ones marker the remainder follows as a 7-bit continuation varint (always present — even a zero remainder writes one 0x00 byte).
|
||
- Attribute: a 1-byte key tag — a single-byte key (< 0x10) rides inline as key|flags; longer keys go through the rolling key cache (`0x20|flags|index` reference; `0x10|flags` + key-length−1 as a tagged width-4 value, then the key bytes) — then the value: inline integer (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), data (tagged width 6), string (width 5, |0x40).
|
||
- Cache flags: 0x40 = first occurrence (full value, enters the rolling value cache), 0x80 = reference (tagged width-8 index into it). Both caches index from the newest entry; the key cache holds 32 entries, the value cache ~4 KiB (the ALPN's `4096:4096` suffixes match the cache sizing).
|
||
- Post-connect sequence (live): connect → connect-response (token) → set-state (20) → filter (9) with the connect-response token plus the SHA-1 topic hashes as enabled topics — unfiltered connections are dropped within seconds, which invalidates the connect token before the login can use it.
|
||
|
||
### 3.3 Commands
|
||
|
||
- **Connect (7):** fields for the device/push token, a state byte (1), connection flags, 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` + optional `0b100` root bit vs Rust `0b01000001` → 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 01` prefix [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.madrid` is the iMessage topic [JJ], [RP:aps.rs], [IG:apns/topic.go]; registered sub-service topics include `com.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.ids` carries server-initiated control [RP:identity_manager.rs], [IG:apns/topic.go]. "ESS" is also used as an alternate name for IDS, but the APNs topic FaceTime uses is specifically `com.apple.ess` [JJ].
|
||
- Push credentials (the APNs connection) and identity credentials (encryption keys) are separated by design, so push credentials can be delegated to a notification relay without exposing message content or the ability to send [BM], [BA].
|
||
- iMessage pushes are deleted from APNs on delivery but, unlike normal pushes, are queued for offline devices — stored on Apple servers for up to 30 days [APS-Send]. Rev-12 correction: 160 is NOT a courier frame command in either repo — it is a payload `command` value inside the command-10 plist message type, meaning offline-message-pending/queue-flush (recorded in Go; absent in Rust) [IG:apns/commands.go]. The earlier "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 mode `E` ("pair" or "pair-ec", present when a body exists); ciphertext `P`; sender handle `sP`; destination handle `tP`; message UUID bytes `U`; numeric message id `i`; send timestamp `e` in unix nanoseconds; sender's 32-byte push token `t`; session token `sT`; version `v` (8 for sends); no-response-needed flag `nr`; expiration seconds `eX`; trusted-sender flag `htu`; user agent `ua`; fanout chunk index `fcn` (starting at 1); and a `dtl` array of per-target bundles [RP:identity_manager.rs], [IG:sendmessagepayload].
|
||
- 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 keys `rc`, `oe`, `sv`, `dmt`, `qI`, `qr`, `qgid`, `qgmc`, `qai`, `qat`, `qv`, `qids`, `ua`.
|
||
- Each `dtl` bundle carries: target handle `tP`, request-delivered boolean `D`, the session token `sT` from the IDS lookup, the target's 32-byte push token `t`, the encrypted payload `P` (absent for payload-less messages), the encryption mode `E` (only for pair-ec), and an optional certified-delivery version `cdv` [RP:identity_manager.rs], [IG:sendmessagepayload]. The delivery-request flag is false for self [IG:connector]; one client excludes its own push token from targets unless a queue id is present [RP:identity_manager.rs].
|
||
- **Encryption-mode placement:** when any encrypted body exists, the top-level `E` is always the legacy value `pair` regardless of the per-recipient scheme actually used; per-`dtl`-entry `E` is written only for entries encrypted with the EC scheme — legacy entries omit `E` at the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neither `E` nor `P` at all; incoming messages with no `E` pass 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): `c` integer; `E` string ("pair"/"pair-ec"); `P` data; `i` integer; `U` data (raw 16-byte UUID); `t` data (push token); `e` integer (unix nanos); `sP`/`tP` strings; `v` integer (8; Rust uses 1850 for the SMS topic, §5.8); `nr` boolean; `fcn` integer; `dtl` is an ARRAY OF DICTIONARIES — nested message dicts, not nested data plists (both agree) [RP:ids/user.rs], [IG:sendmessagepayload]. Placement: the per-recipient fields (`tP`, `t`, `sT`, `P`, `E`, `D`, `cdv`) sit top-level in Go for normal sends; Rust always nests them inside `dtl` entries.
|
||
- 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.
|
||
|
||
### 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 `oC` after 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); subject `s`; participants `p` (full list including self, as URIs); rich XML body `x` (present when multipart, mentions, or styling); group GUID `gid`; group version `gv` = "8"; protocol version `v` = "1"; properties version `pv` (0 for DMs, 1 for groups, per one source); reply/thread reference `tg` (format "r:<part>:<start>:<length>:<UUID>"); screen-effect id `iid`; chat display name `n`; audio flags `a`/`e` (audio / expiring audio); balloon fields `bid`, `bp`, `bpdi`; message-summary info `msi` (content-type number `amc`, source text `ams`, balloon type `amb`); a previous-message UUID `r` [RP:rawmessages.rs], [IG:imessage.go].
|
||
- The XML body's children are either FILE elements (attachments with MMCS download info: owner, URL, hex signature, decryption key, inline-attachment references) or span elements (text parts with part-index attributes and mention markup) [IG:decrypt], [RP:rawmessages.rs]. Text-part positions are counted in UTF-16 code units; part index/start/length is the same indexing scheme used for tapback targets and edit parts [IG:imessage.go].
|
||
- Screen effects use "com.apple.messages.effect.*" names (echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration, shooting star); bubble effects use "com.apple.MobileSMS.expressivesend.*" names (impact, loud, gentle, invisible ink) [IG:imessage.go].
|
||
- Balloon payloads (`bp`) are optionally gzipped and either a raw keyed-archiver plist or a wrapper with payload and attachments; `bpdi` carries MMCS download info (key, size, owner, URL, signature) [IG:imessage.go], [RP:rawmessages.rs]. Rich links live in the URL balloon provider's keyed-archiver payload with URL, title, summary, and icon/image metadata [RP:messages.rs], [IG:balloonparse]. A location-share fallback exists where the text is exactly U+FFFD and the XML carries a breadcrumb-text attribute [IG:decrypt].
|
||
- 1:1 vs group is distinguished by participant count (2 = 1:1; more = group) [IG:decrypt].
|
||
- → C50: the plist value type of each body key is not recorded for most keys (versions `gv`/`v` are strings, `tg` a formatted string; the audio flags, `pv`, the UUID forms for `r`, and the `msi`/`bpdi` sub-dict value types are unstated). The writer phase assumes booleans for the audio flags, bytes for `r`, integers for `pv`/`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:<uuid>" for balloons and "p:<part>/<uuid>" 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 `D` bundle 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 list `sp` (including self, no URI prefixes), target participant list `tp` (membership after change), current name `n`, new name `nn`, old name `old`, and an avatar file-transfer record `tv` [RP:rawmessages.rs], [IG:imessage.go]. Change types: name = "n", avatar = "v", participants = "p"; an additional "vr" is seen but not understood [IG:imessage.go]. Participant add/remove is expressed purely by the sp/tp diff; avatar removal is change type "v" with no file transfer [IG:decrypt], [RP:rawmessages.rs]. → 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`); when `next-hbi` is absent it falls back to seconds remaining until the identity certificate's `notAfter` minus 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: earliest `notAfter` among 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 `notAfter` is authoritative) [IG:connector.go:181,208,301], [RP:ids/user.rs:79-88]. Treat 45 days as a client policy constant, not a measured Apple certificate TTL. → 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.
|
||
|
||
1. **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 `E` values (top-level and per-`dtl` entry) on outgoing and incoming messages; payload-less messages' `E` presence; whether current lookup results still carry the legacy/EC key sets beside the PQ3-registered Kyber-1024 + P-256 keys; the incoming 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).
|
||
2. **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.
|
||
3. **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.
|
||
4. **C41 + C10 + C13 + C14 — lifetimes and cadence.** Real `next-hbi` values (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); real `session-token-expires-seconds`/`-refresh-seconds` values; 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-user `next-hbi`, cert DER), cert validity tagged by handle type, the computed next-renewal deadline and the trigger that fired, and the lookup identity dicts' two token fields.
|
||
5. **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).
|
||
6. **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).
|
||
7. **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.
|
||
8. **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.
|
||
9. **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).
|
||
10. **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.
|
||
10. **C7 — carrier entitlement exchange** per carrier (SIM-less phone auth; request/response shape varies by carrier).
|
||
11. **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).
|
||
12. **C11 — the lookup sender-correlation identifier's exact semantics.**
|
||
13. **C53 residue — the register response's "refresh credentials" marker name** (§1.4) — instrumented register capture; folds into item 2.
|
||
14. **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` + optional `0b100` root bit vs Rust `0b01000001` (§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-data` value encodings: the `push-token` and `sigs` entry shapes inside the authenticate request body (§1.2; key names recorded, value encodings not) — one instrumented phone-user authenticate capture.
|
||
- Albert wire value types: `FairPlayCertChain` / `FairPlaySignature` request encodings and `ack-received` / `show-settings` response encodings (§1.2; names recorded, value types not) — same activation capture.
|
||
|
||
## Revision history
|
||
|
||
- **Rev 48 (2026-10-16 evening):** the §1.4 register body was a STUB — now capture parity. The trapped `wa/register` frames decoded (frame-025/026 = the full user register, frame-051/052 = a second MACHINE register): the genuine body carries the full madrid NGM identity in `client-data` (identity-key = DER SEQ{[1] u16be‖uncompressed P-256 signing point, [2] u16be‖RSA-1280 PKCS#1 DER} = 249 B; prekey-data = protobuf `PreKeyData{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 as `MadridIdentity` (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: []`, NO `x-auth-*` headers (push-only signature), fresh stamp — implemented as the first `idsRegister` step. **Live: both our machine register AND the full register answer `6004`** — decoded via [IG:idserrors]: **6004 = `ActionRetryWithNewAbsintheContext`**, with `message` = an SED server-session token `SED: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; `domergestep2` is XOR-invertible). The session-32 "rng_pad130" verdict is corrected: the scramble pad is thread-RNG filled inside `scramble_body` (second code path, disasm 0x179654d). Transport (rustls ClientHello byte-equivalence), request shape, and staleness eliminated live. The prefpane `ICLOUD_INVALID_DEVICE_TOKEN` blocker 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_TOKEN` survives 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 `two` input = `client_secret`; host init A/B green; `gsa.apple.com` serves an all-Apple chain.
|
||
- Rev 39 (2026-10-13): first live GSA login A/B + gdb OTP capture — OTP input mapping corrected; Kotlin `gen_otp` transcription divergence found (later closed, rev 40).
|
||
- Rev 38 (2026-10-12): §1.1 step-2 `5003/EM` gate 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_bin` ported and byte-exact; first live step: step-2 answered `5003/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 — `au` placement, PET's spd delivery channel, trigger-before-prompt ordering, per-run anisette freshness, delegate sign-in `409 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 `cert` container 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 `5003` invariant 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 + `dtl` corrections; "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.
|