mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
registration: the register body reaches capture parity (rev 48) — NGM identity client-data + gzip; machine register added
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.
This commit is contained in:
1 parent
2b96445cb4
commit
03f0ce12df
6 files changed
+409
-9
No files matched your search
@@ -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:<uuid>:<uuid>:<mint-epoch>:<uuid>:<hash>:EM` (all fields server-side: the hash ≠ sha256(stamp), UUIDs rotate per attempt). Separately: the §1.2 authenticate with the stored delegate credentials now answers **6008 (no cert)** — the delegate token went stale hours after session 33's pass. Open questions: whether the reference's register #1 also 6004s (responses not captured — the trap hooks writes only), and what "new absinthe context" concretely means when the register carries no anisette (the stamp's VEN session is the only context it rides).
|
||||
|
||||
- **Rev 47 (2026-10-16):** §1.1 mint EXONERATED on live data — the Kotlin mint reproduces an accepted genuine stamp byte-for-byte from recovered inputs (pad recovered by inverting the scramble chain; `domergestep2` is XOR-invertible). The session-32 "rng_pad130" verdict is corrected: the scramble pad is thread-RNG filled inside `scramble_body` (second code path, disasm 0x179654d). Transport (rustls ClientHello byte-equivalence), request shape, and staleness eliminated live. The prefpane `ICLOUD_INVALID_DEVICE_TOKEN` blocker narrows to the machine's IDS registration freshness: the reference runs §1.4 register ×2 before the delegate sign-in, and a pre-prefpane register on our side answers 6004 transient-retry.
|
||||
|
||||
One line per revision, newest first. Details live in the sections above and in `registration/dev/clearadi/captures/`.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<String, Any?> = 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<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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -630,7 +630,8 @@ $inner</Protocol>""".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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Int, Int> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user