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 83bd6c5..89a74ad 100644 --- a/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt +++ b/registration/src/main/kotlin/dev/fenn/imessage/registration/GsaDelegateLogin.kt @@ -130,8 +130,10 @@ data class IdsDelegateCredentials( ) /** - * The delegate login of spec §1.5: POST `https://setup.icloud.com/setup/signin/v2/login` with - * Basic `username:PET` auth and the recorded header set; the response's `com.apple.private.ids` + * The delegate login of spec §1.5: POST the configured endpoint (the recorded + * `/setup/signin/v2/login` with the V2 body + Basic auth; the vendored reference's + * `/setup/prefpane/login` with the V1 `AuthRequest` body — credentials in the body, + * RE 2026-10-13) with the recorded header set; the response's `com.apple.private.ids` * delegate serviceData yields the IDS `authToken`/`profileId`. */ class GsaDelegateLoginClient( @@ -144,29 +146,45 @@ class GsaDelegateLoginClient( logEvent("gsa: iCloud sign-in") val basic = Base64.getEncoder() .encodeToString("${request.username}:${request.pet}".toByteArray(Charsets.UTF_8)) + val delegates = linkedMapOf( + IDS_DELEGATE_BUNDLE_ID to linkedMapOf("protocol-version" to "4"), + MOBILEME_DELEGATE_BUNDLE_ID to linkedMapOf(), + ) + // The vendored reference (open_absinthe, RE 2026-10-13) ships ONLY the V1 request + // shape on this endpoint: kebab-case `AuthRequest {apple-id, client-id, delegates, + // password}` with the PET in the body and NO Authorization header — the .so has no + // V2AuthRequest/protocolVersion/userInfo strings at all. The V2 wrapper is kept for + // the recorded v2 endpoint (§1.5 C59). + val prefpane = endpoint == PREFPANE_ENDPOINT + val body = if (prefpane) { + linkedMapOf( + "apple-id" to request.username, + "client-id" to request.clientId.lowercase(), + "delegates" to delegates, + "password" to request.pet, + ) + } else { + linkedMapOf( + "delegates" to delegates, + "protocolVersion" to PROTOCOL_VERSION, + "userInfo" to linkedMapOf( + // rev 26 (live 2026-10-04): the sign-in body's client id rides kebab-case + // (`client-id` — the recorded upstream identity struct is kebab-case on the + // wire); "clientId" was the one body delta vs the live request capture. + "client-id" to request.clientId, + "language" to "en-US", + "timezone" to request.timezone, + ), + ) + } val headers = buildMap { putAll(request.anisette) - put("Authorization", "Basic $basic") + if (!prefpane) put("Authorization", "Basic $basic") put(HEADER_NAS_QUALIFY, Base64.getEncoder().encodeToString(request.validationData)) put(HEADER_ADSID, request.adsid) put(HEADER_CLIENT_INFO, request.clientInfo) put("User-Agent", SIGNIN_USER_AGENT) } - val body = linkedMapOf( - "delegates" to linkedMapOf( - IDS_DELEGATE_BUNDLE_ID to linkedMapOf("protocol-version" to "4"), - MOBILEME_DELEGATE_BUNDLE_ID to linkedMapOf(), - ), - "protocolVersion" to PROTOCOL_VERSION, - "userInfo" to linkedMapOf( - // rev 26 (live 2026-10-04): the sign-in body's client id rides kebab-case - // (`client-id` — the recorded upstream identity struct is kebab-case on the - // wire); "clientId" was the one body delta vs the live request capture. - "client-id" to request.clientId, - "language" to "en-US", - "timezone" to request.timezone, - ), - ) val encoded = XmlPlist.encode(body) val response = http.post(endpoint, headers, encoded, CONTENT_TYPE) if (response.status != 200) { diff --git a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt index f14dda9..e1906f7 100644 --- a/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt +++ b/registration/src/test/kotlin/dev/fenn/imessage/registration/GsaDelegateLoginTest.kt @@ -99,6 +99,33 @@ class GsaDelegateLoginTest { ) } + @Test + fun `prefpane endpoint posts the vendored V1 body without Basic auth`() = runBlocking { + val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse()))) + GsaDelegateLoginClient( + http, + endpoint = GsaDelegateLoginClient.PREFPANE_ENDPOINT, + ).signIn(request()) + + val call = http.calls.single() + assertEquals(GsaDelegateLoginClient.PREFPANE_ENDPOINT, call.url) + assertNull(call.headers["Authorization"]) + assertEquals(Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3)), call.headers["X-Mme-Nas-Qualify"]) + + val body = XmlPlist.decode(call.body) as Map<*, *> + assertEquals("user@example.com", body["apple-id"]) + assertEquals("pet-token", body["password"]) + // the vendored V1 client-id is a plain v4 UUID (no uppercase, RE 2026-10-13) + assertEquals("a1b2c3d4-e5f6-4789-abcd-0123456789ab", body["client-id"]) + assertEquals( + setOf("apple-id", "client-id", "delegates", "password"), + body.keys, + ) + val delegates = body["delegates"] as Map<*, *> + assertEquals(mapOf("protocol-version" to "4"), delegates["com.apple.private.ids"]) + assertEquals(emptyMap(), delegates["com.apple.mobileme"]) + } + @Test fun `posts the recorded body and headers`() = runBlocking { val http = ScriptedIdsHttp(listOf(IdsHttpResponse(200, emptyMap(), okResponse())))