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 96 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 120 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 127 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 144 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 151 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}