mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
spec: rev 49-50 — the empty-services register is the client's DEREGISTER (reference register = status 0); the reference survey closes with OpenBubbles as the only oracle
This commit is contained in:
1 parent
03f0ce12df
commit
d0dd6d057e
1 file changed
+6
-3
@@ -1,6 +1,6 @@
|
||||
# iMessage protocol spec (clean-room)
|
||||
|
||||
**Status:** draft — revision 47 (2026-10-16). Revision history at the bottom. This revision updates the §1.1 live-mint status: the mint is exonerated on live data and the prefpane blocker moves to the machine's IDS registration freshness (details in §1.1).
|
||||
**Status:** draft — revision 50 (2026-10-16). Revision history at the bottom. Rev 49 corrected rev 48's "register #2": the empty-services register is the client's DEREGISTER (the failure-path `reset_state`), not a register step — and the reference's own register is confirmed status 0 (§1.1). Rev 50 closes the reference survey: no implementation available on this machine can confirm a registration, so the reference's captured bytes are the only oracle (see the rev 50 entry in the revision history).
|
||||
|
||||
**How to read this document.** Each section opens with a one-line plain-English summary, then the facts. Facts carry citations (legend below). Where two reference implementations disagree on a wire detail, the disagreement is stated and tagged `TODO(capture):` — running mitmproxy captures arbitrate. All open capture items are collected in §7.
|
||||
|
||||
@@ -111,7 +111,7 @@ A binary blob proving the client is a genuine Apple device; every registration s
|
||||
- `cpim`: an OUTER 276-B message `[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]`; the 160-B plaintext = `u32be(32) ‖ encsec(rng2) ‖ u32be(32) ‖ rng1 ‖ 60 B blob (caller-extra ‖ rng3 pad) ‖ u64be(−2) ‖ u32be(unix-seconds) ‖ u32be(1) ‖ 12 fresh RNG bytes`. The IV is 16 plain RNG bytes; X = `SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ fixed 24-byte .rodata filler)` — written to the wire byte-exact (rev 29 closes the X32/12-B-tail/trailing-head TODOs; the earlier "constant sig block" and "sig16 not on the wire" readings are corrected).
|
||||
- `ptm`: `[u32be(4)][seed 16][u32be(payloadLen)][payload][tail]`; key16 = `encrypt(session, NULL iv, tk)`; block count = payloadLen/16; the finish gate is the decrypt output's `out[408:440] == rng1` echo. ProvisionedData = `{metadata: Vec (400 B live = decrypt out[4:404], length u32be at out[0]), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8}`.
|
||||
|
||||
**Live-mint status (as of rev 48):** the full chain runs live on-device — export-code parse, hardware serialization byte-identical to capture, Apple-ID login with 2FA, fresh-blob mint accepted at step 2 through the rustls transport, courier connected, Albert activation accepted. Current blockers (rev 48): the §1.4 register answers **6004 = `ActionRetryWithNewAbsintheContext`** (both the machine-only register, `services: []` + push-only signature per the trapped wire's register #2, and the full user register; the response's SED message is a server-side VEN session record, not a stamp echo), and the §1.2 authenticate with the stored delegate credentials answers **6008** (stale delegate token — the delegate token dies within hours). The §1.4 register body is now capture parity (rev 48): full NGM identity `client-data` + gzip request body; the register body was a stub before.
|
||||
**Live-mint status (as of rev 49):** the full chain runs live on-device — export-code parse, hardware serialization byte-identical to capture, Apple-ID login with 2FA, fresh-blob mint accepted at step 2 through the rustls transport, courier connected, Albert activation accepted. **Confirmed on the reference (rev 49): its own §1.4 register returns status 0** — forced re-registration rotates every service's `id_keypair` certificate to `notAfter = register_time + 45 d` in id.plist, and rustpush aborts before recording any registration on a non-zero status, so 6004 is not a step in a healthy register. The blockers are therefore ours alone: the §1.4 register answers **6004 = `ActionRetryWithNewAbsintheContext`** (the response's SED message is a server-side VEN session record, not a stamp echo), and the §1.2 authenticate with the stored delegate credentials answers **6008** (stale delegate token — dies within hours). Body parity (rev 48) stands — full NGM identity `client-data` + gzip request body; the remaining request-shape divergence is that the reference sends all four services with both handles while ours sends madrid with one. Rev 49 removed the register's bogus first step (rev 48's "register #2" is the client's deregister).
|
||||
|
||||
### 1.2 Activation endpoints and certificates
|
||||
|
||||
@@ -562,7 +562,10 @@ Each item names its arbitration path. Closed residue is noted inline.
|
||||
|
||||
## Revision history
|
||||
|
||||
- **Rev 48 (2026-10-16 evening):** the §1.4 register body was a STUB — now capture parity. The trapped `wa/register` frames decoded (frame-025/026 = the full user register, frame-051/052 = a second MACHINE register): the genuine body carries the full madrid NGM identity in `client-data` (identity-key = DER SEQ{[1] u16be‖uncompressed P-256 signing point, [2] u16be‖RSA-1280 PKCS#1 DER} = 249 B; prekey-data = protobuf `PreKeyData{x32, ECDSA-SHA256 raw sig over "NGMPrekeySignature"‖x‖f64-le ts, ts}` = 109 B; `kt-loggable-data` = protobuf `{deviceIdentity{x32}, ngmVersion 13, ktVersion 5}` = 40 B) plus the ~47 captured capability booleans. Ported as `MadridIdentity` (four persisted keys: EC signing, RSA-1280 enc, EC device key, EC prekey — the genuine's enc key IS RSA-1280, `Rsa::generate(1280)` in rustpush). The register request body is GZIPPED (`content-encoding: gzip` — C3 resolved by capture; the "un-gzipped lower-assumption" choice was wrong). The genuine wire's register #2 = machine-only register: `services: []`, NO `x-auth-*` headers (push-only signature), fresh stamp — implemented as the first `idsRegister` step. **Live: both our machine register AND the full register answer `6004`** — decoded via [IG:idserrors]: **6004 = `ActionRetryWithNewAbsintheContext`**, with `message` = an SED server-session token `SED:3:VEN-PROD:<uuid>:<uuid>:<mint-epoch>:<uuid>:<hash>:EM` (all fields server-side: the hash ≠ sha256(stamp), UUIDs rotate per attempt). Separately: the §1.2 authenticate with the stored delegate credentials now answers **6008 (no cert)** — the delegate token went stale hours after session 33's pass. Open questions: whether the reference's register #1 also 6004s (responses not captured — the trap hooks writes only), and what "new absinthe context" concretely means when the register carries no anisette (the stamp's VEN session is the only context it rides).
|
||||
- **Rev 50 (2026-10-16, session 36):** reference survey closed — a negative result, recorded so it is not re-run. **No implementation available on this machine can confirm a registration**, so the reference's captured bytes are the only oracle. `open-absinthe` is the official **stub** (every body `todo!()`; "closed source ... no actual functionality"), so it cross-checks nothing — the [OA] legend entry is load-bearing rather than merely descriptive. `imessagego` (snapshot 2024-01-16) **requires an external Mac / jailbroken-iPhone registration provider plus a relay** and never registers standalone, so it evidences request *shape* only. Its back half matches ours: identical `authenticateDS` body shape (`{authentication-data.auth-token, csr, realm-user-id}`), the same eight register keys, **one service `com.apple.madrid`**, and its own degenerate deregister (`DeRegisterIDS` — register with nil handles/cert pairs) confirming independently that the empty-services body is a deregister, not a register step. Its front half is a different Apple-ID world: **no anisette** (zero `X-Apple-I-MD` hits), no `GsService2`, no `spd`/PET, no delegate — login is a single call to `profile.ess.apple.com/…/authenticateUser` (plist `{username, password}`, 2FA code appended to the password), and validation data comes from an external NAC server. Consequence for our 6004: anisette cannot be its cause. Also recorded: the shipped reference binary is **ClearADI-built** (1026 `clearadi` / 0 `RemoteAnisetteProviderV3` / 78 `open_absinthe` symbol hits), so a checkout whose `Cargo.toml` enables `remote-anisette-v3` is not the shipped build — trust the binary.
|
||||
- **Rev 49 (2026-10-16 late evening):** rev 48's "register #2" is NOT a register — an empty-services body with no `x-auth-*` headers is the client's DEREGISTER. rustpush emits an empty-services register in exactly one place: `register(config, aps, &[], &mut [], identity)` inside `reset_state(state, reset_hw, logout)` (`/tmp/openbubbles/rust/src/api/api.rs:2424`, "try deregistering from iMessage, but if it fails we don't really care"), reached from `rustpush_service.dart:348 markFailedToLogin()` → `:4621 Future reset()` → `api.resetState`; both healthy register paths (`api.rs:580 register_ids`, `identity_manager.rs:344`) always send the four services. A captured FULL→EMPTY pair therefore means *register succeeded, a later step failed, the app reset*. The step is removed from `idsRegister`. Also settled by measurement rather than by response capture: **the reference's own register returns status 0** — forcing a re-register (host-side id.plist edit: `registered_at_s = now − 7200` **plus** `heartbeat_interval_s = 600`, a key the app deletes on write) rotates every service `id_keypair` certificate to `notAfter = register_time + 45 d` and renews the `env_hash`/pseudonym set, which `register()` can only do after a zero status (it returns `Err` before recording on any non-zero status). A register carries no anisette headers — `SignedRequest::sign` (`rustpush/src/auth.rs:395+`) appends only `x-{auth|push|id}-nonce|sig|cert[-i]` — so the absinthe context of a register is exactly its `validation-data`. Capture limits worth recording: the register rides HTTP/2 (PRI preface + HPACK), so its *headers* are not recoverable from a plaintext write trap, and its `plist::from_bytes::<Value>` response parse is LTO-inlined into `register`, so no breakpoint fires for the *response* body (needs a rustls read-path hook, e.g. `ConnectionCore::process_new_packets`). Reference body shape re-verified from frame-016: four services (`com.apple.madrid` 17 flags/"Messenger" + sub-services sms/gelato/biz/gamecenter.imessage, `com.apple.private.alloy.multiplex1` 1/"com.apple.private.alloy", `com.apple.private.alloy.facetime.multi` 1/"Invitation", `com.apple.ess` 1/"Invitation"), one user each, `uris` = tel + mailto on every service, `client-data` 52/11/13/9 keys, the 14 `private-device-data` keys, 517 B stamp. Our register still sends madrid only, with one handle.
|
||||
|
||||
- **Rev 48 (2026-10-16 evening):** the §1.4 register body was a STUB — now capture parity. The trapped `wa/register` frames decoded (frame-025/026 = the full user register, frame-051/052 = a second MACHINE register): the genuine body carries the full madrid NGM identity in `client-data` (identity-key = DER SEQ{[1] u16be‖uncompressed P-256 signing point, [2] u16be‖RSA-1280 PKCS#1 DER} = 249 B; prekey-data = protobuf `PreKeyData{x32, ECDSA-SHA256 raw sig over "NGMPrekeySignature"‖x‖f64-le ts, ts}` = 109 B; `kt-loggable-data` = protobuf `{deviceIdentity{x32}, ngmVersion 13, ktVersion 5}` = 40 B) plus the ~47 captured capability booleans. Ported as `MadridIdentity` (four persisted keys: EC signing, RSA-1280 enc, EC device key, EC prekey — the genuine's enc key IS RSA-1280, `Rsa::generate(1280)` in rustpush). The register request body is GZIPPED (`content-encoding: gzip` — C3 resolved by capture; the "un-gzipped lower-assumption" choice was wrong). The genuine wire's register #2 = machine-only register: `services: []`, NO `x-auth-*` headers (push-only signature), fresh stamp — implemented as the first `idsRegister` step. **[Corrected, rev 49: that frame is the client's DEREGISTER (`reset_state`), not a register step — the step was removed.]** **Live: both our machine register AND the full register answer `6004`** — decoded via [IG:idserrors]: **6004 = `ActionRetryWithNewAbsintheContext`**, with `message` = an SED server-session token `SED:3:VEN-PROD:<uuid>:<uuid>:<mint-epoch>:<uuid>:<hash>:EM` (all fields server-side: the hash ≠ sha256(stamp), UUIDs rotate per attempt). Separately: the §1.2 authenticate with the stored delegate credentials now answers **6008 (no cert)** — the delegate token went stale hours after session 33's pass. Open questions: whether the reference's register #1 also 6004s (responses not captured — the trap hooks writes only), and what "new absinthe context" concretely means when the register carries no anisette (the stamp's VEN session is the only context it rides).
|
||||
|
||||
- **Rev 47 (2026-10-16):** §1.1 mint EXONERATED on live data — the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered inputs (pad recovered by inverting the scramble chain; `domergestep2` is XOR-invertible). The session-32 "rng_pad130" verdict is corrected: the scramble pad is thread-RNG filled inside `scramble_body` (second code path, disasm 0x179654d). Transport (rustls ClientHello byte-equivalence), request shape, and staleness eliminated live. The prefpane `ICLOUD_INVALID_DEVICE_TOKEN` blocker narrows to the machine's IDS registration freshness: the reference runs §1.4 register ×2 before the delegate sign-in, and a pre-prefpane register on our side answers 6004 transient-retry.
|
||||
|
||||
|
||||
Reference in new issue
Block a user