From 9e00f95e3bade344fad48866205da67b33188639 Mon Sep 17 00:00:00 2001 From: fenleon Date: Tue, 29 Sep 2026 13:20:23 +0200 Subject: [PATCH] =?UTF-8?q?Registration:=20live-verified=20GSA=20facts=20?= =?UTF-8?q?=E2=80=94=20au=20inside=20Status,=20caller-side=20trusted-devic?= =?UTF-8?q?e=20push=20(no=20re-trigger=20on=20validate),=20fresh-anisette?= =?UTF-8?q?=20requirement=20on=20the=20delegate=20sign-in;=20fixtures=20up?= =?UTF-8?q?dated=20(spec=20rev=2025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dev/fenn/imessage/registration/GsaLogin.kt | 6 +++++- .../imessage/registration/GsaLoginChain.kt | 18 ++++++++++++++++-- .../registration/ActivationDryRunFixtures.kt | 5 +++-- .../registration/ActivationDryRunTest.kt | 4 ++++ .../imessage/registration/GsaLoginChainTest.kt | 10 +++++++--- .../fenn/imessage/registration/GsaLoginTest.kt | 5 +++-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt index 781ca48..d2da623 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLogin.kt @@ -37,6 +37,9 @@ class GsaLoginException(message: String, cause: Throwable? = null) : Exception(m data class GsaStatus( val ec: Int, 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 { const val EC_OK = 0 @@ -143,7 +146,7 @@ class GsaLoginClient( val response = post(XmlPlist.encode(request.toBody()), request.headers) val dict = responseDict(response, "complete") val status = statusOf(dict, "complete") - val secondaryAuth = dict["au"] as? String + val secondaryAuth = status.au if (secondaryAuth == null && status.ec != GsaStatus.EC_OK) { throw GsaLoginException("GSA complete failed: ec=${status.ec} em=${status.em ?: "-"}") } @@ -190,6 +193,7 @@ class GsaLoginClient( ec = integerOrNull(status["ec"]) ?: throw GsaLoginException("GSA $step 'Status' has no integer 'ec' (§1.5)"), em = status["em"] as? String, + au = status["au"] as? String, ) } 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 d3c7b08..4baa63f 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -208,6 +208,16 @@ class GsaLoginChain( suspend fun trustedPhoneNumbers(identityToken: String): GsaAuthExtras = 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 * full SRP login a SECOND time, which then completes as logged-in. @@ -233,7 +243,9 @@ class GsaLoginChain( twoFactor.submitSmsCode(identityToken, phoneId, securityCode, clock()) } 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()) } else -> throw GsaLoginException( @@ -266,7 +278,9 @@ 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. + * 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( username: String, diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt index 4f8e464..9793dcf 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunFixtures.kt @@ -411,12 +411,13 @@ $inner""".toByteArray() ) require(expectedM1.contentEquals(m1)) { "dry-run fake: client M1 did not verify (§1.5 SRP)" } val computedM2 = sha256(s.aPublic + m1 + serverK) + val status = linkedMapOf("ec" to 0L) + if (gsaCompleteCount == 1) status["au"] = GsaStatus.AU_TRUSTED_DEVICE val response = linkedMapOf( "M2" to computedM2, "spd" to encryptSpd(serverK), - "Status" to linkedMapOf("ec" to 0L), + "Status" to status, ) - if (gsaCompleteCount == 1) response["au"] = GsaStatus.AU_TRUSTED_DEVICE petCounter++ return IdsHttpResponse( 200, 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 af73f35..2193126 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/ActivationDryRunTest.kt @@ -239,6 +239,10 @@ class ActivationDryRunTest { assertEquals(1, env.gsaInitCount) 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( username = username, passwordHash = passwordHash, 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 9a369f2..b4fc425 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginChainTest.kt @@ -143,14 +143,17 @@ class GsaLoginChainTest { } @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 chain = chain(server) val required = chain.login(username, password) as GsaLoginResult.SecondaryAuthRequired 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 + chain.triggerTrustedDevicePush(GsaStatus.AU_TRUSTED_DEVICE, required.identityToken!!) val result = chain.completeSecondaryAuth( username = username, passwordHash = passwordHash, @@ -383,12 +386,13 @@ private class FakeGsaServer( } else { computedM2 } + val status = linkedMapOf("ec" to 0L) + secondaryAuth?.let { status["au"] = it } val response = linkedMapOf( "M2" to m2, "spd" to encryptSpd(serverK), - "Status" to linkedMapOf("ec" to 0L), + "Status" to status, ) - secondaryAuth?.let { response["au"] = it } petCounter++ return IdsHttpResponse( 200, diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt index 39ac972..db2a77a 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaLoginTest.kt @@ -216,12 +216,13 @@ class GsaLoginTest { private fun completeRequest() = GsaCompleteRequest("user@example.com", byteArrayOf(7), "c", emptyMap()) private fun okComplete(au: String? = null): ByteArray { + val status = linkedMapOf("ec" to 0L) + au?.let { status["au"] = it } val response = linkedMapOf( "M2" to byteArrayOf(8), "spd" to byteArrayOf(9), - "Status" to linkedMapOf("ec" to 0L), + "Status" to status, ) - au?.let { response["au"] = it } return XmlPlist.encode(mapOf("Response" to response)) } }