mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
registration: the NAC mint is exonerated on live data (rev 47)
NacValidationTest.mintReproducesLiveVendoredStamp reproduces the accepted genuine prefpane stamp byte-for-byte from recovered live inputs: the rngPad130 is recoverable from the stamp itself (domergestep2 is XOR-invertible — dm(x, in2) = state(in2) XOR x), session250 is the genuine descrambler's own inversion, and the scramble pad is thread-RNG filled inside scramble_body (second code path, disasm 0x179654d) — the session-32 'rng_pad130' verdict is corrected. Live A/Bs eliminate the TLS transport (ClientHello byte-equivalence), request shape, and staleness; the prefpane ICLOUD_INVALID_DEVICE_TOKEN blocker narrows to the machine's IDS registration freshness (the reference runs wa/register x2 before the delegate sign-in; a pre-prefpane register on our side answers 6004 transient-retry). Spec rev 47 + NAC-NOTES session-33 addendum.
This commit is contained in:
1 parent
70aef73b19
commit
2b96445cb4
2 files changed
+505
-244
No files matched your search
+482
-244
@@ -1,200 +1,454 @@
|
||||
# iMessage protocol spec (clean-room)
|
||||
|
||||
**Status:** draft — revision 44. The full revision history is at the bottom ([Revision history](#revision-history)); the sections below state the facts as of rev 31.
|
||||
**Status:** draft — revision 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).
|
||||
|
||||
**Firewall rule:** this document contains facts only — no code, no function/type names, no copied expression from any reference implementation. Wire-format field names are quoted as on-the-wire protocol data (observable in captures, capture-arbitrated), never as source expression. Facts derived from SSPL/AGPL sources are used facts-only; nothing from them is reproduced. Where the source-derived fact sets agree, the fact is stated once with both citations; where they disagree, the disagreement is stated and tagged `TODO(capture):` — running mitmproxy captures arbitrate. All `TODO(capture)` items are collected in §7.
|
||||
**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.
|
||||
|
||||
**Citation legend:** [APS-iMessage] Apple Platform Security, iMessage security overview · [APS-CKV] Apple Platform Security, Contact Key Verification · [APS-Send] Apple Platform Security, How iMessage sends and receives messages securely · [PQ3] Apple Security Research, iMessage with PQ3 · [JJ] jjtech.dev, iMessage explained · [BM] Beeper blog, How Beeper Mini Works · [BA] Beeper blog, How Beeper Android Works · [OB-FAQ]/[OB-Renewal]/[OB-Hosted]/[OB-PNR]/[OB-QS]/[OB-MHI] OpenBubbles docs · [CF] Apple's publicly documented binary property-list container format (Core Foundation; public Apple material — not a restricted source) · [CAP-REG]/[CAP-COURIER] own mitmproxy captures of a registered iPhone 14,4, iOS 26.1 (2026-09-10; captures on file, not published) — ground truth, post-PQ3 · [CAP-IDSTLS]/[CAP-BAGCT] direct unauthenticated probes from this machine (2026-09-10: openssl s_client TLS captures; byte-exact bag bodies with response headers) · [RP:file] rustpush source (SSPL, facts-only) · [APA:file] apple-private-apis source (MPL 2.0, facts-only) · [IG:file] imessagego source (AGPL, facts-only) · [OA] OpenAbsinthe-Stub public API surface (official mock of a closed-source implementation; call sites in rustpush) — facts-only · [MHG:file] the Mac-Hardware-Info helper's public upstream repo (github.com/OpenBubbles/Mac-Hardware-Info; unlicensed upstream — facts-only, no code reuse). Bare `[RP]`/`[IG]` = fact from that repo with the source file not recorded in the extraction notes. `[RP-issue-21]` = rustpush GitHub issue #21.
|
||||
**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.
|
||||
|
||||
**Provenance notes:** "NGM" (next-generation-message) is research terminology, not a wire string. Two §4.3 strings — the HKDF salt `"LastPawn-MessageKeys"` (rev-12 correction: it is the HKDF salt, with empty info — not an info string, as earlier recorded) and the fixed ASCII prekey-signature label — are interop-required protocol constants that are not directly wire-observable; both independent sources agree on them, and capture arbitration is trivial (a wrong string fails decryption or signature validation). APNs-bag constant names (§3, §7 C18/C20) and Apple private-framework symbol names (e.g. `NACKeyEstablishment`) are Apple-published/observable data, not source expression.
|
||||
## 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
|
||||
|
||||
### 1.1 What validation data is and what it requires
|
||||
> 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.
|
||||
|
||||
- Registration with IDS requires a binary blob, "validation data", that proves the client is a genuine Apple device; it is produced from device information including serial number, model, and disk UUID [JJ], also described as machine identifiers: serial number, board ID, and model [OB-FAQ], [OB-Renewal]. The signing context for producing it needs product name, OS build number, platform serial number, logic-board serial (MLB), and a ROM value [RP:macos.rs].
|
||||
- Validation data is only valid for 15 minutes after generation [OB-Hosted]. No local 15-minute-validity enforcement is visible in the extraction sources — if the bound is enforced anywhere, it is server-side [RP:macos.rs], [OA].
|
||||
- Apple's own generator binary is heavily obfuscated; third-party clients sidestep it by emulating the binary with device properties (serial number etc.) fed in from a plist [JJ], or by fetching a ready-made blob from a trusted real Mac over an authenticated HTTP bridge [RP:relay.rs] or from a "nacserv"-style provider that returns the blob plus device version metadata and a validity timestamp [IG:nacserv].
|
||||
- Not all validation data is equivalent: account age and a score determine whether a non-genuine serial is accepted or rejected with the "customer code" error (Hackintosh-analogous) [JJ].
|
||||
- Using identifiers derived from a virtual machine is explicitly discouraged: Apple can detect VM-derived identifiers and re-registering with them risks an account ban [OB-FAQ]. One third-party client's defaults present as Mac mini hardware, macOS 13.5, build 22G74 [IG:config.go].
|
||||
- For the real-Mac path, validation data is produced by a three-step exchange with Apple: download a certificate from the `id-validation-cert` bag URL; POST a session-info request to `id-initialize-validation`; then run key establishment with the returned session info and produce a signed blob [RP:macos.rs]. Extraction detail (2026-09-10): step 1's response is a plist with a single `cert` data field; step 2 POSTs a single-key body `session-info-request` (data) with no anisette or authentication headers — body only — and receives `session-info` (data) back [RP:macos.rs], [RP:auth.rs]. — 2026-09-11 (rev 19): the step-2 request construction is recorded at the same level of detail — the body is serialized as an XML plist and posted with NO content-type header, from an HTTP client whose only default request header is `Accept-Language: en-US,en;q=0.9` [RP:util.rs], [RP:macos.rs]. A header-less step-1 GET succeeds because the `id-validation-cert` URL resolves to a CDN static object (a genuine device's capture of that same GET shows `static.ess.apple.com`, plain HTTP, `X-Cache: hit-fresh`, a 2021 `Last-Modified`, `application/octet-stream`, 3474 bytes), so step 1's header-less success is NOT evidence that the endpoints accept header-less requests (§7 item 24). The hardware configuration the context consumes carries product name, OS build number, platform serial number, platform UUID, root-disk UUID, board ID, MAC address (6 bytes), ROM (bytes), and MLB (string), plus pre-computed obfuscated "enc" variants of serial/platform-UUID/root-disk-UUID/ROM/MLB keyed by five fixed label strings (`Gq3489ugfi`, `Fyp98tpgj`, `kbjfrfpoJU`, `oycqAZloTNDm`, `abKPld1EcMni`); the derivation producing the enc variants is closed source — `TODO(capture):` → C57. The blob is parseable back into the hardware fields (round-trip constructor in the mock's API surface) [OA], [RP:macos.rs].
|
||||
- Current shipped flow (OpenBubbles, 2026): the Mac-side helper (`Mac-Hardware-Info` 1.2.0, notarized) only *reads* the machine identifiers — serial, board ID, model, etc. — via public IORegistry/sysctl APIs (docs: "High Sierra and above", no upper macOS version bound) and exports them as a shareable code (≤ 20 users per code, optional one-time-use/48-hour mode); it never invokes an Apple private framework [OB-MHI], [OB-FAQ]. The client then performs the three-step exchange above itself, in software, keyed with those identifiers and paired with an Apple-Account login — the [JJ] emulation path with an official identifier-export helper in front of it. `TODO(capture):` **C56** — the client-side generation procedure behind the three-step exchange: what the client computes with the `id-initialize-validation` session info (the key-establishment math and blob assembly), how it interacts with the Apple-Account/GSA login and the anisette header set (C6) at activation time, and which pieces are fixed protocol constants vs per-account/per-session state. — 2026-09-10: steps 1–2 of C56 are recorded (bullet above); step 3 — key establishment + signing — is closed source: the public OpenAbsinthe-Stub repo is an official mock whose methods are stubs (its README states the real repository is closed source), so the spec records only the API contract: context creation consumes the certificate chain + hardware configuration and outputs the session-info-request blob; key establishment consumes the server response; signing produces the final blob with no further inputs (all state retained in the context) [RP:macos.rs], [RP:activation.rs], [RP:auth.rs], [OA]. Remaining: wire-verification of the blob against one live activation; the enc-variant derivation rides C57. — 2026-09-11 (rev 14 extraction): the five obfuscation labels are now MAPPED to their fields (previously only listed together): serial → `Gq3489ugfi`, platform UUID → `Fyp98tpgj`, root-disk UUID → `kbjfrfpoJU`, ROM → `oycqAZloTNDm`, MLB → `abKPld1EcMni` [OA:nac.rs]; the derivation producing each `_enc` byte variant is still closed source. The hardware-configuration field set is recorded in full: product name (Apple product-type form, e.g. `Macmini9,1`), MAC address (exactly 6 raw bytes), platform serial, platform UUID, root-disk UUID, board ID, OS build number, ROM (raw bytes), MLB, plus the five `_enc` raw-byte variants [OA:nac.rs]. Step 3's closure is confirmed at the source level: the crate holding the key-establishment math ships as a closed-source stub whose mock methods panic; only the API contract is observable [OA:README.md], [OA:nac.rs].
|
||||
- The helper's export-code format (2026-09-11, rev 15 — **C58 closed** by facts-only extraction from the helper's public upstream repo, which is unlicensed — facts only): the payload is a 4-byte ASCII magic `OABS`, a one-byte flag (1 = "prevent sharing" mode, 0 otherwise), then a raw protobuf message (no length prefix; protobuf starts at offset 5). Protobuf field map: outer — 1 nested identifier message, 2 macOS product version (sysctl `kern.osproductversion`), 3 protocol version (hardcoded `1640`), 4 device id (the IOPlatformUUID again), 5 a hardcoded iCloudHelper user-agent string, 6 a hardcoded AOSKit version string; nested identifier message — 1 product name (e.g. `MacBookPro18,3`), 2 MAC address (6 raw bytes), 3 platform serial, 4 platform UUID, 5 root-disk UUID, 6 board ID (Apple Silicon: literal prefix `Mac-` + lowercase hex of the device-tree board-id bytes), 7 OS build number (sysctl `kern.osversion`), 8/9/10/12/14 the five `_enc` obfuscated blobs read from the `IOPower:/` registry under Apple's key names (`Gq3489ugfi` serial, `Fyp98tpgj` platform UUID, `kbjfrfpoJU` root-disk UUID, `oycqAZloTNDm` ROM, `abKPld1EcMni` MLB — matching §1.1's label mapping), 11 ROM (Intel: the device-tree options ROM value; Apple Silicon: last 6 bytes of SHA-256 of `/chosen` `unique-chip-id`), 13 MLB string. Two delivery modes: plain = standard base64 of the payload (clipboard text, or the raw payload bytes as a QR code at medium error correction); restricted = AES-256-CBC/PKCS7 over the payload, ciphertext prefixed with ASCII `Salted__` + 8-byte random salt (legacy OpenSSL `enc` container), key/IV derived by the legacy EVP_BytesToKey KDF (repeated MD5 of previous-hash ‖ password ‖ salt to 48 bytes: first 32 = key, next 16 = IV) keyed by a human activation code (`MB` + 4×4 chars from the alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ123456789`), then base64. The restricted mode additionally uploads the blob (JSON `data` + `id` = lowercase hex SHA-256 of the activation code) to the vendor's server (`hw.openbubbles.app/code`) — the ≤20-user/one-time/48-hour mechanics live SERVER-side; nothing is embedded in the payload (no checksum, signature, compression, or expiry). No public parser exists (the client apps are closed-source); the construction above is the parse specification [MHG:ViewController.swift], [MHG:HwIdentifiers.swift], [MHG:mac_hw_info.pb.swift].
|
||||
- The validation blob is submitted as the `validation-data` field of the IDS register body and, base64, in the `X-Mme-Nas-Qualify` header of the iCloud account login [RP:ids/user.rs], [RP:auth.rs]. `TODO(capture):` C5 — exact byte shape of the blob a genuine Mac produces, and the current exchange over the two validation bag URLs. — 2026-10-04 (rev 26, live): **REPLAY GUARD — one mint = one device record.** Apple keys a validation blob's consumption server-side: re-submitting an already-consumed blob at the sign-in/register pair answers HTTP 409, which is a REPLAY refusal, NOT a blob rejection — a fresh blob under the same device identity succeeds where the reused one failed, and a captured blob of external provenance succeeds outright (session 17: a captured stamp replaced the local mint and the 409 vanished). Client consequences: mint or fetch a fresh blob per registration attempt (the 15-minute validity above makes stale reuse doubly fatal), and mint the sign-in's blob AT SIGN-IN TIME — never a login-start blob carried across the 2FA wait. A caller-injected blob from any source (local mint, relay Mac, staged capture) is a legitimate stamp; the injection points are the register body's `validation-data` and the sign-in's `X-Mme-Nas-Qualify` header.
|
||||
- Hardware-related anisette headers seen in activation flows: `X-Apple-I-SRL-NO` (serial), `X-Apple-I-MLB` (logic-board serial), `X-Apple-I-ROM` (hex ROM, lowercase) [RP:relay.rs], [APA:anisette]. `TODO(capture):` C6 — the full anisette header set a current client actually sends, including which optional hardware headers appear. — 2026-09-10: the header-set half of C6 is closed by facts-only extraction: per-request anisette headers `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-Locale`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, `X-Apple-I-MD` (base64 OTP), `X-Apple-I-MD-M` (base64 machine ID), plus per-platform hardware headers — macOS: `X-Apple-I-MLB`, `X-Apple-I-ROM` (lowercase hex), `X-Apple-I-SRL-NO` [APA:omnisette], [APA:icloud-auth], [RP:relay.rs]. They are produced by an ADI provisioning exchange with gsa.apple.com (start/finish provisioning returning machine tokens), then a local OTP [APA:omnisette]. GSA context (cross-referenced with C6): the Albert activation itself needs NO Apple-Account login — only the FairPlay-signed activation-info plist (§1.2); the GSA login (full Apple-ID password, SRP over gsa.apple.com/grandslam/GsService2, s2k/s2k_fo PBKDF2, 2FA-capable) feeds the delegate logins that yield the IDS `auth-token`/`profile-id` and the PET token [RP:auth.rs], [APA:icloud-auth]. The extraction confirms the validation blob rides base64 in the `X-Mme-Nas-Qualify` header of the iCloud sign-in POST (recorded above) [RP:auth.rs]. — 2026-09-11 (rev 14 extraction round over the anisette crates): the value-format and provisioning halves of C6 are closed. Per-request value forms: `X-Apple-I-Client-Time` = UTC now truncated to whole seconds, RFC 3339/ISO-8601 with the offset suffix replaced by `Z` (provisioning requests send the raw offset form without the Z — a recorded inconsistency between code paths); `X-Apple-I-TimeZone` = literal `UTC` (not the device zone); `X-Apple-Locale` = `en_US` hardcoded (a companion `X-Apple-I-Locale: en_US` variant exists on the browser-style header set); `X-Apple-I-MD-RINFO` = verbatim passthrough of the string returned in the finish-provisioning response; `X-Mme-Device-Id` = lowercase hyphenated UUID string of 16 locally random bytes (`keychain_identifier`); `X-Apple-I-MD-M` = base64 (standard, padded) of the 60-byte `mid`; `X-Apple-I-MD-LU` = lowercase hex (no dashes) SHA-256 over the same 16 bytes (real macOS instead uses AuthKit's local-user UUID) — present in some header sets, omitted in others (disagreement — C57). Header sets are cached 60 s; a fresh OTP is generated per cache miss. The ADI provisioning flow: `GET https://gsa.apple.com/grandslam/GsService2/lookup` → XML plist whose `urls` keys `midStartProvisioning`/`midFinishProvisioning` give the endpoint URLs (never hardcoded); POST to the start URL with an XML plist body of two empty top-level dicts `Header` and `Request` → response `Response` → `spim` (base64); a local session is built from `spim` (+ empty hostuuid, DSID `-2`, flavor `Mac`=1 / `IOS`=3, flavor chosen by whether the client-info string contains `iPhone OS`) yielding `cpim` (base64); POST to the finish URL with `Request` = {`cpim`} → response `Response` → `ptm`, `tk` (both base64) and `X-Apple-I-MD-RINFO`; finishing the session with the decoded `tk`/`ptm` yields the machine state: `client_secret` (32 bytes), `mid` (60 bytes, Apple-issued at provisioning — reused for every OTP until re-provisioning, which happens only on a not-provisioned failure, e.g. ADI error -45061), `metadata` (variable blob). Provisioning HTTP specifics: `Content-Type: application/x-www-form-urlencoded` despite the XML-plist body (intentional), title-cased header names forced, HTTP/1.1, Apple root CA pinned (embedded DER), and a fixed companion header set on all three requests: akd `User-Agent`, `X-Apple-Baa-E: -10000`, `X-Apple-Baa-Avail: 2`, `X-Apple-Baa-UE: AKAuthenticationError:-7066|com.apple.devicecheck.error.baa:-10000`, `X-Apple-Host-Baa-E: -7066`, `X-Apple-Client-App-Name: akd`, `Accept: */*`, `Accept-Language: en-US,en;q=0.9`, plus `X-Apple-I-MD-LU`, `X-Mme-Device-Id`, `X-Mme-Client-Info`, `X-Apple-I-Client-Time` and any caller-supplied hardware headers. State persists as an XML plist with `keychain_identifier` (data) + the provisioned fields (`client_secret`, `mid`, `metadata`, `rinfo`, `flavor`) [APA:omnisette]. CLOSED-SOURCE RESIDUE: the OTP (`X-Apple-I-MD`) and trusted-device 2FA-code derivations live inside a closed-source crate — its public repo is a stub with `todo!()` methods; recorded is only the call contract: inputs (32-byte `client_secret`, 60-byte `mid`, `metadata`, flavor) → raw OTP bytes (base64 into the header) / 32-bit 2FA code [OA-stub], [APA:omnisette]. — rides C57/C56 residue; the seam stays injectable in the code. — 2026-10-07 (rev 27, clean-room RE of the OpenBubbles ADI library — unstripped Linux `.so`, oracle-verified; the derivation is no longer closed residue): the OTP is fully local and purLine truncated
|
||||
### 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 47):** 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 blocker: the delegate sign-in's `400 ICLOUD_INVALID_DEVICE_TOKEN` (rev 45). **Rev 47 EXONERATES the §1.1 mint itself:** the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered live inputs (`NacValidationTest.mintReproducesLiveVendoredStamp` — body576 from the trapped sign-entry ctx, session250 == the genuine descrambler's own inversion, rngPad130 recovered by XOR-inverting the scramble chain via the genuine .so's `domergestep2`, whose `dm(x, in2) = state(in2) ⊕ x` makes every group invertible). The session-32 "rng_pad130 of scrambleBody" verdict is corrected: the pad is genuine thread-RNG filled INSIDE `scramble_body` (two code paths — a `len == 0x17c` fast path and an RNG-fill loop for shorter data; disasm 0x179654d), the scramble input buffer's caller-side tail is not the pad, and the descrambler inverts our stamps cleanly. Also eliminated live: the TLS transport (our rustls 0.20.9/0.21 ClientHellos are byte-equivalent to the reference's — captured via a write/writev interceptor — and stamps minted through either from a genuine blob still 400), the request shape (pass/fail prefpane requests are structurally identical), and stamp staleness. **The remaining discriminator is the machine's IDS registration freshness:** the reference's launch flow runs §1.4 `TDIdentityService/wa/register` TWICE *before* the delegate sign-in (trapped wire), its completed re-registrations (`id_cache.plist` write) are followed by our only two passes, and a pre-prefpane §1.2+§1.4 re-registration attempt on our side reaches the register endpoint but answers **6004 transient-retry** (§1.2 authenticate passes with the stored delegate credentials). Next: chase 6004 (retry/backoff, register-body diff vs the trapped `wa/register` frames) and consider registering before the prefpane as the standing flow.
|
||||
|
||||
### 1.2 Activation endpoints and certificates
|
||||
|
||||
- Public write-ups name `albert.apple.com` as the activation service that sets up the "push" credentials (including the certificate needed for APNs) [BM], [JJ], and `identity.ess.apple.com` as the service that registers the account with iMessage and generates the "identity" credentials used to encrypt/decrypt [BM]. Exact paths in the sources: device activation at `https://albert.apple.com/deviceservices/deviceActivation?device=MacOS` [RP:activation.rs], [IG:pushcert]; IDS registration at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register` [IG:register]. All IDS endpoint URLs are resolved from a plist "bag" fetched at runtime (`getBag?ix=3` at `init.ess.apple.com`), which maps bag keys (`id-register`, `id-query`, `id-authenticate-ds-id`, `id-authenticate-phone-number`, `id-authenticate-multiple-users`, `id-validation-cert`, `id-initialize-validation`, …) to URLs [RP:util.rs]; a parallel bag at `init-p01st.push.apple.com/bag` serves APNs settings [RP:util.rs], [IG:courier].
|
||||
> Plain English: which Apple hosts serve what, how endpoint URLs are resolved ("bags"), and the Albert activation exchange that issues the push certificate.
|
||||
|
||||
- Bag shape and fetching (second-extraction facts): the live bag is fetched over GET from `https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3`; the response body is a plist dictionary with a single `bag` key whose value is embedded plist data — those bytes are themselves a plist parsed into a flat bag-key → URL dictionary [RP:util.rs:57-81]. The fetch is cached for the process lifetime keyed by bag URL; there is no TTL or refresh, and a missing key inside the parsed bag is its own error [RP:util.rs:59-81]. The APNs bag is fetched over plain HTTP from `http://init-p01st.push.apple.com/bag` and resolves integer `APNSCourierHostcount` and string `APNSCourierHostname` [RP:util.rs:56], [RP:aps.rs:1223-1224].
|
||||
- The second implementation never fetches a bag at runtime: all endpoint URLs are compile-time pinned, and the bag fetch URL is only mentioned as the manual refresh source. Its pinned URLs: DS-ID authentication at `https://profile.ess.apple.com/WebObjects/VCProfileService.woa/wa/authenticateDS`, phone-number authentication at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/authenticatePhoneNumber`, dependent registrations at `.../TDIdentityService.woa/wa/getDependentRegistrations`, handles at `.../VCProfileService.woa/wa/idsGetHandles`, lookup at `https://query.ess.apple.com/WebObjects/QueryService.woa/wa/query` (used only inside the APNs HTTP tunnel, never plain HTTPS), registration at `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/register` [IG:ids/bag.go:19-28], [IG:ids/urls.go:19-23], [IG:lookup.go:519-527]. It has no support at all for the alias-provisioning, spam-report, validation-certificate, validation-initialization, or multi-user phone-auth endpoints [IG:ids/bag.go, repo-wide].
|
||||
- IDS HTTPS requests in both implementations use a custom TLS trust pool containing only embedded Apple root certificates, not the system store [RP:util.rs:208-242], [IG:util/appletls/util.go]. — 2026-09-10 [CAP-BAG]: the live bag was fetched directly with an unauthenticated `GET https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3` — no auth, no anisette; the response is a plist with `signature`, `certs`, and `bag` keys, the inner bag a flat dictionary of **405 entries** (full response archived on file as `ids-bag-2026-09-10.xml`). This closes the live-bag-contents half of C1 without an instrumented client. Notably the endpoint presented a TLS chain the local system store rejected (consistent with the sources' Apple-root-only pinning). Every id-* URL that either source pins (`id-register`, `id-query`, `id-authenticate-ds-id`, `id-authenticate-phone-number`, `id-authenticate-multiple-users`, `id-get-dependent-registrations`, `id-get-handles`, `id-validation-cert`, `id-initialize-validation`, …) matches the live bag — no drift. The bag also carries the scalar `id-query-refresh-queued-query-interval: 28800` (8 h, queued-refresh interval). `TODO(capture):` C47 — the concrete plist encoding of both bag responses, and of the embedded `bag` payload those responses carry, is not stated in this fact set (the archives are named `.xml`, and that is the whole of the evidence here); neither is the response content type. — 2026-09-10 [CAP-BAGCT]: the encoding half of C47 is settled. `GET https://init.ess.apple.com/WebObjects/VCPermsService.woa/wa/getBag?ix=3` (the `VCPermsService.woa` app also serves the bag — [CAP-BAG] had used `VCInit.woa`) returned `Content-Type: text/xml`, 94,859 bytes, an XML plist, top-level keys `signature`/`certs`/`bag`; the embedded `bag` value is base64 of an XML plist (decodes to `<?xml version="1.0"…<plist version="1.0"><dict>…`). The APNs bag (`GET http://init-p01st.push.apple.com/bag`) returned `Content-Type: application/x-apple-plist` but the body is still an XML plist, 10,675 bytes, byte-identical across two same-day fetches. Verdict: both bag responses and the embedded bag payload are XML plists in every observed case — no binary plist on the bag wire; the content-type labels are unreliable (`text/xml` vs `application/x-apple-plist` with XML bodies) and parsing goes by content, not label. Also [CAP-IDSTLS]: `openssl s_client` with bare-hostname SNI to `init.ess.apple.com:443` and `query.ess.apple.com:443` — init serves leaf `CN=init.ess.apple.com` ← `Apple Server Authentication CA 2` ← `Apple Root CA - G3`; query serves leaf `CN=query.ess.apple.com` ← `Apple Server Authentication CA` (the older CA) ← `Apple Root CA`; neither root is served, and the unanchored chain is rejected by the system store (OpenSSL verify code 20; curl error 60 with the system bundle) — matching the Apple-root-only pool of the sources. Anchor set to embed: `Apple Root CA - G3` and `Apple Root CA` — Apple roots, distinct from the publicly-trusted Comodo/AAA root the courier chain needs ([CAP-COURIERTLS], §3.1); pin roots only, never the leaf. Both root DERs are committed as `ids/src/main/resources/imessage/AppleRootCA-G3.cer` and `AppleRootCA.cer` (G3 from Apple's live CA page; the G1 `Apple Root CA` is no longer published by Apple — sourced from the public CT log, crt.sh id 1160190, SHA-256 `B0:B1:73:0E:CB:C7:FF:45:05:14:2C:49:F1:29:5E:6E:DA:6B:CA:ED:7E:2C:68:C5:BE:91:B5:A1:10:01:F0:24`; G3 SHA-256 `63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79`) and each captured intermediate openssl-verifies against its root.
|
||||
- The Albert activation request is a form post containing an activation-info plist (activation randomness, activation state, build version, a DER certificate request, device class "MacOS", product type, product version, serial number, and a unique device ID) plus a FairPlay certificate chain and a FairPlay signature over the activation-info bytes [RP:activation.rs]. The certificate request is generated over a fresh 1024-bit RSA key with a "Client Push Certificate" common name [RP:activation.rs]; a second implementation agrees on the 1024-bit RSA push key (2048 also works) and notes a generator-version field [IG:pushcert]. The response embeds a plist whose activation record carries the device certificate (plus chain) [RP:activation.rs], [IG:pushcert]. `TODO(capture):` C8 — the exact activation-info field set and FairPlay certificate choice a genuine current client sends (one source reports signing with one of ten bundled FairPlay keys [RP:activation.rs]). — 2026-09-10: the wire half of C8 is closed by facts-only extraction (both source repos). The request is a POST (form-urlencoded) with a single form key `activation-info` whose value is an XML plist carrying `ActivationInfoComplete` (true), `ActivationInfoXML` (a nested XML plist), `FairPlayCertChain`, and `FairPlaySignature`; no timestamp is sent in the request [RP:activation.rs], [IG:activationinfo.go]. Nested activation-info keys: `ActivationRandomness` (uppercase UUIDv4), `ActivationState` ("Unactivated"), `BuildVersion`, `DeviceCertRequest` (PEM CSR), `DeviceClass`, `ProductType`, `ProductVersion`, `SerialNumber`, `UniqueDeviceID`. The CSR is 1024-bit RSA, CN "Client Push Certificate", OU "iPhone", O "Apple Inc.", signed SHA-1WithRSA. FairPlay signing covers the serialized ActivationInfo plist (SHA-1, RSA PKCS1v15) and yields the (chain, signature) pair placed into the activation body; both sources embed FairPlay identities (Go: two embedded files; Rust: ten certificate/key pairs, one chosen at random per activation — provenance unrecorded, → C57) [IG:fairplay.go], [RP:activation.rs]. The response's outer XML `<Protocol>` block is plist-parsed: `device-activation` → `activation-record` → `DeviceCertificate` (PEM), plus `ack-received` and `show-settings` — only the device certificate returns; the private key is client-generated and never sent, and no device token appears in the response [IG:pushcert], [RP:activation.rs]. Courier link: the DeviceCertificate (with the client private key) becomes connect field 12 (§3.3), and the connect nonce is signed with that key — Go: a 20-byte random nonce with the first byte zeroed; Rust: 0x00 ‖ big-endian unix-milliseconds ‖ 8 random bytes; signature = 0x01 0x01 ‖ RSA-SHA1(nonce) (both) [IG:connection.go], [RP:aps.rs] (nonce-shape tension with §3.3's recorded agreement — C57). — 2026-10-02/03 (rev 26, live): three request/response refinements. (1) `DeviceCertRequest` rides as a plist `<data>` whose content is base64 of the PEM CSR TEXT bytes — a DER-encoded CSR is rejected server-side (the SIGNATURE_VERIFICATION_FAILED page; the earlier "DER `<data>`" reading was dead, session 12). (2) The accepted `FairPlayCertChain` is THREE DER certificates concatenated — leaf + FairPlay CA + Apple Root CA (the two CA links captured in the accepted Android request); leaf-only and leaf+CA were refused. (3) The activation response may wrap the `<Protocol>` block inside a larger document (`<Document><Protocol>…</Protocol><ScrollView>…` whose UI XML breaks a whole-document parse, live 2026-10-02) — a client extracts the `<Protocol>` span and parses it alone; a bare `<Protocol>` document parses as before. Refusals (no DeviceCertificate) carry the reason in wrapped-UI page text, not in a structured field.
|
||||
- Account login requires an authentication token from Apple ID username and password. With 2FA there are two retrofitted options: legacy (2FA code appended to the password) and GrandSlam, where "anisette data" proves device identity so no repeated 2FA entry is needed; the result is a Password Equivalent Token (PET) usable in place of password+2FA [JJ]. GrandSlam login uses an SRP-6a variant (3072-bit group) [APA:client], [IG:appleid]; the Apple ID login side uses `idmsa.apple.com` endpoints with session, scnt, and client-info headers [IG:appleid]. The iCloud setup login (`setup.icloud.com`) posts a delegates map and yields per-delegate service data; the IDS delegate yields an auth token and a profile id [RP:auth.rs].
|
||||
- The short-lived authentication token must be exchanged for a longer-lived authentication certificate; this certificate allows IDS registration but is not sufficient for key lookups [JJ].
|
||||
- IDS authentication certificates: for Apple-ID users the CSR is a fresh 2048-bit RSA key whose common name is a SHA-1 of the realm user id, submitted to the `id-authenticate-ds-id` bag URL [RP:auth.rs] (pinned URL variant: `profile.ess.apple.com` authenticateDS [IG:bag]); for phone-number users the realm id has the form "P:<number>" against `id-authenticate-phone-number` [RP:auth.rs] (pinned variant: `identity.ess.apple.com` authenticatePhoneNumber [IG:bag]). A SIM-less phone route (`id-authenticate-multiple-users`) carries a per-request carrier EAP-AKA signature produced through the carrier's entitlement server, with the APNs connect token as client nonce [RP:auth.rs], [RP:util.rs]. `TODO(capture):` C7 — the carrier entitlement exchange per carrier (request/response shape varies by carrier). `TODO(capture):` C54 — the authenticate exchange's unrecorded wire shape: the request body's plist keys, the CSR's encoding ("CSR" is the recorded term; nothing states PKCS#10 or another form) and its self-signature algorithm, the encoding of the CN's SHA-1 (raw digest vs hex vs base64), whether the §1.4 signature header sets are carried, and the response's wire keys (the short-lived token, the longer-lived authentication certificate, and their relationship). — 2026-09-10: C54 is closed by facts-only extraction (both source repos), with three disagreements tagged for capture (C57). Endpoints: bag keys `id-authenticate-ds-id` / `id-authenticate-phone-number`, one authentication per user; Rust resolves them from the bag, Go pins the full URLs — a recorded client-design disagreement (bag resolution vs pinned URLs), not a wire fact; Rust also has `id-authenticate-multiple-users` for the SMS-less flow (recorded above) [RP:ids/user.rs], [IG:bag.go]. Request body keys (agreed): `authentication-data`, `csr`, `realm-user-id`; the CSR is DER (both). `authentication-data` holds `auth-token` for the Apple/DS flow and `push-token` + `sigs` (array) for the phone flow. The SMS-less variant body (Rust only): `csr`, `sig`, `tag`, `user-id`. Self-signature over the CSR DISAGREES: Rust SHA-1 with RSA/PKCS1, Go SHA256WithRSA — `TODO(capture):` (C57). CN encoding: a hex STRING of the SHA-1 digest of the realm-user-id (not raw, not base64); Rust uppercases the hex, Go lowercases — case-only disagreement, `TODO(capture):` (C57). The §1.4 signature header sets are NOT applied to authenticate in either repo; the headers sent are only `user-agent` and `x-protocol-version` (Rust adds `content-encoding: gzip`) [RP:auth.rs], [IG:authdevice.go]. Response keys: `status`, `cert` — flat, no per-user nesting (both); the multiple-users variant returns an `authentication_responses` array with per-entry `cert`, `user-id`, `status` (Rust only) [RP:auth.rs], [IG:authdevice.go].
|
||||
**Services**
|
||||
|
||||
- FairPlay activation material (2026-09-11, rev 17 — facts-only metadata extraction over the reference repos plus one public community file): both implementations embed the SAME single identity — imessagego's two files and rustpush's `certs/legacy-fairplay/` are byte-identical (SHA-256 `bc18fc4d…` for the leaf cert; same RSA modulus for both key containers), and rustpush's recorded "ten pairs, one chosen at random" are an illusion: upstream gitignores the real `certs/fairplay/` directory and its CI fabricates the ten names by copying one "legacy" pair. All copies trace to a 2023 iCloud/SIM activation-bypass community release ("Albert iTunes Bug", `albertsimlockapple` PHP file, claimed MIT license) — the material was NOT extracted by either project's authors. The identity: one 1024-bit RSA keypair + its leaf certificate (single DER cert, not a chain — issuer `Apple iPhone Device CA`, subject CN a UUID-form donor-device identifier, validity 2021-10-11 → 2024-10-11, EXPIRED yet activations still succeed → albert evidently does not validate this chain's expiry). `FairPlaySignature` = RSA-SHA1 PKCS#1v15 over the exact serialized ActivationInfoXML bytes, signed by the embedded private key; `FairPlayCertChain` = that leaf cert alone (Apple's server fills in the rest). No per-account binding, no refresh/rotation path, unlimited activations per identity in the sources; rustpush ships an unmerged "dummy-fairplay" escape hatch for contributors lacking the material. NO source documents a legitimate own-Mac extraction path — where macOS stores the per-device FairPlay activation identity is an OPEN research item (distinct from the activation response's `DeviceCertificate`, which is the client-generated push-key cert, not FairPlay input).
|
||||
- Capture ground truth [CAP-REG]: on a registered iPhone (iOS 26.1), the single iMessage-relevant artifact visible to a plain HTTP(S)-proxy capture is a plain-HTTP fetch of `http://static.ess.apple.com:80/identity/validation/cert-1.0.plist` by the invitation-registration daemon (user agent `com.apple.invitation-registration [iPhone OS,26.1,23B85,iPhone14,4]`). The plist carries an MD5 in an XML comment (`certificate-production.cer md5=f6e148c97bfbc9867e19c2eeadbab534`) — clients apparently MD5-check the payload and cache it — and a `cert` blob consisting of a 6-byte header (`01 02 00 00 04 16`) followed by a DER certificate: an "Apple System Integration Certification Authority" intermediate issued by Apple Root CA, valid 2011-01-26 to 2019-01-26 — expired, yet still served and still fetched by current iOS. This is the same validation-certificate family as the `id-validation-cert` bag key above, but it is not the validation data itself (§1.1). The pinned hosts (`identity.ess`, `albert`) produce no flows at all through the proxy — their daemons bypass it — so C1 stays open, and the validation-data exchange (C5/C6) remains uncaptured.
|
||||
- `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
|
||||
|
||||
- Phone-number registration without an Apple ID works via an SMS to Apple's "Gateway" service; the carrier-specific gateway responds by SMS and the response contents are submitted to Apple to register the number as iMessage-capable [BM]. An Apple ID login is optional for phone-number-only operation [BM]. At the Apple-account level, phone numbers are verified via the carrier network and SIM, on some networks by SMS with a confirmation dialog [APS-iMessage]. Registering an Apple ID and a phone number "together in the same call" links the handles so all devices on the Apple ID can use both [BM]. Additional email addresses are added by verifying a confirmation link, and all of the user's registered devices show an alert when a new device, phone number, or email address is added [APS-iMessage].
|
||||
> 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
|
||||
|
||||
- Registration POSTs to the `id-register` bag URL [RP:ids/user.rs] (pinned URL: identity.ess register path [IG:register]). The body carries: device name, hardware version, language, OS version, software version, `private-device-data` (dictionary), a `services` array, a `users` array, and the `validation-data` blob [RP:ids/user.rs], [IG:register]. Encoding (second-extraction refinement): both implementations serialize the body as an XML plist; the disagreement is compression only — one gzips the XML and sends `content-encoding: gzip` plus `accept-encoding: gzip` [RP:ids/user.rs:1112-1115], the other sends the raw XML with no content-type and no content-encoding header at all [IG:register.go:366-375]. `TODO(capture):` C3 — whether the live server accepts both encodings or one is stale; current sub-service list and capability flags (below). Wire key names (extraction, agreed — the request has exactly eight top-level keys, none beyond): `device-name`, `hardware-version`, `language`, `os-version`, `software-version`, `private-device-data` (dict), `services` (array), `validation-data` (data) [RP:ids/user.rs], [IG:register]. Services-entry keys: `capabilities` (array), `service`, `sub-services`, `users`; capability-record keys: `flags` (int), `name` (string), `version` (int; both sources set 1 — Go's record is flags 1, name "Messenger") [RP:ids/user.rs], [IG:register].
|
||||
- `private-device-data` (second-extraction refinement — exact wire keys, agreed): `ap` "0", `d` = seconds since the Apple epoch (2001-01-01) as a decimal string, `dt` = integer 1, `gt` "0", `h` "1", `m` "0", `p` "0", `pb` = OS build string, `pn` = platform name, `pv` = OS version, `s` "0", `t` "0", `u` = device UUID (uppercase string), `v` "1"; all values are strings except the `dt` integer [RP:macos.rs:157-178], [IG:register.go:325-344]. The extraction confirms the full sub-key set as the eighteen keys above (`ap` through `v`, including the four both sources model but omit) [IG:register]. The relay/phone profile sets `m` and `s` to "1" instead [RP:relay.rs:189-225]. Both sources also model four further keys (`c`, `ec`, `ktf`, `ktv`) but deliberately omit them from the built request [RP:macos.rs:159-177], [IG:register.go:57-82,331-332].
|
||||
- The `services` array registers `com.apple.madrid` with a `sub-services` list and one capability record named "Messenger" with version 1 — but the flags value is disagreed: 17 [RP:imessage/aps_client.rs:74] vs 1 [IG:register.go:347]. The sub-service lists also disagree: four topics (`com.apple.private.alloy.sms`, `.gelato`, `.biz`, `.gamecenter.imessage`) [RP:imessage/aps_client.rs:19-24] vs seven (those four plus `.safetymonitor`, `.safetymonitor.ownaccount`, `.askto`) [IG:register.go:352-360], [IG:apns/topic.go:24-33]. The first source additionally registers non-iMessage services in the same array when enabled: FaceTime under `com.apple.ess` and `com.apple.private.alloy.facetime.*` (capability name "Invitation") and Find My under `com.apple.private.alloy.multiplex1` (capability name "com.apple.private.alloy") [RP:facetime.rs:26-56], [RP:findmy.rs:26-45]. `TODO(capture):` C3 — current sub-service list and flags.
|
||||
- Per user entry: `client-data` (public identity key structure with version 2, optional NGM/EC key data with `ec-version` 1 and NGM version 13, a signed prekey record, key-transparency data with `kt-version` 5, plus boolean capability flags), `kt-loggable-data`, `uris`, `user-id`, and a `tag` for phone profiles [RP:ids/user.rs], [IG:register]. `tag` is "SIM" for the first phone user and "SIM2", "SIM3", … for subsequent ones [RP:ids/user.rs:1078-1081]; the other source tags any phone-profile user "SIM" [IG:register.go:233-236]. `uris` entries are single-key dictionaries of handle URIs with `tel:` (E.164) or `mailto:` schemes — and neither implementation constructs them locally: both fetch the registered handle list from the server first and echo it back into the register body [IG:util/uri/uri.go:61-64,177-191], [RP:ids/user.rs:839-843,1040-1042], [IG:ids/user.go:132-163]. `kt-loggable-data` is a protobuf blob carrying the compressed NGM device key and the NGM (13) and key-transparency (5) versions [RP:ids/user.rs:1048-1054], [IG:ids/user.go:279-292]. C53 wire names (extraction, agreed): per-user keys are `client-data` (dict), `uris` (array), `user-id`, `kt-loggable-data` (data; optional in Go, always set in Rust when publishing keys), plus `tag` only for phone-number users — value `SIM`/`SIM<N>` (Go tags "SIM" when the user-id starts with `P:`). Register-request `uris` entries are single-key dictionaries with key `uri` (both). `client-data` base wire keys (Rust): `public-message-identity-key`, `public-message-identity-version`, `ec-version`, `public-message-identity-ngm-version`, `public-message-ngm-device-prekey-data-key`, `kt-version`, plus per-service extras; Go adds the same six plus the boolean `supports-*` feature flags and `nicknames-version`/`optionally-receive-typing-indicators`/`prefers-sdr`/`show-peer-errors` recorded above [RP:ids/user.rs], [IG:register].
|
||||
- The capability booleans in `client-data` (wire key names; the union across both sources, all true unless noted): `optionally-receive-typing-indicators`, `show-peer-errors`, `supports-ack-v1`, `supports-audio-messaging-v2`, `supports-autoloopvideo-v1`, `supports-be-v1`, `supports-ca-v1`, `supports-cross-platform-sharing`, `supports-fsm-v1/v2/v3`, `supports-ii-v1`, `supports-impact-v1`, `supports-inline-attachments`, `supports-keep-receipts`, `supports-location-sharing`, `supports-media-v2`, `supports-original-timestamp-v1`, `supports-people-request-messages` (+ `-v2`, `-v3`), `supports-photos-extension-v1/v2`, `supports-protobuf-payload-data-v2`, `supports-rem`, `supports-sa-v1`, `supports-st-v1`, `supports-update-attachments-v1`, `supports-hdr`, `supports-heif`, `supports-dq-nr`, `prefers-sdr` (false), `nicknames-version` 1 [RP:imessage/aps_client.rs:25-73], [IG:register.go:240-299]. Each source sends a further set the other omits — first source only: `is-c2k-equipment`, `supports-activity-sharing-v1`, `supports-shared-exp`, `supports-family-invite-message-bubble`, `supports-live-delivery`, `supports-stick-moji-backs`, `supports-emoji-tapbacks`, `supports-send-later-messages`, `supports-certified-delivery-v1`, `supports-transcript-backgrounds`, `supports-gti`, `supports-polls` [RP:imessage/aps_client.rs:26-72]; second source only: `supports-askto`, `supports-emoji-stickers`, `supports-findmy-plugin-messages`, `supports-sticker-editing`, `supports-recurring-payment-bubbles` [IG:register.go:247-274]. Type discrepancy: the first source encodes the versions as plist real values, the second as plist integers [RP:imessage/aps_client.rs:51-52], [IG:register.go:241,309].
|
||||
- The identity keys themselves: an RSA-1280 encryption key plus a P-256 signing key for the legacy scheme, plus a P-256 device key and a P-256 pre-key for the newer EC scheme [RP:ids/user.rs].
|
||||
- The request is signed once with the push key and once per user with that user's auth key, using indexed header names [RP:auth.rs], [IG:sign]. Second-extraction correction: **the two sources agree** on the construction (rev 2 recorded a disagreement based on a misreading of a wrapper function's name — the "wrap" is plain concatenation, verified in source): the nonce is 17 bytes — one type byte (0x01 for HTTPS requests; 0x00 exists for APNs use), an 8-byte big-endian unix-milliseconds timestamp, 8 random bytes [RP:auth.rs:685-700], [IG:util/nonce/nonce.go:25-42]; the signing payload is that nonce followed by a big-endian u32 length-prefixed concatenation of (bag key string, query string — empty for register, request body, raw push token) [RP:auth.rs:702-708], [IG:ids/sign.go:35-46]; the signature is RSA-SHA1-PKCS1v1.5 over SHA-1 of (nonce ‖ payload), output prefixed with the two bytes 0x01 0x01, then base64 [RP:auth.rs:710-714], [IG:ids/sign.go:54-59,89-95]. Header wire names: push set `x-push-nonce`/`x-push-cert`/`x-push-sig` (plus `x-push-token` carrying the base64 push token), per-user indexed sets `x-auth-user-id-<N>`/`x-auth-nonce-<N>`/`x-auth-cert-<N>`/`x-auth-sig-<N>` starting at N=0 [RP:ids/user.rs:1108-1121], [IG:ids/sign.go:48-96]; lookups use an `x-id-nonce`/`x-id-cert`/`x-id-sig` variant instead of per-user auth headers [IG:ids/sign.go:63-79]. Extraction: the `x-auth-user-id-<N>` value is the plain user-id string (the profile ID; phone users prefixed `P:`), N is the zero-based user index, and the header is register-only (agreed) [RP:ids/user.rs], [IG:sign]. `TODO(capture):` C4 — confirm server acceptance of this construction against a real device (source-side structure is settled).
|
||||
- The request carries a push-token header (base64 of the 32-byte APNs connect token) [RP:ids/user.rs]. Protocol-version header values: "1640" hardcoded in one source [IG:ids/user.go:44]; the other takes it from config, with "1660" in its test fixtures [RP:test.rs:303]. User agent: `com.apple.invitation-registration [<name>,<version>,<build>,<hardware>]` in both [RP:macos.rs:77-79], [IG:ids/config.go:89-92]. Content type on register: `application/x-apple-plist` (one source; the other sends none) [RP:ids/user.rs:1112], [IG:register.go:370-375]. `TODO(capture):` C2 — current protocol-version value.
|
||||
- The response contains a top-level status (0 = ok) and `message` string, a per-service breakdown (`status`, `users`), and per user: status, the X.509 identity certificate (DER) to store, the registered URIs with per-URI statuses, an optional unsigned-integer `next-hbi` (heartbeat interval in seconds; parsed as absent when missing, with no fallback at parse time [RP:ids/user.rs:1160-1180]), an optional `alert` (title/body/button plus an action object with button/type/url) shown on certain errors, and a top-level `retry-interval` integer that the second source models but never reads [RP:ids/user.rs:1127-1183], [IG:register.go:110-144,429-450]. Notable status codes: 6001 incompatible (Contact Key Verification or Advanced Data Protection enabled), 6004 transient retry, 6005 bad authentication, 6009 iMessage access disabled, 5052 alias removed [RP:ids/user.rs], [RP:identity_manager]. Response wire keys (extraction): top level `status`, `message`, `retry-interval`, `services` — Go names all four; Rust reads only `status` and `services` (disagreement on which exist/are read — `TODO(capture):`, C57). Per-service entries nest under `services`, each with `service`, `users`, `status`; per-user keys `uris`, `user-id`, `cert`, `status`, `alert`; per-URI entries `uri`, `status`; Rust additionally reads an optional per-user `next-hbi` (absent in Go's model, recorded above). `alert` keys (Go): `body`, `button`, `title`, nested `action` with `button`, `type`, `url`; Rust models only `title`, `body`, and optional `action.url`. — 2026-09-10: C53 closed by facts-only extraction (both source repos); the "refresh credentials" marker name (§6.1) remains the one capture-bound half, and the two disagreements above join C57.
|
||||
- Certificates and authentication keys are generated and stored locally on the client and never touch third-party relay servers; with Apple ID login the third-party app appears as a trusted device on the account [OB-Hosted].
|
||||
- OpenBubbles' hosted/relay model: one-time access to a Mac (or an always-online iPhone) generates activation codes via a QR generator; the Mac need not stay online afterward and never receives the Apple Account keys [OB-QS], [OB-FAQ]. One Mac's identifiers can be shared with up to 20 users; a "prevent sharing" mode makes codes single-use, 48-hour-expiring, and non-transferable [OB-FAQ].
|
||||
> 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
|
||||
|
||||
- Facts-only extraction (2026-09-11, rev 14) over the GSA-auth crate and the Mac client's auth flow. Endpoints: the two SRP steps both POST `https://gsa.apple.com/grandslam/GsService2` with `Content-Type: text/x-xml-plist`; the body is an XML plist with top-level `Header` (only `Version` = `1.0.1`) and `Request`. Init request keys: `A2k` (client SRP public value, plist data), `cpd` (a client-provided-data dictionary, below), `o` = `init`, `ps` = [`s2k`, `s2k_fo`], `u` = username. Init response (under `Response`): `s` (salt, data), `B` (server public value, data), `i` (iteration count, integer), `c` (challenge string), optional `sp` (selected protocol, default `s2k`), `Status` (dict with integer `ec`, 0 = ok, and string `em`). Complete request keys: `M1` (proof, data), `c`, `cpd`, `o` = `complete`, `u`. Complete response: `M2` (server proof, data), `spd` (encrypted plist data), `Status` whose `au` string selects secondary auth: `secondaryAuth` (SMS/trusted-phone) or `trustedDeviceSecondaryAuth` [APA:icloud-auth].
|
||||
- SRP-6a variant (byte-exact): 2048-bit group, g = 2, SHA-256 throughout. Password key: SHA-256 of the raw password; for `s2k_fo` that digest is ASCII-lowercase-hex-encoded (64 chars) and those bytes feed PBKDF2, for `s2k` the raw 32-byte digest feeds it; PBKDF2-HMAC-SHA256 with server salt `s` and server iteration count `i`, output 32 bytes = the SRP password. Identity hash quirk (GSA-specific, must match): the identity hash uses an EMPTY username — SHA-256(empty ‖ ":" ‖ password-buffer) — while M1's H(username) term uses the REAL username. x = SHA-256(salt ‖ identity-hash); u = SHA-256(A ‖ B); k = SHA-256(N ‖ g padded to N's length); premaster = (B − k·g^x)^(a + u·x) mod N; K = SHA-256(premaster big-endian); M1 = SHA-256(XOR(H(N), H(pad(g))) ‖ H(username) ‖ salt ‖ A ‖ B ‖ K); M2 = SHA-256(A ‖ M1 ‖ K). A is sent as `A2k`; the received `M2` is verified against the computed M2 [APA:icloud-auth].
|
||||
- `spd` decryption: AES-256-CBC + PKCS7; key = HMAC-SHA256(key = K, message = ASCII `extra data key:`), IV = first 16 bytes of HMAC-SHA256(key = K, message = ASCII `extra data iv:`). Decrypted plist carries: a per-service token map `t` (service id → token + expiry in epoch-ms or duration in seconds, e.g. `GsIdmsToken`), `acname`, `DsPrsId`, `adsid`, first/last name [APA:icloud-auth].
|
||||
- Anisette at login: the anisette base headers (§1.1) ride the POSTs filtered to `X-Apple-I-MD-LU`, `X-Apple-I-MD-RINFO`, `X-Apple-I-MD-M`, `X-Apple-I-MD`, `X-Mme-Device-Id`, plus akd-variant `X-MMe-Client-Info`/User-Agent and `X-Apple-AK-Context-Type`/`X-Apple-Client-App-Name`/`X-Apple-I-Client-Bundle-Id`. The `cpd` dictionary carries anisette values as KEYS inside the body: `X-Apple-I-Client-Time`, `X-Apple-I-MD`, `X-Apple-I-MD-LU`, `X-Apple-I-MD-M`, `X-Apple-I-MD-RINFO`, `X-Mme-Device-Id`, optional `ptkn` (APNs token), plus fixed values: `X-Apple-I-Device-Configuration-Mode` = `0`, `X-Apple-I-Request-UUID` (uppercase UUIDv4, one per login, reused across both steps), `X-Apple-Requested-Partition` = `0`, `X-Apple-Security-Upgrade-Context` = `com.apple.authkit.generic`, `capp`, `cbid`, `cou` = `US`, `loc` = `en_US`, `svct` (e.g. `imessage`), and booleans `X-Apple-Offer-Security-Upgrade`=true, `at`=0, `bootstrap`=true, `ckgen`=true, `fcd`=true, `icdrsDisabled`=false, `icscrec`=true, `pbe`=false, `prkgen`=true, `webAccessEnabled`=false [APA:icloud-auth]. — 2026-10-04 (rev 26, live): anisette is fresh PER SRP RUN, not per login flow — GSA rejects an SRP init whose `X-Apple-I-MD` was minted minutes earlier (error `ec` −22421), including the post-2FA re-run of the same login; the reference client mints a new set for every login attempt.
|
||||
- 2FA: `au` = `secondaryAuth` → `GET https://gsa.apple.com/auth` returns JSON `AuthenticationExtras` (trustedPhoneNumbers with `numberWithDialCode`/`lastTwoDigits`/`pushMode`/`id`; HTTP 201 = SMS already sent); request a code with PUT `.../auth/verify/phone` body `{phoneNumber:{id}, mode:"sms"}`; verify with POST `.../auth/verify/phone/securitycode` (same body + `securityCode:{code}`). `au` = `trustedDeviceSecondaryAuth` → trigger the code push with `GET .../auth/verify/trusteddevice`, then submit: `GET .../GsService2/validate` with the code in a `security-code` request header. All 2FA requests carry `X-Apple-Identity-Token` = base64 of `adsid:GsIdmsToken` (from `spd`) and a large browser-style fixed header set. Success returns tokens in RESPONSE HEADERS: repeated `X-Apple-GS-Token` (one per GSA service), `X-Apple-HB-Token`, `X-Apple-PE-Token` — each base64 of colon-separated `ID:TOKEN[:...]` with inconsistent duration/expiration trailer forms (epoch-ms vs seconds, heuristically parsed) [APA:icloud-auth]. After a successful 2FA, the full SRP login is run a SECOND time, which then completes as logged-in [APA:icloud-auth]. An on-device variant exists (Circle PAKE over `.../GsService2/circle`: SRP-6a over the 3072-bit group with the DSID as username and the zero-padded 6-digit code as password, 16-byte random salt, DER/ASN.1 step payloads base64 in `pakedata`; the code arrives AES-128-GCM-encrypted over an SRP-key-derived channel with `recv->send`/`send->recv` info labels) [RP:auth.rs], [APA:icloud-auth]. — 2026-09-29 (rev 25, live): the trusted-device trigger is what makes the code APPEAR on the devices — it must run BEFORE prompting the user, and a SECOND trigger before the same validate invalidates the outstanding code and re-prompts every device (both observed live); `au` is returned inside the complete response's `Status` dict, not at the `Response` level. — 2026-10-04 (rev 26, live): the anisette riding the 2FA calls is fresh PER CALL (trigger and validate each re-mint; a set frozen at login start answers HTTP 434 — the frozen-OTP wall of session 16), the trusted-device trigger and the validate GET both carry `Content-Type: text/x-xml-plist` + `Accept: text/x-xml-plist`, and a failed verify's body is a plist whose `Status.ec`/`em` (or top-level `ec`/`em`) name the real failure — the HTTP status alone is opaque.
|
||||
- PET: arrives as `X-Apple-PE-Token`; stored under service id `com.apple.gs.idms.pet`; default expiry 300 s when no trailer, otherwise parsed seconds/epoch-ms; refreshed by silently re-running the SRP login with the persisted password hash. Used as the HTTP Basic password (`username:PET`) on the iCloud requests below [APA:icloud-auth], [RP:auth.rs]. — 2026-09-29 (rev 25, live): the post-2FA SRP re-login's decrypted `spd` token map `t` is an ALTERNATIVE delivery channel carrying the PET plus the full per-service token set under the same `ID` keys (`com.apple.gs.idms.pet`, `com.apple.gs.icloud.auth`, `com.apple.gs.idms.hb`, `com.apple.gs.pb.auth`, `com.apple.gs.gpk.auth`, …21 observed in total); a no-2FA password login's `spd` carries only `com.apple.gs.appleid.auth` and NO PET — the header route on the 2FA-verification response and the re-login `spd` map are the two live-confirmed PET paths.
|
||||
- Delegate logins: POST `https://setup.icloud.com/setup/signin/v2/login`, XML plist body with `delegates` (dictionary keyed by bundle id: `com.apple.private.ids` → {`protocol-version`: `4`}; `com.apple.mobileme` → {}), `protocolVersion` = `1.0`, `userInfo` = {`clientId` (uppercase UUIDv4), `language` = `en-US`, `timezone`}. Headers: Basic `username:PET`, `X-Mme-Nas-Qualify` = base64 validation blob (§1.1), `X-Apple-ADSID` = adsid, akd-flavor `X-Mme-Client-Info`, `User-Agent` = `com.apple.iCloudHelper/282 …`, full anisette set. Response plist: `status` (0 = ok), optional `localizedError` + `description`, `delegates` dictionary with per-entry `status`, `statusMessage`, `serviceData`. The IDS delegate's serviceData (kebab-case) yields `authToken` and `profileId` — exactly the IDS `auth-token` (sent as `authentication-data` = {`auth-token`: token}) and the `profile-id` (realm user id) that feed the authenticate exchange (§1.2) and registration (§1.4). The MobileMe delegate's serviceData yields a token map (incl. `mmeAuthToken`) and service configs [RP:auth.rs], [RP:macos.rs]. — 2026-09-29 (rev 25, live): the anisette headers minted at login start are SPENT by the GSA hops — the sign-in POST needs a FRESH set (a reused set was refused; the reference client mints per request); and the endpoint answered HTTP 409 with `ErrorID=ICLOUD_UNSUPPORTED_DEVICE` ("This Apple Account is valid but is not an iCloud account" / "Cannot create another iCloud account with this device") — a device-identity-bound iCloud-activation refusal (response plist carries `protocolVersion` 2, `ErrorID`, `Header`, `Body`, `DialogType`, `DefaultButton*`, `AlternateButton*`, `HelpLink`), consistent with the per-Mac-identity account cap the upstream docs put at ~20 accounts. — 2026-10-04 (rev 26, live): **the 409's true root cause is the validation-stamp REPLAY guard (§1.1), not the request shape or a blob rejection** — a captured stamp minted elsewhere passed the sign-in with HTTP 200 and CREATED the iCloud device record, and the full chain then completed: §1.2 authenticate + §1.4 register both green against live Apple, with a `mailto:` handle coming back ACTIVE (§1.3/§1.4). Two response/request refinements: (1) Apple's live sign-in response is KEBAB-CASE — the delegate entries carry `status`/`service-data`/`account-exists` (delegate keys observed live), i.e. `service-data` and `status-message`, NOT the extraction's camelCase `serviceData`/`statusMessage` (which survive as a fallback reading of the recording); (2) the sign-in body's client id rides the kebab-case wire key `client-id` in `userInfo` — the one body delta that survived until the live capture.
|
||||
- The GSA `postdata` liveness event (rev 26, live 2026-10-03; `gsas.apple.com` per the host split above): a `POST .../grandslam/GsService2/postdata` with `Content-Type: text/x-xml-plist` and an XML-plist body of two top-level dictionaries — `Header` (empty) and `Request` — the `liveness` event declaring the device's services `icloud`, `imessage`, `facetime` (with recovery-key generation on), and two account-binding headers: `X-Apple-I-UrlSwitch-Info` = base64(`adsid:postdata`) and `X-Apple-HB-Token` = base64(`adsid:hb-token`) with the hb token from the post-2FA spd token map's `com.apple.gs.idms.hb` entry (§1.5 PET). The anisette set is the postdata-filtered machine/device subset (serial-bearing headers, locale and the request uuid are dropped); the akd-variant fixed headers ride. The APNs push token rides the body as `ptkn` (uppercase hex) when minted — a dead or absent token answers `ec` −800012 "Push token is invalid", so the Albert device certificate + courier connect token must be minted BEFORE the event, and the courier connection must stay OPEN across the whole registration: the connect-response token identifies the CONNECTION, and closing the socket between the mint and the postdata hop drew −800012 on an otherwise valid token. ORDER (correcting the earlier session note): the reference program runs the delegate sign-in FIRST and postdata AFTER it (the sign-in mints its own Nas-Qualify blob at sign-in time, §1.1) — the hop is a post-sign-in liveness declaration, not a sign-in precondition.
|
||||
- Not present in these sources: no `idmsa.apple.com`, no `scnt`/session-token headers — the session role is played by `X-Apple-Identity-Token` (GSA requests) and Basic `username:PET` (iCloud requests); the idmsa-based flow recorded in §1.2's [IG] citations is a different client's route (C57 note) [APA:icloud-auth], [RP:auth.rs].
|
||||
- — 2026-09-11 (rev 15 extraction: C59 closed). Group constants: the GSA login uses the RFC 5054 2048-bit MODP group, g = 2 (the standard Appendix-A prime); the Circle PAKE uses the RFC 5054 3072-bit group with g = 5 [APA:groups.rs], [RP:auth.rs]. The 2FA browser-style header set (all `/auth/verify/*` and `GsService2/validate` requests): the anisette base headers filtered to `X-Apple-I-MD-LU`/`X-Apple-I-MD-RINFO`/`X-Apple-I-MD-M`/`X-Apple-I-MD`/`X-Mme-Device-Id`, plus fixed literals `X-Apple-Client-App-Name`, `X-Apple-I-Client-Bundle-Id`, non-akd `X-MMe-Client-Info`, `X-Apple-I-CDP-Circle-Status: false`, `X-Apple-I-ICSCREC: true`, browser `User-Agent`, `Sec-Fetch-Site: same-origin`, `X-Apple-Requested-Partition: 0`, `X-Apple-I-DeviceUserMode: 0`, `X-Apple-I-Locale: en_US`, `X-Apple-Security-Upgrade-Context: com.apple.authkit.generic`, `Accept-Language: en-US,en;q=0.9`, `X-Apple-I-PRK-Gen: true`, `Sec-Fetch-Mode: cors`, `X-Apple-I-TimeZone: UTC`, `X-Apple-I-OT-Status: false`, `X-Apple-I-TimeZone-Offset: 0`, `X-MMe-Country: US`, `X-Apple-I-CDP-Status: false`, `X-Apple-I-Device-Configuration-Mode: 0`, `Sec-Fetch-Dest: empty`, `X-Apple-AK-Context-Type`, and `X-Apple-I-CFU-State` — a hardcoded base64 whose decoded XML plist is just an empty `<array/>` [APA:anisette.rs]. cpd values: `capp` = client app name, `cbid` = bundle id, `svct` = the AK context type — for the Messages flavor: `capp` = `Messages`, `cbid` = `com.apple.MobileSMS`, `svct` = `imessage` (AppleIDSettings flavor: `icloud` / `com.apple.systempreferences.AppleIDSettings`); `cou` = `US`, `loc` = `en_US`; `ptkn` = the APS push token as uppercase hex [APA:anisette.rs], [RP:lib.rs]. akd request values: `User-Agent` = literal `akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0`; `X-MMe-Client-Info` (akd form) = `<HardwareModel> <OSname;OSversion;OSbuild> <com.apple.AuthKit/1 (com.apple.akd/1.0)>` (observed `<iMac13,1> <macOS;13.6.4;22G513> …` and `<iPhone7,2> <iPhone OS;12.5.5;16H62> …`); `X-Apple-AK-Context-Type` = `imessage` or `icloud` per flavor [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs]. `spd` details: `GsIdmsToken` is a TOP-LEVEL string key (not inside `t`); `t` entries carry `token` (string) plus `expiry` (epoch-ms) or `duration` (seconds); account-name keys `fn`/`ln` (optional strings) [APA:client.rs]. Host split: `postdata` and `teardown` go to `gsas.apple.com` (with the S) with `X-Apple-I-UrlSwitch-Info` = base64(`adsid:postdata`/`adsid:teardown`); circle requests use `X-Apple-Identity-Token` when 2FA-authenticated, `X-Apple-HB-Token` = base64(`adsid:hbtoken`) otherwise; the app-token checksum is HMAC-SHA256 keyed with the session key over `apptokens` ‖ dsid ‖ app name [APA:client.rs]. Sign-in request values: `User-Agent` = `com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0` (static config constants); `userInfo.timezone` = a hardcoded IANA name (`America/New_York` in the reference config); `X-Mme-Client-Info` wraps the AOSKit form `com.apple.AOSKit/282 (com.apple.accountsd/113)` [RP:auth.rs], [RP:test.rs]. HTTP client notes: Apple root CA pinned, HTTP/1.1 title-cased headers forced, cookie store in use; a parser quirk makes the reference's composed sign-in UA literally `com.apple.iCloudHelper/282 com.apple.iCloudHelper/282` (duplicated token — treat the full static string as the recorded form) [RP:relay.rs]. — 2026-09-11 (small follow-up: the three remaining literals): the 2FA browser `User-Agent` = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)` — static in every source, notably with NO Safari/Version token; the non-akd `X-MMe-Client-Info` for the browser set = the same device-bracket form with inner component `com.apple.akd/1.0 (com.apple.akd/1.0)` (rustpush's variant wraps `com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)`); `X-Apple-I-CFU-State`'s hardcoded base64 decodes to the FULL plist wrapper — `<?xml version="1.0" encoding="UTF-8"?>` + Apple DOCTYPE + `<plist version="1.0">` + `<array/>` + `</plist>` + trailing newline (182 bytes), NOT a bare `<array/>`; the sign-in `X-Mme-Client-Info` is bracketed like the others: `<HardwareModel> <macOS;13.6.4;22G513> <com.apple.AOSKit/282 (com.apple.accountsd/113)>`; and the sign-in UA quirk is confirmed — the composed header is the duplicated `com.apple.iCloudHelper/282 com.apple.iCloudHelper/282` while the full `com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0` string is the config literal other endpoints draw from [APA:anisette.rs], [APA:lib.rs], [APA:gsa_auth.rs], [RP:lib.rs], [RP:test.rs], [RP:macos.rs].
|
||||
> 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 the public encryption and signing keys plus "client data" describing device capabilities; a lookup takes target handles and returns one identity per registered device, each with the device's public key, push token, and session token [JJ]. Apple's guide confirms the device contacts IDS to retrieve public keys and APNs addresses for all devices of an addressee [APS-Send]. "IDS" is believed to stand for "IDentity Services" but this is unofficial [JJ].
|
||||
- Lookup is a POST of a plist with a `uris` array to the `id-query` bag URL [RP:ids/user.rs] (pinned URL: `query.ess.apple.com` query path [IG:lookup]), with headers including the sender's own handle, the push token, and the protocol version, a lookup-specific user agent, an optional sub-service topic header, and opt-in flags requesting messages/results [RP:ids/user.rs]; the second source agrees on the self-URI and protocol-version headers [IG:lookup]. Header wire names (extraction, closing C47): inside `h`, Rust sends `x-id-self-uri`, `x-push-token`, `x-protocol-version` (from the stored per-user protocol version), `user-agent` = `com.apple.madrid-lookup <version-ua>`, the `x-id-nonce`/`x-id-cert`/`x-id-sig` signature set, conditionally `x-id-sub-service` (only when the lookup topic differs from the main topic), and `x-required-for-message: true` / `x-result-expected: true` (opt-in flags, both true in most calls) [RP:ids/user.rs]; Go sends `X-ID-Self-URI`, `X-Protocol-Version` (pinned "1640"), `X-ID-Nonce`, `X-ID-Cert`, `X-Push-Token`, `X-ID-Sig` — no user-agent/sub-service/opt-in headers (disagreement on the optional set — C57) [IG:lookup]. No push header set (`x-push-*`) rides lookups in either repo — that C47 half closes negatively. The signature payload matches §1.4: nonce ‖ bag name ("id-query") ‖ empty query string ‖ body ‖ APNs push token, RSA PKCS1v15-SHA1 with the 0x01 0x01 prefix (both) [RP:auth.rs], [IG:sign]. — 2026-09-10: C47's header half closed by facts-only extraction; nothing capture-bound remains here (the optional-set disagreement rides C57).
|
||||
- Batches are capped per request: 18 handles [RP:identity_manager.rs] vs 20 URIs [IG:lookup]. A "response too large" error (5206) causes the batch to be split in half recursively [RP:ids/user.rs], [IG:lookup]. `TODO(capture):` C13 — real batch cap.
|
||||
- **Architecture fact (extraction, 2026-09-10 — supersedes the earlier either-transport framing): IDS lookups ride the APNs tunnel, not plain HTTPS.** The lookup request and its headers travel inside a tunnel payload whose header dictionary is keyed `h`; the tunnel-level response carries a status field (Rust checks it; Go's constant for the HTTP-over-APNs request type is 96) [RP:ids/user.rs], [IG:lookup]. The tunnel mechanics stand as recorded: command 96 carries URL, headers, content type, body, and a 16-byte request UUID; the reply carries the same request UUID and body on the same topic (command 97 with a status field, per Go) [RP:auth.rs], [RP:ids/user.rs], [IG:lookup]; a 1-minute timeout applies [IG:lookup]. `TODO(capture):` C12 — which transport real devices use today.
|
||||
- The response (gzipped plist) maps each handle to a result containing an identities array (one per device), optionally a sender-correlation identifier and a key-transparency account key, and a numeric status [RP:ids/user.rs], [IG:lookup]. Each identity carries the client-data dictionary, a 32-byte push token, a session token, server-declared `session-token-expires-seconds` and `session-token-refresh-seconds`, and optional key-transparency data [RP:ids/user.rs:694-704], [IG:lookup.go:107-109]. Both sources parse the two token timing fields, but only the first consults them: `session-token-expires-seconds` bounds how long the cached identity stays valid, `session-token-refresh-seconds` triggers a proactive re-lookup [RP:identity_manager.rs:52-72]; the second ignores both and uses its own fixed TTLs (§2.2) [IG:lookup.go:242-248]. The semantics of the sender-correlation identifier are not explained by either source. `TODO(capture):` C11 — its exact semantics; C10 — real token TTL values (an instrumented client logs both fields per identity). Element form (extraction): the `uris` elements are PLAIN handle strings (bare `tel:`/`mailto:` strings) in both repos — the contrast with the register request's single-key `uri` dictionaries (§1.4) is real. Response wire keys (extraction): top level `status` + `results`, a map keyed by the plain uri string; per-handle keys `identities`, `kt-account-key`, `sender-correlation-identifier`, `status` (Go names all four; Rust reads `identities` and `sender-correlation-identifier` only — disagreement, C57); per-identity keys, identical six in both: `client-data`, `kt-loggable-data`, `push-token`, `session-token`, `session-token-expires-seconds`, `session-token-refresh-seconds`; client-data keys read: `public-message-identity-key`, `public-message-ngm-device-prekey-data-key`, `ngm-public-identity` (both) plus `supports-certified-delivery-v1` (Rust); Go keeps unrecognized client-data keys in a catch-all, Rust drops them [RP:ids/user.rs], [IG:lookup]. — 2026-09-10: C48 closed by facts-only extraction (both source repos). C55 narrowed: both repos read lookup error statuses from the top-level `status` only — 5206 splits the batch in half (both), 6005 triggers a full re-register then retry (Go at most once per hour), 5040/5041 are defined but unhandled in lookup — and neither checks a per-handle `status`, superseding the per-handle reading that follows and §7 item 19. C16 evidence (§2.3): no PQ3/PQ-typed keys appear anywhere in these results; the identity key is EC P-256 signing + RSA encryption; the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and `ngm-public-identity`/`kt-loggable-data` decode to a compressed EC key [RP:identity_manager.rs], [IG:lookup]. The lookup-orchestration code reads per-handle result statuses only (the one numeric-status location this fact set records) and treats a whole-batch 5206 the same as a per-handle one.
|
||||
- Session tokens prove a recent lookup, are required to send to a device, expire, and cannot be shared — only the account that performed the lookup can use them [JJ]; they are passed verbatim in each outgoing message bundle [RP:identity_manager.rs]. This account binding is an edge case for relay architectures where one account performs lookups for others [JJ].
|
||||
- After registering, the client receives an "identity keypair" used to perform lookups [JJ].
|
||||
- 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 (hardcoded fallback [RP:identity_manager.rs:25]), otherwise valid while elapsed time is under `session-token-expires-seconds` for *every* identity, with proactive refresh when *any* identity exceeds `session-token-refresh-seconds`, a 60-second minimum spacing between forced refreshes, and the cache keyed by a SHA-1 over (id certificate, current APNs token) so either change invalidates everything [RP:identity_manager.rs:27-28,52-96,212-240]. The other never reads the server-provided token fields: it refreshes positive results after 1 day, negative results after 1 hour, and hard-expires entries after 7 days [IG:lookup.go:242-248,339-352]. The send-side error codes for token problems are 5040 (bad session token) and 5041 (expired session token); neither source implements a recovery path for 5041 [IG:ids/types/idserrors.gen.go:104-105]. `TODO(capture):` C14 — observe real lookup cadence to arbitrate.
|
||||
- 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] (pinned URL: getDependentRegistrations path [IG:bag]); this maps own-device push tokens to UUIDs, used to address specific own devices [RP:identity_manager.rs].
|
||||
- Pseudonym "aliases" can be created/deleted via an `id-provision-alias` bag URL with attributes (allowed services, expiry, feature id); aliases are cached with expiry and pruned [RP:ids/user.rs], [RP:identity_manager.rs]. Registered handles can be re-fetched via an `id-get-handles` bag key [RP:ids/user.rs] (pinned URL: idsGetHandles path [IG:bag]).
|
||||
- A spam-report endpoint (`id-report-spam`) submits per-message metadata for suspected spam [RP:ids/user.rs].
|
||||
- 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: c=32 forces re-registration; c=66 means handles changed (client re-fetches handles and re-registers if they differ); c=34 means devices changed (client invalidates its key cache for all handle pairs) [RP:identity_manager.rs]. The other implementation receives but ignores that topic [IG:apns]. `TODO(capture):` C15 — which of these actually occur on a real account.
|
||||
- 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 triggers an IDS re-register and retry, at most once per hour [IG:lookup]; the other source treats 6005 as bad authentication and forces a full re-login [RP:identity_manager.rs]. `TODO(capture):` C39-adjacent — observe real 6005 handling.
|
||||
- With PQ3, each device registers two public encryption keys with IDS and replaces them regularly with fresh ones: a Kyber-1024 key-encapsulation key and a P-256 key-agreement key, signed (with a validity timestamp) by a P-256 authentication key generated inside the Secure Enclave, itself signed by the Contact Key Verification account key together with attributes such as the supported protocol version [PQ3]. Neither reference client implements PQ3; both register only the legacy/EC key sets [repo-wide]. `TODO(capture):` C16 — whether current lookup results carry PQ3 keys or the legacy client-data. Extraction evidence (2026-09-10): current-source lookup results carry no PQ3/PQ-typed keys — the identity key is EC P-256 signing + RSA encryption, the EC material present is the NGM prekey identity (EC key + 64-byte signature + timestamp), and `ngm-public-identity`/`kt-loggable-data` decode to a compressed EC key (§2.1) [RP:identity_manager.rs], [IG:lookup]; what a real Apple device returns still needs the capture.
|
||||
- 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.** Contact Key Verification is an opt-in iOS 17.4+ feature: the verifier pins each contact's devices by comparing per-device keys against a verification code shown in the contact card and against Apple's Key Transparency log [APS-CKV]. Its effect is already visible indirectly in IDS registration: status 6001 ("incompatible") is returned when a target has CKV or Advanced Data Protection enabled [RP:ids/user.rs], [RP:identity_manager].
|
||||
- Observation plan: enable CKV on the capture device and run a full-interception window. CKV hosts are expected to be pinned (like the courier), so the expected yield is SNI and timing evidence only, not payload. No capture ID assigned yet; scheduled as its own window.
|
||||
- **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 are `*-courier.push.apple.com` where `*` is a number in 1–30 [BM] vs 1–50 [IG:courier]; the host count and hostname come from the APNs bag, and the client connects to "<random 1..count>-<hostname>" but sets TLS SNI to the bare hostname [RP:aps.rs], [RP:util.rs]. `TODO(capture):` C17 — current bag hostname/count and the real range. (2026-09-10: `courier2.push.apple.com` observed live [CAP-COURIER]; bag hostname/count and full range still open.)
|
||||
- Capture ground truth [CAP-COURIER]: a registered iPhone (iOS 26.1) connected to `courier2.push.apple.com`, with both ports 5223 and 443 in use — under interception the client alternated between them in a ~5–15 s reconnect loop. The courier pins against interception even with a trusted interception CA installed: the upstream TLS handshake is rejected with the OpenSSL error `bad extension`, i.e. the courier TLS stack is not vanilla TLS — consistent with a custom client-certificate/extension design, which arbitrates nothing between the [RP:aps.rs] (no client cert in handshake) and [IG:connection.go] (mutual TLS) readings; C18 stays open. Hosts observed pinned in the same window: `courier2.push.apple.com`, `gateway.icloud.com`, `configuration.apple.com`, `query.ess.apple.com`; with Apple hosts passed through, delivery works normally. A non-transparent HTTP-proxy capture sees no courier traffic at all — the courier bypasses the Wi-Fi HTTP proxy entirely.
|
||||
- Transport is TCP port 5223 with TLS [RP:aps.rs], [IG:apns/connection.go]; the channel is forward-secret, and metadata such as timestamps and APNs routing information is not encrypted [APS-Send]. The client pins Apple's own root certificate(s) rather than system roots (a `profileidentity.ess.apple.com` root [RP:aps.rs]; a custom Apple root pool [IG:connection.go]).
|
||||
- Client certificate: public write-ups say connecting requires a client certificate issued by the Albert activation server [JJ], [BM]; one source states the client certificate is NOT used in the TLS handshake and authentication happens in the application-layer connect message instead [RP:aps.rs]; the other configures mutual TLS with the Albert-issued certificate [IG:connection.go]. `TODO(capture):` C18 — whether the handshake presents a client certificate. — 2026-09-28 [PROBE-COURIER] (unauthenticated direct probe from this machine, no client certificate configured): the handshake to `courier.push.apple.com` COMPLETES on both 5223 and 443 (TLS 1.3, TLS_AES_256_GCM_SHA384, chain leaf ← `apple public server ecc ca 12 - g1`, anchored at `AAA Certificate Services`), and the server then sends a fatal TLS alert `certificate_required` on the first read — before any APNs frame can flow. C18 is settled by the server side: the courier uses TLS 1.3 **post-handshake client-certificate authentication** with the Albert-issued certificate; an application-layer-auth-only client would never get past the alert. The application-layer connect message (below) additionally carries the certificate+nonce+signature fields — both layers are in play; the client-cert presentation is the transport gate.
|
||||
- ALPN: one source offers `apns-pack-v1` and `apns-security-v3` and switches to a compressed "packed" wire format when a pack protocol is negotiated [RP:aps.rs]; the other negotiates only `apns-security-v3` and speaks only the legacy frame format [IG:connection.go]. `TODO(capture):` C19 — negotiated ALPN and frame format in real traffic. (2026-09-10: the live ClientHello offers both `apns-security-v3` and `apns-pack-v1`; the ServerHello selects no ALPN protocol at all — so the "switch when a pack protocol is negotiated" trigger never fires in the observed session, and the legacy frame format is the default assumption [CAP-COURIER].) — 2026-10-03 (rev 26, live, instrumented client): **C19 CLOSED.** Offering `apns-pack-v1:4096:4096` (the suffixed form), `apns-pack-v1`, and `apns-security-v3`, the server selects `apns-pack-v1:<enc>:<dec>` — observed `apns-pack-v1:4096:4096` on every courier host — and the packed frame format flows; the offer must include the exact suffixed form (a TLS stack that rejects a selection outside the offer otherwise refuses the handshake). With the pack-v1 ALPN the server's chain is leaf ← `Apple Server Authentication CA` ← Apple Root CA (the RSA chain; the working client pins the intermediate itself, distinct from the no-ALPN ECC chain of [CAP-COURIERTLS] anchored at `AAA Certificate Services`), and NO TLS client certificate is demanded on this path — the push certificate rides only in the connect frame (§3.3 field 12); the C18 `certificate_required` alert belongs to the no-ALPN legacy path.
|
||||
|
||||
- Handshake ground truth [CAP-COURIER] (pass-through pcap, 2026-09-10): the live client↔courier session is structurally standard TLS 1.3 — the server selects version 0x0304 with cipher TLS_AES_256_GCM_SHA384 — but the ClientHello carries Apple-private negotiation: a private supported-version value 0xfafa offered alongside TLS 1.3, a private elliptic-curve group 0x9a9a offered alongside the standard curves and the IANA X25519+ML-KEM-768 hybrid group (0x11EC; the offered key share, ~1263 B, is consistent with an ML-KEM-768 share), and two private extension types (0x5A5A, empty; 0x1A1A, one byte). Standard status-request, SCT, signature-algorithms, PSK-mode, and compress-certificate extensions are also offered. This refines the `bad extension` reading above: the courier is not a custom TLS protocol — it is TLS 1.3 plus Apple-private groups/extensions that conventional intercepting stacks do not offer or echo. Because TLS 1.3 encrypts all post-ServerHello handshake messages, whether the handshake presents the Albert client certificate (C18) is unobservable from a passive capture; that question now needs client-side evidence, not a better network vantage. (Note: the transport already negotiates a post-quantum hybrid key exchange even though message payloads ride the legacy envelope — PQ appears at the TLS layer today.)
|
||||
|
||||
- Chain ground truth [CAP-COURIERTLS] (direct uninstrumented TLS connect from this machine, 2026-09-10): `openssl s_client` to `2-courier.push.apple.com:5223` with SNI set to the bare `courier.push.apple.com` completes the TCP+TLS layer — the shard-prefixed host naming and bare-hostname SNI of §3.1 work against the live server. The server presented two certificates: leaf `CN = courier.push.apple.com` (Apple Inc.; notBefore 2026-09-02, notAfter 2026-12-02 — ~90-day rotation, so pin the root, never the leaf) and intermediate `CN = Apple Public Server ECC CA 12 - G1` (Apple Inc.), itself issued by the publicly-trusted Comodo root `CN = AAA Certificate Services` — the root itself was not served. A trimmed system store rejected the chain (missing anchor), consistent with the client-side pinning of §3.1; a pinned-root pool must anchor at `AAA Certificate Services` (or Apple's own pool).
|
||||
- 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: a 1-byte command, a 4-byte big-endian total length, then fields, each a 1-byte id, 2-byte big-endian length, and value [RP:aps.rs], [IG:apns/binary.go]. `TODO(capture):` C45 — the scope of the length prefix (fields-only, as in the documented APNs provider framing, vs the whole frame) and the per-command field-id tables are not captured in this spec (§7 item 15); the transport layer is buildable now, command builders are not. — 2026-09-10: C45 closed by facts-only extraction (both source repos): the 4-byte big-endian length covers the whole field block after the command byte (fields-only scope, agreed), and the per-command field-id tables are recorded in §3.3. The tagged table disagreements (connect flags, filter fields, command 14) and the Go-only extra fields ride C57; command builders are now writable from the spec, with captures arbitrating the tagged items.
|
||||
- The packed format (single source) replaces the length/value fields with tag-based attributes with 4-bit value fields, extension bytes, and rolling 32-entry key/value caches; only a fixed list of short attribute keys is value-cached [RP:aps.rs]. `TODO(capture):` C19 — confirm packed format use and cache-size suffixes. — 2026-10-03 (rev 26, live): **C19 CLOSED — the packed format's full wire shape is now recorded from the live connection** (it flowed with the negotiated `apns-pack-v1` ALPN, §3.1). A frame is a 1-byte command, the attribute-block length as a tagged value of width 8, then the attributes. Tagged value: a 1-byte tag whose low `width` bits carry an inline value while they are not all ones; at the all-ones marker the remainder follows as a 7-bit continuation varint (always present — even a zero remainder writes one 0x00 byte). Attribute: a 1-byte key tag — a single-byte key (< 0x10) rides inline as key|flags; longer keys go through the rolling key cache (0x20|flags|index reference; 0x10|flags + key-length−1 as a tagged width-4 value, then the key bytes) — then the value: inline integer (|0x80, ≤ 0x1f), wide integer ((byte-length−1)|0xa0, +0x08 negative, big-endian magnitude), data (tagged width 6), string (width 5, |0x40). Cache flags: 0x40 = first occurrence (full value, enters the rolling value cache), 0x80 = reference (tagged width-8 index into it). Both caches index from the newest entry; the key cache holds 32 entries, the value cache ~4 KiB of entries (the ALPN's `4096:4096` suffixes match the cache sizing). Post-connect sequence (live): connect → connect-response (token) → set-state (20) → filter (9) with the connect-response token plus the SHA-1 topic hashes as enabled topics — unfiltered connections are dropped within seconds, which invalidates the connect token before the login can use it.
|
||||
- **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 (base 0x41; an extra bit 0x04 marks a root connection), the client certificate, a nonce, and a signature [RP:aps.rs], [IG:apns/commands.go]. Second-extraction correction: **the two sources agree** on the nonce (rev 2 recorded a disagreement that does not hold in source): it is the same structured 17-byte nonce as the HTTPS signature (§1.4) but with the type byte 0x00 (APNs), and it is signed *alone* — SHA-1 of the raw nonce, RSA-SHA1-PKCS1, 0x01 0x01 prefix [RP:aps.rs:1005-1006 with RP:auth.rs:685-714], [IG:apns/connection.go:205-219]. One source also sends a fixed value 9 in a field with id 0x10 [RP:aps.rs]. Field-id table (extraction, agreed unless noted): 1 device token, 2 state (value 1), 5 flags, 12 push certificate, 13 nonce, 14 signature — sent in the order token/state/flags/cert/nonce/signature [RP:aps.rs], [IG:apns/commands.go]; the 0x10 field is Rust-only (integer 9, a hardcoded version identifier). Flags disagreement: Go's base is `0b1000001` with an added `0b100` bit marking a root connection; Rust sends `0b01000001` — the recorded flag-bit sets disagree — `TODO(capture):` (C57). The C8 extraction pass also records the Go connect nonce as a 20-byte random value with the first byte zeroed, against the structured 17-byte agreement above — `TODO(capture):` (C57). `TODO(capture):` C20 — confirm against a real client's connect frame; the meaning of the 0x10 field.
|
||||
- Connect response (8): a status byte (0 = ok; 2 = invalid cert), and a 32-byte connect token (possibly new) which the client stores and uses in later messages [RP:aps.rs], [IG:apns/commands.go]. The other source adds: advertised maximum message size (usually 4 KiB), capabilities, a large-message size (usually 15 KiB), and a server timestamp in unix ms [IG:apns/commands.go]. On status 2, one client regenerates its push key/cert via Albert once and resets all IDS registrations [IG:connector]. Field-id table (extraction): 1 status (0 success; Go comments 2 = error), 3 token — Rust parses only 1 and 3; Go-only extras: 4 maximum message size (~4 KiB), 5, 6 capabilities, 8 large-message size (~15 KiB), 10 server timestamp (unix ms) [RP:aps.rs], [IG:apns/commands.go]. `TODO(capture):` C23 — real advertised sizes (Apple's docs say APNs can relay messages up to 4 KB or 16 KB depending on OS version [APS-Send]).
|
||||
- Filter (9): sent after connect and whenever the topic set changes, carrying the token and per-topic states — enabled, ignored, opportunistic, paused — each topic appearing as the SHA-1 of its topic string [RP:aps.rs]; the other source confirms SHA-1 topic hashes and subscribes to madrid, the SMS alloy topic, and the IDS topic [IG:apns/connection.go]. Field-id sets disagree (extraction): Go sends 1 token, 2 topics (SHA-1-hashed topic list); Rust sends 1 token, 2 enabled, 3 ignored, 4 opportunistic, 5 paused (Go uses only field 2) — `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go].
|
||||
- Send message (10): field ids (extraction, agreed, outgoing): 1 topic, 2 token, 3 payload (a plist), 4 message id (u32 BE; random in 1..2^31−1) [RP:aps.rs], [IG:apns/commands.go]. Incoming adds field 5 expiration (u32; observed 0xffffffff), field 6 timestamp (u64 unix ns), field 7 (observed 0x02) [IG:apns/commands.go]. Rust also parses an optional field 29 as a channel payload (Rust-only) [RP:aps.rs]. `TODO(capture):` C26-adjacent — confirm incoming-only fields.
|
||||
- Send ack (11): token, the message id being acknowledged, and a status (0 = accepted); incoming notifications are auto-acked with status 0 [RP:aps.rs]; the other source acks every received send message and reports status 0x80 = "sent too early" (retried) and 0x03 = too large [IG:apns/connection.go]. Field ids (extraction): 1 token, 4 message id, 8 status byte; Go additionally reads field 6 (commented as a possible error) — Go-only, `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go].
|
||||
- Keepalive (12) / keepalive ack (13): no fields (both) [RP:aps.rs], [IG:apns/commands.go]. Command 14 disagrees (extraction): Go labels it "filter-topics-ack" (uncertain; field 1 = token); Rust decodes it as a no-storage marker carrying no fields — `TODO(capture):` (C57) [RP:aps.rs], [IG:apns/commands.go]. `TODO(capture):` C22 — real meaning of command 14.
|
||||
- Set state (20): field 1 state (u8), field 2 interval, constant 0x7fffffff (both) [RP:aps.rs], [IG:apns/connection.go]. Command 29 (0x1d) — subscribe-to-channels — exists in one source only, with fields 1 index, 2 message (protobuf), 3 token, and a response carrying status field 4 [RP:aps.rs]. `TODO(capture):` C24 — whether real clients use channel subscribe.
|
||||
- **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 seconds and waits for the pong, capping response waits at 15 seconds (a timeout forces a connection reload) [RP:aps.rs]; the other pings every 60 s with the first at 5 s after connect, suppresses pings when traffic arrives, and drops the connection after three silent intervals (180 s) [IG:apns/connection.go]. Reconnect: one source uses exponential backoff capped at 30 seconds, indefinitely [RP:aps.rs]; the other waits 3 s after a failed connect, 6 s if the previous connection lasted under 90 s, otherwise reconnects immediately [IG:connector]. `TODO(capture):` C21 — real keepalive pacing and reconnect behavior of Apple clients. (2026-09-10: see the packet-capture note below — a 2.0 s client activity cadence was observed, not 60 s pings [CAP-COURIER].)
|
||||
- Topics: `com.apple.madrid` is the iMessage topic [JJ], [RP:aps.rs], [IG:apns/topic.go]; registered sub-service topics include `com.apple.private.alloy.sms`, `com.apple.private.alloy.gelato`, `com.apple.private.alloy.biz`, `com.apple.private.alloy.gamecenter.imessage` [RP:ids/user.rs], plus `com.apple.private.alloy.safetymonitor` (and an "ownaccount" variant), `com.apple.private.alloy.fitnessfriends.imessage`, and `com.apple.private.alloy.askto` [IG:apns/topic.go]. `com.apple.private.ids` carries server-initiated control [RP:identity_manager.rs], [IG:apns/topic.go]. "ESS" is also used as an alternate name for IDS, but the APNs topic FaceTime uses is specifically `com.apple.ess` [JJ].
|
||||
- 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 "sends a cache-flush command (160) on connect" framing is withdrawn; the related no-storage-marker behavior (command 14, §3.3) stands.
|
||||
- Packet-capture ground truth [CAP-COURIER]: a 1-minute capture of an active courier session (post-PQ3 device) shows a single long-lived TCP session carrying send, receive, edit, and tapback traffic — no per-message connections. While active, the client emits ~40-byte records on a hard 2.0-second cadence — an activity timer, not the 60-second idle keepalive either implementation models; idle behavior and reconnect pacing remain uncaptured (C21). Outbound messages appear as 2.3–2.8 KB bursts; inbound frames are ≈115 B and ≈520 B. Frame format and ALPN are not observable through the pinned TLS (C19 stays open).
|
||||
- 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]. One source adds: retry count `rc`, original send time `oe` (epoch nanos, on retries), queued-message flag `sv`, scheduled delivery time `dmt`, queue id `qI`, FaceTime relay-allocation fields, and a legacy unencrypted body field `p` [RP:identity_manager.rs], [RP:ids/mod.rs]. `TODO(capture):` C26-adjacent — confirm the full current field set.
|
||||
- Each `dtl` bundle carries: target handle `tP`, a request-delivered boolean `D`, the session token `sT` from the IDS lookup, the target's 32-byte push token `t`, the encrypted payload `P` (absent for payload-less messages), the encryption mode `E` (only for pair-ec), and an optional certified-delivery version `cdv` [RP:identity_manager.rs], [IG:sendmessagepayload]. The delivery-request flag is false for self [IG:connector]; one client excludes its own push token from targets unless a queue id is present [RP:identity_manager.rs].
|
||||
- Encryption-mode placement (second-extraction facts): when any encrypted body exists, the top-level `E` is always the legacy value `pair` regardless of the per-recipient scheme actually used, and per-`dtl`-entry `E` is written only for entries encrypted with the EC scheme — legacy entries omit `E` entirely at the entry level [RP:identity_manager.rs:1124-1161]. Payload-less sends (receipts and similar) carry neither `E` nor `P` at all — in the second source's receipt path the encryption routine is never invoked [RP:identity_manager.rs:1124-1135], [IG:connector.go:1743-1751]; incoming messages with no `E` pass through without decryption [RP:identity_manager.rs:814-838]. One source's fan-out builder keeps duplicate entries for the same destination push token when the destination's identities support different schemes, with a note that Apple senders are observed sending the same destination both ways — i.e. mixed per-device schemes within one send appear in real traffic [IG:connector.go:1605-1619]. `TODO(capture):` C25 — settle which schemes current Apple clients emit (§4.5, §7 item 1).
|
||||
- Value types (extraction, agreed, closing C49): `c` integer; `E` string ("pair"/"pair-ec"); `P` data; `i` integer; `U` data (raw 16-byte UUID); `t` data (push token); `e` integer (unix nanos — Go-only key); `sP`/`tP` strings; `v` integer (8; Rust uses 1850 for the SMS topic, §5.8); `nr` boolean; `fcn` integer; and `dtl` is an ARRAY OF DICTIONARIES — nested message dicts, not nested data plists (both agree) [RP:ids/user.rs], [IG:sendmessagepayload]. Placement: the per-recipient fields (`tP`, `t`, `sT`, `P`, `E`, `D`, `cdv`) sit top-level in Go for normal sends; Rust always nests them inside `dtl` entries and omits a per-entry `E` when it is "pair" (present for "pair-ec"). Rev-12 key-attribution correction to the first bullet of this section: the extraction records `e`, `eX`, `htu` as Go-only and `ua` as Rust-only. Go-only keys: `e`, `oC`, `htu`, `eX`, `cdr`, plus HTTP (`u`, `b`, `cT`, `h`, `hs`, `hr`, `s`), MMCS (`mO`, `mS`, `mL`, `mA`, `mR`, `mU`), content (`cV`, `cH`, `cB`), and failure (`aDI`, `fR`, `fU`, `fM`, `fRM`, `fFS`, `fS`) groups; Rust-only keys: `rc`, `oe`, `sv`, `dmt`, `qI`, `qr`, `qgid`, `qgmc`, `qai`, `qat`, `qv`, `qids`, `ua`. — 2026-09-10: C49 closed by facts-only extraction (both source repos); any residual per-key value-type doubt is capture-bound (§7 item 17).
|
||||
- The inner plaintext is a binary plist, optionally gzipped [RP:messages.rs], [IG:connector]. Gzip placement (extraction): Go gzips a converted binary plist of the message before encryption; the Rust gzip location was not traced (unresolved — C57) [IG:connector].
|
||||
- Binary plist container format (the encoding of any plist above that travels in binary form — inner plaintexts, APNs-tunneled requests, archived balloon payloads; this is Apple's publicly documented container format, not something extracted from the restricted sources): a file is an 8-byte magic `bplist00`, then the object area, then an offset table with one fixed-width entry per object (each the byte offset of that object from the start of the file), then a 32-byte trailer: 5 unused bytes, 1 byte sort version, 1 byte offset-table integer width, 1 byte object-reference width, then three 8-byte big-endian integers — object count, top-object index (the offset-table index of the root object), and the offset table's byte offset. Every multi-byte integer in the format is big-endian. Widths are the minimal 1/2/4/8-byte width that holds the largest value present (object-reference width is the minimal 1/2/4/8 whose 2^(8w) ≥ the object count, so every reference 0..count−1 is representable; offset-table width must hold the final file offset). The trailer's sort-version byte is written as 0.
|
||||
- Binary plist object encoding (continuing [CF]): each object starts with a marker byte — high nibble type, low nibble info. Simple values: 0x00 null, 0x08 false, 0x09 true. Integers are marker 0x1n and occupy 2^n bytes; the 1-, 2-, and 4-byte forms are unsigned (zero-extended on decode), the 8-byte form is two's-complement signed. Encoders emit non-negative values in the minimal 1/2/4/8-byte width that holds them and negative values only in the 8-byte form. Reals are 0x2n, IEEE 754 big-endian, 2^n bytes (4- or 8-byte). Dates are marker 0x33 plus an 8-byte big-endian double of seconds since 2001-01-01T00:00:00Z. Data 0x4n; ASCII strings 0x5n with a byte count; UTF-16BE strings 0x6n with a length counted in UTF-16 code units (not bytes); arrays 0xAn; dictionaries 0xDn, encoded as key references followed by value references. UIDs are marker 0x8n occupying 1<<n bytes (n = 0..3). For every variable-length marker, a count/length < 15 sits in the low nibble; at 15 or more the low nibble is 0xF and the count follows immediately as an integer (an inline 0x1n encoding, not an object-table entry). Collections store their children as object references — indices into the offset table, each of the trailer's reference width. A dictionary's keys may in principle be any object; the plists in this protocol use string keys.
|
||||
- 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"
|
||||
|
||||
- Envelope framing (agreed): a 0x02 tag byte, a 2-byte big-endian body length, the body, a 1-byte signature length, then the signature [RP:ids/user.rs], [IG:encryptsign_pair].
|
||||
- Key derivation (agreed by all three source families): the message is encrypted with AES-128-CTR under a 128-bit key built from an 88-bit random value and a 40-bit value derived via HMAC-SHA-256 from the plaintext and the sender/receiver public keys; the 40-bit part verifies integrity of the decrypted plaintext [APS-Send]. Both implementations spell out the same construction: 11 random bytes plus the first 5 bytes of an HMAC-SHA-256 over (plaintext, the 0x02 marker, the sender's identity hash, the recipient's identity hash), where an identity hash is SHA-256 over the concatenation of the two marshalled keys [RP:ids/user.rs], [IG:encryptsign_pair]. The cipher's fixed IV is 15 zero bytes followed by 0x01 [RP:ids/user.rs], [IG:encryptsign_pair]. A message encrypted with an entirely random key fails to decrypt on newer devices [JJ].
|
||||
- Key transport (agreed): the AES key plus the first 100 bytes of the ciphertext are encrypted with RSA-OAEP (SHA-1) under the recipient's 1280-bit RSA encryption key (the RSA block is 160 bytes, so a decryptable body must be at least that long); the remaining ciphertext is appended in cleartext [RP:ids/user.rs], [IG:encryptsign_pair]. Apple's guide confirms the per-message AES key is encrypted with RSA-OAEP to the receiver's public key [APS-Send]. From iOS 13 / iPadOS 13.1 devices may use ECIES for the key encryption instead of RSA [APS-Send]; Apple switched the protocol from RSA to ECC in 2019 with keys protected by the Secure Enclave and a periodic rekey mechanism for self-healing [PQ3].
|
||||
- Signature (agreed): the SHA-1 of (RSA block + remaining ciphertext) is signed with ECDSA over P-256 using the sender's signing key, verified by the receiver against the sender's published identity key [RP:ids/user.rs], [IG:encryptsign_pair]; Apple's guide confirms the encrypted text and key are hashed with SHA-1 and signed with ECDSA [APS-Send]. Third-party clients may skip signature verification but it is recommended [JJ].
|
||||
- Published identity key structure (agreed): an ASN.1-style blob holding an uncompressed P-256 signing point and a PKCS#1 RSA-1280 encryption key, each sub-blob carrying a 2-byte type prefix [RP:ids/user.rs], [IG:identity].
|
||||
- — 2026-09-10: C51 closed by facts-only extraction (both source repos); all three open details above are settled. The HMAC is keyed with the 11 random bytes themselves and computed over plaintext ‖ 0x02 ‖ sender-identity-hash ‖ recipient-identity-hash; only its first 5 bytes are used, appended to the 11-byte seed to form the AES-128 key — and the HMAC is never placed in the envelope. An identity hash is SHA-256 of the concatenated DER-encoded signing + encryption key blobs. The RSA-OAEP block is SHA-1 (MGF1-SHA1, empty label) under the recipient's RSA encryption key and encrypts the 16-byte AES key plus the first min(100, len) ciphertext bytes, placed at the start of the body — so a shorter ciphertext transports only what exists. The signature is SHA1withECDSA (P-256) over the exact body bytes, DER/ASN.1-encoded [RP:ids/user.rs], [IG:encryptsign_pair].
|
||||
> 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"
|
||||
|
||||
- Used when both sides advertise the newer key set. The choice is made per recipient device at send time: the sender falls back to "pair" if its own registration lacks the NGM device key or prekey, or if the recipient's lookup result lacks a public prekey or an NGM identity (in client data or key-transparency data); otherwise it uses "pair-ec" [RP:ids/user.rs:525-531], [IG:ids/encryptsign.go:31-66]. One source gates pair-ec sending behind a configuration flag that defaults to off — it sends only "pair" unless opted in [IG:config/config.go:57], [IG:example-config.yaml:84-85]; the other always prefers pair-ec when the keys exist. A recipient supporting only one scheme gets that scheme without failing the send. Prekey-signature validation failure only warns [RP:ids/user.rs], [IG:encryptsign.go:57-59].
|
||||
- Wire form (agreed): a protobuf message with fields for the ciphertext, a 33-byte compressed ephemeral P-256 public key, a 64-byte raw signature, and a 7-byte key validator; the inner message carries the plaintext (the gzipped plist), a counter, and optional key-transparency gossip [RP:ids/user.rs], [IG:idsproto]. Field numbers (extraction, agreed): payload = 1, key = 2, signature = 3, validator = 99; the field-2 ephemeral key is the raw compressed P-256 point (this extraction records 32 bytes against the 33-byte reading above — `TODO(capture):`) [IG:idsproto], [RP:ids/user.rs].
|
||||
- Key agreement (agreed): a fresh ephemeral P-256 key is ECDH'd with the recipient's published prekey; HKDF-SHA256 with SALT = the ASCII string "LastPawn-MessageKeys" and EMPTY info yields 48 bytes — the first 32 the AES-256 key, the next 16 the CTR IV (rev-12 correction: the string is the HKDF **salt**, not an info string — both sources agree) [RP:ids/user.rs], [IG:encryptsign_pair_ec]. The plaintext is padded with random bytes to a 16-byte multiple plus a 4-byte little-endian padding-length trailer, then encrypted with AES-256-CTR: the ciphertext block is the inner protobuf ‖ random padding to the next multiple of 16 (none if already aligned) ‖ the 4-byte little-endian uint32 padding length, under the derived IV (IV not prepended; big-endian counter increment) [RP:ids/user.rs], [IG:encryptsign_pair_ec].
|
||||
- Signature (agreed): a raw 64-byte ECDSA-P256-SHA256 signature over (shared secret, recipient prekey bytes, sender ephemeral key bytes, recipient device-key bytes, ciphertext) [RP:ids/user.rs], [IG:encryptsign_pair_ec].
|
||||
- The validator is the 2-byte prefixes of (sender device key, recipient device key, recipient prekey) plus a final version byte. Second-extraction correction: the final byte is **agreed** to be the literal value 0x0c (rev 2's "0xc0-ish" reading was wrong) [RP:ids/user.rs:585-590]; the other source treats it as a version indicator ("appears to be the NGM version"), logs it on verification failure, and only verifies the first six bytes [IG:ids/encryptsign_pair_ec.go:96-103,231-274]. The outer protobuf carries the validator in field number 99 [IG:ids/idsproto/ids.proto:24]. `TODO(capture):` C26 — confirm Apple's live validator bytes (and watch whether the final byte ever moves off 0x0c).
|
||||
- The inner counter is a per-sender-pair monotonically increasing value; whether receivers enforce it is unknown to both sources, and neither verifies the received counter [RP:ids/user.rs:520], [IG:ids/encryptsign_pair_ec.go:218]. The sources disagree on keying and persistence: one keys it on a hash of the three public keys (sender device, recipient device, recipient prekey), persists it to disk, starts at 0, and so implicitly resets it whenever the recipient rotates the prekey or the sender re-registers [RP:ids/user.rs:533-547], [RP:identity_manager.rs:175-181]; the other keys it on the recipient URI only (so prekey rotation does not reset it), starts at 1, and persists it either in memory (resetting on process restart) or in a per-recipient database row [IG:ids/encryptsign_pair_ec.go:44-68], [IG:database/outgoingcounter.go:31-44]. Rev-12 extraction correction: both sources persist the counter between sessions and start it at 0; Rust keys it by a hash of own device key ‖ peer device key ‖ peer prekey, Go keys it per destination store — the "starts at 1 / per-URI / possibly memory-only" reading above is superseded [RP:ids/user.rs], [IG:encryptsign_pair_ec]. `TODO(capture):` C27 — receiver-side counter/replay enforcement, and which keying/reset behavior Apple clients actually use.
|
||||
- The inner message has two further optional fields beyond the payload and counter — key-transparency gossip data and debug info — that both sources always send empty; what Apple devices put there is not recoverable from either source [RP:ids/user.rs:549-554], [IG:ids/idsproto/ids.proto:24-30]. Inner-message field numbers (extraction, agreed, closing C50's protobuf half): message = 1 (bytes), counter = 2 (optional uint32), key-transparency gossip = 3 (bytes), debug info = 99 (bytes) — identical in both [RP:ids/user.rs], [IG:idsproto]. On the receive path, "signature failed but validator matched" is a distinct expected error condition, evidence that key mismatch after a rekey happens in practice [IG:ids/encryptsign_pair_ec.go:236-282].
|
||||
- The published prekey record (agreed) holds the 33-byte compressed prekey, a 64-byte signature by the device key, and a timestamp (double unix seconds); the signature is over SHA-256 of a fixed ASCII label ("NGMPrekeySignature") followed by the compressed prekey and the little-endian timestamp bytes [RP:ids/user.rs:240-277,451-469], [IG:ids/user.go:240-277]. Receivers verify it against the sender's device key [RP:ids/user.rs:531], [IG:ids/user.go:250].
|
||||
- — 2026-09-10: C52 closed by facts-only extraction (both source repos): the outer field numbers (payload 1, key 2, signature 3, validator 99) and the inner field numbers (§4.3 above) are recorded, and the HKDF input is settled as a salt, not an info string. Remaining pair-ec question: where the Rust side applies the inner-body gzip (C57).
|
||||
> 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
|
||||
|
||||
- Oversized text or attachments are encrypted with AES-CTR under a random 256-bit key and uploaded to Apple's cloud storage; the message then carries the attachment's AES key, its URI, and an SHA-1 hash of the encrypted form [APS-Send].
|
||||
- Upload (implementation detail, one source): the client generates a random 32-byte key, AES-256-CTR-encrypts (zero nonce) while streaming, authorizes via an APNs-tunneled request (command 150) yielding a storage domain and object id, then PUTs to a CDN URL; the message references the SHA-1 hex signature, owner/object id, URL, and the decryption key (hex, prefixed with a 0x00 byte), plus file size [RP:messages.rs]. Download uses command 151 with protocol/plist-version headers; requests carry a client-info header and an "IMTransferAgent" user agent with the Messenger data class [RP:messages.rs]. `TODO(capture):` C30 — confirm command codes and header versions in real traffic.
|
||||
> 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 about 5000 per APNs message, splitting across batches [RP:identity_manager.rs]; the other uses the connect-ack-advertised 4 KiB normal / 15 KiB large limits and chunks fanout at max 20 devices per chunk, recomputing chunk size against the large-message limit [IG:connector]. `TODO(capture):` C29 — real limits and chunking strategy.
|
||||
- Very large payloads can also arrive as command 104 ("async") pointing at an MMCS object, reprocessed under the original command stored in field `oC` after download [IG:decrypt]. `TODO(capture):` C36 — confirm command 104.
|
||||
- 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)
|
||||
|
||||
- PQ3 initial key establishment: the sender combines an ECDH result (sender ephemeral key with the receiver's registered P-256 key) with a Kyber encapsulation against the receiver's post-quantum key; both are entropy-extracted via HKDF-SHA384-Extract (twice), then a third Extract call combines the 48-byte secret with a domain-separation string and session information (user identifiers, public keys used, encapsulated secret) [PQ3].
|
||||
- PQ3 uses three ratchets: a symmetric ratchet (per-message key derived from the session key, which is itself ratcheted forward; message keys deleted after decryption; 256-bit keys, HKDF-SHA384), an ECDH ratchet (NIST P-256, 32 bytes per message), and a conditionally executed Kyber KEM ratchet (Kyber-768 in-band, adding over 2 KB, amortized) [PQ3].
|
||||
- Rekeying at launch: post-quantum rekey roughly every 50 messages, guaranteed at least every 7 days, adaptive on message count, elapsed time, and connectivity. Completing a ratchet requires a send+receive, so iMessage uses encrypted delivery receipts to complete ratchets without a user reply [PQ3].
|
||||
- PQ3 messages are padded before encryption with the Padmé heuristic (≤12% overhead), encrypted with AES-CTR under a 256-bit key with an IV derived from the message key, and individually signed with ECDSA over P-256 using the Secure Enclave-protected device authentication key. The signature covers unique user identifiers, push tokens, the encrypted payload, authenticated data, a ratchet-derived message-key indicator, and any public key information in the protocol [PQ3].
|
||||
- PQ3 authentication remains classical: sender identity is verified by mapping the sender's identifier to the public key; with Contact Key Verification enabled, device keys are checked against a Key Transparency log [PQ3].
|
||||
- Neither reference client implements PQ3; everything above is from Apple's own research post. Second-extraction facts: a repo-wide search finds zero PQ3/post-quantum handling, mention, or fallback in either client — an incoming message whose envelope is neither "pair" nor decodable as the EC outer message simply fails to decrypt [repo-wide]. Apple's post states PQ3 rolled out with iOS/iPadOS 17.4, watchOS 10.4, macOS 14.4, that conversations between PQ3-capable devices "automatically ramp up" to it, and that PQ3 "will fully replace the existing protocol within all supported conversations" — stated in 2024, with no published completion date; Apple never names the wire envelope type PQ3 messages use, and the PQ3 post does not mention the "pair"/"pair-ec" strings at all [PQ3]. `TODO(capture):` C31 — the PQ3 wire format as actually sent (container, field names, envelope) and C25 — whether current Apple-to-Apple traffic uses PQ3 exclusively, making pair/pair-ec legacy-only; the cleanest observable signal is whether current lookup results still carry the legacy/EC key sets next to the PQ3-registered Kyber-1024 + P-256 keys.
|
||||
> 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
|
||||
|
||||
- Both sources agree on the core table for the madrid/sms topics' `c` field: 100 = iMessage payload (text, reactions, typing, effects); 101 = delivered; 102 = read; 109 = certified-delivery receipt; 111 = mark unread; 118 = edit and unsend; 130 = peer cache invalidate / new-device notice; 140/141 = incoming SMS/MMS forwarded from a real phone; 143/144 = SMS/MMS sent by this client; 145 = SMS relay enable/toggle confirm; 147 = read-on-device (SMS variant of 102); 180 = profile/settings changes (including a settings code 60001 for global read receipts and 70000 for profile sharing); 181 = chat delete (with recoverable metadata under 182); 190 = group events [RP:messages.rs], [RP:aps_client.rs], [IG:imessage.go], [IG:sendmessagepayload].
|
||||
- Single-source entries to verify: 113 = notify-anyways [RP]; 120 = error report [RP]; 122 = sticker/extension update [RP]; 131 = share profile [RP]; 138 = transcript background change [RP]; 160 = offline-message-pending/queue-flush as a payload `command` value inside command-10 plists, not a frame command (rev-12 correction) [IG]; 103 = unschedule [RP]; 196 = link-preview edit [IG]; 104 = async large payload [IG]; 142 = SMS enable request [IG]; 146/149 = SMS send confirm success/failure [RP]; 255 = send-time ack (both [RP:identity_manager.rs], [IG:ackwaiter.go]); APNs-tunnel HTTP commands 96/97 (both). `TODO(capture):` C36 — confirm the single-source command numbers against a real capture.
|
||||
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)
|
||||
|
||||
@@ -202,176 +456,160 @@
|
||||
- The XML body's children are either FILE elements (attachments with MMCS download info: owner, URL, hex signature, decryption key, inline-attachment references) or span elements (text parts with part-index attributes and mention markup) [IG:decrypt], [RP:rawmessages.rs]. Text-part positions are counted in UTF-16 code units; part index/start/length is the same indexing scheme used for tapback targets and edit parts [IG:imessage.go].
|
||||
- Screen effects use "com.apple.messages.effect.*" names (echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration, shooting star); bubble effects use "com.apple.MobileSMS.expressivesend.*" names (impact, loud, gentle, invisible ink) [IG:imessage.go].
|
||||
- Balloon payloads (`bp`) are optionally gzipped and either a raw keyed-archiver plist or a wrapper with payload and attachments; `bpdi` carries MMCS download info (key, size, owner, URL, signature) [IG:imessage.go], [RP:rawmessages.rs]. Rich links live in the URL balloon provider's keyed-archiver payload with URL, title, summary, and icon/image metadata [RP:messages.rs], [IG:balloonparse]. A location-share fallback exists where the text is exactly U+FFFD and the XML carries a breadcrumb-text attribute [IG:decrypt].
|
||||
- 1:1 vs group is distinguished by participant count (2 participants = 1:1; more = group) [IG:decrypt].
|
||||
- `TODO(capture):` C50 — the plist value type of each body key is not recorded for most keys; the spec's own quoting settles some (versions `gv`/`v` are strings, `tg` a formatted string) but the audio flags, `pv`, the UUID forms (`U`-style bytes vs GUID strings for `r`), and the `msi`/`bpdi` sub-dict value types are unstated. The writer phase assumes booleans for the audio flags, bytes for `r`, and integers for `pv`/`amc`; a capture must arbitrate. — 2026-09-10: C50's protobuf-numbering half is closed by facts-only extraction (§4.3); the body-plist value-type half stays capture-bound.
|
||||
- 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, and extends the set with 6 = custom emoji (carrying the emoji string) and 7 = sticker, plus app-extension reaction codes 2/4000/1000 [RP:messages.rs]; the other documents only 2000–2005 [IG:tapback.go]. The target string is disagreed: "bp:<uuid>" for balloons and "p:<part>/<uuid>" otherwise [IG:tapback.go] vs a raw target GUID (for balloons) or "p:"-shaped variants [RP:messages.rs]. `TODO(capture):` C32 — capture real reaction codes and target-string formats.
|
||||
- The emoji mapping used by clients to display reactions: heart, thumbs up/down, laugh, exclamation, question marks [IG:tapback.go].
|
||||
- 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]. `TODO(capture):` C38 — confirm the −1 case. No edit history is transmitted — only the current content of the edited part; the edit XML is an HTML document with one span per edited part, with visible text recovered by stripping tags [IG:editparse].
|
||||
- A link-preview edit is a separate command 196 whose body decodes like a normal message and only contributes rich-link metadata [IG:decrypt]. `TODO(capture):` C36.
|
||||
- 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]. `TODO(capture):` C33 — capture a real start/stop pair.
|
||||
- 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) [RP:messages.rs].
|
||||
- Disagreed body: one source says 101/102 carry no payload [RP:messages.rs]; the other says 102 is sent unencrypted with the timestamp of the read event and the message UUID set to the "read up to" GUID, fanned out to all participants [IG:connector]. `TODO(capture):` C34 — capture a real 102.
|
||||
- Certified delivery (agreed): a delivered message can carry a certified-delivery version plus an opaque receipt; the receiving client acknowledges via command 109 on madrid with the receipt, a new APNs id, the original UUID, the sender's token, version 8, no-response-needed, and optionally the target/sender handles and a notify flag [RP:identity_manager.rs], [IG:connector]. Whether real Apple clients emit these receipts is unknown (one implementation disables the capability). `TODO(capture):` C35.
|
||||
- Send-time acks (agreed): command 255 echoes the message UUID with a per-device push token and a numeric status; an empty token means a server-level error for the whole send [IG:ackwaiter.go]; one source reports status 0 and 5008 as delivered and 5032 as "refresh keys and resend" [RP:identity_manager.rs], while the other's status table reads 5032 as bad signature (same remedy: re-lookup and resend once) plus 1002 message removed, 1003 message inhibited, 5006 bad push token, 5040/5041 bad/expired session token, 6005 refresh credentials, 6006 refresh registration, 7000 rate limited, 5000 unauthenticated [IG:errors.json]. `TODO(capture):` C39 — confirm the status table.
|
||||
- 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]. `TODO(capture):` C37 — observe "vr" and real pv values.
|
||||
- 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]. `TODO(capture):` C40 — confirm constants.
|
||||
- An SMS enabling handshake exists: request (142) and toggle confirmation (145) with accepted/rejected/disabled states [IG:sendmessagepayload]; one source also reports read-on-device as 147 on the SMS topic addressed only to the own phone token [RP:aps_client.rs], [IG:connector].
|
||||
- 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]. With an iPhone-relay registration the iPhone and relay must stay online at all times; Mac-based registration can renew indefinitely with no Mac online [OB-Renewal]. The exact renewal interval in days is not documented publicly [OB-Renewal].
|
||||
- Client-side scheduling policies (second-extraction refinement): the first source reads `next-hbi` as "seconds from registration time until the client should re-register" and schedules the next re-registration at the minimum, across all users, of (registration time + `next-hbi`); when `next-hbi` is absent it falls back to seconds remaining until the identity certificate's `notAfter` minus a 300-second (5-minute) safety margin, and rechecks the wall clock every 60 seconds [RP:ids/user.rs:79-98,1171-1180], [RP:identity_manager.rs:491-525]. Failed registrations retry with exponential backoff — minimum 5 minutes, maximum 1 day, indefinitely — and a triggered refresh is a no-op if one succeeded within the last 15 seconds, with callers waiting at most 30 seconds for the running attempt [RP:identity_manager.rs:434-443], [RP:util.rs:916-917,1116-1133]. The second source recomputes its re-register ticker after every successful registration: it starts from the earliest `notAfter` among all identity certs but caps the interval at 45 days, clamps to a maximum of 1 day and a minimum of 30 minutes, then subtracts a 2-minute buffer [IG:connector.go:178-224]. It also forces re-registration whenever 45 days have elapsed since the last successful registration [IG:connector.go:301]. Its per-cert safety buffer is tiered by approximate certificate lifetime (registered-time → `notAfter`): under 8 hours → re-register when less than 3 minutes remain (fresh phone-number certs are described as short-lived); under 1 day → 30 minutes; under 7 days → 4 hours; otherwise 24 hours [IG:connector.go:317-334]. When its validation-data provider is unreachable it pauses by resetting the ticker to 1 hour [IG:connector.go:238-243].
|
||||
- Forced immediate re-registration (second-extraction refinement): a push on the private IDS topic with command 32 (re-register) or 66 (handles changed — re-fetch handles, re-register if they differ) triggers it in the first source; command 34 (devices changed) only invalidates the cached identities for own handles [RP:identity_manager.rs:758-797]. A lookup returning 6005 triggers a re-register and retry, at most once per hour with a two-strike guard, in the second source [IG:lookup.go:447-471]; the first treats a 6005 from registration itself as permanent ("do not retry") pending re-login [RP:identity_manager.rs:369-380]. An APNs command 130 received from self also forces re-registration in the second source [IG:connector.go:824-841]. Disagreement: the second source's receive path explicitly ignores the private IDS topic entirely, so server-pushed commands 32/66/34 do nothing there [IG:connector.go:810-813]. A stored client-data hash differing from what the client would now send triggers immediate re-registration (first source) [RP:identity_manager.rs:400-412].
|
||||
- Second-extraction correction on the "~45-day certificate" folklore: 45 days appears in the sources only as the second source's client-side cap on re-registration cadence and on time-since-last-registration; neither source records an observed server-issued certificate validity, and the first source assumes no certificate lifetime at all (the certificate's own `notAfter` is authoritative) [IG:connector.go:181,208,301], [RP:ids/user.rs:79-88]. Treat 45 days as a client policy constant, not a measured Apple certificate TTL. `TODO(capture):` C41 — real `next-hbi` values and whether Apple sends it at all, real identity-certificate `notBefore`/`notAfter` per handle type (phone vs Apple ID), real `session-token-expires-seconds`/`-refresh-seconds` values, and which renewal trigger fires in practice. An instrumented client closes this with a debug log of: the raw register response plist (top-level keys, per-user `next-hbi`, cert DER), cert validity tagged by handle type, the computed next-renewal deadline and the trigger that fired, and the lookup identity dicts' two token fields.
|
||||
- **Mac-renewal arbitration (2026-09-10 research; Phase 0 go/no-go input):** renewal never needs the Mac again — every re-registration submits freshly generated validation data, produced on-device by the client's own NAC key-establishment session keyed with the cached Mac hardware identifiers [RP:ids/user.rs], [RP:macos.rs]. OpenBubbles documents Mac-based registration as renewing indefinitely with no connection to the Mac, and never requiring the Mac online after setup [OB-Renewal], [OB-FAQ]; corten-matrix likewise generates validation data locally from the hardware key extracted from the Mac once (the extraction produces an already-complete key, so no Mac round-trip is needed later). Contrast: in the iPhone-relay configuration each renewal is an HTTP call to the relay, so the iPhone must be live at every renewal [RP:relay.rs]. Client-side schedule confirmed: earliest of (registration time + `next-hbi`) or the identity-cert `notAfter` − 5 min; failure retries back off 5 min–24 h; re-registration also fires reactively on APS re-register commands and identity errors. IDS status 6005 is a server-side logout needing an Apple-ID relogin (not a Mac/validation-data problem). No public source states the actual day count — treat the ~45-day max-certificate figure as approximate. **Standing risk:** macOS 26 protected `NACKeyEstablishment` with PAC (rustpush issue #21) — an Apple-side protocol change can break local generation; watch rustpush/corten releases. — 2026-09-10: the standing risk **fired** — on macOS 26.6.3 (confirmed live on a test machine) `NACKeyEstablishment` is PAC-protected and SIGABRTs when called [RP-issue-21], and beeper's `mac-registration-provider` is archived (Apr 2026) with support only up to macOS 14.3. Minting validation data currently requires a Mac on macOS ≤ 14.3; the issue is open with no upstream fix or comments. rustpush's own fallback note stands: any genuine Mac's validation data works — the identifiers only need to be valid, not the capturing client's own hardware. — 2026-09-10 correction (OpenBubbles-flow research): the ≤ 14.3 conclusion above is **narrowed** — it binds only helpers that call the framework themselves (beeper's `mac-registration-provider`; rustpush running *on* a Mac, which is what issue #21 breaks). OpenBubbles' shipped flow never calls `NACKeyEstablishment` on the Mac at all: the helper exports identifiers only (public APIs, any macOS ≥ High Sierra including 26.x), and the client performs the generation in software (§1.1, C56). A macOS 26 Mac therefore suffices as the identifier source; the remaining C5/C6 work is client-side software (the generation procedure + Apple-Account login), not hardware access.
|
||||
- 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. `TODO(capture):` C43.
|
||||
- Deregistration: neither reference client implements client-initiated un-registration; deregistration is handled by Apple (e.g. when the account is used elsewhere) [RP:identity_manager.rs]. Operationally: reset the app and delete the device from the Apple Account page; an already-registered number can be deregistered via Apple's selfsolve deregistration page [OB-Renewal]. The MobileMe delegate token state is refreshed when its stored refresh timestamp is older than 7 days (604800 s) — a lazy refresh at fetch time, client-side policy, not a measured server TTL [RP:auth.rs:204-212]. `TODO(capture):` C42 — observe the deregistration flow end-to-end.
|
||||
- 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]. `TODO(capture):` C45 — real key-replacement cadence.
|
||||
- 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
|
||||
|
||||
Ordered by value. IDs match the inline `TODO(capture):` markers above.
|
||||
Open items only, ordered by value. Closed items are recorded in the revision history. IDs match the inline `→ C##` markers above.
|
||||
|
||||
1. **C25 — pair vs pair-ec vs PQ3 in current traffic.** The single most load-bearing question: do today's Apple clients still send "pair"/"pair-ec" envelopes, or has PQ3 replaced them entirely? Determines whether §4.2–4.3 are sufficient for receive compatibility. Second-extraction sharpening: Apple stated in 2024 that PQ3 "will fully replace the existing protocol within all supported conversations", but neither source repo has any post-2024 evidence, and one repo's fan-out code notes Apple senders shipping the same destination both with and without EC keys in one send (mixed schemes observed pre-PQ3) [IG:connector.go]. Payloads are end-to-end encrypted, so network captures can never settle this — the only path is an instrumented client. Observe: per-recipient `E` values (top-level and per-`dtl` entry) on outgoing and incoming messages; payload-less messages' `E` presence; whether current lookup results still carry the legacy/EC key sets beside the PQ3-registered Kyber-1024 + P-256 keys; the incoming validator final byte; incoming inner counters and gossip fields.
|
||||
2. **C17 + C18 + C19 — APNs transport stack.** Courier hostname/count from the APNs bag (range 1–30 vs 1–50); whether the TLS handshake presents the Albert client certificate (mutual TLS) or auth happens only in the connect message; negotiated ALPN (`apns-security-v3` vs packed variants) and therefore legacy vs packed frame format. — 2026-09-10: `courier2.push.apple.com` observed live, 5223↔443 both used; vanilla-TLS interception rejected (`bad extension`) — Apple-private TLS negotiation confirmed (see §3.1). 2026-09-10 pcap mining: live session is TLS 1.3 (server picks 0x0304 / TLS_AES_256_GCM_SHA384) with Apple-private version 0xfafa, group 0x9a9a, extensions 0x5A5A/0x1A1A, and the X25519+ML-KEM-768 hybrid group offered; client offers both ALPNs, server selects none → legacy frame format assumed. C18 is unobservable by passive capture under TLS 1.3 — needs client-side evidence. — 2026-09-10 [CAP-APNSBAG]: the APNs bag was fetched directly (`GET http://init-p01st.push.apple.com/bag`, unauthenticated plain HTTP; archived on file as `apns-bag-2026-09-10.xml`): `APNSCourierHostname: courier.push.apple.com`, `APNSCourierHostcount: 50` — the 1–50 range is confirmed (`courier2` is one host within it); `APNSAllowTLS1_3: 1` is consistent with the observed TLS 1.3 session. C17's hostname/count half is closed; C18 still needs client-side evidence. [CAP-COURIER] — 2026-10-03 (rev 26): C19 CLOSED — with `apns-pack-v1` offered the server selects `apns-pack-v1:4096:4096` and the packed frame format flows; the packed chain anchors at the `Apple Server Authentication CA` intermediate (§3.1/§3.2), and no TLS client certificate is demanded on the packed path (refining C18: the post-handshake client-cert alert belongs to the no-ALPN legacy path only).
|
||||
3. **C1 + C3 — registration request shape.** Second-extraction state: the bag fetch URL, nested-plist shape (`bag` key → embedded plist), one-per-process caching, and both clients' full endpoint sets are now known (§1.2) — rustpush resolves 11 bag keys at runtime with no fallbacks [RP]; the other pins everything and never fetches. What remains needs an instrumented client (pinned hosts bypass every proxy tier tried): the **live** bag contents (all `id-*` → URL mappings as served today), the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies. — 2026-09-10: the bag contents are now observed directly — both bags fetched unauthenticated, no drift in the pinned URLs ([CAP-BAG]/[CAP-APNSBAG], §1.2). What remains C1/C3-bound to an instrumented client: the current sub-service list and Messenger capability flags (17 vs 1), the capability-boolean set the server expects, and whether the server accepts both gzip-XML and plain-XML register bodies — these live in the register request/response pair, which does require authentication (and Apple throttles abusive registrations, so no blind probing). C2 (protocol-version: "1640" vs "1660") and C4 (signature construction — structure now source-agreed, §1.4) also resolve from the same instrumented-client capture.
|
||||
4. **C32 + C33 + C34 — semantic wire forms.** Tapback action codes (2006/2007/4000/1000/2 extensions) and target-string formats ("bp:" vs raw GUID vs "p:"-variants); typing start/stop encoding (empty payload vs body-field stop); whether command 102 carries a body (timestamp + read-up-to UUID) or none.
|
||||
5. **C41 + C10 — lifetimes.** Real `next-hbi` values (and whether Apple sends it at all — one source parses it, the other doesn't read it), real identity-certificate lifetimes per handle type (the "~45 days" figure is now known to be a client-side cap, not an observed cert TTL — §6.1), real `session-token-expires-seconds`/`-refresh-seconds` values (one source consults them, the other ignores them), the effective renewal cadence, and which renewal trigger fires in practice (timer, private-IDS push 32/66, 6005, handle change). All reachable only via an instrumented client's debug log — the exact logging list is in §6.1.
|
||||
6. **C5 + C6 — validation data & anisette.** Exact validation-data blob shape/inputs on a genuine Mac (and the two validation bag-URL exchanges); the full anisette header set current clients send (including optional hardware headers and `X-Mme-Nas-Qualify`). — 2026-09-11 (rev 14): the anisette halves are closed — header set, per-request value formats, the ADI provisioning flow (§1.1), and the GSA login chain end-to-end (§1.5) are recorded. Remaining open here: the validation blob itself (C5), the OTP/2FA-code derivation (closed-source crate — contract recorded, §1.1), and wire-verification of the whole chain against one live activation.
|
||||
7. **C20 + C22 + C23 + C24 — APNs message details.** Second-extraction narrowing: C20's connect nonce is now source-agreed (structured 17-byte nonce with type byte 0x00, signed alone — §3.3); what remains is confirming a real client's connect frame and the fixed 0x10 field value. Still open: the meaning of command 14; connect-ack advertised max/large message sizes (4/15 KiB [IG] vs Apple-doc 4/16 KB [APS-Send]); whether channel subscribe (0x1d) is used.
|
||||
8. **C26 + C27 + C29 — pair-ec details & chunking.** Second-extraction narrowing: C26's validator final byte is source-agreed at 0x0c (§4.3) — capture only confirms Apple's live value and whether it ever differs. Still open: receiver-side counter enforcement, which counter keying/reset behavior Apple uses (per-key-hash persisted from 0 vs per-URI from 1 — §4.3), and real send-chunking (≈5000-byte packing vs ≤20 devices against 15 KiB).
|
||||
9. **C35 + C36 + C37 + C38 + C39 + C40 — semantics sweep.** Certified-delivery (109) emission in the wild; single-source command numbers (196, 104, 113, 120, 122, 131, 138, 103, 142, 146/149 — §3 table, tagged per source; 160 withdrawn as a frame command, §3.3); group "vr" change type and real pv values; edit part-index −1 (subject); full ack status table; SMS body constants.
|
||||
10. **C42 + C43 + C44 + C46 — lifecycle edges.** Server-initiated ids-topic commands (c=32/34/66) in practice; deregistration flow; expired-validation-data/renewal-failure behavior; session-token account binding as seen in relay scenarios.
|
||||
11. **C21 — timing.** Real keepalive pacing (60 s confirm), silence-drop threshold (15 s ack timeout vs 180 s), and reconnect strategy (backoff cap 30 s vs 3/6 s rules). — 2026-09-10: 2.0 s client activity-record cadence observed while active; idle keepalive and reconnect pacing still open [CAP-COURIER]. — 2026-09-10 [CAP-APNSBAG]: Apple's client-side connection-policy constants observed in the APNs bag (client policy, not wire measurement): `KeepAliveV2TimeDriftMaxAllowed: 30`, `APNSDelayedReconnectIntervalAll: 60`, `APNSDelayedReconnectMaxInterval: 90` with Min/MaxIntervalTrigger 30/50 and `APNSDelayedReconnectTLSInterval: 120`, `APNSWiFiKeepAliveEarlyFireConstantInterval: 120`, `ClientConnectionRetryAttempts: 100`, `APNSBagExpiry: 10800`. These bound the pacing questions but do not replace observing a real client.
|
||||
12. **C28 + C30 — plumbing.** How often the inner gzip layer appears; MMCS command codes (150/151) and header versions in current traffic.
|
||||
13. **C31 — PQ3 wire format.** If captures show PQ3, dump its container/envelope; Apple's post describes the cryptography but not the wire encoding, and no third-party implementation exists to cross-check.
|
||||
14. **C7 + C8 — activation edge.** Carrier entitlement exchange per carrier (SIM-less phone auth); exact Albert activation-info field set and FairPlay certificate choice.
|
||||
15. **C45 — APNs legacy framing gaps (blocks command construction).** The §3.2 legacy frame's per-field 1-byte ids for every command (connect 7: token/state/flags/cert/nonce/signature; connect-response 8: status/token; filter 9; send 10; ack 11; keepalive 12/13; set-state 20; cache-flush 160), plus the length-prefix scope question (§3.2). Frames are TLS-encrypted, so passive captures see nothing — needs an instrumented client or apsd introspection. Until closed, the courier transport (TLS, frames, keepalive, reconnect) is implementable but no command builders can be written. Related: which Apple root certificate(s) to pin for the courier trust pool (§3.1). — 2026-09-10: closed by facts-only extraction (both source repos) — the length-prefix scope is settled (covers the whole field block after the command byte) and the field-id tables are recorded (§3.2, §3.3). What remains here is only the tagged disagreement set — connect flags bits, filter field set, command-14 semantics, ack field 6, connect-response Go-only fields, connect-nonce shape — now tracked under C57 (item 21); the "cache-flush 160" entry above is withdrawn (payload value, not a frame command — §3.3, §5.1).
|
||||
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.
|
||||
|
||||
16. **C47 + C48 — IDS endpoint encodings and the lookup wire details the phase-1 set never captured.** C47: the plist encoding of the bag responses and of the embedded `bag` payload (§1.2); the lookup request's header wire names beyond the `x-id-*` set and the push-token name recorded in §1.4, and whether a lookup carries the push header set at all beside `x-id-*` (§2.1). C48: the element form of the lookup `uris` array, and the lookup response's per-identity/result wire key names beyond `identities`, `status`, and the two `session-token-*-seconds` fields (§2.1). Phase-2 code takes each of these as configuration instead of guessing, so a wrong name fails loudly rather than silently. The three source repos are closed to the writer phase; these close by capture (an instrumented client's lookup request/response pair, or a bag fetch archived with its content type and byte-exact body) or by a deliberate post-Phase-2 extraction. — 2026-09-10 [CAP-BAGCT]: the C47 encoding half is closed (§1.2) — both bag responses and the embedded bag payload are XML plists; the `text/xml` vs `application/x-apple-plist` labels do not change the parse. Remaining open here: the lookup header wire names, the `uris` element form, and the lookup response key names. — 2026-09-10: closed by facts-only extraction (both source repos) — the header wire names (§2.1), the plain-string `uris` element form, and the response key names (§2.1) are all recorded, and no push header set rides lookups. Nothing capture-bound remains for C47/C48; the optional-header-set and per-handle-key-set disagreements ride C57.
|
||||
17. **C49 + C50 + C51 + C52 — codec assumptions (Phase-2 component 4).** The value-type assumptions of the outer-envelope/body codecs (C49 §4.1, C50 §5.2), the pair envelope's HMAC key and signature encoding (C51 §4.2), and the pair-ec protobuf field numbers (validator 99 excepted) + HKDF salt (C52 §4.3). All are closed by an instrumented client's encrypt/send round trip; until then the code fails loudly rather than guessing. — 2026-09-10: C49, C51, and C52 closed by facts-only extraction (§4.1, §4.2, §4.3), and C50's protobuf-numbering half is closed (§4.3). What remains: the command-100 body-plist value types (§5.2) and the Rust-side inner-body gzip location (C57) — both capture-bound.
|
||||
18. **C53 + C54 — registration wire names (Phase-2 component 5).** C53 (§1.4): the register request's top-level keys beyond `private-device-data`/`services`/`users`/`validation-data` (device name, hardware version, language, OS version, software version), the service-entry and capability-record key names inside the `services` array, the register response's per-service breakdown shape and where its `users` sit (top level vs inside it), the per-user identity-certificate and per-URI-status keys, the `alert` object's keys, the "refresh credentials" marker (§6.1), and the value form carried by `x-auth-user-id-<N>`. C54 (§1.2): the authenticate request/response pair — body wire keys, CSR encoding and self-signature algorithm, the CN's encoding of the SHA-1 realm-user-id hash, whether the §1.4 signature header sets apply, and the response's token/certificate wire keys. The component-5 code takes every one of these as caller-supplied configuration or keeps it in a raw map, failing loudly rather than guessing; both close with an instrumented client's authenticate+register capture. — 2026-09-10: closed by facts-only extraction (both source repos) — request/response wire keys, CSR = DER, CN = hex SHA-1 string, no §1.4 signature headers on authenticate, flat `status`/`cert` response (§1.2, §1.4). Remaining: the "refresh credentials" marker name (C53 half, §6.1) stays capture-bound, and the capture-arbitrated disagreements (self-signature hash, CN hex case, bag-vs-pinned endpoint resolution) ride C57.
|
||||
### 7.1 Extraction disagreements & closed-source residue (C57)
|
||||
|
||||
19. **C55 — lookup error-status locations (Phase 2 lookup-orchestration component).** Where the lookup response carries its error statuses — 5206 ("response too large", the §2.1 split-in-half trigger), 6005 (bad authentication, §2.3), and the session-token codes 5040/5041 (§2.2 records them as *send-side* codes; nothing states whether a lookup can also return them): top-level response status, per-handle result status, or both. The §2.1 response shape records only a per-handle numeric status, so the lookup-orchestration code reads per-handle statuses only; a capture of one real lookup response's full plist (top-level keys and per-handle status values) closes this alongside C48's response-key half. — 2026-09-10: narrowed by facts-only extraction — both clients read lookup error statuses from the top-level `status` only (5206 → split-in-half, 6005 → re-register + retry, 5040/5041 defined but unhandled); neither checks a per-handle `status`. The per-handle-status reading above (and the lookup-orchestration code built on it) is superseded; a capture still confirms the live wire.
|
||||
|
||||
20. **C56 — client-side validation-data generation (§1.1).** The procedure a client runs in software to produce the validation blob: the key establishment over the `id-initialize-validation` session info (inputs, algorithm, blob assembly), its interaction with the Apple-Account (GSA) login and the anisette header set (C6) at activation time, and which pieces are fixed protocol constants vs per-account/per-session state. The shipped OpenBubbles flow proves the procedure runs entirely client-side from exported Mac identifiers; closing C56 needs a facts-only extraction from a current client's implementation, then wire-verification against one live activation. — 2026-09-10: steps 1–2 recorded by facts-only extraction (`id-validation-cert` → single `cert` data field; `id-initialize-validation` body-only POST → `session-info`; §1.1); step 3's math is closed source — OpenAbsinthe-Stub is an official mock whose methods are stubs, so only the API contract is recorded (§1.1). The GSA login and the full anisette header set are recorded (§1.1). What remains: wire-verification against one live activation, and the enc-variant derivation for the five obfuscation labels (closed source — C57).
|
||||
|
||||
21. **C57 — extraction disagreements & closed-source residue (rev 12).** Gaps that survived the rev-12 extraction round, each with its arbitration path:
|
||||
- CN hex case: uppercase [RP] vs lowercase [IG] hex of the SHA-1 realm-user-id (§1.2) — one instrumented authenticate capture.
|
||||
- Authenticate self-signature hash: SHA-1 RSA/PKCS1 [RP] vs SHA256WithRSA [IG] (§1.2) — same capture.
|
||||
- Connect flags bits: Go base `0b1000001` + optional `0b100` root bit vs Rust `0b01000001` (§3.3) — client-side connect-frame dump.
|
||||
- Connect nonce shape: Go 20-byte random with first byte zeroed vs the recorded 17-byte structured agreement (§3.3/§1.2) — same dump.
|
||||
- Filter field set: Go 1/2 vs Rust 1/2/3/4/5 (§3.3) — client-side filter-frame dump.
|
||||
- Command-14 semantics: Go "filter-topics-ack" (field 1) vs Rust no-storage marker with no fields (§3.3) — same dump.
|
||||
- Ack field 6 (Go-only, §3.3) and connect-response Go-only fields 4/5/6/8/10 (§3.3) — same dump.
|
||||
- Protocol-version pinning: "1640" hardcoded [IG] vs taken from the stored per-user protocol version [RP] (§1.4, §2.1) — instrumented-client header log.
|
||||
- The "enc"-variant derivation for the five obfuscation labels (§1.1) — closed source; needs a new facts-only source or clean-room reconstruction checked against one live device.
|
||||
- The pair-ec inner-body gzip location on the Rust side (§4.1) — instrumented encrypt/send round trip.
|
||||
- Multi-topic filter-field encoding: whether one state field holds concatenated 20-byte topic hashes or the fields repeat (§3.3) — client-side filter-frame dump.
|
||||
- Position of the Rust-only connect field 0x10 (value 9) within the connect field order (§3.3) — same dump.
|
||||
- Connect-response fields 5 and 6 (capabilities) value shapes (§3.3) — same dump.
|
||||
- APNs-tunnel framing residue (§2.1, rides C12): the frame field ids of tunnel commands 96/97 (the field SET is recorded, the 1-byte ids are not); whether the tunnel payload rides as framed fields at all, and the headers-field encoding (an `h`-keyed dict is recorded; its serialization on the tunnel is not); the tunnel-level status value table beyond 0 = ok — one instrumented tunnel capture.
|
||||
- Phone-flow `authentication-data` value encodings: the `push-token` and `sigs` entry shapes inside the authenticate request body (§1.2; key names recorded, value encodings not) — one instrumented phone-user authenticate capture.
|
||||
- Albert wire value types: `FairPlayCertChain` / `FairPlaySignature` request encodings and `ack-received` / `show-settings` response encodings (§1.2; names recorded, value types not) — same activation capture.
|
||||
- FairPlay identity provenance/licensing: ten embedded certificate/key pairs with unrecorded provenance (§1.2) — a policy decision, not a wire fact; resolve before any distribution. — 2026-09-11 (rev 17): the provenance half is CLOSED by metadata extraction (§1.2): all known copies are ONE identity (1024-bit RSA + leaf cert), byte-identical across repos, descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies of it; the cert expired 2024-10 yet activations succeed. What remains: the own-Mac extraction method (open research — nothing documents where macOS stores the per-device FairPlay identity), and the distribution decision (bundling the community pair in a public APK is the provenance problem; BYO-identity is the sane architecture but needs the extraction method first). — 2026-09-11 USER DECISION: bundle the community pair (same one every third-party client already activates with; per-user extraction only if Apple ever starts validating) — staged as `resources/imessage/fairplay/leaf.crt` + `identity.key` (SHA-256 `bc18fc4d…` / `03aca53e…`, matching the recorded provenance); own-Mac extraction DEFERRED until needed.
|
||||
|
||||
22. **C58 — the Mac-Hardware-Info export-code format (the decided identifier source).** — 2026-09-11 (rev 15): CLOSED by facts-only extraction from the helper's public upstream repo (unlicensed — facts only): `OABS` magic + flag byte + raw protobuf with the full field map, plain-base64 or OpenSSL-`Salted__` AES-256-CBC delivery modes, sharing mechanics server-side; §1.1 records the parse specification. Residue: the vendor-server upload endpoint (`hw.openbubbles.app/code`) is only relevant to the restricted mode's sharing mechanics, which our app does not need (we parse, not mint).
|
||||
|
||||
23. **C59 — GSA login residue (writer-pass gaps from the §1.5 implementation).** — 2026-09-11 (rev 15): CLOSED by facts-only extraction — the SRP modulus (RFC 5054 2048-bit, g=2; Circle 3072-bit, g=5), the full 2FA browser header set with literal values, the cpd values per flavor, the akd UA/`X-MMe-Client-Info` forms, the `t`-map inner keys and top-level `GsIdmsToken`, `fn`/`ln`, and the sign-in UA/timezone forms are all recorded in §1.5; the implementation converts its caller-supplied configuration to the recorded constants.
|
||||
|
||||
24. **C60 — validation-data step-3 reconstruction hypotheses.** The step-3 math is closed source (confirmed at source level, §1.1), so the implementation reconstructs it from the recorded contract with every non-recorded choice explicit. Each hypothesis below is arbitrable ONLY by one live activation (or an instrumented-Mac capture of the exchange); a wrong choice fails the server side loudly:
|
||||
- Key agreement: ECDH P-256 (default; Apple's recorded crypto is NIST-curve-heavy) vs X25519 as the implemented alternative.
|
||||
- KDF: HKDF-SHA256 with EMPTY salt and info (both unrecorded).
|
||||
- Blob signature: ECDSA P-256/SHA-256 with a context-generated keypair (public key rides the blobs) vs HMAC-SHA256 keyed by the session key (symmetric hypothesis).
|
||||
- Session-info interpretation: the whole `session-info` blob is the server's raw public point (SEC1 33/65-byte for P-256, 32 raw for X25519).
|
||||
- Blob framing: TLV v1 (1 tag byte + 2-byte BE length; tags 0x01–0x0E hardware fields in §1.1 order, 0x0F cert, 0x10/0x11 public keys, 0x20 signature last) — an invented framing, nothing recorded.
|
||||
- Step-2 content-type: `text/x-xml-plist` (inferred from the GSA plist posts; §1.1 records body-only).
|
||||
All six live as parameters/strategies in the code; capture or live-attempt results collapse them.
|
||||
- 2026-09-11 (first live step-1 attempt, rev 18): the DEFAULT hypothesis set (P-256 ECDH, HKDF-SHA256 empty salt/info, ECDSA-P256-SHA256, TLV v1, RAW_PUBLIC_POINT, `text/x-xml-plist`) was rejected at STEP 2 — the bag fetch and step-1 cert GET both succeeded (HTTP 200, TLS pinned to the embedded Apple roots, cert parsed), and the step-2 POST returned HTTP 200 with a plist dict of exactly two keys: `status` (integer **5003**) and `message` (a server trace string of the form `SED:3:VEN-PROD:<uuid>:<uuid>:<epoch>:<uuid>:<64-hex>`; exact values not recorded), with NO `session-info` key. The exchange died before key establishment, so this data point does NOT yet arbitrate the crypto hypotheses — it shows the step-2 request itself was refused. The meaning of SED status 5003 is unrecorded: `TODO(capture)` the SED error-status table (candidate refusal causes: the body shape under a wrong C60 hypothesis, the C56 content-type, or a required-but-unrecorded step-2 header).
|
||||
- 2026-09-11 (probe matrix, same session): FIVE step-2 attempts returned the byte-identical 498-byte error shape (`status` 5003): default set; KA=X25519; sig=HMAC-SHA256; content-type `application/x-www-form-urlencoded`; content-type `application/octet-stream`. The refusal is INVARIANT under every flipped variable — the server rejects something common to all attempts. Eliminated as causes: the key-agreement scheme, the signature scheme, the step-2 content-type. Remaining suspects, ranked: (1) the invented TLV v1 framing itself (tags/lengths wrong → body unparsable → generic error), (2) the single-key plist wrapper's encoding (XML `<data>` vs base64-string value form), (3) a required-but-unrecorded step-2 header (protocol drift since the 2024-era recordings, e.g. an anisette set now enforced), (4) the exported Mac identifiers being stale/invalid server-side. Next lever is a wire capture of a genuine client performing the exchange, not more blind flips.
|
||||
- 2026-09-11 (rev 19, step-2 self-check round): the reconstruction was audited offline against the extracted client's own request construction and the archived genuine captures; the audit MOVES the ranking rather than adding a hypothesis. (a) Wrapper — CLOSED as a suspect: the extracted client serializes the `session-info-request` body with the plist XML writer (the same `<data>`-base64 XML form this implementation emits), so suspect 2 is retired. (b) Content-type — CORRECTED, not just probed: the extracted client sets NO content-type on the step-2 POST (only `Accept-Language` rides the client's default headers), so `text/x-xml-plist` was an unsupported inference carried in from the §1.5 GSA posts and the probe matrix's three content-types were all non-faithful; the code's constant is now absent (§1.1). (c) Invariance — CORRECTED: the five probe attempts did NOT send identical bodies (each mints fresh key-agreement key material, the X25519 and P-256 variants differ in that field's width, and the HMAC variant drops the signing-key field), so "byte-identical response" shows the refusal is independent of the body's CONTENT — which retires the key-agreement and signature *values* as variables at this step (step 2 precedes key establishment entirely) and leaves the body's FRAMING (the invented TLV v1 layout) and something outside the body as the surviving explanations. (d) Headers — WEAKENED as a suspect but not retired: the archived 2026-09-10 capture of a genuine device shows the validation endpoint family reached with the full anisette + BAA header set (`X-Apple-I-MD`, `X-Apple-I-MD-M`, `X-Apple-I-MD-RINFO`, `X-Apple-I-Client-Time`, `X-Apple-I-TimeZone`, `X-Apple-I-Locale`, `x-apple-i-device-type`, the `x-apple-baa-*` set) under an `invitation-registration` user-agent, while this implementation posts body-only; but that captured request is the CDN-served static step-1 cert, which is exactly why it succeeds header-less here, so the capture neither requires nor acquits a header set at step 2. No archived capture contains the step-2 POST (every capture on file was searched for the request's key). Next levers, in order: (1) live — one faithful step-2 POST (no content-type) plus ONE deliberate control POST whose body is not the session-info-request plist, to learn whether the endpoint examines the body at all (control identical to the 5003 shape ⇒ the refusal precedes the body, so framing is not the cause; control different ⇒ the body is parsed, so framing is the cause); (2) a genuine-client wire capture of the exchange, still the only way to READ the real framing — the Mac-side client performs it in software from exported identifiers, so the capture host is whatever machine runs a client that completes the exchange (the identifier-export helper alone never performs it).
|
||||
- 2026-09-11 (rev 20, live control experiment): the BODY half of this item is CLOSED. A deliberate control — 20 bytes that are not a plist at all, POSTed to the same live step-2 URL with the same (absent) content-type — was answered exactly like the reconstruction: HTTP 200, 498 bytes, `status` 5003, same nine token classes. The live step-2 URL the bag serves is `https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation`. Consequences: (1) the invented TLV framing, the wrapper encoding, and the step-2 content-type are all retired as causes — the endpoint refuses WITHOUT examining the body, so no further body flip can arbitrate anything; (2) the rev-18 "byte-identical under five variants" reading is corrected: the trace carries a live per-request epoch (the two rev-20 runs' epochs sit 28 s apart, matching the gap between them) and three per-request uuids, so those responses were shape-identical, not byte-identical — and the rev-19 inference built on that reading is superseded by this experiment; (3) the surviving suspect class is the request ENVELOPE: the header set a genuine client carries (the anisette + BAA set, §1.1/C6) or an identity/attestation the endpoint resolves before parsing; (4) next levers, ranked: a one-call envelope probe with a plausible client header set (a UA, `X-Apple-I-Client-Time`/`TimeZone`/`Locale`, `X-Mme-Device-Id` — none of which needs the OTP derivation that is still missing), then the genuine-client wire capture, which is now the only way to see the real envelope. Instrumentation note (dev-only, uncommitted): the live runner dumps the exact request bytes mode-0600 beside the blob and classifies the server trace token-by-token (shape plus boolean match against our own digests/identifiers), so every attempt is post-mortem-analyzable without spending another call.
|
||||
- 2026-09-11 (rev 21, offline re-read + cross-checks — this SUPERSEDES the rev-20 conclusion that the body half is closed): (a) the rev-20 experiment cannot prove the endpoint ignores the body. Both requests it compared were invalid — the 20-byte control is not a plist at all, and our reconstruction's `session-info-request` is the invented TLV v1 blob, emitted in place of the closed-source library's real output — so "one generic 5003 for both" is equally consistent with "the server parses each, rejects each, and reports a single generic code". Retiring the TLV framing is therefore withdrawn: framing is live again, and it is now the leading suspect, because the reference client's envelope is body-only (below). Content-type and the step-2 crypto *values* stay retired (they were flipped and changed nothing). (b) 5003 = `IDSStatusBadRequest` in the IDS status space [IG:imessage/direct/ids/types/idserrors.gen.go]; that family has dedicated body-level codes (`ParseFailure` 1000, `InvalidField` 1001, `DecodeFailureBase64`/`Hex` 1004/1005, `MissingRequiredKey` 5008) and none came back — weak evidence for a pre-body rejection, equally explicable by this endpoint simply not using the fine-grained codes; not decisive either way. (c) The model client is current and still body-only: the rustpush reference IS the OpenBubbles fork (`origin` github.com/OpenBubbles/rustpush), re-fetched 2026-09-11, unchanged from the snapshot on file; its step-2 call remains `REQWEST.post(bag_url).body(xml_plist)`, no content-type, `Accept-Language: en-US,en;q=0.9` the sole default header (`src/macos.rs`, `src/util.rs`). If that client completes the exchange today, headers are not the cause — and the two envelopes then differ only at the HTTP layer (client stack, protocol version, header casing). (d) Route confirmed against a genuine device: the archived 2026-09-10 capture's `id-initialize-validation` bag value is byte-identical to the URL the live run posted to. (e) The genuine bag enables `absinthe` attestation on this endpoint family (`absinthe-baa-option-enabled`, `-ios13`, `absinthe-v3-enabled` all true) — the one mechanism that could still demand an envelope this project cannot currently produce. (f) SED trace grammar from the two recorded responses: `SED:3:VEN-PROD:<uuid>:<uuid>:<unix-epoch>:<uuid>:<64-hex>:EM`; every uuid and the epoch differ between the runs (the epochs 28 s apart, matching the calls) and the 64-hex differs too and matches no digest of the recorded body, the empty string, or the step-2 URL — per-request state, not a hash of our input. (g) Ranked next levers: the genuine-client capture, now valued primarily for the real `session-info-request` BLOB (plus, in the same exchange, the real step-3 validation blob) rather than for the header set; then, only if the capture shows a richer envelope, the envelope probe. The capture needs a client that *performs* the exchange (an OpenBubbles app or a rustpush-based bridge — the macOS identifier-export helper never does); one is reported available.
|
||||
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 36 (2026-10-12, C56-c CLOSED — the session250 hardware-identity serialization ported to Kotlin and keyed off the C58 export identity): `HardwareConfig::to_bin` (the 2026-10-08 static-disasm grammar, NAC-NOTES.md) is implemented as `registration` `HardwareConfiguration.toBin()` — `write_str` = `u8(len+1) ‖ utf8 ‖ NUL`, `write_item` = `u8(len) ‖ data`; serial order — product name, **MAC address** (raw 6 B, no prefix), serial, platform UUID, root-disk UUID, board ID, OS build, the constant dword `00 03 0b 52`, then the seven binary items in Mac-Hardware-Info export field order — serialEnc, platformUuidEnc, rootDiskUuidEnc, ROM, romEnc, MLB (ASCII bytes, no NUL), mlbEnc. Two corrections fall out of the export cross-check: (1) the raw second field is the MAC address, NOT the ROM — the rev-32/35 "u32le@+0x138 = ROM" label is corrected (the live capture's 6-B Vec item carries the multicast bit, which no unicast MAC does; the OA stub's field types put the inline `[u8;6]` `io_mac_address` at that struct slot, and ROM is a `Vec`); (2) the seven binary Vecs are exactly the export's identifier fields 8–14 (the five `_enc` blobs, ROM bytes, MLB ASCII) — a 1:1 mapping with nothing unpinned remaining. Verified: `toBin` round-trips the live 250-B capture byte-exactly (capture stays in the gitignored captures/ dir; tests are synthetic). Kotlin: 217 registration tests green; imessage `ClearNacValidationSource.mint(oabsExport)` unstubbed at C56-c — the mint chain is fully real offline (`ValidationDataSource.mint` now takes the parsed export; rngPad generalized to `0x17c − session250.size`). No §1.1 input mapping remains open; the next milestone is a live registration attempt. — First live step (2026-10-12, same day): a JVM smoke ran the real exchange against Apple — step 1 (bag + static cert, byte-identical to the 10-08 capture) OK; step 2 POST of the closed blob → HTTP 200 error plist `status 5003`, mnemonic `EM` (reproduced ~8x, both chain certs as the OAEP key, with/without default headers). The blob construction re-verified against the replay dumps while chasing it (init_key(pearKey16) via the captured rk176, mapA/mapB, field32 = mapA[ciphertext]); the residue is therefore outside the blob bytes (anisette-on-step-2 / transport fingerprinting / an unseen precondition — NAC-NOTES session-26), and is the live-mint gate.
|
||||
- Rev 37 (2026-10-12, live-provisioning wire facts + the step-2 EM A/B): driving the Kotlin `ClearAnisetteProvider` against Apple's live endpoints for the first time (the session-26 EM A/B, NAC-NOTES session-26 addendum) surfaced two wire facts the offline scripted-HTTP tests could not catch: (1) the GSA lookup GET **404s without `X-Mme-Client-Info`**, and accepts only the angle-bracket form `<Model> <macOS;ver;build> <com.apple.AuthKit/1 (…)>` — a bare `com.apple.AuthKit/1 (com.apple.akd/1.0)` value 404s (the app's real value: `<MacBookPro18,3> <macOS;26.6.2;25G83> <com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)>`; imessage's `loginInfoFor`/`DEFAULT_LOGIN_INFO` already use the angle form); (2) the start/finish provisioning POST body keys are **case-sensitive**: `{header, request}` (lowercase — what the Kotlin sent) gets a **silent HTTP 200 with an EMPTY body**, `{Header, Request}` (as §1.5/C6 records) gets the real `Response.spim`/`ptm`/`tk` — fixed in `ClearAnisetteProvider` (Kotlin now sends the capitalized keys; 217 registration tests green; first live Kotlin provisioning run succeeded: metadata 400 B / mid 60 B). The vendored anisette client's fixed companion headers (rev-14 set) were confirmed against a live 2026-10-03 capture of the app's own lookup. Step-2 gate A/B (all via the identical transport): the app's genuine 10-08 blob replay → 5003/EM; our minted blob → 5003/EM; a FRESH blob minted by the app's own unmodified `ValidationCtx::new` the same hour (replay-state eliminated) → 5003/EM; with the full anisette header set on step 2 (± hardware headers, both blobs) → 5003/EM ×4 — **the anisette-on-step-2 hypothesis is falsified**. Residue: transport fingerprinting vs a server-side gate for everyone; the app's own-flow verdict is blocked on interactive IDS re-registration (user Apple-ID login) — removing `anisette_test/state.plist` re-fires provisioning but never the mint (no `ValidationCtx::new` hit in a fully-trapped run), and the vendored anisette client no longer traverses an intercepting proxy (baked DER root, SSL_CERT_FILE ignored).
|
||||
- Rev 38 (2026-10-12 late, the §1.1 step-2 5003/EM gate CLOSED — it is a TLS-stack fingerprint, not a blob/server-gate problem): after the user's 2FA re-login re-fired the app's own IDS registration and the full mint chain was observed ACCEPTED live (`vctx_new → step2_serialize → key_establishment → vctx_sign` ×3, gdb traps), fresh genuine blobs minted per-test (gdb `ValidationCtx::new`) settled the transport matrix: reqwest 0.11 + rustls 0.21 (+ the vendored client's baked Apple root DER, extracted from the .so at 0x24afa9 — webpki- roots/Mozilla does not include this Apple private root) POSTing the identical XML-plist envelope gets a real `session-info` response; curl (OpenSSL 3) over BOTH HTTP/2 and HTTP/1.1, and curl with UA/Accept headers removed (Accept-Language only), gets 5003/EM on the same fresh blobs. HTTP version and request headers are irrelevant: the VEN endpoint (Akamai-fronted identity.ess.apple.com) fingerprints the TLS ClientHello and rejects OpenSSL-shaped handshakes with the app-layer error 5003/EM. Earlier probes (JDK HttpURLConnection, python urllib, both cert-verif-off) were rejected for the same reason. Kotlin `NacRequestBlob` and the §1.1 framing are fully vindicated — the blob and envelope were never wrong. Consequence: the imessage tool's §1.1 HTTP must present a rustls-shaped handshake — JVM/Android default stacks (conscrypt/BoringSSL, OpenSSL-shaped) are expected to be rejected (unverified on-device); Conscrypt (Android's default stack, BoringSSL) is ALSO rejected — verified with a plain `HttpsURLConnection` probe on the lightos AVD posting a fresh genuine blob (EM-REJECTED) — so the accepted shape so far is rustls specifically, and the platform TLS stacks cannot do §1.1 step 2 at all. Implementation: a rust helper (rustls) behind UniFFI in `:server` (the calls/ pattern); a fingerprint-shaped JVM stack is not viable given the BoringSSL result. Evidence + probe sources: `registration/dev/clearadi/captures/c56j/tls-ab/` (NAC-NOTES session-26 EM-gate closure).
|
||||
- Rev 35 (2026-10-12, C56-a CLOSED — direct in-process replay of ValidationCtx::new, two byte-verified runs): the request blob's full recipe — header ‖ OAEP(pubkey, pearKey16 ‖ hmacKey20) ‖ RNG16 ‖ u32be(32) ‖ field32 ‖ HMAC-SHA1(hmacKey20, field32), with field32 = mapA[encrypt_cbc(mapB[02 ‖ u32be(1) ‖ innerKey16 ‖ zeros], iv, init_key(pearKey16))] and mapA/mapB the 0x2249f9/0x224af9 byte-substitution tables (table[byte], not xor). Kotlin `NacRequestBlob`; imessage unstubbed at C56-a; live-mint gate = C56-c only.
|
||||
- Rev 46 (2026-10-15, the trapped login decoded offline — the anisette is SESSION-WIDE, one mint; the prefpane "fresh anisette" reading corrected; VEN step-2 is anisette-free): full decode of the trapped 2FA login (`captures/c56k/ob-trap-20261014-201103/`, rustls + static-OpenSSL frames, HPACK-decoded): (1) **ONE anisette set serves the ENTIRE session** — one `X-Apple-I-MD` OTP, one `X-Apple-I-MD-M`, one `X-Apple-I-Client-Time` frozen at the session's mint (never refreshed) — byte-identical (hash-compared) across GsService2 init/complete, circle, 2FA validate, postdata ×5, CloudKit/escrowproxy, and BOTH prefpane `loginDelegates` POSTs. The rev-25/26 "OTP spent per request / the sign-in POST needs a fresh set" reading is CORRECTED: those observations predate the loginDelegates endpoint fix (session 29) and are confounded with the stamp replay-guard refusals of that era. The accepted prefpane POST pairs the PET with the OTP window it was issued under — the anisette↔login-session binding. (2) **The VEN step-2 (`TDIdentityService.woa/wa/initializeValidation`, 4 occurrences) carries ZERO anisette** — 8 headers only (h2 pseudo + accept/accept-language/accept-encoding/content-length), no OTP/MID/Client-Time/UA — no step-2 binding exists and our header-less step-2 is at parity; body = single `session-info-request` key, the 338-B blob with the constant `01 08 4b 2c 91 48 1d 9b 7d a0` header (`NacRequestBlob` exact). No separate keyEstablishment request exists on the wire. (3) A fresh stamp per delegate POST (2 POSTs → 2 distinct stamps; 692 B base64 = the 517-B stamp encoded). Code: `GsaLoginChain` now mints ONE cpd per login run (the cpd dict, the POST headers, and the exposed `lastLoginAnisette` — Client-Time included — share it) and `GsaDelegateLoginClient`'s prefpane branch reuses a supplied Client-Time instead of re-minting; imessage `register()` presents the login run's set at the prefpane. The 2FA-validate and postdata paths keep their fresh-per-call mints for now (the live 434 wall of rev 26 contradicts the wire's reuse — untangle only if the prefpane fix leaves residue); registration tests green.
|
||||
- Rev 45 (2026-10-13, the vendored delegate sign-in request RE'd to FULL parity — the prefpane endpoint speaks a V1 protocol, and the 400 ICLOUD_INVALID_DEVICE_TOKEN survives both body shapes): rustpush is now PUBLIC (OpenBubbles/rustpush, 2026-10-12) and the vendored .so embeds it with symbols — `login_apple_delegates` disassembled (closure 0xc3a5d0): fresh `os_config.generate_validation_data()` per sign-in (the public byte-exact VEN chain), the account's ClearADI anisette (persisted provisioning, OTP per call), the recorded header family (UA com.apple.iCloudHelper/282, X-Mme-Client-Info, X-Mme-Nas-Qualify, X-Apple-ADSID), Basic `username:PET`, optional `Cookie: termsAccepted=true` (terms flow only) — **and the body is the V1 kebab-case `AuthRequest {apple-id, client-id (plain v4, no uppercase), delegates, password = PET}` at `/setup/prefpane/login`** (`MacOSConfig::get_login_url` returns prefpane in the vendored build; the .so carries ZERO V2AuthRequest/protocolVersion/userInfo strings — the V1 shape public rustpush has commented out is what ships; the V2 wrapper stays recorded for `/setup/signin/v2/login`). Ladder (all on-device, same account, real C58): prefpane + V1 body WITHOUT Basic → 401 UNAUTHORIZED (the server reads Basic even on the V1 shape); prefpane + V1 + Basic + fresh PET → 400 ICLOUD_INVALID_DEVICE_TOKEN — the SAME verdict as V2 + Basic + fresh PET, so the body shape is not the variable and the request is at parity. User intel: the C58 Mac may have been REMOVED from the account — the 400 reads as Apple failing to tie the validation stamp's machine identity to a device record the account still has (the vendored client's last success 2026-10-10 predates any removal); next attempt is gated on confirming/re-verifying the Mac (or a fresh C58 from a currently-registered Mac). Code: `GsaDelegateLoginClient` V1 branch on PREFPANE_ENDPOINT; logs `captures/c56k/register-ladder-log-2026-10-13b.txt`.
|
||||
- Rev 44 (2026-10-13 later, the albert GENERIC_ERROR root-caused — it was OURS, not Apple's: **the public-repo port shipped a PLACEHOLDER FairPlay identity**): the staged `leaf.crt`/`identity.key` are self-signed (`CN = open-madrid-placeholder`, valid to 2036) — Apple bytes cannot ship in the public repo, and the port never wired the real pair. Apple's verdict for it is `SIGNATURE_VERIFICATION_FAILED` (not GENERIC_ERROR — that earlier page was the string-form `ActivationInfoXML`; with the rev-43 shapes the error is specific). The real identity (the one accepted live 2026-10-03) verifies fine: **host A/B with the genuine pair → device certificate on the FIRST probe, anisette/hardware headers unnecessary** (UA-only accepted too). Changes: `BundledFairPlaySigner` accepts an out-of-repo identity override (DER leaf + PKCS#1 PEM; null = committed placeholder — shape tests stay green); the imessage tool loads `/data/local/tmp/imessage-fairplay/{leaf.crt,identity.key}` (adb push, world-readable, no Apple bytes in any repo) and logs which identity is in use; the real pair lives in the gitignored captures (`captures/fairplay-identity/`). **On-device: activation ACCEPTED, courier connected, delegate sign-in reached the session-15 final wall `409 ICLOUD_UNSUPPORTED_DEVICE`** ("Cannot create another iCloud account with this device") — the recorded Nas-Qualify device-flag hypothesis (session 15) is now the only remaining §1.2 residue. Logs: `captures/c56k/register-final-log-2026-10-13.txt` + the anisette A/B (`AlbertAnisetteAbTest.kt`, also disproving the anisette-gate hypothesis).
|
||||
- Rev 43 (2026-10-13, REGISTER step-2 ACCEPTED on-device; the chain then moved to §1.2 and stalled at a NEW albert refusal): the rev-42 fix held live — the tool's step-2 POST returned a real `session-info` on the lightos AVD (validation → `initializing the validation session`, no 5080). Two follow-on fixes, then a new residue. (1) **Transport trust**: the engine SSLContext trusted ONLY the embedded Apple roots, but albert.apple.com chains to **DigiCert Global Root G2** (complete 3-cert chain on the wire) — `AppleTrust.platformTrustManager()` = system store + the Apple roots merged; the imessage engine context uses it (GSA's rev-40 pool + the public-CA endpoints in one manager). (2) **Albert request-shape regression**: the 7f960db port dropped two accepted shapes that session 15 carried — `ActivationInfoXML` must ride plist **`<data>` (bytes)**, not a string (the string form draws the `<Document>` GENERIC_ERROR page — pinned live 2026-10-02, re-confirmed 2026-10-13 by a host A/B), and the post carries **`User-Agent: ApplePushService/4.0 com.apple.iCloudHelper/282`** (rustpush `get_normal_ua("ApplePushService/4.0")`); both restored + shape tests updated. (3) **OPEN residue — albert GENERIC_ERROR**: with the restored shapes the activation is STILL refused with the generic wrapped-UI page (`ALEndPointErrorPage.genericError`, "Device activation failed. Please contact support.") for BOTH the user's real C58 identifiers and a synthetic identity (host A/B, same transport) — the shape now matches the session-15 accepted construction, so the refusal is server-side: top hypothesis is enforcement of the legacy FairPlay identity's leaf expiry (valid to 2024-10-11; activations stopped being observable after 2026-10-03), alternative is a new serial/record check. Next session: characterize against a CURRENT genuine activation (the vendored client's re-activation under gdb is user-gated) or a fresh FairPlay identity. Registration log excerpts archived (gitignored) in `captures/c56k/`; the A/B probe source archived beside it (`AlbertAbTest.kt`).
|
||||
- Rev 42 (2026-10-13, C56-a fully CLOSED — the 5080 root cause pinned and fixed: **the pear-AES IV is derived from the wire slot, never drawn**): the slot16 at `[266:282]` IS a fresh RNG16 draw (rev 35 was right about that) — but the `encrypt_cbc` IV is **`blobFieldMap⁻¹(slot16)`**, the inverse of the same 0x2249f9 substitution table applied to field32's ciphertext. Pinned from the c56j replay dumps without a gdb round: the IV reaches `encrypt_cbc` (r8 @0x17944f8) through a `SpecFromIterNested::from_iter` Vec built over the `[rsp+0x260]` sample (entry-arg rdx = the closure context), and on the genuine data `ecbc_arg_r8 == tableA⁻¹[blob[266:282]]` byte-exactly (tableA = the mapA at 0x2249f9; `xortable.bin` in the c56j dumps is its first 64 B). The server recomputes the IV from the blob's slot — which explains every observed verdict: the rev-41 draw-isolation 5080s were the Kotlin builder drawing an INDEPENDENT fourth random (iv16) instead of deriving it, so any fresh slot or fresh iv broke the slot↔iv pairing the server checks; the rev-41 "consistency value derived from the four draws" reading is corrected (the slot is an input, not a derived value). Fix: `NacRequestBlob` draws THREE values (hmacKey20, innerKey16, slot16) and computes `iv16 = blobFieldMap⁻¹(slot16)`; new c56j genuine-draw vector test (deterministic regions byte-exact incl. field32 + HMAC). **Live proof: a fully fresh Kotlin blob (fresh pear/hmac/inner/slot, derived IV, our own Java OAEP) through the accepted rustls probe → real `session-info` (HTTP 200, 1263 B)** — probe log `captures/c56k/ab_step2_fresh_iv_2026-10-13.txt`. `:registration:test` green.
|
||||
- Rev 41 (2026-10-13, live REGISTER attempt #1-2 — the §1.1 step-2 rejectionis ISOLATED to the 16-B blob slot [266:282]): the real export-code flow ran end-to-end for the first time (export-code parse → the hardware-configuration serialization **byte-identical to the recorded capture** — the identifier grammar is verified with a real machine); the Apple-ID login chain completed live on-device including the second-factor exchange and the §1.5 re-login; then step-2 answered the error verdict **`EM status=5080`** through the rustls transport (distinct from the TLS-gate 5003 — the application layer is reached). Host A/B through the previously-accepted probe client: the genuine blobs remain accepted today, the freshly built blob is rejected, and a **field-by-field reconstruction of a genuine blob from its recorded randomness is byte-exact in every deterministic region and ACCEPTED with our own RSA-OAEP block** — exonerating the OAEP encoding, the whole derivation chain (map→white-box-AES→map, HMAC), and the transport. Isolation: replacing ANY one of the five random inputs with fresh bytes (including the `[266:282]` slot) while keeping the rest genuine → 5080. Conclusion: **the rev-35 reading of the 20-B `[266:286]` slot head as "a third fresh random 16" is falsified — the slot is a consistency value derived from the other four inputs** (the server checks it; its bytes are visible materializing between the white-box-AES step and the serializer in the session-25 replay dumps — the same rewritten-buffer window left untrapped in rev 34). C58/session250: CLOSED with real data; remaining: the slot derivation (trap the rewrite window 0x17945cc-0x1794651 / the `[rsp+0x260]` source at 0x179494c), then the register completes. Probe sources + inputs archived (gitignored) in `registration/dev/clearadi/captures/c56k/`.
|
||||
- Rev 40 (2026-10-13, the rev-39 "divergence" CLOSED as a debugger artifact; the real -29004 root cause fixed — the OTP `two` input wiring — and the host init A/B GREEN): a direct uninstrumented oracle over the vendored white-box (dlopen + call the local derivation at its known address; no app launch) proves **the derivation is breakpoint-sensitive**: identical inputs yield a DIFFERENT signature under debugger traps (software and hardware alike — the trap hits, not code patching), while the intermediate working state stays byte-identical. The rev-39 trace's recorded sig was therefore the breakpointed value, not the client's real one. Re-derived ground truth: the Kotlin transcription matches the binary exactly on real machine inputs (sig `786f83af…` both sides) and on the recorded synthetic vectors; the rev-39 item (6) "final mixing rounds divergence" dissolves, and the stage-B working-state block write (LE [h3,h2,h1,h0] at 0x110, [h4..h7] at 0x120) is confirmed byte-identical at the post-schedule boundary under hardware breakpoints. (2) **The real -29004 root cause: the header factory fed the 60-B mid as the cipher's `two`**; the rev-39 item (3) mapping (`two` = the 32-B client secret) was correct but the wiring still used the mid — mid-as-`two` yields a well-formed OTP with a wrong sig, i.e. exactly ladder rung two. Fixed in the Kotlin header factory (`two` = client secret; the mid rides only the MD-M header). (3) **Host init A/B GREEN:** fresh live provisioning from the host JVM (2.7 s; 400-B metadata / 60-B mid / 32-B client secret; Mac flavor byte 1 → the `gen_otp` path), then the GSA SRP init POST with a dummy account **passed the environment check** — init returned salt/B and SRP completed; the server answered `ec=-20101` (credential rejection for the dummy account), anisette accepted, no -29004. (4) TLS fact: **gsa.apple.com now serves an all-Apple chain** (leaf ← Apple Server Authentication CA ← Apple Root CA, observed 2026-10-13) — the §1.2 Apple-root pinning applies to the GSA endpoints too, not only IDS/ess; the JVM default store fails the handshake. Oracle + probe + A/B method archived (gitignored) in `registration/dev/clearadi/captures/c58live/` (NOTES-true-sig.md).
|
||||
- Rev 39 (2026-10-13, first live GSA login A/B of the Kotlin chain + gdb OTP capture — the anisette OTP input mapping is CORRECTED and a Kotlin transcription divergence is found): driving the imessage tool's real Apple-ID login on the lightos AVD (user-gated) produced a clean error ladder that the host JVM reproduces exactly (throwaway harness, deleted; method + data in `registration/dev/clearadi/captures/c58live/`): (1) GSA `GsService2` POSTs **404 without the device header set** — `GsaLoginChain.headerConfig`/`twoFactorHeaders` must be wired (the rev-37 lookup-404 signature applies to the SRP POSTs too); (2) the provisioning-time machine identity must MATCH the login-time GSA header slots (provisioned `Macmini9,1` + login `MacBookPro18,3` → `ec=-22421`); (3) **the OTP cipher's `two` input is the 32-byte `client_secret`, NOT the mid** — the rev-27 "32-byte mid" reading is another mid/secret swap (gdb entry-arg capture of the working client's `gen_otp` call: rdi = gen = unix_seconds/30, rsi = the 400-B provisioning **metadata** as `one`, rdx = `client_secret` as `two`; two=mid[0:32] → -22421, two=client_secret → past the OTP check); (4) the Mac machine dispatches **`gen_otp` (0x17fba60)** — the OTP flavor byte 1 — confirming `FLAVOR_MAC = 1` (rev-27's "0 (Mac) → gen_otp_ios" dispatch numbering is inverted on the Mac path too); (5) **error ladder: -22421 = structurally invalid OTP; -29004 "environment mismatch" = well-formed OTP whose sig payload is wrong** (client-info flavor, `X-Apple-I-MD-LU` form, and hardware headers all exonerated by A/B); (6) **the Kotlin `gen_otp` transcription diverges from the binary on real inputs**: live ctx checkpoints (`captures/c58live/otp-live-trace.log`) are byte-identical to Kotlin through gensource → gentable → stage A (the only delta was ctx[0x110..0x120] = LE [h3,h2,h1,h0] — the fitted "written but never read, order [h3,h1,h2,h0]" comment was wrong; the block write is added to `genOtp` stage B, synthetic suite still green) — so the divergence sits in the FINAL MIXING ROUNDS (tail/sieve/6×9-round loop/w-block, binary 0x17fbd70–0x17fc4c3); ground truth for the round-state hunt: the live trace (gen, sig16 per launch, ctx at every stage boundary) + the vendored app's provisioned machine (`obstate.json`) as oracle inputs. The vendored oracle: its own machine state through the Kotlin OTP does NOT reproduce its accepted logins — the transcription (not the machine/provisioning/transport) is the residue. Kotlin: `ClearAnisetteProvider` flavor flip + `ClearAdiOtp` h-block fix; 217+ registration tests green.
|
||||
|
||||
- Rev 34 (2026-10-09, C56-a request-blob field map ~85% pinned — four instrumented runs): the 338-B request blob decomposes as header ‖ RSA-2048-OAEP-SHA1(cert pubkey, pearKey16 ‖ hmacKey20) ‖ 20 B (unpinned, ends u32be(32)) ‖ 32 B pear-AES field (derivation open) ‖ HMAC-SHA1(hmacKey20, the 32-B field) — the HMAC chain verified byte-exact. Live mint still gated on the two unpinned C56-a sub-fields; C58 parser + §1.2/§1.4 activation chain wired in imessage (`register()`), tool UI inputs (export-code paste, courier push token) pending.
|
||||
- Rev 33 (2026-10-09, the sign-model fresh-input divergence CLOSED — the last functional live-mint blocker on the sign path): two session-20-frozen constants inside `SignState::sign` are falsified and replaced by their input-dependent forms — the staging maze's i==10 `ecx2` index (`perm2u[st[0x6fa2]]`, was frozen `perm2u[0x40]`) and the ENTIRE P region state+0x6898 (7×256; five of seven tables are parameterized on five body-derived byte XOR pairs via the SSE builder at 0x17a1381-0x17a20b5 — the "byte-identical across base/mod/body-flip" claim held only because those captures shared a body). The builder stream is serialized into generated Kotlin (`NacPTable`) and re-executed lane-wise, byte-exact vs the emulated builder on fresh + random + s20 states. `NacSign` re-ported with all body-derived regions computed (region6f98/0x6fd8/0x6fe8/0x6fec/N/cursor/RC); fresh live sig16 + full 517-B stamp byte-exact, session-20 still byte-exact; 209 registration tests green, imessage `:server:testDebugUnitTest` green. Live mint still gated on C56-a + C56-c (capture) and C58 (OABS parser).
|
||||
- Rev 32 (2026-10-08, three instrumented live runs — the mint's input mappings C56-b/c/d/e + cert79 are wire-pinned; a live-input divergence in the rev-30/31 sign model is discovered and recorded as a new residue): pearKey16/rngPad130/rand16 = fresh client RNG draws; session250 = the HardwareConfig::to_bin hardware-identity blob; body576 = the established payload's [2:578]; cert79 = the session-info response's trailing[0:79] (NOT the step-1 cert — falsified); step-2 request blob wire-captured (338 B, constant header + OAEP + digests; field map open). Kotlin `keyEstablishmentFull`; 209 tests green. Live mint blocked on: C56-a field map, C56-c grammar, and the sign-model fresh-input divergence.
|
||||
- Rev 31 (2026-10-08, Kotlin port of the sign model — the rev-30 sig16 residue is CLOSED; method in `registration/dev/clearadi/captures/NAC-NOTES.md`, constant inventory in `STAGE7-10-REPORT.md` session 6): `SignState::sign` is transcribed to Kotlin `registration` `NacSign` (full pipeline — scatter → u32 flag table → perms → divisor/key map → keygen → matrix/phases B–E → 16 position rounds, then the 30-run tail: RS32/rec0/r70ab init, run-start feedback, soup57/soup80, the 9-round threshold cipher, staging from the generator soup, final formula, chain1 fold), byte-exact vs the pure-Python model on LCG-seeded synthetic vectors incl. edge inputs, with the .rodata tables embedded as a generated base64 image (`NacSignTablesData`/`NacSignTables`, PearTablesData pattern; the 17-loop byte tables are in-range slices of the T2_BIG span, no duplicate embedding). `NacValidation.mint(cert79, session250, rngPad130, rand16, body576)` now computes the full 517-B stamp including sig16 internally (assembly-with-sig kept as `mintWithSig`); full-mint e2e test byte-exact vs the python-generated vector. 208 tests green (synthetic vectors only, no Apple bytes).
|
||||
- Rev 30 (2026-10-08, clean-room RE of the NAC mint — the validation blob "C5" — gdb oracle + python model + Kotlin; method/findings in `registration/dev/clearadi/captures/NAC-NOTES.md`): the 517-B validation stamp is fully derived — stamp = `0x02 ‖ rand16 ‖ sig16 ‖ u32be(0x1e0) ‖ blob480`, blob480 = `05 ‖ u32be(1) ‖ u32be(0x180) ‖ scramble_body(session250, rng_pad130) ‖ u64be(79) ‖ cert79` — with pear_aes (white-box AES variant: 9-round CBC, custom whitening/final maps, pluggable sbox schedules, establish_key T-transform on blocks 1–9), domergestep2 (21/21 live captures) and key_establishment (EstablishKeyResponse wire `tag ‖ key16@1 ‖ u32be len@0x11 ‖ body@0x15 ‖ 85-B trailing`) byte-exact vs three independent live runs, and the full stamp computed byte-exact end-to-end from (cert79, session250, rng_pad130, body576, rand16). SignState::sign (the 16-B OTS) is structurally decoded with stages 1–9 hand-verified; the tail threshold cipher is checkpointed (270 rounds) and CLOSED the same day via an empirical fit against those checkpoints — the capture-free pure-Python model now computes the full 517-B stamp byte-exact from its inputs, with the constant inventory recorded for the Kotlin port (the Kotlin signature port is the next step). Kotlin `PearTables`/`PearAes`/`NacMerge`/`NacValidation`; 206 tests green.
|
||||
- Rev 29 (2026-10-07, continuation capture — two further live OpenBubbles provisioning runs under gdb, `gdb_prov8/9.gdb`): the cpim's X group is fully pinned — X = SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ a fixed 24-byte .rodata filler constant, digest byte-exact on the wire — closing the X32, 12-B-plaintext-tail (12 fresh RNG bytes, same pool construction as the IV) and trailing[0:16] (the signature input) TODOs; the "constant sig block" reading is corrected (it was a stale session's trailing head) and "sig16/digest not on the wire" is resolved (both ride transitively through X). Kotlin `CpimShaTailSeal` wired as the production default; 195 tests green.
|
||||
- Rev 28 (2026-10-07, live capture — one real OpenBubbles provisioning run under gdb over the unstripped Linux `.so`; dumps + method in `registration/dev/clearadi/captures/WIRE-NOTES.md`): the §1.1 anisette framer is corrected on every point the previous session left `TODO(capture)`. The cpim is an OUTER 276-B message `[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]` over a 160-B plaintext; the PTM is `[u32 BE 4][seed 16][u32 BE payloadLen][payload]` with the seed riding into decryptPTM as its chain seed; ProvisionedData = `{metadata Vec (400 B), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8}` with the finish gate = the decrypt output's out[408:440] == rng1 echo; the spim carries 99 trailing bytes (the ≤23-B cap was a probe artifact) and trailing[16:76] is relayed verbatim as the wire tail's `Y` group. Superseded readings recorded dead: the 'IV from Spim trailing fields' (it is the thread-RNG pool), the 'two 256-bit sig/sha tail fields' (neither the spim_map_signature output nor the SHA-256 digest appears on the wire), and the OTP-path ProvisionedMachine mid/secret ordering. Remaining `TODO(capture)`: the X32 source, the 12-B plaintext tail, trailing[0:16]. Kotlin `ClearAdiFraming`/`ClearAnisetteProvider` restructured to the live shapes; 194 tests green.
|
||||
- Rev 26 (2026-10-02→04, live registration of this protocol family against real Apple endpoints — Albert activation, APNs courier (packed ALPN), the full GSA chain through 2FA, and IDS authenticate + register all green; user-gated with a real Apple ID, `mailto:` handle ACTIVE): six live-verified fact groups beyond the extraction record. (1) **The 409's root cause is the validation-stamp REPLAY guard (§1.1)** — one mint = one device record: a consumed blob re-submitted answers HTTP 409 `ICLOUD_UNSUPPORTED_DEVICE`, a fresh (or captured-elsewhere) blob succeeds; the earlier readings (request shape, blob provenance) are dead. §1.2 authenticate + §1.4 register completed green after the stamp route was fixed, with the `mailto:` handle active (§1.3/§1.4). (2) **The courier's packed format is live (§3.1/§3.2, C19 CLOSED)** — the server selects `apns-pack-v1:4096:4096` and speaks the tag-based packed codec (full wire shape recorded from the wire); its chain anchors at the `Apple Server Authentication CA` intermediate, no TLS client certificate is demanded on that path, and the post-connect set-state + filter sequence is mandatory (unfiltered connections are dropped within seconds, invalidating the connect token). (3) **Apple's live GSA sign-in response is kebab-case** — delegate `service-data`/`status-message` (camelCase recordings become the fallback), and the sign-in body's client id rides `client-id` (§1.5). (4) **Anisette freshness is per-run and per-call** — fresh per SRP run incl. the post-2FA re-run (stale → `ec` −22421), fresh per 2FA call (frozen set → HTTP 434); the trigger/validate GETs carry the plist content type, and failed verifies name the real failure in the body plist's `Status.ec`/`em` (§1.5). (5) **The GSA `postdata` liveness event is recorded in full (§1.5)** — services `icloud`/`imessage`/`facetime`, `X-Apple-I-UrlSwitch-Info`/`X-Apple-HB-Token` binding, `ptkn` (−800012 with a dead push token — the courier connection must stay open across the registration), and the order sign-in FIRST, postdata AFTER (correcting the earlier reversed note). (6) **Albert activation refinements (§1.2)** — `DeviceCertRequest` is `<data>`(base64(PEM)), DER rejected; the accepted `FairPlayCertChain` is leaf + FairPlay CA + Apple Root CA concatenated; the response's `<Protocol>` block may arrive wrapped in a larger `<Document>` (parse the span); refusals state their reason in wrapped-UI page text. Code side: the packed courier codec + ALPN negotiation, `GsaPostdata`, the kebab-case parsing, the per-run/per-call anisette providers, the `<Protocol>`-span extraction, and the three-cert FairPlay chain all landed; the validation stamp stays caller-injected with the replay guard documented.
|
||||
- Rev 25 (2026-09-29, first LIVE GSA login of this protocol family — SRP init/complete, both 2FA steps, and the PET-bearing re-login against `gsa.apple.com` with a real Apple ID, user-gated; the delegate sign-in POST was attempted and refused): the §1.5 chain is live-confirmed end-to-end up to the delegate sign-in, with five behavioral facts beyond the extraction record. (1) `au` (`trustedDeviceSecondaryAuth` observed) rides INSIDE the complete response's `Status` dict — reading it at the `Response` level silently turns a 2FA login into a "logged in, no PET" dead end (§1.5 2FA). (2) The PET's second live-confirmed delivery channel: the post-2FA re-login's decrypted `spd` token map `t` (21 service tokens observed incl. `com.apple.gs.idms.pet`), while a no-2FA login's `spd` carries only `com.apple.gs.appleid.auth` and no PET — an earlier implementation inference that a no-2FA login suffices for the PET is dead; 2FA is mandatory for the PET on this account class (§1.5 PET). (3) The trusted-device trigger creates the code on the devices and must precede the prompt; triggering twice before one validate invalidates the outstanding code and re-prompts (§1.5 2FA). (4) Anisette is one-time per request: headers minted at flow start are spent by the GSA hops, and the delegate sign-in with a reused set is refused — mint fresh per POST, matching the reference client's per-request provider (§1.5 delegate logins). Post-login ADI behavior confirmed live: after an account login the machine re-provisions for the personal DSID (GetLoginCode → not-provisioned → full exchange succeeds), which is the personal-`X-Apple-I-MD-M` path, not a defect. (5) The delegate sign-in answered HTTP 409 `ICLOUD_UNSUPPORTED_DEVICE` with the recorded error-plist shape (§1.5 delegate logins) — a device-identity-bound refusal, the current open blocker. Velocity note: a persisted-password-hash re-login was rejected once with -22406 ("enter the correct password") after four full logins in 30 minutes, with the identical hash derivation succeeding twice earlier in the same window — treat as server-side velocity throttling, not a derivation defect.
|
||||
- Rev 24 (2026-09-28, first live probes of this implementation — two unauthenticated bag GETs + a no-client-cert courier TLS handshake, runner `:engine:runProbe`; nothing else touched): (1) C18 SETTLED — the courier demands the Albert client certificate via a fatal TLS 1.3 `certificate_required` alert after a successful handshake, on both ports (§3.1); (2) the bags are TYPED CONFIG DICTIONARIES, not URL maps — the live IDS bag carries ints/bools/floats/string-lists alongside 400+ URLs and a `bag-expiry-timestamp` (~17-day observed TTL; 403 entries live vs 405 on 2026-09-10, two new push-debounce keys in, four A/B-test keys out); the APNs bag is 65 stable entries incl. BAA-authentication flags (`APNSEnableBAAAuthenticationForDevices` etc. — C17/C20-relevant); (3) AppleTrust's embedded-root pinning validates the live chains of `init.ess.apple.com` and `courier.push.apple.com` (no root served — chain anchored at the embedded root); (4) rev-23's container-fact follow-ups: sort-version 0 and the ref-width rule are now capture/probe-consistent.
|
||||
- Rev 23 (2026-09-27, writer-pass follow-up, [CF] public material + host-decoder empirical cross-check only — no restricted source): three §4 container-format gaps found by the first writer session are closed — integer signedness (1/2/4-byte forms unsigned; the 8-byte form is two's-complement signed; non-negative values take the minimal width, negative values always the 8-byte form), the object-reference width rule (minimal width whose 2^(8w) ≥ object count, so every reference 0..count−1 is representable), and the sort-version byte (written as 0).
|
||||
- Rev 22 (2026-09-12, offline byte-audit of the saved live step-2 request — no network call, nothing committed): the CLIENT-SIDE ENCODING class is closed. The exact 3898 bytes the live runner sent (§7 item 24) parse as an XML plist with the single `session-info-request` `<data>` key, and its 2729-byte payload walks the invented TLV v1 to the last byte with ZERO slack, 17 distinct tags, no duplicates and no trailing bytes — our bytes are exactly what the code intends, so no writer bug hides there (the framing *intent* stays invented and unverified). By-product fact: the step-1 `cert` data field is NOT a bare DER cert — it is Apple's own container, `01 02` followed by two `(4-byte BE length, DER cert)` pairs (1046 and 1329 bytes; 2+4+1046+4+1329 = 2385 exactly), carried through verbatim by this implementation (§1.1) and cross-checking against the archived genuine step-1 response size. Apple's length convention there is 4-BYTE big-endian where our TLV v1 uses 2 — the only Apple framing ground truth in hand, and it weakens TLV v1 further.
|
||||
- Rev 21 (2026-09-11, step-2 refusal re-read + offline cross-checks — no new live calls): one correction and five facts. CORRECTION — the rev-20 "BODY-INDEPENDENT" conclusion is withdrawn as unproven: a 20-byte non-plist body and our 3898-byte reconstruction returning the same generic `status` 5003 establishes only that 5003 is this endpoint's generic request-level failure; it cannot separate "the body is never parsed" from "both bodies are parsed and both rejected identically", because the reconstruction is itself unverified — the `session-info-request` blob is minted by the closed-source library (§1.1 [OA]). Blob FRAMING is therefore live again beside the envelope. FACTS — (1) 5003 is named in the IDS status space: `IDSStatusBadRequest` [IG:imessage/direct/ids/types/idserrors.gen.go], a table auto-generated from web FaceTime's JavaScript, so it is the IDS family's space and not proven for this WebObjects service; that same table shows what a body-level rejection in this family looks like (`ParseFailure` 1000, `InvalidField` 1001, `DecodeFailureBase64`/`Hex` 1004/1005, `MissingRequiredKey` 5008) and the endpoint returned none of them. (2) the rustpush reference IS the OpenBubbles fork (github.com/OpenBubbles/rustpush), re-fetched 2026-09-11 and unchanged from the snapshot on file, and its step-2 call is still `REQWEST.post(bag_url).body(xml_plist)` with no content-type and one default header `Accept-Language: en-US,en;q=0.9` — a current model client's envelope is body-only, which makes the header half the weakest suspect and the blob the strongest. (3) The route is confirmed against a GENUINE device: the archived 2026-09-10 capture's `id-initialize-validation` bag value is byte-identical to the URL the live run posted to. (4) That genuine bag enables attestation on this endpoint family — `absinthe-baa-option-enabled`/`-ios13` and `absinthe-v3-enabled` all true, `absinthe-baa-cert-ttl-minutes` 144000 — the one mechanism that could still put the envelope back in front. (5) Both SED traces are now recorded as a grammar: `SED:3:VEN-PROD:<uuid>:<uuid>:<unix-epoch>:<uuid>:<64-hex>:EM`; between the two runs every uuid and the epoch differ (the epochs 28 s apart, matching the calls' spacing) and the 64-hex differs, so it is per-request, not a digest of any recorded input (checked against the body, the empty string and the step-2 URL). Next lever unchanged: the genuine-client capture — valued now for the BLOB it reveals, not the header set.
|
||||
- Rev 20 (2026-09-11, live step-2 control experiment): the step-2 refusal is BODY-INDEPENDENT — a 20-byte body that is not a plist at all and the full reconstructed 3898-byte request are answered identically (HTTP 200, the same 498-byte `status` 5003 plus a nine-token SED trace; only its per-request epoch and uuids differ between the two runs). The invented TLV framing, the wrapper encoding, and the step-2 content-type are therefore ALL retired as causes at this endpoint: nothing in the body is examined before the refusal. The rev-18 "byte-identical" reading is corrected — the trace carries a live per-request epoch (two runs 28 s apart) and three per-request uuids, so the responses were shape-identical, and the rev-19 inference chain from that reading is superseded by this direct experiment. Surviving suspect class: the request ENVELOPE (the header set a genuine client carries, or an identity the endpoint resolves before parsing); the live step-2 URL is now recorded (§1.1, §7 item 24).
|
||||
- Rev 19 (2026-09-11, step-2 self-check round): the step-2 request reconstruction was audited against the extracted client's own request construction (§7 item 24) — the XML-plist wrapper is confirmed, while our `text/x-xml-plist` default turned out to be an unsupported inference: the recorded client sets NO content-type on the step-2 POST (§1.1), so all three content-types of the rev-18 probe matrix were non-faithful (the constant is now absent in the code); the rev-18 "invariant under every flipped variable" claim is corrected to a *body-content* independence (the five attempts did NOT send identical bodies — fresh key material each time), which retires the step-2 crypto variables and leaves the invented TLV framing as the prime suspect; and an archived genuine-device capture shows the validation endpoint family reached with the full anisette/BAA header set while step 1 succeeds header-less because that URL is a CDN static object (§1.1) — next levers: one faithful (no content-type) step-2 POST beside a deliberate control POST, then a genuine-client capture.
|
||||
- Rev 18 (2026-09-11, first live step-1 attempt + probe matrix): the §1.1 validation exchange ran against the live Apple endpoints for the first time — bag + step-1 cert GET OK (pinned TLS, HTTP 200); FIVE step-2 POST variants (default C60 set, X25519, HMAC, three content-types) all answered HTTP 200 with the same 498-byte error plist (`status` 5003, SED trace message) and no `session-info` (§1.1, §7 item 24) — the refusal is invariant under every flipped variable, so the C60 crypto hypotheses remain open and the next lever is a genuine-client wire capture; the SED status table is a new TODO(capture).
|
||||
- Rev 17 (2026-09-11, FairPlay material metadata extraction): the provenance half of the FairPlay identity question is closed (§1.2, §7 item 21) — all known embedded copies across the reference repos are ONE 1024-bit RSA identity (leaf cert expired 2024, yet accepted), descending from a 2023 iCloud-bypass community release; rustpush's "ten pairs" are ten copies; no own-Mac extraction path is documented anywhere (open research item); BYO-identity is recorded as the sane architecture.
|
||||
- Rev 16 (2026-09-11, activation implementation round): the three remaining GSA header literals are pinned (§1.5 — browser UA, non-akd `X-MMe-Client-Info`, the full-wrapper `X-Apple-I-CFU-State` plist, the bracketed sign-in client-info); the validation-data exchange is implemented — steps 1–2 concrete, step 3 as an explicit contract reconstruction with six named hypotheses (key agreement, KDF, signature, session-info interpretation, blob framing, step-2 content-type) collected as new **C60** (§7 item 24) and arbitrable only by one live activation; the Mac-Hardware-Info export parser and the C59 constants are wired into code.
|
||||
- Rev 15 (2026-09-11, second activation extraction round — C59 + C58 closed): the GSA-login residue is recorded (§1.5 closure paragraph: RFC 5054 2048-bit SRP group with g=2, the full 2FA browser header set with literal values, per-flavor cpd values, akd UA/`X-MMe-Client-Info` forms, `spd` `t`-map inner keys + top-level `GsIdmsToken` + `fn`/`ln`, the `gsas.apple.com` postdata/teardown host split, the sign-in UA/timezone forms); C58 is closed — the Mac-Hardware-Info export-code format is fully recorded in §1.1 (`OABS` magic + flag byte + protobuf field map; plain-base64 or OpenSSL-`Salted__` AES-256-CBC delivery; sharing mechanics server-side; upstream repo unlicensed — facts only). Citation legend gains [MHG].
|
||||
- Rev 14 (2026-09-11, activation extraction round — facts-only over the anisette/GSA crates and the Mac client, facts-only extraction): C6's value-format and provisioning halves are closed (§1.1 — per-request anisette value forms, the ADI provisioning flow with `spim`/`cpim`/`ptm`/`tk`, the 60-byte Apple-issued `mid`, the 16-byte local `keychain_identifier`, machine-state persistence); new §1.5 records the full GSA login chain end-to-end (SRP-6a 2048-bit with the GSA empty-username identity-hash quirk, `s2k`/`s2k_fo`, `spd` decryption, both 2FA variants + the Circle PAKE, PET semantics, delegate logins yielding the IDS `authToken`/`profileId`, the `X-Mme-Nas-Qualify` sign-in POST); the five obfuscation labels are mapped to their fields (§1.1); C56 step-3 and the OTP derivation are CONFIRMED closed at source (upstream ships stubs — contracts only).
|
||||
- Rev 13 (2026-09-11, implementer round — rev 12's closed fact sets wired into code; no new protocol facts): courier command builders + connect/auth + the engine's courier frame handler are concrete (§3.3, C45); lookups ride the APNs tunnel with the C47/C48 wire names, plain-string `uris`, and C55 top-level-status routing (§2.1); register/authenticate bodies and the Albert activation wire half are concrete (C53/C54/C8, §1.2/§1.4); envelope fixes landed (HKDF salt `"LastPawn-MessageKeys"` with empty info, pair HMAC keying, recorded pair-ec field numbers, dtl array-of-dicts regression-tested, §4). Nothing closed was re-opened. C57 (§7 item 21) gains six writer-pass gaps: filter multi-topic encoding, connect field-0x10 position, connect-response 5/6 shapes, tunnel 96/97 field ids + payload framing/encoding + status table, phone-flow authentication-data value encodings, Albert request/response value types.
|
||||
- Rev 12 (2026-09-10, facts-only extraction round over both source repos plus the OpenAbsinthe-Stub mock): closed C45 (courier field-id tables + length-prefix scope, §3.2–3.3), C47/C48 (lookup headers/keys, §2.1), C49/C51/C52 and C50's protobuf half (§4), C53 (register wire names, §1.4), C54 (authenticate exchange, §1.2), C8's wire half (Albert activation, §1.2), and C56's steps 1–2 with the GSA login + full anisette set (§1.1). Corrected: the HKDF "info string" is the salt (§4.3); `dtl` is an array of dicts; payload command 160 is not a frame command (§3.3, §5.1). Narrowed: C55 — both clients read lookup error statuses from the top-level `status` only (§2.1, §7 item 19). New first-class architecture fact: IDS lookups ride the APNs tunnel, never plain HTTPS (§2.1). New **C57** (§7 item 21) collects extraction disagreements and closed-source residue. Legend gains [OA].
|
||||
- Rev 11 (2026-09-10, OpenBubbles-flow research — C5/C6 model corrected): the Mac's role in activation is **identifier export only** — the shipped OpenBubbles helper (`Mac-Hardware-Info` 1.2.0, notarized) reads serial/board-ID/model via public IORegistry/sysctl APIs on any macOS ≥ High Sierra *including 26.x*, and never calls an Apple private framework (§1.1, §6.1). The prior "minting requires a Mac on macOS ≤ 14.3" conclusion bound only framework-calling helpers (beeper's archived `mac-registration-provider`; rustpush running *on* a Mac); the remaining C5/C6 work is client-side software — the generation procedure, recorded as new **C56** (§7 item 20). Legend gains [OB-MHI].
|
||||
- Rev 10 (2026-09-10, Phase 2 lookup-orchestration component): added C55 (§2.1) — the lookup response's error-status locations: whether 5206 ("response too large"), 6005, and the send-side 5040/5041 session-token codes appear as a top-level response status, per-handle result statuses, or both; the lookup flow reads per-handle result statuses only, the one numeric-status location the fact set records.
|
||||
- Rev 9 (2026-09-10, Phase 2 component 5): registered the registration wire-name gaps the component's writer pass hit — C53 (§1.4: register request/response key names beyond the recorded set, and the `x-auth-user-id-<N>` value form) and C54 (§1.2: the authenticate request/response pair — body wire keys, CSR encoding, CN encoding, self-signature algorithm, response keys) — plus §7 item 18. The activation code takes every unrecorded name as caller-supplied configuration and keeps every unrecorded response field in a raw map.
|
||||
- Rev 8 (2026-09-10, Phase 2 component 4): [CAP-IDSTLS] (§1.2) fixed the IDS TLS anchor set — `Apple Root CA - G3` and `Apple Root CA`, roots not served, system store rejects the unanchored chain — and [CAP-BAGCT] (§1.2, §7 item 16) closed C47's encoding half: both bag responses and the embedded bag payload are XML plists on the wire, content-type labels notwithstanding. The component-4 writer pass added TODO(capture) C49–C52 and §7 item 17 (unrecorded plist/body value types, the §4.2 HMAC key + ECDSA wire encoding, §4.3 protobuf field numbers beyond validator=99 and the HKDF salt).
|
||||
- Rev 7 (2026-09-10, Phase 2 component 3): recorded the IDS lookup/bag wire details the phase-1 fact set never captured — endpoint plist encodings and the unrecorded lookup header/body/response key names (C47/C48, §1.2, §2.1, §7 item 16). The writer phase guesses none of them: they are configuration in the code until a capture closes them.
|
||||
- Rev 6 (2026-09-10, Phase 2 component 2): added [CAP-COURIERTLS] chain ground truth to §3.1 and the C45 framing-gaps TODO (§3.2, §7 item 15).
|
||||
- Rev 5 (2026-09-10, Phase-2 start): added the publicly documented binary-plist container format to §4.1 ([CF]) so the clean-room codec can be written from the spec alone.
|
||||
- Revision 2 merged three phase-1 extractions plus first capture ground truth (own mitmproxy captures of a registered iPhone 14,4 running iOS 26.1, 2026-09-10 — still the only post-PQ3 evidence here).
|
||||
- Revision 3 (2026-09-10) is a second targeted extraction round over both source repos on the three highest-value gaps (lifetimes C41/C10, envelope selection C25, bag/registration C1/C3); it corrects two rev-2 misreadings (the HTTP signature construction is agreed, not disagreed, §1.4; the pair-ec validator final byte is agreed 0x0c, not "0xc0-ish", §4.3) and re-reads the ~45-day certificate figure as a client-side cap (§6.1).
|
||||
- Revision 4 (2026-09-10) is the phase-1 review-gate pass: removed one leaked source identifier (`registered_at`, rewritten as prose), paraphrased the only reproduced source fragment, added citation-form legend entries and the protocol-constant provenance notes; no copied expression, code, or structural transcription found.
|
||||
- **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.
|
||||
@@ -119,4 +119,27 @@ class NacValidationTest {
|
||||
val sig16 = NacSign.sign(body576, rand16, blob480)
|
||||
assertContentEquals(hex("243a5125f615b468975318a233a9a1fa"), sig16)
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE full-mint replay (2026-10-16): the accepted prefpane stamp
|
||||
* (stamp-from-req2-001) reproduced end to end from its own recovered inputs —
|
||||
* body576 from the sign-entry ctx dump, session250 == the genuine descrambler's
|
||||
* inversion of the stamp's scrambled region, rngPad130 recovered by inverting the
|
||||
* scramble chain with the genuine .so's domergestep2 (dm(x, in2) = state(in2) XOR x,
|
||||
* so the chain is XOR-invertible), rand16/cert79 read back from the stamp. Proves the
|
||||
* Kotlin mint as a COMPOSITION on live inputs (the synthetic e2e vector above was
|
||||
* generated by the Python model and cannot catch a shared transcription error).
|
||||
*/
|
||||
@Test
|
||||
fun mintReproducesLiveVendoredStamp() {
|
||||
val cert79 = hex("019a2c776657740f6777fa683f187bb66514701976000000360101a53e26262f1c4d611eb6a2b5cad938c56acf3d76c20216e7eb1e92d816c0a336b59ab58f6e5f8d3232977e95b64a913a117a0105")
|
||||
val session250 = hex("0f4d6163426f6f6b50726f31382c33006cb133a271c80b5852374b544334435236002542334630453731312d374541412d353630302d383737412d314144413233354434433937002537434644433833412d304638332d343941302d384243462d454133414339334432323930000d4d61632d3038303030303030000632354738330000030b52119410f32494c634512632031f8eebe5ea28110e728ba7737c207f2e3649670a94476a89113f510fc0b9905d7d711a9bfc92cbffb1bf062f1cf630e1df11854f8a274dc694efcdaebdacdb9f5d3bfa114330323234343630305458303433444155112b016cd059fa3bcf7ae1c8eb84658590a8")
|
||||
val rngPad130 = hex("29d1aa55c85169c9ca1b1a0138d6dd039d6dab588b1fdad82ccad8481e3cf710c772d2e97fa36c05d9607cc94ac36eeaefe6b01acdec709ae0da535882c51b7b2b1187435f8a79d931c33d7e1e2af97bd05f3a63f7d3a8bd346e807a033f6b7c79d2a0900b1dbe604594cbb1485ef9b75d3391a8e1c99a18ba0dca9281e70bbaf144")
|
||||
val rand16 = hex("8643e3aad31555888fd5062958aa5b45")
|
||||
val body576 = hex("d79c641294d322c36c13783828c35b6db6532501db84ea0091a98d3391a99b3dd199ea2c78fc6dbfa3ec110af0dcb8b007a9d90a0a5ab3b6ba772217591db591635d1e9be2238175269d17b8e3c5b1296553a03869eda592feaa932db42d7a918821a1e4b8325b6517cd7c045a15f81c0609f58c2b37ef6a2bc2b5264f4ec536b98a6ac8375bd1f07c33f5bd77521fc71dad9deced5603384c0d32c0abd5b1d79a57b8f4a7c259bcc508fa0ce43da4e3a0060be4e357e8975b9fb909b3ad6f2bb2ad1a7a82311b8d145c1412eb892f26734d7d0345a35af8ac1de407cc712b7436b779473ba535168a2e36b25505f69cdb3d8dcf60292374c8ab139afa750792c7efc3b10bf6423c03d07fd645f753d0c8795ab855dcc186a5134a546a34940d899623666ea2332e9f99945df5a268f0d49b44eded843b6255b4cf9f8118fdd91f848ba6aab9c781ea7cf660f78d1e18b1995e87b2bd0ca00b163afed51e7c4cc41ba4d419758753eb6a61cdaee446a15e698313d056fa2c84ac5e923005131871c1d904c16fe22d145184c98797bb8c13d43d3331c2e6aafe834846007ff8e4d4e0839dc5dee70d028b3631fcd09f066a395e8b4277dfaca78f40bf68cc99acaf446e7c8e1e6d7c8cbea08397c60eae0b398e76c1b5b512d7ea8436211644ad67de7bc6e7a2c87a2782e6a12ef70031349984453a2f37ae87ddc43780c81720a994a52584197f8b514f808a42cfdb4240788d4a725f62f7626b487732fbcac713dd839b8dd6e23fdf6d3add706ec5547b96419531d5f5a2196a88782b56b844")
|
||||
val expected = hex("028643e3aad31555888fd5062958aa5b45243a5125f615b468975318a233a9a1fa000001e005000000010000018031c00e0be63b52cdf02017a1b256ab5a6d52330bab33a435b19150bde1d05b4678278073ed7c15903870bdfb22319d00d0a18f511e3348ea58103173c9632d50fcd2fedd57d3fecfd6734454d7d106f7ab43140f9847c18f699271e2dd80bca4342543843299a67471275454de605a39e5472b7b34f76f20f92eb64157126b6b436518e48034aee18c2fd85d23e9466d0e1185d64c7f2edc5dc79bac8c30a321f997d12a441011f1b7c484af74c738f928365f3860c59e8af199a5c61f837f1fc88a091d44232f2b132b3d3276c0b456ec764f63458c2be46dee12b000cef395602dbc30528f2817a291c52dd08d38865e678ecc3e401f187af1b013d097d0756581e4068a37e55cd40bbb15cecd7918dceeda809591b189b720c87e0c2db0354c12e5f9b1b43756880e4b8fa336b0961a7049073491794b0a391d2269f18da456b395d996bcf250eb870e6243b4fc3f51c1d2967f1c132c979faa84869b9238b93208eb7a1ffb079e3876787f035718487395f4705c051e77a62b3b46d3a493000000000000004f019a2c776657740f6777fa683f187bb66514701976000000360101a53e26262f1c4d611eb6a2b5cad938c56acf3d76c20216e7eb1e92d816c0a336b59ab58f6e5f8d3232977e95b64a913a117a0105")
|
||||
val stamp = NacValidation.mint(cert79, session250, rngPad130, rand16, body576)
|
||||
assertEquals(517, stamp.size)
|
||||
assertContentEquals(expected, stamp)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user