From 478a473629908a6747ff18e84e397b39727b46f7 Mon Sep 17 00:00:00 2001 From: fenleon Date: Wed, 14 Oct 2026 16:57:07 +0200 Subject: [PATCH] registration: prefpane parity + the GSA postdata liveness event + the live sig16 vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GsaDelegateLogin: the prefpane anisette headers carry the accepted request's exact wire casing (X-Apple-I-Md / -Md-M / -Md-Rinfo / X-Apple-Adsid / X-Apple-I-Timezone — real AuthKit forms; the recorded GSA names are all-caps and setup.icloud.com answers ICLOUD_INVALID_DEVICE_TOKEN with them at full value parity). GSA keeps its recorded working set. - GsaHeaderConfig: hardwareHeaders (X-Apple-I-SRL-NO / MLB / ROM) ride every GSA call — the reference chains the C58 machine's headers into all of them. - GsaPostdataClient wired: the postdata liveness event fires before the delegate sign-in (vendored do_login order — the earlier 'sign-in first' note was wrong), with the APNs push token (the endpoint 401s ec=-800012 without it); this is what creates the trusted device record on the account. - NacValidationTest: the live vector — sign() reproduces the vendored client's real sig16 byte-exactly on gdb-captured inputs (the mint's signature model exonerated on live data). - ActivationDryRunTest: postdata ordering corrected to the vendored sequence. --- .../dev/fenn/imessage/registration/GsaCpd.kt | 9 ++- .../imessage/registration/GsaDelegateLogin.kt | 68 ++++++++++++++++--- .../imessage/registration/GsaLoginChain.kt | 35 ++++++++++ .../fenn/imessage/registration/GsaPostdata.kt | 13 ++-- .../registration/ActivationDryRunTest.kt | 17 ++--- .../registration/NacValidationTest.kt | 16 +++++ 6 files changed, 134 insertions(+), 24 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 f79b13d..de5b524 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaCpd.kt @@ -155,6 +155,13 @@ data class GsaHeaderConfig( val osVersion: String, /** The `` slot (§1.5, device-dependent). */ val osBuild: String, + /** + * The Mac hardware-identity headers (§1.1) — `X-Apple-I-SRL-NO` / `X-Apple-I-MLB` / + * `X-Apple-I-ROM` — riding every GSA call. The vendored reference chains these into + * every GSA request (icloud-auth anisette.rs `hardware_headers`): they are what anchors + * the real machine to the account session/device record. Empty = the headers are absent. + */ + val hardwareHeaders: Map = emptyMap(), ) { /** The akd-form template: ` `. */ @@ -181,7 +188,7 @@ data class GsaHeaderConfig( "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, - ) + ) + hardwareHeaders companion object { /** The recorded akd `User-Agent` literal (§1.5, C59). */ diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt index 66042bd..cd32b19 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -157,6 +157,31 @@ class GsaDelegateLoginClient( // 401 UNAUTHORIZED, so the server reads Basic even on the V1 shape). The V2 wrapper // is kept for the recorded v2 endpoint (§1.5 C59). val prefpane = endpoint == PREFPANE_ENDPOINT + // The captured prefpane request (vendored client, live 2026-10-14, /tmp/ob-req-002.bin) + // carries NO X-Apple-I-MD-LU and DOES carry X-Apple-Locale/X-Apple-I-Timezone — the + // inverse of the GSA cpd header set (headersFor() adds MD-LU, drops both). MD-LU here is + // a machine-identity header (real macOS sends its AuthKit local-user UUID) and the + // endpoint's verdict is ICLOUD_INVALID_DEVICE_TOKEN — the fabricated sha256(keychain-id) + // form is the prime suspect for the 400. GSA keeps its recorded set (rev 15). + val anisette = if (prefpane) { + request.anisette + .filterKeys { !it.equals("X-Apple-I-MD-LU", true) } + // The accepted prefpane request's exact wire casing (live capture 2026-10-14, + // captures/c56k/mint-repro/ob-req-002.bin): real AuthKit sends mixed-case + // Md/Adsid/Timezone, while the §1.1-recorded GSA names carry all-caps + // MD/ADSID/TimeZone — and setup.icloud.com answers ICLOUD_INVALID_DEVICE_TOKEN + // with the caps forms at full value parity (2026-10-14 byte diff), so the + // endpoint is treated as case-sensitive. GSA keeps its recorded (working) set. + .mapKeys { (k, _) -> PREFPANE_HEADER_CASE[k.lowercase()] ?: k } + + mapOf( + "X-Apple-Locale" to "en_US", + "X-Apple-I-Timezone" to "UTC", + // fresh per request, the get_headers Z form (capture 2026-10-14) + "X-Apple-I-Client-Time" to iso8601Z(System.currentTimeMillis()), + ) + } else { + request.anisette + } val body = if (prefpane) { linkedMapOf( "apple-id" to request.username, @@ -179,11 +204,16 @@ class GsaDelegateLoginClient( ) } val headers = buildMap { - putAll(request.anisette) + putAll(anisette) put("Authorization", "Basic $basic") put(HEADER_NAS_QUALIFY, Base64.getEncoder().encodeToString(request.validationData)) - put(HEADER_ADSID, request.adsid) - put(HEADER_CLIENT_INFO, request.clientInfo) + // Mixed case on the prefpane path (see PREFPANE_HEADER_CASE): the accepted + // request carries X-Apple-Adsid, the recorded GSA name is all-caps ADSID. + put(if (prefpane) "X-Apple-Adsid" else HEADER_ADSID, request.adsid) + // The captured prefpane request (vendored client, live 2026-10-14, /tmp/ob-req-002.bin) + // carries the AuthKit/MobileSMS inner component here, not the AOSKit form the v2 + // endpoint was recorded with. + put(HEADER_CLIENT_INFO, if (prefpane) PREFPANE_CLIENT_INFO else request.clientInfo) put("User-Agent", SIGNIN_USER_AGENT) } val encoded = XmlPlist.encode(body) @@ -199,7 +229,10 @@ class GsaDelegateLoginClient( ) } } - val response = http.post(endpoint, headers, encoded, CONTENT_TYPE) + // No Content-Type on the prefpane path: the captured accepted request sends the XML + // body bare (reqwest default), BridgeHttp maps "" to no header. The recorded v2 + // endpoint keeps its content type. + val response = http.post(endpoint, headers, encoded, if (prefpane) "" else CONTENT_TYPE) if (response.status != 200) { throw GsaLoginException( "iCloud sign-in POST $endpoint → HTTP ${response.status}${errorDetail(response.body)}", @@ -273,11 +306,30 @@ class GsaDelegateLoginClient( const val SIGNIN_ENDPOINT = "https://setup.icloud.com/setup/signin/v2/login" /** - * The reference client's delegate sign-in endpoint (.so strings, 2026-10-13) — same - * header/body family as [SIGNIN_ENDPOINT]; the endpoint where its delegate sign-ins - * succeed live (the recorded v2 form answers 409 ICLOUD_UNSUPPORTED_DEVICE). + * The reference client's delegate sign-in endpoint — captured LIVE 2026-10-14 from the + * vendored client under gdb (`/tmp/ob-req-002.bin`): the wire path is + * `/setup/prefpane/loginDelegates`, NOT `/setup/prefpane/login` (the `.so`'s rodata + * string table concatenation `…/login` ‖ `Delegates…` misread rev 45 — `login` is the + * plain machine sign-in whose failure dialog is exactly the 400 + * `ICLOUD_INVALID_DEVICE_TOKEN` every on-device attempt answered). The successful + * sign-in CREATES the account's device record (live 2026-10-14: after the user removed + * the record, the reference's next sign-in re-added it — no 2FA needed, stored-hash + * re-auth). */ - const val PREFPANE_ENDPOINT = "https://setup.icloud.com/setup/prefpane/login" + const val PREFPANE_ENDPOINT = "https://setup.icloud.com/setup/prefpane/loginDelegates" + + /** The accepted prefpane request's exact anisette wire casing (live 2026-10-14, + * ob-req-002.bin) — see the mapKeys call in [signIn]. */ + private val PREFPANE_HEADER_CASE = mapOf( + "x-apple-i-md" to "X-Apple-I-Md", + "x-apple-i-md-m" to "X-Apple-I-Md-M", + "x-apple-i-md-rinfo" to "X-Apple-I-Md-Rinfo", + "x-apple-i-timezone" to "X-Apple-I-Timezone", + ) + + /** The captured prefpane `X-Mme-Client-Info` inner component (live 2026-10-14). */ + const val PREFPANE_CLIENT_INFO = + " " const val CONTENT_TYPE = "application/x-apple-plist" const val PROTOCOL_VERSION = "1.0" const val IDS_DELEGATE_BUNDLE_ID = "com.apple.private.ids" diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt index 8d06f97..fc0b2f7 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -148,11 +148,46 @@ class GsaLoginChain( private val loginClient = GsaLoginClient(http, logEvent = logEvent) private val delegateClient = GsaDelegateLoginClient(http, endpoint = delegateEndpoint, logEvent = logEvent) + private val postdataClient = GsaPostdataClient(http, logEvent = logEvent) /** §1.5: the filtered anisette header set + the akd-variant headers, one per login. */ private suspend fun gsaHeaders(): Map = headerConfig?.let { cpd().headersFor() + it.toHeaders() } ?: cpd().headersFor() + /** + * The §1.5 postdata liveness event — the account device-record registration the reference + * client fires before the delegate sign-in (vendored `do_login`, source-verified + * 2026-10-14). This is what makes the device TRUSTED on the account (the "Apple Device" + * record vs an unverified model-name record) — the sequence step our flow always skipped. + * The endpoint requires a live APNs push token (live 2026-10-14 without one: 401, + * ec=-800012 "Push token is invalid"), so the caller fires it once the courier connect + * has minted one, immediately before the delegate sign-in. Skipped when the spd lacks + * the adsid/idms.hb pair (a pre-2FA login carries neither); failures are logged, never + * fatal — the sign-in decides its own verdict. + */ + suspend fun postdataLiveness(spd: GsaSpd?, pushTokenHex: String? = null) { + val adsid = spd?.adsid + val hb = spd?.tokens?.get(GsaSpd.SERVICE_IDMS_HB)?.token + if (adsid == null || hb == null) { + logEvent("gsa: postdata skipped (no adsid/hb token in the spd)") + return + } + try { + postdataClient.post( + GsaPostdataRequest( + adsid = adsid, + hbToken = hb, + anisette = cpd().headersFor(), + clientInfo = headerConfig?.clientInfo ?: GsaHeaderConfig.AOSKIT_CLIENT_INFO, + hardwareHeaders = headerConfig?.hardwareHeaders ?: emptyMap(), + pushToken = pushTokenHex, + ), + ) + } catch (e: Exception) { + logEvent("gsa: postdata failed: ${e.message}") + } + } + /** Full login with the raw password; the hash is computed here and never logged. */ suspend fun login(username: String, password: String): GsaLoginResult = loginWithPasswordHash(username, sha256(password.toByteArray(Charsets.UTF_8))) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt index 4668c67..d83251e 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaPostdata.kt @@ -31,15 +31,19 @@ data class GsaPostdataRequest( val anisette: Map, /** The akd-form `X-Mme-Client-Info` ([GsaHeaderConfig.clientInfo]). */ val clientInfo: String, + /** The Mac hardware-identity headers (SRL-NO/MLB/ROM) — the vendored chains them onto + * the postdata header set too (`hardware_headers`, icloud-auth anisette.rs). */ + val hardwareHeaders: Map = emptyMap(), /** The APNs push token hex — optional; absent on a first login (registration follows it). */ val pushToken: String? = null, ) /** - * The GSA postdata client: one [post] per login, AFTER the delegate sign-in (rev 26: the - * reference program order is sign-in first, postdata after — the delegate sign-in mints its - * own Nas-Qualify blob at sign-in time, and the liveness event rides the post-sign-in - * credentials; the earlier reverse-order note was wrong). + * The GSA postdata client: one [post] per login, BEFORE the delegate sign-in (vendored + * `do_login` parity, source-verified 2026-10-14: `update_postdata` runs first, then + * `login_apple_delegates` — the liveness event creates the account device record the + * prefpane sign-in's "verify the identity of this Mac" check consumes; the docstring's + * earlier "sign-in first" claim was wrong). */ class GsaPostdataClient( private val http: IdsHttp, @@ -54,6 +58,7 @@ class GsaPostdataClient( logEvent("gsa: postdata liveness (services=${SERVICES.joinToString(",")})") val headers = buildMap { putAll(filteredAnisette(request.anisette)) + putAll(request.hardwareHeaders) put("Accept", "*/*") put("Accept-Language", "en-US,en;q=0.9") put("User-Agent", GsaHeaderConfig.AKD_USER_AGENT) 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 1850dae..463b1ce 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt @@ -272,6 +272,9 @@ class ActivationDryRunTest { // The delegate sign-in consumes the step-3 blob — wire it for the server-side assertion. env.expectedValidationBlob = validation.blob + // §1.5 postdata liveness BEFORE the sign-in (vendored do_login order), fired by the + // register flow once the APNs token exists — here with the spd's idms.hb token. + chain.postdataLiveness(authenticated.spd) val credentials = chain.idsDelegateCredentials( username = username, pet = authenticated.pet!!.token, @@ -285,21 +288,13 @@ class ActivationDryRunTest { assertEquals(env.authToken, credentials.authToken) assertEquals(env.profileId, credentials.profileId) - // The GSA postdata liveness event fires AFTER the delegate sign-in (rev 26 order — - // sign-in first, postdata after; the sign-in mints its own Nas-Qualify blob), with a + // The GSA postdata liveness event fired BEFORE the delegate sign-in (the corrected + // vendored do_login order — the earlier "sign-in first" note was wrong), with a // FRESH anisette set and the spd's com.apple.gs.idms.hb token. - GsaPostdataClient(env).post( - GsaPostdataRequest( - adsid = env.adsid, - hbToken = authenticated.spd!!.tokens[GsaSpd.SERVICE_IDMS_HB]!!.token, - anisette = anisette, - clientInfo = deviceConfig.clientInfo, - ), - ) assertEquals(1, env.postdataCount) val signInIndex = env.callUrls().indexOfFirst { it == GsaDelegateLoginClient.SIGNIN_ENDPOINT } val postdataIndex = env.callUrls().indexOfFirst { it == GsaPostdataClient.ENDPOINT } - assertTrue(signInIndex in 0 until postdataIndex, "postdata must come after the delegate sign-in (rev 26)") + assertTrue(postdataIndex in 0 until signInIndex, "postdata must come before the delegate sign-in (vendored do_login order, source-verified 2026-10-14)") store.updateAccount { account -> account.copy( diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/NacValidationTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/NacValidationTest.kt index 99c17e1..6654ba9 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/NacValidationTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/NacValidationTest.kt @@ -103,4 +103,20 @@ class NacValidationTest { // body576 = payload[2:578] (the ctx [0x40:0x280] region, wire-verified ×3 live) assertEquals(592, full.payload.size) } + + /** + * LIVE vector (2026-10-14, gdb): the vendored client's sign() inputs captured at + * ValidationCtx::sign entry (body576 = ctx[0x40:0x280]) with the blob480 + sig16 + * recovered from the byte-exact accepted prefpane request's X-Mme-Nas-Qualify stamp. + * sign() must reproduce the vendor's sig16 exactly — the session-29 closing experiment + * (mint signature exonerated on live data; the residual 400 gate is server-side). + */ + @Test + fun signMatchesLiveVendoredSignature() { + val body576 = hex("d79c641294d322c36c13783828c35b6db6532501db84ea0091a98d3391a99b3dd199ea2c78fc6dbfa3ec110af0dcb8b007a9d90a0a5ab3b6ba772217591db591635d1e9be2238175269d17b8e3c5b1296553a03869eda592feaa932db42d7a918821a1e4b8325b6517cd7c045a15f81c0609f58c2b37ef6a2bc2b5264f4ec536b98a6ac8375bd1f07c33f5bd77521fc71dad9deced5603384c0d32c0abd5b1d79a57b8f4a7c259bcc508fa0ce43da4e3a0060be4e357e8975b9fb909b3ad6f2bb2ad1a7a82311b8d145c1412eb892f26734d7d0345a35af8ac1de407cc712b7436b779473ba535168a2e36b25505f69cdb3d8dcf60292374c8ab139afa750792c7efc3b10bf6423c03d07fd645f753d0c8795ab855dcc186a5134a546a34940d899623666ea2332e9f99945df5a268f0d49b44eded843b6255b4cf9f8118fdd91f848ba6aab9c781ea7cf660f78d1e18b1995e87b2bd0ca00b163afed51e7c4cc41ba4d419758753eb6a61cdaee446a15e698313d056fa2c84ac5e923005131871c1d904c16fe22d145184c98797bb8c13d43d3331c2e6aafe834846007ff8e4d4e0839dc5dee70d028b3631fcd09f066a395e8b4277dfaca78f40bf68cc99acaf446e7c8e1e6d7c8cbea08397c60eae0b398e76c1b5b512d7ea8436211644ad67de7bc6e7a2c87a2782e6a12ef70031349984453a2f37ae87ddc43780c81720a994a52584197f8b514f808a42cfdb4240788d4a725f62f7626b487732fbcac713dd839b8dd6e23fdf6d3add706ec5547b96419531d5f5a2196a88782b56b844") + val blob480 = hex("05000000010000018031c00e0be63b52cdf02017a1b256ab5a6d52330bab33a435b19150bde1d05b4678278073ed7c15903870bdfb22319d00d0a18f511e3348ea58103173c9632d50fcd2fedd57d3fecfd6734454d7d106f7ab43140f9847c18f699271e2dd80bca4342543843299a67471275454de605a39e5472b7b34f76f20f92eb64157126b6b436518e48034aee18c2fd85d23e9466d0e1185d64c7f2edc5dc79bac8c30a321f997d12a441011f1b7c484af74c738f928365f3860c59e8af199a5c61f837f1fc88a091d44232f2b132b3d3276c0b456ec764f63458c2be46dee12b000cef395602dbc30528f2817a291c52dd08d38865e678ecc3e401f187af1b013d097d0756581e4068a37e55cd40bbb15cecd7918dceeda809591b189b720c87e0c2db0354c12e5f9b1b43756880e4b8fa336b0961a7049073491794b0a391d2269f18da456b395d996bcf250eb870e6243b4fc3f51c1d2967f1c132c979faa84869b9238b93208eb7a1ffb079e3876787f035718487395f4705c051e77a62b3b46d3a493000000000000004f019a2c776657740f6777fa683f187bb66514701976000000360101a53e26262f1c4d611eb6a2b5cad938c56acf3d76c20216e7eb1e92d816c0a336b59ab58f6e5f8d3232977e95b64a913a117a0105") + val rand16 = hex("8643e3aad31555888fd5062958aa5b45") + val sig16 = NacSign.sign(body576, rand16, blob480) + assertContentEquals(hex("243a5125f615b468975318a233a9a1fa"), sig16) + } }