mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
registration: the register body is capture-exact (rev 51) — four services, id-get-handles, one prekey mint
The trapped reference register (frame-026) is now matched field for field. - RegisterServiceCatalog: the reference's four services in wire order (madrid + multiplex1 + facetime.multi + ess), each row a constant from its own source, with per-service capabilities/sub-services/client-data and a per-service ec-version (real 1.0 on madrid, integer 1 on the other three — the plist types differ). - IdsHandleFetcher: id-get-handles, the bodiless signed GET the reference calls per user before building the body; the register echoes the server's handle list on every service instead of inventing one from the username. - One prekey mint and one kt-loggable-data blob for the whole body: the reference mints outside the service loop and clones, and the capture's blobs are byte-identical across the four services. - MadridIdentity.clientData now builds the six identity keys only; the per-service capability keys come from the catalog row. Key order matches the capture (top-level and per-user). - RegisterServiceCatalogTest + IdsHandleFetcherTest pin the shape against a PII-free projection of the capture (registration/src/test/resources/imessage/register-reference-shape.plist). Nothing here is verified against Apple yet — the next live register is the first one whose body matches the oracle's.
This commit is contained in:
1 parent
d0dd6d057e
commit
28e0432f09
9 files changed
+856
-24
No files matched your search
@@ -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 [<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` (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 `-<N>` 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::<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.
|
||||
|
||||
|
||||
@@ -105,11 +105,11 @@ data class RegisterUser(
|
||||
val ktLoggableData: ByteArray?,
|
||||
) {
|
||||
fun toDict(): Map<String, Any?> = 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))
|
||||
|
||||
@@ -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<String> {
|
||||
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<String, String>()
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, Any?> {
|
||||
val base: LinkedHashMap<String, Any?> = 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<String, Any?> = 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<String, Any?> = 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),
|
||||
|
||||
+156
@@ -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<RegisterCapability>,
|
||||
val subServices: List<String>,
|
||||
/**
|
||||
* 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<String, Any?>,
|
||||
) {
|
||||
|
||||
fun clientData(identity: MadridIdentity, prekeyBlob: ByteArray): Map<String, Any?> =
|
||||
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<Map<String, String>>,
|
||||
authKey: PrivateKey,
|
||||
authCertificateDer: ByteArray,
|
||||
): List<RegisterService> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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:[email protected]")))),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("tel:+15557654321", "mailto:[email protected]"),
|
||||
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<IdsActivationException> {
|
||||
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<ByteArray>,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
+163
@@ -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<String, Any?> = 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<Map<String, Any?>> =
|
||||
(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:[email protected]"),
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>source</key>
|
||||
<string>captures/c56k/ob-trap-20261014-201103/frame-026.bin (gzip -> 13301 B XML plist)</string>
|
||||
<key>source-sha256</key>
|
||||
<string>33ccdfbea1d31725fb36c556343a1ef873c1f1c8af778fed7123d1a3e2a58ed4</string>
|
||||
<key>note</key>
|
||||
<string>SHAPE ONLY - values identifying the reference user (device-name, os-version, user-id, uris, identity keys, validation-data) are deliberately not recorded here.</string>
|
||||
<key>top-level-keys</key>
|
||||
<array>
|
||||
<string>device-name</string>
|
||||
<string>hardware-version</string>
|
||||
<string>language</string>
|
||||
<string>os-version</string>
|
||||
<string>private-device-data</string>
|
||||
<string>services</string>
|
||||
<string>software-version</string>
|
||||
<string>validation-data</string>
|
||||
</array>
|
||||
<key>private-device-data-keys</key>
|
||||
<array>
|
||||
<string>ap</string>
|
||||
<string>d</string>
|
||||
<string>dt</string>
|
||||
<string>gt</string>
|
||||
<string>h</string>
|
||||
<string>m</string>
|
||||
<string>p</string>
|
||||
<string>pb</string>
|
||||
<string>pn</string>
|
||||
<string>pv</string>
|
||||
<string>s</string>
|
||||
<string>t</string>
|
||||
<string>u</string>
|
||||
<string>v</string>
|
||||
</array>
|
||||
<key>services</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>service</key>
|
||||
<string>com.apple.madrid</string>
|
||||
<key>capabilities</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>flags</key>
|
||||
<integer>17</integer>
|
||||
<key>name</key>
|
||||
<string>Messenger</string>
|
||||
<key>version</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>sub-services</key>
|
||||
<array>
|
||||
<string>com.apple.private.alloy.sms</string>
|
||||
<string>com.apple.private.alloy.gelato</string>
|
||||
<string>com.apple.private.alloy.biz</string>
|
||||
<string>com.apple.private.alloy.gamecenter.imessage</string>
|
||||
</array>
|
||||
<key>user-keys</key>
|
||||
<array>
|
||||
<string>client-data</string>
|
||||
<string>kt-loggable-data</string>
|
||||
<string>uris</string>
|
||||
<string>user-id</string>
|
||||
</array>
|
||||
<key>client-data-keys</key>
|
||||
<array>
|
||||
<string>public-message-identity-key</string>
|
||||
<string>public-message-identity-version</string>
|
||||
<string>ec-version</string>
|
||||
<string>public-message-identity-ngm-version</string>
|
||||
<string>public-message-ngm-device-prekey-data-key</string>
|
||||
<string>kt-version</string>
|
||||
<string>is-c2k-equipment</string>
|
||||
<string>optionally-receive-typing-indicators</string>
|
||||
<string>show-peer-errors</string>
|
||||
<string>supports-ack-v1</string>
|
||||
<string>supports-activity-sharing-v1</string>
|
||||
<string>supports-audio-messaging-v2</string>
|
||||
<string>supports-autoloopvideo-v1</string>
|
||||
<string>supports-be-v1</string>
|
||||
<string>supports-ca-v1</string>
|
||||
<string>supports-fsm-v1</string>
|
||||
<string>supports-fsm-v2</string>
|
||||
<string>supports-fsm-v3</string>
|
||||
<string>supports-ii-v1</string>
|
||||
<string>supports-impact-v1</string>
|
||||
<string>supports-inline-attachments</string>
|
||||
<string>supports-keep-receipts</string>
|
||||
<string>supports-location-sharing</string>
|
||||
<string>supports-media-v2</string>
|
||||
<string>supports-photos-extension-v1</string>
|
||||
<string>supports-st-v1</string>
|
||||
<string>supports-update-attachments-v1</string>
|
||||
<string>supports-people-request-messages</string>
|
||||
<string>supports-people-request-messages-v2</string>
|
||||
<string>supports-people-request-messages-v3</string>
|
||||
<string>supports-rem</string>
|
||||
<string>nicknames-version</string>
|
||||
<string>supports-cross-platform-sharing</string>
|
||||
<string>supports-original-timestamp-v1</string>
|
||||
<string>supports-sa-v1</string>
|
||||
<string>supports-photos-extension-v2</string>
|
||||
<string>prefers-sdr</string>
|
||||
<string>supports-shared-exp</string>
|
||||
<string>supports-protobuf-payload-data-v2</string>
|
||||
<string>supports-hdr</string>
|
||||
<string>supports-heif</string>
|
||||
<string>supports-dq-nr</string>
|
||||
<string>supports-family-invite-message-bubble</string>
|
||||
<string>supports-live-delivery</string>
|
||||
<string>supports-findmy-plugin-messages</string>
|
||||
<string>supports-stick-moji-backs</string>
|
||||
<string>supports-emoji-tapbacks</string>
|
||||
<string>supports-send-later-messages</string>
|
||||
<string>supports-certified-delivery-v1</string>
|
||||
<string>supports-transcript-backgrounds</string>
|
||||
<string>supports-gti</string>
|
||||
<string>supports-polls</string>
|
||||
</array>
|
||||
<key>ec-version</key>
|
||||
<real>1.0</real>
|
||||
<key>identity-key-bytes</key>
|
||||
<integer>249</integer>
|
||||
<key>prekey-data-bytes</key>
|
||||
<integer>109</integer>
|
||||
<key>kt-loggable-data-bytes</key>
|
||||
<integer>40</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>service</key>
|
||||
<string>com.apple.private.alloy.multiplex1</string>
|
||||
<key>capabilities</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>flags</key>
|
||||
<integer>1</integer>
|
||||
<key>name</key>
|
||||
<string>com.apple.private.alloy</string>
|
||||
<key>version</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>sub-services</key>
|
||||
<array>
|
||||
<string>com.apple.private.alloy.fmf</string>
|
||||
<string>com.apple.private.alloy.fmd</string>
|
||||
<string>com.apple.private.alloy.status.keysharing</string>
|
||||
<string>com.apple.private.alloy.status.personal</string>
|
||||
<string>com.apple.private.alloy.findmy.itemsharing-crossaccount</string>
|
||||
</array>
|
||||
<key>user-keys</key>
|
||||
<array>
|
||||
<string>client-data</string>
|
||||
<string>kt-loggable-data</string>
|
||||
<string>uris</string>
|
||||
<string>user-id</string>
|
||||
</array>
|
||||
<key>client-data-keys</key>
|
||||
<array>
|
||||
<string>public-message-identity-key</string>
|
||||
<string>public-message-identity-version</string>
|
||||
<string>ec-version</string>
|
||||
<string>public-message-identity-ngm-version</string>
|
||||
<string>public-message-ngm-device-prekey-data-key</string>
|
||||
<string>kt-version</string>
|
||||
<string>supports-fmd-v2</string>
|
||||
<string>supports-incoming-fmd-v1</string>
|
||||
<string>supports-findmy-plugin-messages</string>
|
||||
<string>supports-beacon-sharing-v3</string>
|
||||
<string>supports-beacon-sharing-v2</string>
|
||||
</array>
|
||||
<key>ec-version</key>
|
||||
<integer>1</integer>
|
||||
<key>identity-key-bytes</key>
|
||||
<integer>249</integer>
|
||||
<key>prekey-data-bytes</key>
|
||||
<integer>109</integer>
|
||||
<key>kt-loggable-data-bytes</key>
|
||||
<integer>40</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>service</key>
|
||||
<string>com.apple.private.alloy.facetime.multi</string>
|
||||
<key>capabilities</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>flags</key>
|
||||
<integer>1</integer>
|
||||
<key>name</key>
|
||||
<string>Invitation</string>
|
||||
<key>version</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>sub-services</key>
|
||||
<array/>
|
||||
<key>user-keys</key>
|
||||
<array>
|
||||
<string>client-data</string>
|
||||
<string>kt-loggable-data</string>
|
||||
<string>uris</string>
|
||||
<string>user-id</string>
|
||||
</array>
|
||||
<key>client-data-keys</key>
|
||||
<array>
|
||||
<string>public-message-identity-key</string>
|
||||
<string>public-message-identity-version</string>
|
||||
<string>ec-version</string>
|
||||
<string>public-message-identity-ngm-version</string>
|
||||
<string>public-message-ngm-device-prekey-data-key</string>
|
||||
<string>kt-version</string>
|
||||
<string>show-peer-errors</string>
|
||||
<string>supports-avless</string>
|
||||
<string>supports-co</string>
|
||||
<string>supports-gft-calls</string>
|
||||
<string>supports-gft-errors</string>
|
||||
<string>supports-modern-gft</string>
|
||||
<string>supports-self-one-to-one-invites</string>
|
||||
</array>
|
||||
<key>ec-version</key>
|
||||
<integer>1</integer>
|
||||
<key>identity-key-bytes</key>
|
||||
<integer>249</integer>
|
||||
<key>prekey-data-bytes</key>
|
||||
<integer>109</integer>
|
||||
<key>kt-loggable-data-bytes</key>
|
||||
<integer>40</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>service</key>
|
||||
<string>com.apple.ess</string>
|
||||
<key>capabilities</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>flags</key>
|
||||
<integer>1</integer>
|
||||
<key>name</key>
|
||||
<string>Invitation</string>
|
||||
<key>version</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>sub-services</key>
|
||||
<array>
|
||||
<string>com.apple.private.alloy.facetime.lp</string>
|
||||
<string>com.apple.private.alloy.facetime.mw</string>
|
||||
<string>com.apple.private.alloy.facetime.video</string>
|
||||
</array>
|
||||
<key>user-keys</key>
|
||||
<array>
|
||||
<string>client-data</string>
|
||||
<string>kt-loggable-data</string>
|
||||
<string>uris</string>
|
||||
<string>user-id</string>
|
||||
</array>
|
||||
<key>client-data-keys</key>
|
||||
<array>
|
||||
<string>public-message-identity-key</string>
|
||||
<string>public-message-identity-version</string>
|
||||
<string>ec-version</string>
|
||||
<string>public-message-identity-ngm-version</string>
|
||||
<string>public-message-ngm-device-prekey-data-key</string>
|
||||
<string>kt-version</string>
|
||||
<string>show-peer-errors</string>
|
||||
<string>supports-gft-errors</string>
|
||||
<string>supports-live-delivery</string>
|
||||
</array>
|
||||
<key>ec-version</key>
|
||||
<integer>1</integer>
|
||||
<key>identity-key-bytes</key>
|
||||
<integer>249</integer>
|
||||
<key>prekey-data-bytes</key>
|
||||
<integer>109</integer>
|
||||
<key>kt-loggable-data-bytes</key>
|
||||
<integer>40</integer>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in new issue
Block a user