registration: the prefpane presents the LOGIN RUN's anisette set (session-wide reuse, wire-pinned)

The trapped-binary decode (2026-10-15) shows ONE anisette mint serves the whole session — GSA hops, 2FA validate, postdata, both prefpane POSTs — so the PET is paired with the OTP window it was issued under. The rev-25/26 'fresh set at the sign-in POST' reading is corrected (confounded with the replay-guard era). GsaLoginChain mints one cpd per login run and exposes it as lastLoginAnisette (Client-Time frozen at the mint); GsaDelegateLoginClient's prefpane branch reuses a supplied Client-Time instead of re-minting. VEN step-2 wire fact recorded in spec rev 46: zero anisette on initializeValidation, no step-2 binding. Also wire-proven live: the GSA init 'ps' field is REQUIRED (omission answers ec=-80001).
This commit is contained in:
fenleon committed 2026-10-15 18:57:00 +02:00
1 parent 9da53e31f8
commit 2ff994751c
5 files changed
+43 -14

No files matched your search

@@ -127,6 +127,10 @@ class GsaCpd(
anisette[AnisetteHeaderProvider.DEVICE_ID]?.let { put(AnisetteHeaderProvider.DEVICE_ID, it) }
}
/** The set's `X-Apple-I-Client-Time` value (this mint's frozen time) — the delegate
* sign-in reuses it (the accepted wire carries one Client-Time session-wide). */
fun clientTime(): String? = anisette[AnisetteHeaderProvider.CLIENT_TIME]
companion object {
/** The anisette header names the §1.5 cpd carries as keys (values from the provider). */
val ANISETTE_KEYS = listOf(
@@ -164,7 +164,7 @@ class GsaDelegateLoginClient(
// 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
val mapped = 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
@@ -176,9 +176,12 @@ class GsaDelegateLoginClient(
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()),
)
// Client-Time rides the supplied set when it carries one — the LOGIN RUN's
// frozen mint (the accepted wire reuses ONE anisette set session-wide,
// 2026-10-15 decode); fresh only when the caller minted none.
if (mapped.keys.any { it.equals("X-Apple-I-Client-Time", true) }) mapped
else mapped + ("X-Apple-I-Client-Time" to iso8601Z(System.currentTimeMillis()))
} else {
request.anisette
}
@@ -207,6 +207,8 @@ class GsaLoginClient(
"A2k" to a2k,
"cpd" to cpd,
"o" to OPERATION_INIT,
// Wire-proven 2026-10-15: `ps` is REQUIRED — omitting it (the trapped-binary
// decode's "no ps" reading) answers ec=-80001 "Protocols are invalid" live.
"ps" to protocols,
"u" to username,
),
@@ -150,9 +150,16 @@ class GsaLoginChain(
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 anisette header set of the last completed login run — the same set its GSA hops
* presented, `X-Apple-I-Client-Time` included (frozen at the run's mint). The delegate
* sign-in must present THIS set: the accepted wire (trapped binary, 2026-10-15) reuses
* ONE minted set across the entire session — GSA init/complete, 2FA validate, postdata,
* the prefpane POSTs — so the PET is paired with the OTP window it was issued under.
* Empty until the first login run.
*/
var lastLoginAnisette: Map<String, String> = emptyMap()
private set
/**
* The §1.5 postdata liveness event — the account device-record registration the reference
@@ -200,8 +207,16 @@ class GsaLoginChain(
*/
suspend fun loginWithPasswordHash(username: String, passwordHash: ByteArray): GsaLoginResult {
val requestUuid = newRequestUuid()
val cpdDict = cpd().dictFor(requestUuid)
val gsaHeaders = gsaHeaders()
// ONE anisette mint per login run: the cpd dict and the POST headers carry the same
// set (the accepted wire reuses one set across the whole session, 2026-10-15), and
// the run's set is what the delegate sign-in must present (lastLoginAnisette).
val cpdSet = cpd()
val anisetteHeaders = cpdSet.headersFor()
lastLoginAnisette = cpdSet.clientTime()
?.let { anisetteHeaders + (AnisetteHeaderProvider.CLIENT_TIME to it) }
?: anisetteHeaders
val cpdDict = cpdSet.dictFor(requestUuid)
val gsaHeaders = headerConfig?.let { anisetteHeaders + it.toHeaders() } ?: anisetteHeaders
val srp = GsaSrpClient(random)
val a2k = srp.begin()
val init = loginClient.init(
@@ -342,12 +357,16 @@ class GsaLoginChain(
* the §1.1 blob (the chunk-C exchange mints it — caller-supplied here). [timezone] defaults
* to the recorded value (§1.5 C59); [clientInfo] defaults to the recorded bracketed AOSKit
* form when the device config is wired ([GsaHeaderConfig.signInClientInfo]), falling back to
* the bare recorded AOSKit inner component. [anisette] must be FRESHLY minted for this
* call — the one-time OTP headers spent on the GSA hops are refused here (observed live;
* the reference client mints per request). [validationData] must be a FRESH mint per
* sign-in (minted at sign-in time, never a login-start blob carried across the 2FA wait) —
* a consumed blob answers HTTP 409, the stamp REPLAY refusal, not a blob rejection
* (rev 26, §1.4's replay guard).
* the bare recorded AOSKit inner component. [anisette] is the LOGIN RUN's set
* ([lastLoginAnisette] — the same set the GSA hops of the PET's session presented,
* Client-Time frozen at that mint): the accepted wire (trapped binary, 2026-10-15)
* reuses ONE anisette set across the whole session, so the prefpane POST pairs the
* PET with the OTP window it was issued under. The earlier "freshly minted here"
* reading (rev 25/26) predates the loginDelegates endpoint fix and is confounded
* with the stamp replay-guard refusals of that era. [validationData] must be a FRESH
* mint per sign-in (minted at sign-in time, never a login-start blob carried across
* the 2FA wait) — a consumed blob answers HTTP 409, the stamp REPLAY refusal, not a
* blob rejection (rev 26, §1.4's replay guard).
*/
suspend fun idsDelegateCredentials(
username: String,