From 9da53e31f85de15ef49011727ca6adff5af3855d Mon Sep 17 00:00:00 2001 From: fenleon Date: Wed, 14 Oct 2026 20:23:41 +0200 Subject: [PATCH] registration: GSA cpd/headers wire-pinned against the trapped binary (2026-10-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-ever capture of the binary's GSA init/complete/postdata/2FA requests (static OpenSSL via ssl3_write_bytes; rustls only ever saw the prefpane path): - cpd carries NO X-Apple-I-MD-LU (neither headers nor cpd) — dropped; the keychainIdentifier constructor param is gone with the derivation. - cpd 'at' is the plist INTEGER 0 (the boolean-false recording was also live-accepted; the integer form is what the binary sends). - GSA login headers: Accept */* added; casing aligned to the binary (X-Mme-Client-Info, X-Apple-Ak-Context-Type). - 2FA browser-style set: hardware-identity headers ride it as HTTP headers (unlike the login POSTs); same casing alignment. - ptkn rides every login cpd once the app holds a push token (imessage side wires the persisted courier token). --- .../dev/fenn/imessage/registration/GsaCpd.kt | 46 ++++++++----------- .../imessage/registration/GsaTwoFactor.kt | 9 ++-- .../registration/ActivationDryRunTest.kt | 6 +-- .../fenn/imessage/registration/GsaCpdTest.kt | 26 +++++------ .../registration/GsaLoginChainTest.kt | 1 - .../imessage/registration/GsaTwoFactorTest.kt | 21 +++++---- 6 files changed, 52 insertions(+), 57 deletions(-) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt index 917e098..02fc906 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt @@ -51,8 +51,9 @@ enum class GsaFlavor( /** * The GSA `cpd` (client-provided-data) dictionary of spec §1.5 — a dict inside the request * body carrying the anisette values as KEYS plus the fixed-value set. Anisette values come - * from chunk A's [AnisetteHeaderProvider]; the `X-Apple-I-MD-LU` value is derived here from - * the keychain identifier per §1.1 (lowercase hex SHA-256 over the same 16 bytes). + * from chunk A's [AnisetteHeaderProvider]. Wire-pinned against the trapped binary's GSA + * init (2026-10-14): NO `X-Apple-I-MD-LU` anywhere (headers or cpd), the hardware-identity + * headers chain LAST, and `ptkn` rides every login once the app holds a push token. * * `capp`/`cbid`/`svct` ride the recorded per-flavor values ([GsaFlavor], C59); `ptkn` (APNs * token) is optional. @@ -63,15 +64,12 @@ enum class GsaFlavor( * `X-Apple-Security-Upgrade-Context` = `com.apple.authkit.generic`, `cou` = `US`, * `loc` = `en_US`, and the booleans `X-Apple-Offer-Security-Upgrade`=true, `bootstrap`=true, * `ckgen`=true, `fcd`=true, `icdrsDisabled`=false, `icscrec`=true, `pbe`=false, - * `prkgen`=true, `webAccessEnabled`=false, `at`=false (the recorded wire value is the plist - * boolean false from a genuine Mac capture; the vendored Rust source writes Integer(0) — - * both live-accepted, the recording wins). + * `prkgen`=true, `webAccessEnabled`=false, `at`= the plist INTEGER 0 (wire-proven; the + * earlier boolean-false reading was also live-accepted). */ class GsaCpd( /** The §1.1 anisette headers (from [AnisetteHeaderProvider.headers]). */ private val anisette: Map, - /** The 16-byte keychain identifier — feeds the `X-Apple-I-MD-LU` derivation (§1.1). */ - private val keychainIdentifier: ByteArray, /** The recorded per-flavor `capp`/`cbid`/`svct` values (§1.5, C59). */ val flavor: GsaFlavor = GsaFlavor.MESSAGES, /** Optional APNs token (`ptkn`). */ @@ -90,7 +88,6 @@ class GsaCpd( fun dictFor(requestUuid: String): Map { val dict = LinkedHashMap() ANISETTE_KEYS.forEach { key -> anisette[key]?.let { dict[key] = it } } - dict["X-Apple-I-MD-LU"] = mdLu() dict["X-Apple-I-Device-Configuration-Mode"] = "0" dict[REQUEST_UUID_KEY] = requestUuid dict["X-Apple-Requested-Partition"] = "0" @@ -101,9 +98,8 @@ class GsaCpd( dict["loc"] = "en_US" dict["svct"] = flavor.akContextType dict["X-Apple-Offer-Security-Upgrade"] = true - // The recorded wire value is the boolean false (genuine Mac capture); the vendored - // Rust source writes Integer(0) — both live-accepted, keep the recording (2026-10-14). - dict["at"] = false + // Wire-proven 2026-10-14 (trapped GSA init): the binary sends the plist INTEGER 0. + dict["at"] = 0 dict["bootstrap"] = true dict["ckgen"] = true dict["fcd"] = true @@ -117,23 +113,18 @@ class GsaCpd( return dict } - /** §1.1: lowercase hex (no dashes) SHA-256 over the 16-byte keychain identifier. */ - private fun mdLu(): String = java.security.MessageDigest.getInstance("SHA-256") - .digest(keychainIdentifier) - .joinToString("") { "%02x".format(it) } - /** * The §1.5 header set that rides the GSA POSTs: the base anisette headers filtered to - * `X-Apple-I-MD-LU`, `X-Apple-I-MD-RINFO`, `X-Apple-I-MD-M`, `X-Apple-I-MD`, - * `X-Mme-Device-Id` — the same values the cpd carries, as headers. The akd-variant - * headers ([GsaHeaderConfig]) are appended by the caller. + * `X-Apple-I-MD-RINFO`, `X-Apple-I-MD-M`, `X-Apple-I-MD`, `X-Mme-Device-Id` — the same + * values the cpd carries, as headers. Wire-proven 2026-10-14: the binary's GSA POSTs + * carry NO `X-Apple-I-MD-LU` (neither headers nor cpd). The akd-variant headers + * ([GsaHeaderConfig]) are appended by the caller. */ fun headersFor(): Map = buildMap { anisette[AnisetteHeaderProvider.MACHINE_OTP]?.let { put(AnisetteHeaderProvider.MACHINE_OTP, it) } anisette[AnisetteHeaderProvider.RINFO]?.let { put(AnisetteHeaderProvider.RINFO, it) } anisette[AnisetteHeaderProvider.MACHINE_ID]?.let { put(AnisetteHeaderProvider.MACHINE_ID, it) } anisette[AnisetteHeaderProvider.DEVICE_ID]?.let { put(AnisetteHeaderProvider.DEVICE_ID, it) } - put("X-Apple-I-MD-LU", mdLu()) } companion object { @@ -195,18 +186,19 @@ data class GsaHeaderConfig( get() = "<$hardwareModel> <${osName};${osVersion};${osBuild}> <$AOSKIT_CLIENT_INFO>" /** - * The GSA login POST header set — the vendored reference's `get_gsservice_headers` - * (icloud-auth anisette.rs, 2026-10-14 source diff): the akd `User-Agent`, the akd - * `X-MMe-Client-Info`, the flavor's context/bundle headers and `Accept-Language`. The - * hardware-identity headers are NOT here — the reference carries them inside the cpd - * dict only ([GsaCpd.hardwareHeaders]). + * The GSA login POST header set — wire-proven against the trapped binary's GSA init + * (2026-10-14): the akd `X-Mme-Client-Info` (lowercase e — the binary's form), + * akd `User-Agent`, the flavor's context/bundle headers (`X-Apple-Ak-Context-Type`, + * lowercase k), `Accept: any` and `Accept-Language`. No hardware headers and no + * MD-LU (both live inside the cpd / nowhere). */ fun toHeaders(): Map = linkedMapOf( - "X-MMe-Client-Info" to clientInfo, + "X-Mme-Client-Info" to clientInfo, "User-Agent" to AKD_USER_AGENT, - "X-Apple-AK-Context-Type" to flavor.akContextType, + "X-Apple-Ak-Context-Type" to flavor.akContextType, "X-Apple-Client-App-Name" to flavor.clientAppName, "X-Apple-I-Client-Bundle-Id" to flavor.clientBundleId, + "Accept" to "*/*", "Accept-Language" to "en-US,en;q=0.9", ) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt index ddfa866..887f4cc 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaTwoFactor.kt @@ -96,7 +96,10 @@ class GsaTwoFactorHeaders( putAll(anisette) put("X-Apple-Client-App-Name", flavor.clientAppName) put("X-Apple-I-Client-Bundle-Id", flavor.clientBundleId) - clientInfo?.let { put("X-MMe-Client-Info", it) } + clientInfo?.let { put("X-Mme-Client-Info", it) } + // Wire-proven 2026-10-14 (trapped /validate): the hardware-identity headers ride + // the 2FA browser-style set as HTTP headers (unlike the GSA login POSTs). + device?.hardwareHeaders?.forEach { (key, value) -> put(key, value) } put("X-Apple-I-CDP-Circle-Status", "false") put("X-Apple-I-ICSCREC", "true") put("User-Agent", userAgent) @@ -111,11 +114,11 @@ class GsaTwoFactorHeaders( put("X-Apple-I-TimeZone", "UTC") put("X-Apple-I-OT-Status", "false") put("X-Apple-I-TimeZone-Offset", "0") - put("X-MMe-Country", "US") + put("X-Mme-Country", "US") put("X-Apple-I-CDP-Status", "false") put("X-Apple-I-Device-Configuration-Mode", "0") put("Sec-Fetch-Dest", "empty") - put("X-Apple-AK-Context-Type", flavor.akContextType) + put("X-Apple-Ak-Context-Type", flavor.akContextType) put("X-Apple-I-CFU-State", CFU_STATE) } diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt index 463b1ce..0d0d3a1 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt @@ -222,7 +222,7 @@ class ActivationDryRunTest { osVersion = export.macOsProductVersion!!, osBuild = identifiers.osBuildNumber!!, ) - val cpd = GsaCpd(anisette = anisette, keychainIdentifier = ByteArray(16) { (it + 1).toByte() }) + val cpd = GsaCpd(anisette = anisette) val chain = GsaLoginChain( http = env, cpd = { cpd }, @@ -456,10 +456,10 @@ class ActivationDryRunTest { ) val chain = GsaLoginChain( http = env, - cpd = { GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)) }, + cpd = { GsaCpd(anisette = anisetteHeaders()) }, twoFactorHeaders = { GsaTwoFactorHeaders.of( - GsaCpd(anisette = anisetteHeaders(), keychainIdentifier = ByteArray(16)), + GsaCpd(anisette = anisetteHeaders()), device = deviceConfig, ) }, diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt index 028066a..6b22802 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaCpdTest.kt @@ -59,7 +59,6 @@ class GsaCpdTest { private fun cpd(apnsToken: String? = null, flavor: GsaFlavor = GsaFlavor.MESSAGES) = GsaCpd( anisette = anisette, - keychainIdentifier = keychainIdentifier, flavor = flavor, apnsToken = apnsToken, ) @@ -83,10 +82,9 @@ class GsaCpdTest { } @Test - fun `md-lu is lowercase hex sha-256 of the keychain identifier`() { - val expected = MessageDigest.getInstance("SHA-256").digest(keychainIdentifier) - .joinToString("") { "%02x".format(it) } - assertEquals(expected, dict()["X-Apple-I-MD-LU"]) + fun `md-lu is absent — wire-proven (the binary carries it nowhere on GSA)`() { + assertNull(dict()["X-Apple-I-MD-LU"]) + assertNull(cpd().headersFor()["X-Apple-I-MD-LU"]) } @Test @@ -117,7 +115,7 @@ class GsaCpdTest { fun `recorded booleans are pinned`() { val dict = dict() assertEquals(true, dict["X-Apple-Offer-Security-Upgrade"]) - assertEquals(false, dict["at"]) + assertEquals(0, dict["at"]) assertEquals(true, dict["bootstrap"]) assertEquals(true, dict["ckgen"]) assertEquals(true, dict["fcd"]) @@ -135,14 +133,14 @@ class GsaCpdTest { } @Test - fun `the filtered anisette header set rides the POSTs with the derived md-lu`() { + fun `the filtered anisette header set rides the POSTs`() { val headers = cpd().headersFor() assertEquals("an-otp", headers["X-Apple-I-MD"]) assertEquals("a-machine-id", headers["X-Apple-I-MD-M"]) assertEquals("17106176", headers["X-Apple-I-MD-RINFO"]) assertEquals("1234ABCD-5678-90EF-1234-ABCD567890EF", headers["X-Mme-Device-Id"]) - assertEquals(dict()["X-Apple-I-MD-LU"], headers["X-Apple-I-MD-LU"]) - // The filtered set: client-time is a cpd key only, hardware headers appear on neither. + // The filtered set: client-time is a cpd key only; MD-LU and the hardware + // headers appear on neither (wire-proven 2026-10-14). assertNull(headers["X-Apple-I-Client-Time"]) assertNull(headers["X-Apple-I-MLB"]) } @@ -158,12 +156,13 @@ class GsaCpdTest { ).toHeaders() assertEquals( mapOf( - "X-MMe-Client-Info" to " ", + "X-Mme-Client-Info" to " ", "User-Agent" to "akd/1.0 CFNetwork/1494.0.7 Darwin/23.4.0", - "X-Apple-AK-Context-Type" to "imessage", + "X-Apple-Ak-Context-Type" to "imessage", "X-Apple-Client-App-Name" to "Messages", "X-Apple-I-Client-Bundle-Id" to "com.apple.MobileSMS", - // The vendored reference's get_gsservice_headers carries it (2026-10-14 source diff). + // Wire-proven against the trapped binary's GSA init (2026-10-14). + "Accept" to "*/*", "Accept-Language" to "en-US,en;q=0.9", ), headers, @@ -175,7 +174,7 @@ class GsaCpdTest { osVersion = "12.5.5", osBuild = "16H62", ).toHeaders() - assertEquals("icloud", icloud["X-Apple-AK-Context-Type"]) + assertEquals("icloud", icloud["X-Apple-Ak-Context-Type"]) assertEquals("icloud", icloud["X-Apple-Client-App-Name"]) assertEquals("com.apple.systempreferences.AppleIDSettings", icloud["X-Apple-I-Client-Bundle-Id"]) } @@ -185,7 +184,6 @@ class GsaCpdTest { val expected = setOf( "X-Apple-I-Client-Time", "X-Apple-I-MD", - "X-Apple-I-MD-LU", "X-Apple-I-MD-M", "X-Apple-I-MD-RINFO", "X-Mme-Device-Id", diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt index 1b7625c..a0eb234 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt @@ -63,7 +63,6 @@ class GsaLoginChainTest { "X-Apple-I-MD-M" to "machine-id", "X-Mme-Device-Id" to "device-id", ), - keychainIdentifier = ByteArray(16) { it.toByte() }, ) private fun chain( diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt index db420aa..489d449 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaTwoFactorTest.kt @@ -70,7 +70,6 @@ class GsaTwoFactorTest { "X-Apple-I-MD-M" to "machine-id", "X-Mme-Device-Id" to "device-id", ), - keychainIdentifier = ByteArray(16) { it.toByte() }, ), device = device, userAgent = userAgent, @@ -133,6 +132,10 @@ class GsaTwoFactorTest { osName = "macOS", osVersion = "13.6.4", osBuild = "22G513", + hardwareHeaders = mapOf( + "X-Apple-I-MLB" to "MLBVALUE", + "X-Apple-I-ROM" to "aa11bb22cc33", + ), ) val headers = browserHeaders(device = device).toHeaders() val expected = mapOf( @@ -140,15 +143,15 @@ class GsaTwoFactorTest { "X-Apple-I-MD-RINFO" to "17106176", "X-Apple-I-MD-M" to "machine-id", "X-Mme-Device-Id" to "device-id", - "X-Apple-I-MD-LU" to - java.security.MessageDigest.getInstance("SHA-256") - .digest(ByteArray(16) { it.toByte() }) - .joinToString("") { "%02x".format(it) }, + // Wire-proven 2026-10-14: the binary's /validate carries the hardware-identity + // headers as HTTP headers and NO MD-LU. + "X-Apple-I-MLB" to "MLBVALUE", + "X-Apple-I-ROM" to "aa11bb22cc33", "X-Apple-Client-App-Name" to "Messages", "X-Apple-I-Client-Bundle-Id" to "com.apple.MobileSMS", // Spec §1.5 rev-15 closure: the device-bracket form with the fixed // `com.apple.akd/1.0 (com.apple.akd/1.0)` inner component. - "X-MMe-Client-Info" to " ", + "X-Mme-Client-Info" to " ", "X-Apple-I-CDP-Circle-Status" to "false", "X-Apple-I-ICSCREC" to "true", // Spec §1.5 rev-15 closure: the browser UA literal — NO Safari/Version token. @@ -165,11 +168,11 @@ class GsaTwoFactorTest { "X-Apple-I-TimeZone" to "UTC", "X-Apple-I-OT-Status" to "false", "X-Apple-I-TimeZone-Offset" to "0", - "X-MMe-Country" to "US", + "X-Mme-Country" to "US", "X-Apple-I-CDP-Status" to "false", "X-Apple-I-Device-Configuration-Mode" to "0", "Sec-Fetch-Dest" to "empty", - "X-Apple-AK-Context-Type" to "imessage", + "X-Apple-Ak-Context-Type" to "imessage", "X-Apple-I-CFU-State" to GsaTwoFactorHeaders.CFU_STATE, ) assertEquals(expected, headers) @@ -178,7 +181,7 @@ class GsaTwoFactorTest { @Test fun `without device slots the non-akd client-info is omitted`() { val headers = browserHeaders().toHeaders() - assertEquals(false, headers.containsKey("X-MMe-Client-Info")) + assertEquals(false, headers.containsKey("X-Mme-Client-Info")) assertEquals(GsaTwoFactorHeaders.BROWSER_USER_AGENT, headers["User-Agent"]) }