Registration: live-verified GSA facts — au inside Status, caller-side trusted-device push (no re-trigger on validate), fresh-anisette requirement on the delegate sign-in; fixtures updated (spec rev 25)

This commit is contained in:
fenleon committed 2026-09-29 13:20:23 +02:00
1 parent e2075458b4
commit 9e00f95e3b
6 files changed
+38 -10

No files matched your search

@@ -37,6 +37,9 @@ class GsaLoginException(message: String, cause: Throwable? = null) : Exception(m
data class GsaStatus( data class GsaStatus(
val ec: Int, val ec: Int,
val em: String?, val em: String?,
/** Apple returns `au` INSIDE the Status dict (reference: apple-private-apis reads
* `status.get("au")` — the top-level Response dict has no `au`). */
val au: String? = null,
) { ) {
companion object { companion object {
const val EC_OK = 0 const val EC_OK = 0
@@ -143,7 +146,7 @@ class GsaLoginClient(
val response = post(XmlPlist.encode(request.toBody()), request.headers) val response = post(XmlPlist.encode(request.toBody()), request.headers)
val dict = responseDict(response, "complete") val dict = responseDict(response, "complete")
val status = statusOf(dict, "complete") val status = statusOf(dict, "complete")
val secondaryAuth = dict["au"] as? String val secondaryAuth = status.au
if (secondaryAuth == null && status.ec != GsaStatus.EC_OK) { if (secondaryAuth == null && status.ec != GsaStatus.EC_OK) {
throw GsaLoginException("GSA complete failed: ec=${status.ec} em=${status.em ?: "-"}") throw GsaLoginException("GSA complete failed: ec=${status.ec} em=${status.em ?: "-"}")
} }
@@ -190,6 +193,7 @@ class GsaLoginClient(
ec = integerOrNull(status["ec"]) ec = integerOrNull(status["ec"])
?: throw GsaLoginException("GSA $step 'Status' has no integer 'ec' (§1.5)"), ?: throw GsaLoginException("GSA $step 'Status' has no integer 'ec' (§1.5)"),
em = status["em"] as? String, em = status["em"] as? String,
au = status["au"] as? String,
) )
} }
@@ -208,6 +208,16 @@ class GsaLoginChain(
suspend fun trustedPhoneNumbers(identityToken: String): GsaAuthExtras = suspend fun trustedPhoneNumbers(identityToken: String): GsaAuthExtras =
twoFactorClient().trustedPhoneNumbers(identityToken) twoFactorClient().trustedPhoneNumbers(identityToken)
/** Push the code to the trusted devices — must run BEFORE prompting for the code (the
* push itself is what makes the code appear; reference: apple-private-apis
* `send_2fa_to_devices`). No-op for the SMS variant (its SMS is requested on submit).
* Triggering twice before one validate invalidates the outstanding code (observed live). */
suspend fun triggerTrustedDevicePush(variant: String, identityToken: String) {
if (variant == GsaStatus.AU_TRUSTED_DEVICE) {
twoFactorClient().triggerTrustedDevice(identityToken)
}
}
/** /**
* §1.5: resolve secondary auth with [securityCode], then — the recorded behavior — run the * §1.5: resolve secondary auth with [securityCode], then — the recorded behavior — run the
* full SRP login a SECOND time, which then completes as logged-in. * full SRP login a SECOND time, which then completes as logged-in.
@@ -233,7 +243,9 @@ class GsaLoginChain(
twoFactor.submitSmsCode(identityToken, phoneId, securityCode, clock()) twoFactor.submitSmsCode(identityToken, phoneId, securityCode, clock())
} }
GsaStatus.AU_TRUSTED_DEVICE -> { GsaStatus.AU_TRUSTED_DEVICE -> {
twoFactor.triggerTrustedDevice(identityToken) // The push already fired when the login surfaced the 2FA prompt
// (triggerTrustedDevicePush) — triggering again would invalidate
// the first code and re-prompt the devices (observed live).
twoFactor.submitTrustedDeviceCode(identityToken, securityCode, clock()) twoFactor.submitTrustedDeviceCode(identityToken, securityCode, clock())
} }
else -> throw GsaLoginException( else -> throw GsaLoginException(
@@ -266,7 +278,9 @@ class GsaLoginChain(
* the §1.1 blob (the chunk-C exchange mints it — caller-supplied here). [timezone] defaults * 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 * 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 * form when the device config is wired ([GsaHeaderConfig.signInClientInfo]), falling back to
* the bare recorded AOSKit inner component. * 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).
*/ */
suspend fun idsDelegateCredentials( suspend fun idsDelegateCredentials(
username: String, username: String,
@@ -411,12 +411,13 @@ $inner</Protocol>""".toByteArray()
) )
require(expectedM1.contentEquals(m1)) { "dry-run fake: client M1 did not verify (§1.5 SRP)" } require(expectedM1.contentEquals(m1)) { "dry-run fake: client M1 did not verify (§1.5 SRP)" }
val computedM2 = sha256(s.aPublic + m1 + serverK) val computedM2 = sha256(s.aPublic + m1 + serverK)
val status = linkedMapOf<String, Any?>("ec" to 0L)
if (gsaCompleteCount == 1) status["au"] = GsaStatus.AU_TRUSTED_DEVICE
val response = linkedMapOf<String, Any?>( val response = linkedMapOf<String, Any?>(
"M2" to computedM2, "M2" to computedM2,
"spd" to encryptSpd(serverK), "spd" to encryptSpd(serverK),
"Status" to linkedMapOf<String, Any?>("ec" to 0L), "Status" to status,
) )
if (gsaCompleteCount == 1) response["au"] = GsaStatus.AU_TRUSTED_DEVICE
petCounter++ petCounter++
return IdsHttpResponse( return IdsHttpResponse(
200, 200,
@@ -239,6 +239,10 @@ class ActivationDryRunTest {
assertEquals(1, env.gsaInitCount) assertEquals(1, env.gsaInitCount)
assertEquals(1, env.gsaCompleteCount) assertEquals(1, env.gsaCompleteCount)
// The push fires when the login surfaces the 2FA prompt (the caller's job — the
// UI shows ENTER CODE only after the devices were pinged); completeSecondaryAuth
// validates only (a second trigger invalidates the first code — observed live).
chain.triggerTrustedDevicePush(required.variant, required.identityToken!!)
val authenticated = chain.completeSecondaryAuth( val authenticated = chain.completeSecondaryAuth(
username = username, username = username,
passwordHash = passwordHash, passwordHash = passwordHash,
@@ -143,14 +143,17 @@ class GsaLoginChainTest {
} }
@Test @Test
fun `trusted-device secondary auth triggers then validates with the security-code header`() = runBlocking { fun `trusted-device secondary auth pushes on the chain then validates with the security-code header`() = runBlocking {
val server = FakeGsaServer(passwordHash, secondaryAuth = GsaStatus.AU_TRUSTED_DEVICE) val server = FakeGsaServer(passwordHash, secondaryAuth = GsaStatus.AU_TRUSTED_DEVICE)
val chain = chain(server) val chain = chain(server)
val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired
assertEquals(GsaStatus.AU_TRUSTED_DEVICE, required.variant) assertEquals(GsaStatus.AU_TRUSTED_DEVICE, required.variant)
// The push is the caller's job (production: right after SecondaryAuthRequired);
// completeSecondaryAuth validates only — a second trigger invalidates the code.
server.secondaryAuth = null server.secondaryAuth = null
chain.triggerTrustedDevicePush(GsaStatus.AU_TRUSTED_DEVICE, required.identityToken!!)
val result = chain.completeSecondaryAuth( val result = chain.completeSecondaryAuth(
username = username, username = username,
passwordHash = passwordHash, passwordHash = passwordHash,
@@ -383,12 +386,13 @@ private class FakeGsaServer(
} else { } else {
computedM2 computedM2
} }
val status = linkedMapOf<String, Any?>("ec" to 0L)
secondaryAuth?.let { status["au"] = it }
val response = linkedMapOf<String, Any?>( val response = linkedMapOf<String, Any?>(
"M2" to m2, "M2" to m2,
"spd" to encryptSpd(serverK), "spd" to encryptSpd(serverK),
"Status" to linkedMapOf<String, Any?>("ec" to 0L), "Status" to status,
) )
secondaryAuth?.let { response["au"] = it }
petCounter++ petCounter++
return IdsHttpResponse( return IdsHttpResponse(
200, 200,
@@ -216,12 +216,13 @@ class GsaLoginTest {
private fun completeRequest() = GsaCompleteRequest("[email protected]", byteArrayOf(7), "c", emptyMap()) private fun completeRequest() = GsaCompleteRequest("[email protected]", byteArrayOf(7), "c", emptyMap())
private fun okComplete(au: String? = null): ByteArray { private fun okComplete(au: String? = null): ByteArray {
val status = linkedMapOf<String, Any?>("ec" to 0L)
au?.let { status["au"] = it }
val response = linkedMapOf<String, Any?>( val response = linkedMapOf<String, Any?>(
"M2" to byteArrayOf(8), "M2" to byteArrayOf(8),
"spd" to byteArrayOf(9), "spd" to byteArrayOf(9),
"Status" to linkedMapOf<String, Any?>("ec" to 0L), "Status" to status,
) )
au?.let { response["au"] = it }
return XmlPlist.encode(mapOf("Response" to response)) return XmlPlist.encode(mapOf("Response" to response))
} }
} }