diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index eb8b7e7..6d268a1 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -1,6 +1,6 @@ # iMessage protocol spec (clean-room) -**Status:** draft — revision 50 (2026-10-16). Revision history at the bottom. Rev 49 corrected rev 48's "register #2": the empty-services register is the client's DEREGISTER (the failure-path `reset_state`), not a register step — and the reference's own register is confirmed status 0 (§1.1). Rev 50 closes the reference survey: no implementation available on this machine can confirm a registration, so the reference's captured bytes are the only oracle (see the rev 50 entry in the revision history). +**Status:** draft — revision 51 (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 closed the reference survey: no implementation available on this machine can confirm a registration, so the reference's captured bytes are the only oracle. Rev 51 ports that oracle's remaining request-shape facts into code: the four-service catalog (§1.4) and the `id-get-handles` handle fetch (§1.4). **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. @@ -20,6 +20,7 @@ - [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) +- [CAP-REG42] — the shipped reference's own `id-register` request body, trapped from its process on 2026-10-14 (`captures/c56k/ob-trap-20261014-201103/frame-026.bin`; gzip → 13301 B XML plist; shape-only projection checked in at `registration/src/test/resources/imessage/register-reference-shape.plist`) — the only working oracle's bytes (§rev 50) - [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) @@ -111,7 +112,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 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). +**Live-mint status (as of rev 51):** 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 last two request-shape divergences are now closed in code (rev 51: the four-service catalog + the `id-get-handles` handle fetch), so the next live register is the first attempt whose body matches the oracle's field for field. 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 @@ -178,13 +179,16 @@ A binary blob proving the client is a genuine Apple device; every registration s **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]. +- 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 CLOSED by capture (rev 51): the shipped reference gzips the body and sets BOTH `content-encoding: gzip` and `accept-encoding: gzip` [CAP-REG42], [RP:ids/user.rs:1112-1115]. +- 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]. The captured body writes them in this order: `device-name`, `hardware-version`, `language`, `os-version`, `private-device-data`, `services`, `software-version`, `validation-data` [CAP-REG42] — a plist dict is unordered, so this is cosmetic, but the writer now emits it. - 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 [,,,]` (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` (C3 CLOSED by capture, rev 51):** the shipped reference registers exactly **four** services, in this order — `com.apple.madrid`, `com.apple.private.alloy.multiplex1`, `com.apple.private.alloy.facetime.multi`, `com.apple.ess` [CAP-REG42]. The list is the `id_services` argument at the one healthy call site [RP:api.rs:580], and each entry's row is a constant: madrid = flags **17** / "Messenger"; the other three = flags **1** / "com.apple.private.alloy" (multiplex1), "Invitation" (facetime.multi, ess) [RP:imessage/aps_client.rs:17], [RP:findmy.rs:25], [RP:facetime.rs:24], [RP:facetime.rs:40]. Sub-services: madrid 4 (`.sms`, `.gelato`, `.biz`, `.gamecenter.imessage`), facetime.multi 0, ess 3 (`.facetime.lp`, `.mw`, `.video`), multiplex1 5 (`.fmf`, `.fmd`, `.status.keysharing`, `.status.personal`, `.findmy.itemsharing-crossaccount` — the vendored checkout carries 4, the shipped binary 5, capture wins). The four-topic vs seven-topic disagreement [IG:register.go:352-360] is a *different* Apple-ID flow (no anisette, §rev 50) and not evidence of a second madrid shape. +- **Per-service `client-data` (capture):** every service carries the same six identity keys (the same NGM identity, byte-identical across services) plus its own extras — madrid 46 (`is-c2k-equipment`, `optionally-receive-typing-indicators`, `show-peer-errors`, the `supports-*` set, `nicknames-version`, `prefers-sdr` false), multiplex1 5 (`supports-fmd-v2`, `supports-incoming-fmd-v1`, `supports-findmy-plugin-messages`, `supports-beacon-sharing-v2`, `-v3`), facetime.multi 7 (`show-peer-errors`, `supports-avless`, `-co`, `-gft-calls`, `-gft-errors`, `-modern-gft`, `-self-one-to-one-invites`), ess 3 (`show-peer-errors`, `supports-gft-errors`, `supports-live-delivery`) [CAP-REG42]. `ec-version` is a **real** `1.0` on madrid and the **integer** `1` on the other three — the plist types differ, and this is the only value difference on the shared keys. - 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]. + - **Wire order, and one blob, not four:** the captured user entries write `client-data`, `kt-loggable-data`, `uris`, `user-id` (a `tag`, when present, last) [CAP-REG42] — the writer follows. All four services carry the **byte-identical** prekey and `kt-loggable-data` blobs, i.e. the reference mints each once outside the service loop and clones it [RP:ids/user.rs:982 `register`] — minting per service would put four different ECDSA signatures in one body [CAP-REG42]. + - **The handle fetch is `id-get-handles`** [RP:ids/user.rs:791 `get_possible_handles`, called per user from `register` at :987 before the body is built]: a **bodiless GET** to the bag URL, headers `x-push-token` + `x-protocol-version` + an **unindexed** `x-auth-user-id`, plus the six signature headers from `base_request` (:712 — `sign(..., Auth, aps, None)` → no `-` postfix; then the push signature with the same tuple). Response: `status` (0 = ok; the reference maps non-zero to `AuthInvalid(status)`) + `handles` = array of `{uri}`. The captured register's `uris` are the same two handles (`tel:` + `mailto:`) on **every** service — the reference fetches once per USER and reuses the list across services [CAP-REG42]. - `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]. @@ -562,6 +566,7 @@ Each item names its arbitration path. Closed residue is noted inline. ## Revision history +- **Rev 51 (2026-10-16, session 37):** the §1.4 **request body is now capture-exact** — the two remaining divergences from the only working oracle are closed on paper and in code. (1) **Four services, not one:** the reference registers `[madrid, multiplex1, facetime.multi, ess]` in that order, each row a constant from its own source (`api.rs:580` + the four `IDSService` constants), with per-service capabilities/sub-services/`client-data` and a per-service `ec-version` that is a real `1.0` on madrid and the integer `1` elsewhere [CAP-REG42]. Our register sent madrid alone. Ported as `RegisterServiceCatalog` (four rows; one NGM identity and one auth certificate shared by all four, as the capture shows). (2) **The `uris` are server-fetched, not derived:** the reference calls `id-get-handles` per user before building the body and puts the returned list on every service [RP:ids/user.rs:791,987], [CAP-REG42] — our register invented one handle from the login username. Ported as `IdsHandleFetcher` (bodiless signed GET, unindexed `x-auth-user-id`); the login-derived handle survives only as a loud fallback. Also recorded: the register sets `accept-encoding: gzip` alongside `content-encoding: gzip` [CAP-REG42] (C3's last open half). Structural parity is pinned by `RegisterServiceCatalogTest` against a shape-only fixture extracted from the captured body (`registration/src/test/resources/imessage/register-reference-shape.plist`, source sha256 in the fixture; the reference user's identifying values are not recorded). Nothing here is verified against Apple yet — the 6004 read is still pending. - **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::` 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. diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt index ab7dad9..cc0e547 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt @@ -105,11 +105,11 @@ data class RegisterUser( val ktLoggableData: ByteArray?, ) { fun toDict(): Map = buildMap { - put("user-id", userId) - put("uris", uris) put("client-data", clientData) - tag?.let { put("tag", it) } ktLoggableData?.let { put("kt-loggable-data", it) } + put("uris", uris) + put("user-id", userId) + tag?.let { put("tag", it) } } } @@ -205,9 +205,9 @@ data class RegisterBody( "hardware-version" to hardwareVersion, "language" to language, "os-version" to osVersion, - "software-version" to softwareVersion, "private-device-data" to privateDeviceData.toDict(), "services" to services.map { it.toDict() }, + "software-version" to softwareVersion, "validation-data" to validationData, ) @@ -333,6 +333,9 @@ class IdsRegistrar( headers[PROTOCOL_VERSION_HEADER] = config.protocolVersion headers[USER_AGENT_HEADER] = config.userAgent headers["content-encoding"] = "gzip" + // The reference's register sets BOTH directions of the encoding ([rustpush:ids/user.rs + // register]: `content-encoding: gzip` + `accept-encoding: gzip`). + headers["accept-encoding"] = "gzip" val response = http.post(url, headers, gzipped, CONTENT_TYPE) if (response.status != 200) throw IdsActivationException("POST $url → HTTP ${response.status}") return parseResponse(gunzipIfNeeded(response.body)) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsHandleFetcher.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsHandleFetcher.kt new file mode 100644 index 0000000..e54ad0c --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsHandleFetcher.kt @@ -0,0 +1,96 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.PlistFormatException +import dev.fenn.imessage.codec.integerOrNull +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import dev.fenn.imessage.codec.typeName +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.IdsHttp +import dev.fenn.imessage.ids.IdsSigning +import dev.fenn.imessage.ids.gunzipIfNeeded +import java.security.PrivateKey +import java.util.Base64 + +/** + * The §1.4 handle fetch — the reference client's `id-get-handles`, the step that yields the + * `uris` a register echoes back [rustpush:ids/user.rs:791 `get_possible_handles`, called from + * `register` at :987 for every user before the body is built]. + * + * That is why [RegisterUser.uris] must be server-supplied: the register echoes the user's + * registered handle list, it does not invent one. The reference does this fetch per user, once, + * and then puts the same list on every service entry. + * + * The request is bare by design: GET, no body, no user-agent, and the signature headers WITHOUT + * an index postfix (`base_request` at :712 appends `x-auth-user-id` unindexed, then + * `sign(..., KeyType::Auth, aps, None)` → `x-auth-*`; the push signature rides along the same + * payload tuple). Headers carried: `x-protocol-version`, `x-push-token`, `x-auth-user-id`, and + * the six signature headers. A non-zero `status` is the reference's `AuthInvalid(status)`. + */ +class IdsHandleFetcher( + private val http: IdsHttp, + private val bag: Bag, + private val config: RegisterConfig, +) { + + suspend fun handles( + userId: String, + authKey: PrivateKey, + authCertificateDer: ByteArray, + ): List { + val url = bag.url(BAG_KEY) + // The §1.4 signed tuple for a bodiless GET: bag key, empty query, empty body, push token. + val fields = listOf(BAG_KEY.toByteArray(Charsets.UTF_8), EMPTY_QUERY, EMPTY_BODY, config.pushToken) + val headers = LinkedHashMap() + headers[IdsRegistrar.PROTOCOL_VERSION_HEADER] = config.protocolVersion + headers["x-push-token"] = base64(config.pushToken) + headers["x-auth-user-id"] = userId + val pushNonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS) + headers["x-push-nonce"] = base64(pushNonce) + headers["x-push-cert"] = base64(config.pushCertificateDer) + headers["x-push-sig"] = base64(IdsSigning.sign(config.pushKey, pushNonce, fields)) + val authNonce = IdsSigning.nonce(IdsSigning.TYPE_HTTPS) + headers["x-auth-nonce"] = base64(authNonce) + headers["x-auth-cert"] = base64(authCertificateDer) + headers["x-auth-sig"] = base64(IdsSigning.sign(authKey, authNonce, fields)) + + val response = http.get(url, headers) + if (response.status != 200) throw IdsActivationException("GET $url → HTTP ${response.status}") + return parse(gunzipIfNeeded(response.body)) + } + + private fun parse(bytes: ByteArray): List { + val parsed = try { + Plist.parse(bytes) + } catch (e: PlistFormatException) { + throw IdsActivationException("id-get-handles response is not a plist: ${e.message}", e) + } + val dict = stringKeyedDictOrNull(parsed) + ?: throw IdsActivationException("id-get-handles response is ${typeName(parsed)}, expected a dict") + val status = integerOrNull(dict["status"]) + ?: throw IdsActivationException( + "id-get-handles response has no integer 'status' (${typeName(dict["status"])})", + ) + if (status != 0) throw IdsActivationException("id-get-handles status $status") + val handles = dict["handles"] ?: return emptyList() + if (handles !is List<*>) { + throw IdsActivationException("id-get-handles 'handles' is ${typeName(handles)}, expected an array") + } + return handles.mapIndexed { i, entry -> + val handle = stringKeyedDictOrNull(entry) + ?: throw IdsActivationException("id-get-handles handles[$i] is ${typeName(entry)}, expected a dict") + handle["uri"] as? String + ?: throw IdsActivationException("id-get-handles handles[$i] has no 'uri' string") + } + } + + private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + companion object { + /** Bag key for the handle endpoint (spec §1.2/§1.4). */ + const val BAG_KEY = "id-get-handles" + + private val EMPTY_QUERY = ByteArray(0) + private val EMPTY_BODY = ByteArray(0) + } +} diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt index 80bf8a9..06de782 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt @@ -116,7 +116,6 @@ class MadridIdentity( "supports-people-request-messages-v3" to true, "supports-rem" to true, "nicknames-version" to 1.0, - "ec-version" to 1.0, "supports-cross-platform-sharing" to true, "supports-original-timestamp-v1" to true, "supports-sa-v1" to true, @@ -139,19 +138,38 @@ class MadridIdentity( "supports-polls" to true, ) - /** The identity-key base keys that ride before [CLIENT_DATA] (§1.4 client-data). */ - fun clientData(identity: MadridIdentity, prekeyTimestampEpochSeconds: Long): Map { - val base: LinkedHashMap = linkedMapOf( - "public-message-identity-key" to identity.identityKeyDer(), - "public-message-identity-version" to 2L, - "ec-version" to 1.0, - "public-message-identity-ngm-version" to 13L, - "public-message-ngm-device-prekey-data-key" to identity.prekeyData(prekeyTimestampEpochSeconds), - "kt-version" to 5L, - ) - base.putAll(CLIENT_DATA) - return base - } + /** + * The six identity keys that open every service's `client-data` (§1.4) — no capability + * keys: the ones beyond these are per service and come from [RegisterServiceCatalog] + * (madrid's are [CLIENT_DATA]). [ecVersion] is capture-bound per service: madrid sends + * the REAL 1.0, the other three send the INTEGER 1L — the plist distinguishes them, so + * this is `Any`. + */ + fun clientData( + identity: MadridIdentity, + prekeyTimestampEpochSeconds: Long, + ecVersion: Any = 1.0, + ): Map = clientData(identity, identity.prekeyData(prekeyTimestampEpochSeconds), ecVersion) + + /** + * The same dict with a caller-minted [prekeyBlob]. The reference mints the prekey ONCE, + * outside the service loop, and clones the same bytes into every service's `client-data` + * (`rustpush/src/ids/user.rs:982`) — every fresh mint is a fresh ECDSA signature, so + * minting per service would put four different blobs in one body. Callers registering + * more than one service pass one blob to all of them ([RegisterServiceCatalog.services]). + */ + fun clientData( + identity: MadridIdentity, + prekeyBlob: ByteArray, + ecVersion: Any = 1.0, + ): Map = linkedMapOf( + "public-message-identity-key" to identity.identityKeyDer(), + "public-message-identity-version" to 2L, + "ec-version" to ecVersion, + "public-message-identity-ngm-version" to 13L, + "public-message-ngm-device-prekey-data-key" to prekeyBlob, + "kt-version" to 5L, + ) fun generate(): MadridIdentity = MadridIdentity( signing = keyPair("EC", 256), diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalog.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalog.kt new file mode 100644 index 0000000..929c65e --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalog.kt @@ -0,0 +1,156 @@ +package dev.fenn.imessage.registration + +import java.security.PrivateKey + +/** + * One service entry of the reference client's §1.4 register catalog: the capability record, the + * sub-service topics, the service's `ec-version`, and the capability keys its `client-data` + * carries beyond the identity keys ([MadridIdentity.clientData]). + * + * The four entries below are the shipped reference's whole register — `api.rs:580 register_ids` + * sends `[&MADRID_SERVICE, &MULTIPLEX_SERVICE, &FACETIME_SERVICE, &VIDEO_SERVICE]`, and the + * trapped body (`captures/c56k/ob-trap-20261014-201103/frame-026.bin`, gzip → 13301 B XML + * plist) carries exactly those four, with the same NGM identity in every one. + */ +class RegisterServiceSpec( + val service: String, + val capabilities: List, + val subServices: List, + /** + * The plist type is capture-bound and differs per service: madrid sends the real `1.0`, the + * other three the integer `1L`. + */ + val ecVersion: Any, + /** The capability keys on top of the identity keys — [MadridIdentity.CLIENT_DATA] for madrid. */ + val clientData: Map, +) { + + fun clientData(identity: MadridIdentity, prekeyBlob: ByteArray): Map = + MadridIdentity.clientData(identity, prekeyBlob, ecVersion) + clientData +} + +/** + * The §1.4 register's service catalog — the reference's four services, in its wire order. + * + * Facts, and where each is pinned: + * - the list and its order: `rust/src/api/api.rs:580` (the `id_services` argument) — confirmed + * by the captured body's service order; + * - per-service `capabilities` (flags + name), `sub-services`, and the extra `client-data` + * keys: the capture, cross-checked against the vendored constants + * (`rustpush/src/imessage/aps_client.rs:17` madrid, `src/findmy.rs:25` multiplex1, + * `src/facetime.rs:24/40` facetime.multi + ess). + * + * Where the vendored checkout and the shipped binary disagree, the capture wins (the checkout + * is a later/other build — its multiplex1 lacks `com.apple.private.alloy.findmy.itemsharing-crossaccount` + * and the three beacon/findmy-plugin capability keys the shipped body carries). + * + * The caller decides *which* services to register; the four here are the shape that is known to + * return status 0 (WORKLOG 2026-10-16 session 35, measured on the reference three times). + */ +object RegisterServiceCatalog { + + val MADRID = RegisterServiceSpec( + service = RegisterService.MADRID, + capabilities = listOf(RegisterCapability(flags = 17, name = "Messenger")), + subServices = listOf( + "com.apple.private.alloy.sms", + "com.apple.private.alloy.gelato", + "com.apple.private.alloy.biz", + "com.apple.private.alloy.gamecenter.imessage", + ), + ecVersion = 1.0, + clientData = MadridIdentity.CLIENT_DATA, + ) + + val MULTIPLEX1 = RegisterServiceSpec( + service = "com.apple.private.alloy.multiplex1", + capabilities = listOf(RegisterCapability(flags = 1, name = "com.apple.private.alloy")), + subServices = listOf( + "com.apple.private.alloy.fmf", + "com.apple.private.alloy.fmd", + "com.apple.private.alloy.status.keysharing", + "com.apple.private.alloy.status.personal", + "com.apple.private.alloy.findmy.itemsharing-crossaccount", + ), + ecVersion = 1L, + clientData = linkedMapOf( + "supports-fmd-v2" to true, + "supports-incoming-fmd-v1" to true, + "supports-findmy-plugin-messages" to true, + "supports-beacon-sharing-v3" to true, + "supports-beacon-sharing-v2" to true, + ), + ) + + val FACETIME_MULTI = RegisterServiceSpec( + service = "com.apple.private.alloy.facetime.multi", + capabilities = listOf(RegisterCapability(flags = 1, name = "Invitation")), + subServices = emptyList(), + ecVersion = 1L, + clientData = linkedMapOf( + "show-peer-errors" to true, + "supports-avless" to true, + "supports-co" to true, + "supports-gft-calls" to true, + "supports-gft-errors" to true, + "supports-modern-gft" to true, + "supports-self-one-to-one-invites" to true, + ), + ) + + val ESS = RegisterServiceSpec( + service = "com.apple.ess", + capabilities = listOf(RegisterCapability(flags = 1, name = "Invitation")), + subServices = listOf( + "com.apple.private.alloy.facetime.lp", + "com.apple.private.alloy.facetime.mw", + "com.apple.private.alloy.facetime.video", + ), + ecVersion = 1L, + clientData = linkedMapOf( + "show-peer-errors" to true, + "supports-gft-errors" to true, + "supports-live-delivery" to true, + ), + ) + + /** The reference's register list, in its wire order. */ + val ALL = listOf(MADRID, MULTIPLEX1, FACETIME_MULTI, ESS) + + /** + * The [ALL] entries as body entries: one service per catalog row, one user each, the same + * identity in every user (the reference builds `client-data` per service from ONE NGM + * identity, ONE prekey mint and ONE `kt-loggable-data` blob — both are byte-identical on + * every service in the capture), and the SAME handle list on every service (it fetches + * them once per user from `id-get-handles` — [IdsHandleFetcher]). + */ + fun services( + identity: MadridIdentity, + prekeyTimestampEpochSeconds: Long, + userId: String, + uris: List>, + authKey: PrivateKey, + authCertificateDer: ByteArray, + ): List { + val prekeyBlob = identity.prekeyData(prekeyTimestampEpochSeconds) + val ktLoggableData = identity.ktLoggableData() + return ALL.map { spec -> + RegisterService( + service = spec.service, + subServices = spec.subServices, + users = listOf( + RegisterUser( + userId = userId, + uris = uris, + tag = null, + authKey = authKey, + authCertificateDer = authCertificateDer, + clientData = spec.clientData(identity, prekeyBlob), + ktLoggableData = ktLoggableData, + ), + ), + capabilities = spec.capabilities, + ) + } + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsHandleFetcherTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsHandleFetcherTest.kt new file mode 100644 index 0000000..b753f9d --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsHandleFetcherTest.kt @@ -0,0 +1,108 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.XmlPlist +import dev.fenn.imessage.ids.Bag +import dev.fenn.imessage.ids.IdsHttpResponse +import dev.fenn.imessage.ids.IdsSigning +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class IdsHandleFetcherTest { + + private val url = "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/getHandles" + private val pushToken = ByteArray(32) { it.toByte() } + private val pushCert = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00) + private val authCert = byteArrayOf(0x30, 0x82.toByte(), 0x03, 0x00) + + private fun fetcher(http: ScriptedIdsHttp) = IdsHandleFetcher( + http, + Bag(mapOf(IdsHandleFetcher.BAG_KEY to url)), + RegisterConfig( + pushToken = pushToken, + pushKey = PUSH.private, + pushCertificateDer = pushCert, + protocolVersion = "1660", + userAgent = "com.apple.invitation-registration [Test,1,1,Test]", + ), + ) + + private fun handlesResponse(status: Long = 0L, uris: List = listOf("tel:+15557654321")) = XmlPlist.encode( + mapOf( + "status" to status, + "handles" to uris.map { mapOf("uri" to it) }, + ), + ) + + @Test + fun theRequestIsABodilessGetSignedByBothIdentitiesWithoutAnIndex() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), handlesResponse()))) + fetcher(http).handles("D:10000000000", AUTH.private, authCert) + + val call = http.calls.single() + assertEquals("GET", call.method) + assertEquals(url, call.url) + assertEquals("1660", call.headers.getValue("x-protocol-version")) + assertEquals(Base64.getEncoder().encodeToString(pushToken), call.headers.getValue("x-push-token")) + assertEquals("D:10000000000", call.headers.getValue("x-auth-user-id")) + // No user-agent: the reference's id-get-handles request carries none (user.rs:712 base_request). + assertFalse(call.headers.containsKey("user-agent"), "the reference sends no user-agent on id-get-handles") + assertFalse(call.headers.keys.any { it.endsWith("-0") }, "base_request signs with item=None: no index postfix") + + // The signed tuple is the same shape as the register's: bag key, empty query, empty body, push token. + val fields = listOf(IdsHandleFetcher.BAG_KEY.toByteArray(), ByteArray(0), ByteArray(0), pushToken) + verifySignature(call, "x-push-nonce", "x-push-sig", PUSH, fields) + verifySignature(call, "x-auth-nonce", "x-auth-sig", AUTH, fields) + } + + @Test + fun theHandlesComeBackInWireOrder() = runBlocking { + val http = ScriptedIdsHttp( + listOf(IdsHttpResponse(200, emptyMap(), handlesResponse(uris = listOf("tel:+15557654321", "mailto:user@example.com")))), + ) + assertEquals( + listOf("tel:+15557654321", "mailto:user@example.com"), + fetcher(http).handles("D:1", AUTH.private, authCert), + ) + } + + @Test + fun aNonZeroStatusFailsLoudly() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), handlesResponse(status = 6005L)))) + val failure = assertFailsWith { + fetcher(http).handles("D:1", AUTH.private, authCert) + } + assertTrue(failure.message!!.contains("6005"), failure.message) + } + + private fun verifySignature( + call: ScriptedIdsHttp.Call, + nonceHeader: String, + sigHeader: String, + key: KeyPair, + fields: List, + ) { + val nonce = Base64.getDecoder().decode(call.headers.getValue(nonceHeader)) + assertEquals(IdsSigning.NONCE_BYTES, nonce.size) + assertEquals(IdsSigning.TYPE_HTTPS, nonce[0]) + val signature = Base64.getDecoder().decode(call.headers.getValue(sigHeader)) + assertContentEquals(IdsSigning.SIGNATURE_PREFIX_BYTES, signature.copyOfRange(0, 2)) + val verifier = Signature.getInstance("SHA1withRSA") + verifier.initVerify(key.public) + verifier.update(IdsSigning.signingBytes(nonce, fields)) + assertTrue(verifier.verify(signature.copyOfRange(2, signature.size)), "$sigHeader does not verify") + } + + private companion object { + val PUSH: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + val AUTH: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt index 1607654..80e566f 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt @@ -96,7 +96,7 @@ class MadridIdentityTest { @Test fun clientDataCarriesIdentityPlusCapturedStatics() { - val data = MadridIdentity.clientData(identity, 1_760_000_000L) + val data = RegisterServiceCatalog.MADRID.clientData(identity, identity.prekeyData(1_760_000_000L)) assertTrue(data["public-message-identity-key"] is ByteArray) assertEquals(2L, data["public-message-identity-version"]) assertEquals(1.0, data["ec-version"]) diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalogTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalogTest.kt new file mode 100644 index 0000000..0d43cf0 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/RegisterServiceCatalogTest.kt @@ -0,0 +1,163 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.codec.Plist +import dev.fenn.imessage.codec.stringKeyedDictOrNull +import java.security.KeyPair +import java.security.KeyPairGenerator +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Structural parity of the §1.4 register body against the shipped reference's captured one. + * + * The fixture is a shape-only projection of `captures/c56k/ob-trap-20261014-201103/frame-026.bin` + * (gzip → 13301 B XML plist, sha256 recorded in the fixture): service order, capability records, + * sub-service lists, key sets, `ec-version` values and the identity-blob lengths. The reference + * user's identifying values (device-name, os-version, user-id, uris, the key bytes themselves, + * validation-data) are deliberately absent — this is a public repo. + */ +class RegisterServiceCatalogTest { + + private val shape: Map = Plist.parse( + checkNotNull(javaClass.getResourceAsStream("/imessage/register-reference-shape.plist")) { + "fixture /imessage/register-reference-shape.plist missing" + }.readBytes(), + ).let { stringKeyedDictOrNull(it) ?: error("fixture is not a dict") } + + private val capturedServices: List> = + (shape["services"] as List<*>).mapIndexed { i, entry -> + stringKeyedDictOrNull(entry) ?: error("fixture services[$i] is not a dict") + } + + private val identity = MadridIdentity.generate() + + private val handleUris = listOf( + mapOf("uri" to "tel:+15557654321"), + mapOf("uri" to "mailto:user@example.com"), + ) + + private fun services() = RegisterServiceCatalog.services( + identity = identity, + prekeyTimestampEpochSeconds = 1_760_000_000L, + userId = "D:10000000000", + uris = handleUris, + authKey = AUTH.private, + authCertificateDer = byteArrayOf(0x30, 0x01), + ) + + @Test + fun catalogIsTheCapturedFourServicesInTheCapturedOrder() { + assertEquals(capturedServices.map { it["service"] }, RegisterServiceCatalog.ALL.map { it.service }) + assertEquals( + listOf( + "com.apple.madrid", + "com.apple.private.alloy.multiplex1", + "com.apple.private.alloy.facetime.multi", + "com.apple.ess", + ), + services().map { it.service }, + ) + } + + @Test + fun everyServiceCarriesTheCapturedCapabilitiesAndSubServices() { + val built = services() + capturedServices.forEachIndexed { i, captured -> + val expected = captured["capabilities"] as List<*> + assertEquals(expected.size, built[i].capabilities.size, "services[$i] capability count") + expected.forEachIndexed { j, entry -> + val cap = stringKeyedDictOrNull(entry) ?: error("fixture capability is not a dict") + assertEquals(cap["flags"], built[i].capabilities[j].flags.toLong(), "services[$i].capabilities[$j].flags") + assertEquals(cap["name"], built[i].capabilities[j].name, "services[$i].capabilities[$j].name") + assertEquals(cap["version"], built[i].capabilities[j].version.toLong()) + } + assertEquals(captured["sub-services"], built[i].subServices, "services[$i] sub-services") + } + } + + @Test + fun everyUserCarriesTheCapturedKeysAndEcVersion() { + val built = services() + capturedServices.forEachIndexed { i, captured -> + val dict = built[i].toDict() + val user = (dict["users"] as List<*>).single() as Map<*, *> + assertEquals(captured["user-keys"], user.keys.toList(), "services[$i] user keys") + val clientData = user["client-data"] as Map<*, *> + assertEquals( + (captured["client-data-keys"] as List<*>).toSet(), + clientData.keys.toSet(), + "services[$i] client-data keys", + ) + assertEquals(captured["ec-version"], clientData["ec-version"], "services[$i] ec-version") + // Identity keys open the dict, in the reference's order, before the capability keys. + assertEquals( + listOf( + "public-message-identity-key", "public-message-identity-version", "ec-version", + "public-message-identity-ngm-version", "public-message-ngm-device-prekey-data-key", + "kt-version", + ), + clientData.keys.take(6).toList(), + ) + } + } + + @Test + fun everyServiceSharesOneIdentityAndTheCapturedBlobSizes() { + val built = services() + val identities = built.map { (((it.toDict()["users"] as List<*>).single() as Map<*, *>)["client-data"] as Map<*, *>)["public-message-identity-key"] as ByteArray } + val prekeys = built.map { (((it.toDict()["users"] as List<*>).single() as Map<*, *>)["client-data"] as Map<*, *>)["public-message-ngm-device-prekey-data-key"] as ByteArray } + val ktData = built.map { ((it.toDict()["users"] as List<*>).single() as Map<*, *>)["kt-loggable-data"] as ByteArray } + + identities.drop(1).forEach { assertContentEquals(identities.first(), it, "identity key differs per service") } + prekeys.drop(1).forEach { assertContentEquals(prekeys.first(), it, "prekey data differs per service") } + ktData.drop(1).forEach { assertContentEquals(ktData.first(), it, "kt-loggable-data differs per service") } + + capturedServices.forEachIndexed { i, captured -> + assertEquals(captured["identity-key-bytes"], identities[i].size.toLong(), "services[$i] identity key size") + assertEquals(captured["prekey-data-bytes"], prekeys[i].size.toLong(), "services[$i] prekey size") + assertEquals(captured["kt-loggable-data-bytes"], ktData[i].size.toLong(), "services[$i] kt size") + } + } + + @Test + fun theSameHandleListRidesOnEveryServiceAndTheUserIdIsVerbatim() { + services().forEach { service -> + val user = (service.toDict()["users"] as List<*>).single() as Map<*, *> + assertEquals(handleUris, user["uris"], "${service.service} uris") + assertEquals("D:10000000000", user["user-id"], "${service.service} user-id") + assertFalse(user.containsKey("tag"), "the DS:ID flow has no SIM tag") + } + } + + @Test + fun bodyTopLevelKeysAndPrivateDeviceDataKeysMatchTheCapture() { + val body = RegisterBody( + deviceName = "LP3", + hardwareVersion = "h", + language = "en-US", + osVersion = "o", + softwareVersion = "s", + privateDeviceData = PrivateDeviceData( + osBuild = "b", + platformName = "p", + osVersion = "o", + deviceUuid = "abcd-ef", + appleEpochSeconds = 800_000_000L, + ), + services = services(), + validationData = byteArrayOf(1), + ).toDict() + assertEquals(shape["top-level-keys"], body.keys.toList()) + assertEquals( + (shape["private-device-data-keys"] as List<*>), + (body["private-device-data"] as Map<*, *>).keys.toList(), + ) + } + + private companion object { + // 1024-bit test key, per the IdsActivationTest precedent — never hardcoded key material. + val AUTH: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + } +} diff --git a/registration/src/test/resources/imessage/register-reference-shape.plist b/registration/src/test/resources/imessage/register-reference-shape.plist new file mode 100644 index 0000000..e75f11a --- /dev/null +++ b/registration/src/test/resources/imessage/register-reference-shape.plist @@ -0,0 +1,283 @@ + + + + + source + captures/c56k/ob-trap-20261014-201103/frame-026.bin (gzip -> 13301 B XML plist) + source-sha256 + 33ccdfbea1d31725fb36c556343a1ef873c1f1c8af778fed7123d1a3e2a58ed4 + note + SHAPE ONLY - values identifying the reference user (device-name, os-version, user-id, uris, identity keys, validation-data) are deliberately not recorded here. + top-level-keys + + device-name + hardware-version + language + os-version + private-device-data + services + software-version + validation-data + + private-device-data-keys + + ap + d + dt + gt + h + m + p + pb + pn + pv + s + t + u + v + + services + + + service + com.apple.madrid + capabilities + + + flags + 17 + name + Messenger + version + 1 + + + sub-services + + com.apple.private.alloy.sms + com.apple.private.alloy.gelato + com.apple.private.alloy.biz + com.apple.private.alloy.gamecenter.imessage + + user-keys + + client-data + kt-loggable-data + uris + user-id + + client-data-keys + + public-message-identity-key + public-message-identity-version + ec-version + public-message-identity-ngm-version + public-message-ngm-device-prekey-data-key + kt-version + is-c2k-equipment + optionally-receive-typing-indicators + show-peer-errors + supports-ack-v1 + supports-activity-sharing-v1 + supports-audio-messaging-v2 + supports-autoloopvideo-v1 + supports-be-v1 + supports-ca-v1 + supports-fsm-v1 + supports-fsm-v2 + supports-fsm-v3 + supports-ii-v1 + supports-impact-v1 + supports-inline-attachments + supports-keep-receipts + supports-location-sharing + supports-media-v2 + supports-photos-extension-v1 + supports-st-v1 + supports-update-attachments-v1 + supports-people-request-messages + supports-people-request-messages-v2 + supports-people-request-messages-v3 + supports-rem + nicknames-version + supports-cross-platform-sharing + supports-original-timestamp-v1 + supports-sa-v1 + supports-photos-extension-v2 + prefers-sdr + supports-shared-exp + supports-protobuf-payload-data-v2 + supports-hdr + supports-heif + supports-dq-nr + supports-family-invite-message-bubble + supports-live-delivery + supports-findmy-plugin-messages + supports-stick-moji-backs + supports-emoji-tapbacks + supports-send-later-messages + supports-certified-delivery-v1 + supports-transcript-backgrounds + supports-gti + supports-polls + + ec-version + 1.0 + identity-key-bytes + 249 + prekey-data-bytes + 109 + kt-loggable-data-bytes + 40 + + + service + com.apple.private.alloy.multiplex1 + capabilities + + + flags + 1 + name + com.apple.private.alloy + version + 1 + + + sub-services + + com.apple.private.alloy.fmf + com.apple.private.alloy.fmd + com.apple.private.alloy.status.keysharing + com.apple.private.alloy.status.personal + com.apple.private.alloy.findmy.itemsharing-crossaccount + + user-keys + + client-data + kt-loggable-data + uris + user-id + + client-data-keys + + public-message-identity-key + public-message-identity-version + ec-version + public-message-identity-ngm-version + public-message-ngm-device-prekey-data-key + kt-version + supports-fmd-v2 + supports-incoming-fmd-v1 + supports-findmy-plugin-messages + supports-beacon-sharing-v3 + supports-beacon-sharing-v2 + + ec-version + 1 + identity-key-bytes + 249 + prekey-data-bytes + 109 + kt-loggable-data-bytes + 40 + + + service + com.apple.private.alloy.facetime.multi + capabilities + + + flags + 1 + name + Invitation + version + 1 + + + sub-services + + user-keys + + client-data + kt-loggable-data + uris + user-id + + client-data-keys + + public-message-identity-key + public-message-identity-version + ec-version + public-message-identity-ngm-version + public-message-ngm-device-prekey-data-key + kt-version + show-peer-errors + supports-avless + supports-co + supports-gft-calls + supports-gft-errors + supports-modern-gft + supports-self-one-to-one-invites + + ec-version + 1 + identity-key-bytes + 249 + prekey-data-bytes + 109 + kt-loggable-data-bytes + 40 + + + service + com.apple.ess + capabilities + + + flags + 1 + name + Invitation + version + 1 + + + sub-services + + com.apple.private.alloy.facetime.lp + com.apple.private.alloy.facetime.mw + com.apple.private.alloy.facetime.video + + user-keys + + client-data + kt-loggable-data + uris + user-id + + client-data-keys + + public-message-identity-key + public-message-identity-version + ec-version + public-message-identity-ngm-version + public-message-ngm-device-prekey-data-key + kt-version + show-peer-errors + supports-gft-errors + supports-live-delivery + + ec-version + 1 + identity-key-bytes + 249 + prekey-data-bytes + 109 + kt-loggable-data-bytes + 40 + + + +