diff --git a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt index ec160f6..1ec4144 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -164,11 +164,34 @@ class GsaDelegateLoginClient( val encoded = XmlPlist.encode(body) val response = http.post(endpoint, headers, encoded, CONTENT_TYPE) if (response.status != 200) { - throw GsaLoginException("iCloud sign-in POST $endpoint → HTTP ${response.status}") + throw GsaLoginException( + "iCloud sign-in POST $endpoint → HTTP ${response.status}${errorDetail(response.body)}", + ) } return parseResponse(response.body) } + /** Top-level error-shape facts only (keys, integer codes, error strings) — never token values. */ + private fun errorDetail(bytes: ByteArray): String { + val parsed = try { + Plist.parse(bytes) + } catch (_: Exception) { + return "" + } + val dict = stringKeyedDictOrNull(parsed) ?: return " (${typeName(parsed)})" + val scalars = dict.entries + .mapNotNull { (k, v) -> + when { + v is Int || v is Long -> "$k=$v" + v is String && k !in setOf("dsid", "auth-token", "adsid") -> "$k=$v" + v is Map<*, *> -> "$k keys=${stringKeyedDictOrNull(v)?.keys ?: v.keys}" + else -> null + } + } + .joinToString(", ") + return " — body: $scalars" + } + private fun parseResponse(bytes: ByteArray): IcloudSignInResponse { val parsed = try { Plist.parse(bytes) 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 4baa63f..0916811 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaLoginChain.kt @@ -181,14 +181,27 @@ class GsaLoginChain( val spd = complete.spd?.let { GsaSpd.parse(GsaSpdDecryptor.decrypt(it, proof.k)) } return when (val secondaryAuth = complete.secondaryAuth) { null -> { - val result = GsaLoginResult.Authenticated(spd = spd, pet = complete.pet) + // The PET rides the response headers when Apple sends them; on the post-2FA + // SRP re-login it arrives instead inside the spd token map under the PET + // service id (observed live — the header was absent on that path). A no-2FA + // login's spd carries only com.apple.gs.appleid.auth and no PET. + val pet = complete.pet + ?: spd?.tokens[PET_SERVICE_ID]?.let { + GsaPet( + it.token, + it.expiresAtEpochMs + ?: (clock() + GsaTokenHeaders.PET_DEFAULT_EXPIRY_SECONDS * 1000), + ) + } + logEvent("gsa: pet=${complete.pet != null} spdToken=${pet != null} tokens=${spd?.tokens?.keys}") + val result = GsaLoginResult.Authenticated(spd = spd, pet = pet) credentialStore?.save( GsaPersistedCredentials( username = username, passwordHash = passwordHash, adsid = spd?.adsid, - petToken = complete.pet?.token, - petExpiresAtEpochMs = complete.pet?.expiresAtEpochMs, + petToken = pet?.token, + petExpiresAtEpochMs = pet?.expiresAtEpochMs, ), ) result @@ -344,5 +357,8 @@ class GsaLoginChain( companion object { const val GSA_STATUS_SECONDARY = GsaStatus.AU_SECONDARY_AUTH const val GSA_STATUS_TRUSTED = GsaStatus.AU_TRUSTED_DEVICE + + /** The spd token-map service id under which the PET is delivered (§1.5 PET). */ + const val PET_SERVICE_ID = "com.apple.gs.idms.pet" } }