registration: prefpane parity + the GSA postdata liveness event + the live sig16 vector

- 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.
This commit is contained in:
fenleon committed 2026-10-14 16:57:07 +02:00
1 parent e5352e77ce
commit 478a473629
6 files changed
+134 -24

No files matched your search

@@ -155,6 +155,13 @@ data class GsaHeaderConfig(
val osVersion: String,
/** The `<OSbuild>` 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<String, String> = emptyMap(),
) {
/** The akd-form template: `<HardwareModel> <OSname;OSversion;OSbuild> <com.apple.AuthKit/1 (com.apple.akd/1.0)>`. */
@@ -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). */
@@ -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<String, Any?>(
"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 =
"<MacBookPro18,3> <macOS;26.6.2;25G83> <com.apple.AuthKit/1 (com.apple.MobileSMS/1262.500.151.1.2)>"
const val CONTENT_TYPE = "application/x-apple-plist"
const val PROTOCOL_VERSION = "1.0"
const val IDS_DELEGATE_BUNDLE_ID = "com.apple.private.ids"
@@ -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<String, String> =
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)))
@@ -31,15 +31,19 @@ data class GsaPostdataRequest(
val anisette: Map<String, String>,
/** 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<String, String> = 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)
@@ -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(
@@ -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)
}
}