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:
fenleon committed 2026-10-16 21:43:31 +02:00
1 parent d0dd6d057e
commit 28e0432f09
9 files changed
+856 -24

No files matched your search

+10 -5
View File
@@ -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),
@@ -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"])
@@ -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 -&gt; 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>