From 03f0ce12df5bd6fe35b2489ea95f37f2f24f8d2c Mon Sep 17 00:00:00 2001 From: fenleon Date: Fri, 16 Oct 2026 19:11:07 +0200 Subject: [PATCH] =?UTF-8?q?registration:=20the=20register=20body=20reaches?= =?UTF-8?q?=20capture=20parity=20(rev=2048)=20=E2=80=94=20NGM=20identity?= =?UTF-8?q?=20client-data=20+=20gzip;=20machine=20register=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trapped wa/register frames (ob-trap-20261014-201103 frame-025/026/051/052) decode to two registers: the full user register (x-auth families) and a machine-only register (services: [], push-only signature, fresh stamp). Our body was a stub: client-data carried only client-data-version. MadridIdentity ports the NGM identity (EC signing + RSA-1280 enc + EC device key + EC prekey — the genuine enc key IS RSA-1280), the identity-key DER (249 B at 1280), the prekey-data protobuf (109 B), kt-loggable-data (40 B), and the ~47 captured capability booleans. The request body is gzipped (C3 resolved by capture). Live: both registers answer 6004 ActionRetryWithNewAbsintheContext (SED server-session record in the message); the stored delegate credentials now answer 6008 at §1.2 authenticate. --- docs/imessage-protocol-spec.md | 4 +- .../imessage/registration/IdsActivation.kt | 14 +- .../imessage/registration/MadridIdentity.kt | 255 ++++++++++++++++++ .../registration/ActivationDryRunFixtures.kt | 3 +- .../registration/IdsActivationTest.kt | 6 +- .../registration/MadridIdentityTest.kt | 136 ++++++++++ 6 files changed, 409 insertions(+), 9 deletions(-) create mode 100644 registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt create mode 100644 registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt diff --git a/docs/imessage-protocol-spec.md b/docs/imessage-protocol-spec.md index 5b23999..d908fb3 100644 --- a/docs/imessage-protocol-spec.md +++ b/docs/imessage-protocol-spec.md @@ -111,7 +111,7 @@ A binary blob proving the client is a genuine Apple device; every registration s - `cpim`: an OUTER 276-B message `[u32be(5)][iv 16][u32be(160)][encout 160][X 32][u32be(52)][u32be(4)][Y 52]`; the 160-B plaintext = `u32be(32) ‖ encsec(rng2) ‖ u32be(32) ‖ rng1 ‖ 60 B blob (caller-extra ‖ rng3 pad) ‖ u64be(−2) ‖ u32be(unix-seconds) ‖ u32be(1) ‖ 12 fresh RNG bytes`. The IV is 16 plain RNG bytes; X = `SHA-256(encout ‖ spim_map_signature(trailing[0:16]) ‖ fixed 24-byte .rodata filler)` — written to the wire byte-exact (rev 29 closes the X32/12-B-tail/trailing-head TODOs; the earlier "constant sig block" and "sig16 not on the wire" readings are corrected). - `ptm`: `[u32be(4)][seed 16][u32be(payloadLen)][payload][tail]`; key16 = `encrypt(session, NULL iv, tk)`; block count = payloadLen/16; the finish gate is the decrypt output's `out[408:440] == rng1` echo. ProvisionedData = `{metadata: Vec (400 B live = decrypt out[4:404], length u32be at out[0]), client_secret = rng2 (32 B), mid = rng3 (60 B), flavor u8}`. -**Live-mint status (as of rev 47):** the full chain runs live on-device — export-code parse, hardware serialization byte-identical to capture, Apple-ID login with 2FA, fresh-blob mint accepted at step 2 through the rustls transport, courier connected, Albert activation accepted. Current blocker: the delegate sign-in's `400 ICLOUD_INVALID_DEVICE_TOKEN` (rev 45). **Rev 47 EXONERATES the §1.1 mint itself:** the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered live inputs (`NacValidationTest.mintReproducesLiveVendoredStamp` — body576 from the trapped sign-entry ctx, session250 == the genuine descrambler's own inversion, rngPad130 recovered by XOR-inverting the scramble chain via the genuine .so's `domergestep2`, whose `dm(x, in2) = state(in2) ⊕ x` makes every group invertible). The session-32 "rng_pad130 of scrambleBody" verdict is corrected: the pad is genuine thread-RNG filled INSIDE `scramble_body` (two code paths — a `len == 0x17c` fast path and an RNG-fill loop for shorter data; disasm 0x179654d), the scramble input buffer's caller-side tail is not the pad, and the descrambler inverts our stamps cleanly. Also eliminated live: the TLS transport (our rustls 0.20.9/0.21 ClientHellos are byte-equivalent to the reference's — captured via a write/writev interceptor — and stamps minted through either from a genuine blob still 400), the request shape (pass/fail prefpane requests are structurally identical), and stamp staleness. **The remaining discriminator is the machine's IDS registration freshness:** the reference's launch flow runs §1.4 `TDIdentityService/wa/register` TWICE *before* the delegate sign-in (trapped wire), its completed re-registrations (`id_cache.plist` write) are followed by our only two passes, and a pre-prefpane §1.2+§1.4 re-registration attempt on our side reaches the register endpoint but answers **6004 transient-retry** (§1.2 authenticate passes with the stored delegate credentials). Next: chase 6004 (retry/backoff, register-body diff vs the trapped `wa/register` frames) and consider registering before the prefpane as the standing flow. +**Live-mint status (as of rev 48):** the full chain runs live on-device — export-code parse, hardware serialization byte-identical to capture, Apple-ID login with 2FA, fresh-blob mint accepted at step 2 through the rustls transport, courier connected, Albert activation accepted. Current blockers (rev 48): the §1.4 register answers **6004 = `ActionRetryWithNewAbsintheContext`** (both the machine-only register, `services: []` + push-only signature per the trapped wire's register #2, and the full user register; the response's SED message is a server-side VEN session record, not a stamp echo), and the §1.2 authenticate with the stored delegate credentials answers **6008** (stale delegate token — the delegate token dies within hours). The §1.4 register body is now capture parity (rev 48): full NGM identity `client-data` + gzip request body; the register body was a stub before. ### 1.2 Activation endpoints and certificates @@ -562,6 +562,8 @@ Each item names its arbitration path. Closed residue is noted inline. ## Revision history +- **Rev 48 (2026-10-16 evening):** the §1.4 register body was a STUB — now capture parity. The trapped `wa/register` frames decoded (frame-025/026 = the full user register, frame-051/052 = a second MACHINE register): the genuine body carries the full madrid NGM identity in `client-data` (identity-key = DER SEQ{[1] u16be‖uncompressed P-256 signing point, [2] u16be‖RSA-1280 PKCS#1 DER} = 249 B; prekey-data = protobuf `PreKeyData{x32, ECDSA-SHA256 raw sig over "NGMPrekeySignature"‖x‖f64-le ts, ts}` = 109 B; `kt-loggable-data` = protobuf `{deviceIdentity{x32}, ngmVersion 13, ktVersion 5}` = 40 B) plus the ~47 captured capability booleans. Ported as `MadridIdentity` (four persisted keys: EC signing, RSA-1280 enc, EC device key, EC prekey — the genuine's enc key IS RSA-1280, `Rsa::generate(1280)` in rustpush). The register request body is GZIPPED (`content-encoding: gzip` — C3 resolved by capture; the "un-gzipped lower-assumption" choice was wrong). The genuine wire's register #2 = machine-only register: `services: []`, NO `x-auth-*` headers (push-only signature), fresh stamp — implemented as the first `idsRegister` step. **Live: both our machine register AND the full register answer `6004`** — decoded via [IG:idserrors]: **6004 = `ActionRetryWithNewAbsintheContext`**, with `message` = an SED server-session token `SED:3:VEN-PROD::::::EM` (all fields server-side: the hash ≠ sha256(stamp), UUIDs rotate per attempt). Separately: the §1.2 authenticate with the stored delegate credentials now answers **6008 (no cert)** — the delegate token went stale hours after session 33's pass. Open questions: whether the reference's register #1 also 6004s (responses not captured — the trap hooks writes only), and what "new absinthe context" concretely means when the register carries no anisette (the stamp's VEN session is the only context it rides). + - **Rev 47 (2026-10-16):** §1.1 mint EXONERATED on live data — the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered inputs (pad recovered by inverting the scramble chain; `domergestep2` is XOR-invertible). The session-32 "rng_pad130" verdict is corrected: the scramble pad is thread-RNG filled inside `scramble_body` (second code path, disasm 0x179654d). Transport (rustls ClientHello byte-equivalence), request shape, and staleness eliminated live. The prefpane `ICLOUD_INVALID_DEVICE_TOKEN` blocker narrows to the machine's IDS registration freshness: the reference runs §1.4 register ×2 before the delegate sign-in, and a pre-prefpane register on our side answers 6004 transient-retry. One line per revision, newest first. Details live in the sections above and in `registration/dev/clearadi/captures/`. diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt index 93a87ac..ab7dad9 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/IdsActivation.kt @@ -307,8 +307,8 @@ data class IdsRegisterResponse( /** * IDS register client per spec §1.4: POST the XML-plist body to the bag's `id-register` URL, * signed once with the push key and once per user with that user's auth key (§1.4's indexed - * header sets). The body goes out with the recorded content type and no gzip — the - * gzip-vs-plain disagreement is C3 and the un-gzipped variant is the lower-assumption one. + * header sets). The body goes out gzipped with the recorded content type — capture + rustpush + * parity (C3 resolved). * * [refreshCredentialsMarker] is the §6.1 "refresh credentials" status string — capture-bound, * so caller-supplied. With it unset (the default), any string-valued per-user/per-service @@ -324,10 +324,16 @@ class IdsRegistrar( suspend fun register(body: RegisterBody): IdsRegisterResponse { val url = bag.url(BAG_KEY) val encoded = XmlPlist.encode(body.toDict()) - val headers = signatureHeaders(encoded, body.users) + // C3 resolved by capture: the recorded reference gzips the register body + // (`content-encoding: gzip`, [rustpush:gzip_normal]). + val gzipped = ByteArrayOutputStream().also { body -> + GZIPOutputStream(body).use { it.write(encoded) } + }.toByteArray() + val headers = signatureHeaders(gzipped, body.users) headers[PROTOCOL_VERSION_HEADER] = config.protocolVersion headers[USER_AGENT_HEADER] = config.userAgent - val response = http.post(url, headers, encoded, CONTENT_TYPE) + headers["content-encoding"] = "gzip" + val response = http.post(url, headers, gzipped, CONTENT_TYPE) if (response.status != 200) throw IdsActivationException("POST $url → HTTP ${response.status}") return parseResponse(gunzipIfNeeded(response.body)) } diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt new file mode 100644 index 0000000..80bf8a9 --- /dev/null +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/MadridIdentity.kt @@ -0,0 +1,255 @@ +package dev.fenn.imessage.registration + +import dev.fenn.imessage.crypto.EcCrypto +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 + +/** + * The com.apple.madrid NGM identity carried in the §1.4 register's `client-data` + * [rustpush:ids/user.rs IDSNGMIdentity]: four keys — + * + * - `signing` — EC P-256, the legacy identity signing key (payload signatures); + * - `encryption` — RSA-1280, the legacy identity encryption key (payload decryption); + * - `deviceKey` — EC P-256, the NGM device identity key (`kt-loggable-data`, KT gossip); + * - `preKey` — EC P-256, the NGM device prekey (`public-message-ngm-device-prekey-data-key`). + * + * The identity is created once and persisted across registers — peers encrypt to its public + * keys via lookups, so a rotating identity strands conversations (same shape as the push + * identity's persistence, [rustpush:identity.save/restore]). + */ +class MadridIdentity( + val signing: KeyPair, + val encryption: KeyPair, + val deviceKey: KeyPair, + val preKey: KeyPair, +) { + + /** + * The `public-message-identity-key` blob [rustpush:IDSUserIdentity.encode]: a DER + * SEQUENCE of two context-tagged primitive OCTET STRINGs, each content-prefixed with the + * u16 big-endian key length — [1] the 65-byte uncompressed P-256 signing point, [2] the + * RSA PKCS#1 public-key DER of the encryption key. 249 bytes at RSA-1280. + */ + fun identityKeyDer(): ByteArray { + val signingPub = signing.public as ECPublicKey + val w = signingPub.w + val point = byteArrayOf(0x04) + fixed32(w.affineX) + fixed32(w.affineY) + val enc1 = u16be(point.size) + point + val rsa = rsaPkcs1PublicDer(encryption.public as RSAPublicKey) + val enc2 = u16be(rsa.size) + rsa + return derSequence(derContextPrimitive(1, enc1) + derContextPrimitive(2, enc2)) + } + + /** + * The `public-message-ngm-device-prekey-data-key` blob [rustpush:build_prekey_data] — + * protobuf `PreKeyData`: the prekey's x-coordinate (32 B), the device key's ECDSA-SHA256 + * raw signature over `"NGMPrekeySignature" ‖ x ‖ f64-le timestamp`, and the timestamp. + * 109 bytes. + */ + fun prekeyData(epochSeconds: Long): ByteArray { + val x = xOnly(preKey.public as ECPublicKey) + val seconds = epochSeconds.toDouble() + val payload = "NGMPrekeySignature".toByteArray(Charsets.US_ASCII) + x + + seconds.toRawBits().let { b -> + val out = ByteArray(8) + for (i in 0 until 8) out[i] = (b ushr (8 * i)).toByte() + out + } + val sig = Signature.getInstance("SHA256withECDSA") + sig.initSign(deviceKey.private) + sig.update(payload) + val raw = EcCrypto.derToRaw(sig.sign()) + return protoBytes(1, x) + protoBytes(2, raw) + byteArrayOf(0x09) + fixed64le(seconds.toRawBits()) + } + + /** + * The `kt-loggable-data` blob [rustpush:KtLoggableData] — protobuf: the NGM device + * identity's x-coordinate, ngm-version 13, kt-version 5. 40 bytes. + */ + fun ktLoggableData(): ByteArray { + val inner = protoBytes(1, xOnly(deviceKey.public as ECPublicKey)) + return protoBytes(1, inner) + byteArrayOf(0x10, 0x0d, 0x18, 0x05) + } + + /** Persisted form — per key pair: PKCS#8 private + X.509 public, base64, colon-joined. */ + fun serialize(): String = listOf( + signing.private.encoded, signing.public.encoded, + encryption.private.encoded, encryption.public.encoded, + deviceKey.private.encoded, deviceKey.public.encoded, + preKey.private.encoded, preKey.public.encoded, + ).joinToString(":") { b64(it) } + + companion object { + /** The static capability half of the madrid `client-data` — capture + [rustpush:MADRID_SERVICE]. */ + val CLIENT_DATA: Map = linkedMapOf( + "is-c2k-equipment" to true, + "optionally-receive-typing-indicators" to true, + "show-peer-errors" to true, + "supports-ack-v1" to true, + "supports-activity-sharing-v1" to true, + "supports-audio-messaging-v2" to true, + "supports-autoloopvideo-v1" to true, + "supports-be-v1" to true, + "supports-ca-v1" to true, + "supports-fsm-v1" to true, + "supports-fsm-v2" to true, + "supports-fsm-v3" to true, + "supports-ii-v1" to true, + "supports-impact-v1" to true, + "supports-inline-attachments" to true, + "supports-keep-receipts" to true, + "supports-location-sharing" to true, + "supports-media-v2" to true, + "supports-photos-extension-v1" to true, + "supports-st-v1" to true, + "supports-update-attachments-v1" to true, + "supports-people-request-messages" to true, + "supports-people-request-messages-v2" to true, + "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, + "supports-photos-extension-v2" to true, + "prefers-sdr" to false, + "supports-shared-exp" to true, + "supports-protobuf-payload-data-v2" to true, + "supports-hdr" to true, + "supports-heif" to true, + "supports-dq-nr" to true, + "supports-family-invite-message-bubble" to true, + "supports-live-delivery" to true, + "supports-findmy-plugin-messages" to true, + "supports-stick-moji-backs" to true, + "supports-emoji-tapbacks" to true, + "supports-send-later-messages" to true, + "supports-certified-delivery-v1" to true, + "supports-transcript-backgrounds" to true, + "supports-gti" to true, + "supports-polls" to true, + ) + + /** The identity-key base keys that ride before [CLIENT_DATA] (§1.4 client-data). */ + fun clientData(identity: MadridIdentity, prekeyTimestampEpochSeconds: Long): Map { + val base: LinkedHashMap = linkedMapOf( + "public-message-identity-key" to identity.identityKeyDer(), + "public-message-identity-version" to 2L, + "ec-version" to 1.0, + "public-message-identity-ngm-version" to 13L, + "public-message-ngm-device-prekey-data-key" to identity.prekeyData(prekeyTimestampEpochSeconds), + "kt-version" to 5L, + ) + base.putAll(CLIENT_DATA) + return base + } + + fun generate(): MadridIdentity = MadridIdentity( + signing = keyPair("EC", 256), + encryption = keyPair("RSA", 1280), + deviceKey = keyPair("EC", 256), + preKey = keyPair("EC", 256), + ) + + fun restore(serialized: String): MadridIdentity? = runCatching { + val parts = serialized.split(":") + require(parts.size == 8) { "madrid identity serialization is 8 base64 fields" } + fun pair(algorithm: String, pkcs8: String, x509: String): KeyPair { + val factory = KeyFactory.getInstance(algorithm) + return KeyPair( + factory.generatePublic(java.security.spec.X509EncodedKeySpec(Base64.getDecoder().decode(x509))), + factory.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(pkcs8))), + ) + } + MadridIdentity( + signing = pair("EC", parts[0], parts[1]), + encryption = pair("RSA", parts[2], parts[3]), + deviceKey = pair("EC", parts[4], parts[5]), + preKey = pair("EC", parts[6], parts[7]), + ) + }.getOrNull() + + private fun keyPair(algorithm: String, bits: Int): KeyPair = + KeyPairGenerator.getInstance(algorithm).apply { initialize(bits) }.generateKeyPair() + + private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes) + + private fun xOnly(key: ECPublicKey): ByteArray = fixed32(key.w.affineX) + + private fun fixed32(v: BigInteger): ByteArray { + val raw = v.toByteArray() + val out = ByteArray(32) + raw.copyInto(out, maxOf(0, 32 - raw.size), maxOf(0, raw.size - 32)) + return out + } + + private fun fixed64le(bits: Long): ByteArray { + val out = ByteArray(8) + for (i in 0 until 8) out[i] = (bits ushr (8 * i)).toByte() + return out + } + + private fun u16be(v: Int) = byteArrayOf((v ushr 8).toByte(), v.toByte()) + + /** Minimal DER writer: lengths ≥ 128 take the long form (both our inner blobs do). */ + private fun derLength(len: Int): ByteArray = when { + len < 0x80 -> byteArrayOf(len.toByte()) + len < 0x100 -> byteArrayOf(0x81.toByte(), len.toByte()) + else -> byteArrayOf(0x82.toByte(), (len ushr 8).toByte(), len.toByte()) + } + + private fun derSequence(content: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + out.write(0x30) + out.write(derLength(content.size)) + out.write(content) + return out.toByteArray() + } + + private fun derContextPrimitive(tag: Int, content: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + out.write(0x80 or tag) + out.write(derLength(content.size)) + out.write(content) + return out.toByteArray() + } + + /** RSA PKCS#1 RSAPublicKey DER: SEQUENCE(INTEGER modulus, INTEGER publicExponent). */ + private fun rsaPkcs1PublicDer(key: RSAPublicKey): ByteArray { + val modulus = derInteger(key.modulus) + val exponent = derInteger(key.publicExponent) + return derSequence(modulus + exponent) + } + + private fun derInteger(v: BigInteger): ByteArray { + val body = v.toByteArray() // two's complement, minimal with sign bit — exactly DER + val out = ByteArrayOutputStream() + out.write(0x02) + out.write(derLength(body.size)) + out.write(body) + return out.toByteArray() + } + + private fun protoBytes(field: Int, value: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + var key = (field shl 3) or 2 + while (key >= 0x80) { + out.write((key and 0x7f) or 0x80) + key = key ushr 7 + } + out.write(key) + out.write(derLength(value.size)) // varint == short DER for < 128; our blobs stay small + out.write(value) + return out.toByteArray() + } + } +} diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt index 8fc53a8..cdd9255 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt @@ -630,7 +630,8 @@ $inner""".toByteArray() } require(headers["x-auth-user-id-0"] == profileId) { "x-auth-user-id-0 is not the profile id (§1.4)" } - val request = Plist.parse(body) as? Map<*, *> ?: throw IllegalStateException("register body is not a plist (§1.4)") + require(headers["content-encoding"] == "gzip") { "register body is not gzipped (§1.4 capture parity)" } + val request = Plist.parse(gunzip(body)) as? Map<*, *> ?: throw IllegalStateException("register body is not a plist (§1.4)") // §1.4: exactly the eight recorded top-level keys, none beyond. require(request.keys == setOf( "device-name", "hardware-version", "language", "os-version", "software-version", diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt index eaa2ca8..2b97d3a 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/IdsActivationTest.kt @@ -150,7 +150,7 @@ class IdsActivationTest { val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) registrar(http).register(body()) - val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + val decoded = Plist.parse(gunzipIfNeeded(http.calls.single().body)) as Map<*, *> assertEquals( setOf( "device-name", "hardware-version", "language", "os-version", "software-version", @@ -171,7 +171,7 @@ class IdsActivationTest { val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) registrar(http).register(body()) - val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + val decoded = Plist.parse(gunzipIfNeeded(http.calls.single().body)) as Map<*, *> val service = (decoded["services"] as List<*>).single() as Map<*, *> assertEquals(setOf("capabilities", "service", "sub-services", "users"), service.keys) assertEquals("com.apple.madrid", service["service"]) @@ -197,7 +197,7 @@ class IdsActivationTest { val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) registrar(http).register(body()) - val decoded = Plist.parse(http.calls.single().body) as Map<*, *> + val decoded = Plist.parse(gunzipIfNeeded(http.calls.single().body)) as Map<*, *> assertEquals( mapOf( "ap" to "0", diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt new file mode 100644 index 0000000..1607654 --- /dev/null +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/MadridIdentityTest.kt @@ -0,0 +1,136 @@ +package dev.fenn.imessage.registration + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertNotNull + +/** + * Shape parity of the madrid identity blobs against the captured genuine §1.4 register + * (ob-trap-20261014-201103 frame-026): identity-key 249 B (RSA-1280), prekey 109 B, + * kt-loggable-data 40 B — and the prekey signature verifies against the device key. + */ +class MadridIdentityTest { + + private val identity = MadridIdentity.generate() + + @Test + fun identityKeyDerIsTheCapturedShape() { + val der = identity.identityKeyDer() + // 3 (SEQ header `30 81 f6`) + ctx1 (tag + len + 67 = 69) + ctx2 (tag + len + 174 = 177) + // = 249 at RSA-1280 (248 if the modulus skips its leading zero byte). + assertTrue(der.size in 248..249, "identity key DER is ${der.size} bytes") + assertEquals(0x30, der[0].toInt() and 0xFF) + var i = 1 + val seqLen = readLength(der, i).also { i = it.second }.first + + // [1] — u16be key length + the 65-byte uncompressed P-256 signing point + assertEquals(0x81, der[i].toInt() and 0xFF) + val len1 = readLength(der, i + 1).also { i = it.second }.first + val c1 = der.copyOfRange(i, i + len1) + i += len1 + val pointLen = ((c1[0].toInt() and 0xFF) shl 8) or (c1[1].toInt() and 0xFF) + assertEquals(65, pointLen) + assertEquals(0x04, c1[2].toInt() and 0xFF, "signing point is uncompressed") + assertEquals(67, len1) + + // [2] — u16be key length + RSA PKCS#1 RSAPublicKey DER + assertEquals(0x82, der[i].toInt() and 0xFF) + val len2 = readLength(der, i + 1).also { i = it.second }.first + val c2 = der.copyOfRange(i, i + len2) + i += len2 + val rsaLen = ((c2[0].toInt() and 0xFF) shl 8) or (c2[1].toInt() and 0xFF) + assertEquals(len2 - 2, rsaLen) + assertEquals(0x30, c2[2].toInt() and 0xFF, "encryption key is a PKCS#1 RSAPublicKey") + + assertEquals(seqLen, i - 3, "DER consumed exactly") + } + + @Test + fun prekeyDataIsTheCapturedShape() { + val ts = 1_760_000_000L + val blob = identity.prekeyData(ts) + assertEquals(109, blob.size, "prekey blob is ${blob.size} bytes") + assertEquals(0x0a, blob[0].toInt()) + assertEquals(0x20, blob[1].toInt()) + assertEquals(0x12, blob[34].toInt()) + assertEquals(0x40, blob[35].toInt()) + assertEquals(0x09, blob[100].toInt()) + val timestamp = java.lang.Double.longBitsToDouble(readLe64(blob, 101)) + assertEquals(ts.toDouble(), timestamp) + } + + @Test + fun prekeySignatureVerifiesAgainstTheDeviceKey() { + val ts = 1_760_000_000L + val blob = identity.prekeyData(ts) + val x = blob.copyOfRange(2, 34) + val sig = blob.copyOfRange(36, 100) + val payload = "NGMPrekeySignature".toByteArray(Charsets.US_ASCII) + x + + java.lang.Double.longBitsToDouble(readLe64(blob, 101)).let { + ByteArray(8) { i -> (java.lang.Double.doubleToLongBits(it) ushr (8 * i)).toByte() } + } + val verifier = java.security.Signature.getInstance("SHA256withECDSA") + verifier.initVerify( + java.security.KeyFactory.getInstance("EC").generatePublic( + java.security.spec.X509EncodedKeySpec(identity.deviceKey.public.encoded), + ), + ) + verifier.update(payload) + assertTrue(verifier.verify(dev.fenn.imessage.crypto.EcCrypto.rawToDer(sig))) + } + + @Test + fun ktLoggableDataIsTheCapturedShape() { + val blob = identity.ktLoggableData() + assertEquals(40, blob.size, "kt blob is ${blob.size} bytes") + assertEquals(0x0a, blob[0].toInt()) + assertEquals(0x22, blob[1].toInt()) + assertEquals(0x0a, blob[2].toInt()) + assertEquals(0x20, blob[3].toInt()) + assertEquals(0x10, blob[36].toInt()) + assertEquals(0x0d, blob[37].toInt(), "ngm-version 13") + assertEquals(0x18, blob[38].toInt()) + assertEquals(0x05, blob[39].toInt(), "kt-version 5") + } + + @Test + fun clientDataCarriesIdentityPlusCapturedStatics() { + val data = MadridIdentity.clientData(identity, 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"]) + assertEquals(13L, data["public-message-identity-ngm-version"]) + assertTrue(data["public-message-ngm-device-prekey-data-key"] is ByteArray) + assertEquals(5L, data["kt-version"]) + assertEquals(true, data["is-c2k-equipment"]) + assertEquals(false, data["prefers-sdr"]) + assertEquals(1.0, data["nicknames-version"]) + assertEquals(52, data.size, "6 identity keys + 47 captured statics, ec-version deduped") + } + + @Test + fun serializeRestoreRoundTripsTheIdentity() { + val restored = MadridIdentity.restore(identity.serialize()) + assertNotNull(restored) + assertTrue(restored.identityKeyDer().contentEquals(identity.identityKeyDer())) + assertTrue(restored.ktLoggableData().contentEquals(identity.ktLoggableData())) + } + + private fun readLength(der: ByteArray, at: Int): Pair { + val first = der[at].toInt() and 0xFF + return if (first < 0x80) first to at + 1 + else { + val n = first and 0x7F + var v = 0 + for (k in 1..n) v = (v shl 8) or (der[at + k].toInt() and 0xFF) + v to at + 1 + n + } + } + + private fun readLe64(b: ByteArray, at: Int): Long { + var v = 0L + for (k in 7 downTo 0) v = (v shl 8) or (b[at + k].toLong() and 0xFF) + return v + } +}