1package com.peachpasswords.passkeys23import com.peachpasswords.vault.PasskeyData4import com.peachpasswords.vault.VaultEntry5import java.math.BigInteger6import java.security.MessageDigest7import java.security.KeyPairGenerator8import java.security.Signature9import java.security.interfaces.ECPrivateKey10import java.security.interfaces.ECPublicKey11import java.security.spec.ECGenParameterSpec12import java.util.Base6413import kotlinx.serialization.json.Json14import kotlinx.serialization.json.contentOrNull15import kotlinx.serialization.json.jsonObject16import kotlinx.serialization.json.jsonPrimitive17import org.junit.Assert.assertEquals18import org.junit.Assert.assertFalse19import org.junit.Assert.assertNotNull20import org.junit.Assert.assertTrue21import org.junit.Test22import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters23import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters24import org.bouncycastle.crypto.signers.Ed25519Signer2526class PasskeyAssertionTest {27 @Test fun builds_es256_assertion_and_increments_counter() {28 val keyPair = KeyPairGenerator.getInstance("EC").apply {29 initialize(ECGenParameterSpec("secp256r1"))30 }.generateKeyPair()31 val entry = passkeyEntry(keyPair.private as ECPrivateKey)3233 val result = PasskeyAssertion.build(34 entry = entry,35 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",36 origin = "https://login.example.com",37 )3839 assertNotNull(result)40 result!!41 assertEquals(42, result.nextSignCount)42 val response = Json.parseToJsonElement(result.responseJson).jsonObject43 assertEquals("cred-1", response["id"]?.jsonPrimitive?.contentOrNull)44 val payload = response["response"]!!.jsonObject45 val clientDataBytes = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)46 val clientData = String(clientDataBytes, Charsets.UTF_8)47 val client = Json.parseToJsonElement(clientData).jsonObject48 assertEquals("webauthn.get", client["type"]?.jsonPrimitive?.contentOrNull)49 assertEquals("https://login.example.com", client["origin"]?.jsonPrimitive?.contentOrNull)50 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)51 assertEquals(37, authData.size)52 assertEquals(42, ((authData[33].toInt() and 0xff) shl 24) or ((authData[34].toInt() and 0xff) shl 16) or ((authData[35].toInt() and 0xff) shl 8) or (authData[36].toInt() and 0xff))5354 val verifier = Signature.getInstance("SHA256withECDSA")55 verifier.initVerify(keyPair.public as ECPublicKey)56 verifier.update(authData + MessageDigest.getInstance("SHA-256").digest(clientDataBytes))57 assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))58 }5960 @Test fun builds_ed25519_assertion() {61 val seed = ByteArray(32) { (it + 1).toByte() }62 val privateKey = Ed25519PrivateKeyParameters(seed, 0)63 val publicKey = privateKey.generatePublicKey().encoded64 val entry = passkeyEntry(null).copy(65 passkey = passkeyEntry(null).passkey!!.copy(66 privateKeyJwk = """{"kty":"OKP","crv":"Ed25519","d":"${b64(seed)}","x":"${b64(publicKey)}"}""",67 algorithm = -8,68 ),69 )7071 val result = PasskeyAssertion.build(72 entry = entry,73 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",74 origin = "https://example.com",75 )7677 assertNotNull(result)78 result!!79 val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject80 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)81 val clientData = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)82 val message = authData + MessageDigest.getInstance("SHA-256").digest(clientData)83 val signature = b64Decode(payload["signature"]!!.jsonPrimitive.content)84 assertEquals(64, signature.size)85 val verifier = Ed25519Signer()86 verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))87 verifier.update(message, 0, message.size)88 assertTrue(verifier.verifySignature(signature))89 }9091 @Test fun browser_client_data_hash_does_not_emit_conflicting_client_data_json() {92 val keyPair = KeyPairGenerator.getInstance("EC").apply {93 initialize(ECGenParameterSpec("secp256r1"))94 }.generateKeyPair()95 val clientDataHash = MessageDigest.getInstance("SHA-256")96 .digest("framework-owned-client-data".toByteArray())9798 val result = PasskeyAssertion.build(99 entry = passkeyEntry(keyPair.private as ECPrivateKey),100 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",101 origin = "https://example.com",102 clientDataHashOverride = clientDataHash,103 )104105 assertNotNull(result)106 val payload = Json.parseToJsonElement(result!!.responseJson).jsonObject["response"]!!.jsonObject107 assertFalse(payload.containsKey("clientDataJSON"))108 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)109 val verifier = Signature.getInstance("SHA256withECDSA")110 verifier.initVerify(keyPair.public as ECPublicKey)111 verifier.update(authData + clientDataHash)112 assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))113 }114115 @Test fun returns_null_for_missing_private_key() {116 val entry = passkeyEntry(null)117 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))118 }119120 @Test fun rejects_missing_or_blank_origin() {121 val keyPair = KeyPairGenerator.getInstance("EC").apply {122 initialize(ECGenParameterSpec("secp256r1"))123 }.generateKeyPair()124 val entry = passkeyEntry(keyPair.private as ECPrivateKey)125 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", null))126 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", " "))127 assertEquals(128 PasskeyAssertionFailure.MISSING_ORIGIN,129 (PasskeyAssertion.buildDetailed(130 entry,131 """{"challenge":"c","rpId":"example.com"}""",132 null,133 ) as PasskeyAssertionBuild.Failure).reason,134 )135 }136137 @Test fun reports_caller_binding_mismatch_without_exposing_request_data() {138 val keyPair = KeyPairGenerator.getInstance("EC").apply {139 initialize(ECGenParameterSpec("secp256r1"))140 }.generateKeyPair()141 val entry = passkeyEntry(keyPair.private as ECPrivateKey)142143 val outcome = PasskeyAssertion.buildDetailed(144 entry,145 """{"challenge":"c","rpId":"example.com"}""",146 "https://unrelated.test",147 )148149 assertEquals(150 PasskeyAssertionFailure.CALLER_NOT_ALLOWED,151 (outcome as PasskeyAssertionBuild.Failure).reason,152 )153 }154155 @Test fun rejects_plaintext_http_origin() {156 val keyPair = KeyPairGenerator.getInstance("EC").apply {157 initialize(ECGenParameterSpec("secp256r1"))158 }.generateKeyPair()159 assertEquals(160 null,161 PasskeyAssertion.build(162 passkeyEntry(keyPair.private as ECPrivateKey),163 """{"challenge":"c","rpId":"example.com"}""",164 "http://example.com",165 ),166 )167 }168169 @Test fun bound_credential_refuses_every_other_caller() {170 val keyPair = KeyPairGenerator.getInstance("EC").apply {171 initialize(ECGenParameterSpec("secp256r1"))172 }.generateKeyPair()173 val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"174 val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"175 val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(176 passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),177 )178 val request = """{"challenge":"c","rpId":"example.com"}"""179 assertNotNull(PasskeyAssertion.build(bound, request, appA))180 assertEquals(null, PasskeyAssertion.build(bound, request, appB))181 182 assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))183 }184185 @Test fun legacy_credential_stays_hidden_from_native_app_callers() {186 val keyPair = KeyPairGenerator.getInstance("EC").apply {187 initialize(ECGenParameterSpec("secp256r1"))188 }.generateKeyPair()189 val legacy = passkeyEntry(keyPair.private as ECPrivateKey)190 assertEquals(191 null,192 PasskeyAssertion.build(193 legacy,194 """{"challenge":"c","rpId":"example.com"}""",195 "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",196 ),197 )198 199 assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))200 }201202 @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {203 204 205 206 val keyPair = KeyPairGenerator.getInstance("EC").apply {207 initialize(ECGenParameterSpec("secp256r1"))208 }.generateKeyPair()209 val privKey = keyPair.private as ECPrivateKey210 val backupEntry = passkeyEntry(privKey).copy(211 passkey = passkeyEntry(privKey).passkey!!.copy(212 backupEligible = true,213 backupState = false,214 ),215 )216217 val verified = PasskeyAssertion.build(218 entry = backupEntry,219 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",220 origin = "https://example.com",221 userVerified = true,222 )223 assertNotNull(verified)224 assertEquals(0x0d, extractFlags(verified!!.responseJson))225226 227 val unverified = PasskeyAssertion.build(228 entry = backupEntry,229 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",230 origin = "https://example.com",231 userVerified = false,232 )233 assertEquals(0x09, extractFlags(unverified!!.responseJson))234235 236 val plainEntry = passkeyEntry(privKey).copy(237 passkey = passkeyEntry(privKey).passkey!!.copy(238 backupEligible = false,239 backupState = false,240 ),241 )242 val plain = PasskeyAssertion.build(243 entry = plainEntry,244 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",245 origin = "https://example.com",246 userVerified = false,247 )248 assertEquals(0x01, extractFlags(plain!!.responseJson))249 250 assertEquals(0, 0x40 and extractFlags(plain.responseJson))251 }252253 private fun extractFlags(responseJson: String): Int {254 val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject255 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)256 return authData[32].toInt() and 0xff257 }258259 private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(260 id = "entry-1",261 type = "passkey",262 title = "Example",263 passkey = PasskeyData(264 credentialId = "cred-1",265 publicKeyCose = "cose",266 privateKeyJwk = privateKey?.let(::privateJwk),267 rpId = "example.com",268 userHandle = "user-handle",269 userName = "june",270 userDisplayName = "June",271 algorithm = -7,272 signCount = 41,273 ),274 createdAt = 1,275 modifiedAt = 1,276 )277278 private fun privateJwk(key: ECPrivateKey): String =279 """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""280281 private fun BigInteger.toFixed32(): ByteArray {282 val raw = toByteArray()283 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw284 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }285 }286287 private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)288 private fun b64Decode(value: String): ByteArray =289 Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))290}