mirror of
https://github.com/fenleon/open-madrid.git
synced 2026-10-22 16:12:35 +00:00
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:
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(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-2
@@ -411,12 +411,13 @@ $inner</Protocol>""".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<String, Any?>("ec" to 0L)
|
||||
if (gsaCompleteCount == 1) status["au"] = GsaStatus.AU_TRUSTED_DEVICE
|
||||
val response = linkedMapOf<String, Any?>(
|
||||
"M2" to computedM2,
|
||||
"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++
|
||||
return IdsHttpResponse(
|
||||
200,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Any?>("ec" to 0L)
|
||||
secondaryAuth?.let { status["au"] = it }
|
||||
val response = linkedMapOf<String, Any?>(
|
||||
"M2" to m2,
|
||||
"spd" to encryptSpd(serverK),
|
||||
"Status" to linkedMapOf<String, Any?>("ec" to 0L),
|
||||
"Status" to status,
|
||||
)
|
||||
secondaryAuth?.let { response["au"] = it }
|
||||
petCounter++
|
||||
return IdsHttpResponse(
|
||||
200,
|
||||
|
||||
@@ -216,12 +216,13 @@ class GsaLoginTest {
|
||||
private fun completeRequest() = GsaCompleteRequest("[email protected]", byteArrayOf(7), "c", emptyMap())
|
||||
|
||||
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?>(
|
||||
"M2" to byteArrayOf(8),
|
||||
"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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user