PasskeyRegistrationTest.kt

7.7 KB · raw

1package com.peachpasswords.passkeys23import kotlinx.serialization.json.Json4import kotlinx.serialization.json.contentOrNull5import kotlinx.serialization.json.int6import kotlinx.serialization.json.jsonArray7import kotlinx.serialization.json.jsonObject8import kotlinx.serialization.json.jsonPrimitive9import org.junit.Assert.assertEquals10import org.junit.Assert.assertNotNull11import org.junit.Assert.assertTrue12import org.junit.Test13import java.util.Base641415class PasskeyRegistrationTest {16    @Test fun builds_es256_registration_response_and_entry() {17        val result = PasskeyRegistration.build(18            requestJson = """19                {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}20            """.trimIndent(),21            origin = "https://login.example.com",22            now = 123,23        )2425        assertNotNull(result)26        result!!27        val passkey = result.entryDraft.passkey!!28        assertEquals("example.com", passkey.rpId)29        assertEquals("june", passkey.userName)30        assertEquals(-7, passkey.algorithm)31        assertEquals(123, passkey.createdAt)32        assertTrue(passkey.privateKeyJwk!!.contains("\"d\""))33        assertTrue(b64Decode(passkey.credentialId).size == 32)34        assertTrue(b64Decode(passkey.publicKeyCose).isNotEmpty())3536        val response = Json.parseToJsonElement(result.responseJson).jsonObject37        assertEquals(passkey.credentialId, response["id"]?.jsonPrimitive?.contentOrNull)38        val payload = response["response"]!!.jsonObject39        assertEquals(-7, payload["publicKeyAlgorithm"]?.jsonPrimitive?.int)40        assertEquals("internal", payload["transports"]?.jsonArray?.single()?.jsonPrimitive?.contentOrNull)41        val clientData = Json.parseToJsonElement(String(b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content), Charsets.UTF_8)).jsonObject42        assertEquals("webauthn.create", clientData["type"]?.jsonPrimitive?.contentOrNull)43        assertEquals("https://login.example.com", clientData["origin"]?.jsonPrimitive?.contentOrNull)44        assertTrue(b64Decode(payload["attestationObject"]!!.jsonPrimitive.content).isNotEmpty())45    }4647    @Test fun prefers_eddsa_registration_when_offered() {48        val result = PasskeyRegistration.build(49            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-8},{"alg":-7}]}""",50            "https://example.com",51        )5253        assertNotNull(result)54        result!!55        assertEquals(-8, result.entryDraft.passkey?.algorithm)56        val jwk = Json.parseToJsonElement(result.entryDraft.passkey!!.privateKeyJwk!!).jsonObject57        assertEquals("OKP", jwk["kty"]?.jsonPrimitive?.contentOrNull)58        assertEquals("Ed25519", jwk["crv"]?.jsonPrimitive?.contentOrNull)59        val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject60        assertEquals(-8, payload["publicKeyAlgorithm"]?.jsonPrimitive?.int)61    }6263    @Test fun rejects_requests_without_supported_algorithms() {64        val result = PasskeyRegistration.build(65            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-257}]}""",66            "https://example.com",67        )68        assertEquals(null, result)69    }7071    @Test fun rejects_missing_or_blank_origin() {72        val request = """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}"""73        assertEquals(null, PasskeyRegistration.build(request, null))74        assertEquals(null, PasskeyRegistration.build(request, "  "))75    }7677    @Test fun rejects_web_origin_outside_rp_id_domain() {78        val request = """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}"""79        assertEquals(null, PasskeyRegistration.build(request, "https://evil.com"))80        assertEquals(null, PasskeyRegistration.build(request, "https://evilexample.com"))81    }8283    @Test fun rejects_plaintext_http_origin() {84        val request = """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}"""85        assertEquals(null, PasskeyRegistration.build(request, "http://example.com"))86    }8788    @Test fun stores_verified_caller_origin_on_the_credential() {89        val web = PasskeyRegistration.build(90            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}""",91            "https://login.example.com",92        )93        assertEquals("https://login.example.com", web?.entryDraft?.passkey?.callerOrigin)9495        // Native-app origins carry no domain, so no rpId domain check applies;96        // the binding itself is the protection on get.97        val native = PasskeyRegistration.build(98            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}""",99            "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",100        )101        assertEquals(102            "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",103            native?.entryDraft?.passkey?.callerOrigin,104        )105    }106107    @Test fun registration_sets_uv_bit_when_user_verified() {108        val result = PasskeyRegistration.build(109            requestJson = """110                {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}111            """.trimIndent(),112            origin = "https://example.com",113            userVerified = true,114        )115116        assertNotNull(result)117        result!!118        val passkey = result.entryDraft.passkey!!119        // Persisted backup state must match the flag bits on the wire —120        // the prior audit finding was that these contradicted each other.121        assertEquals(true, passkey.backupEligible)122        assertEquals(false, passkey.backupState)123124        val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject125        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)126        // H-07: UV=1 is warranted only when the ceremony actually ran.127        // UP(0x01) | UV(0x04) | BE(0x08) | AT(0x40) = 0x4d.128        assertEquals(0x4d, authData[32].toInt() and 0xff)129    }130131    @Test fun registration_clears_uv_bit_when_user_not_verified() {132        val result = PasskeyRegistration.build(133            requestJson = """134                {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}135            """.trimIndent(),136            origin = "https://example.com",137            userVerified = false,138        )139140        assertNotNull(result)141        result!!142        val passkey = result.entryDraft.passkey!!143        // Backup semantics are unchanged by UV: BE stays true, BS stays144        // false at registration time regardless of the ceremony outcome.145        assertEquals(true, passkey.backupEligible)146        assertEquals(false, passkey.backupState)147148        val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject149        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)150        // H-07: no ceremony, no UV claim. UP(0x01) | BE(0x08) | AT(0x40) = 0x49;151        // UV (0x04) must be CLEAR, BS (0x10) must be CLEAR.152        assertEquals(0x49, authData[32].toInt() and 0xff)153    }154155    private fun b64Decode(value: String): ByteArray =156        Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))157}