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 }128129 @Test fun bound_credential_refuses_every_other_caller() {130 val keyPair = KeyPairGenerator.getInstance("EC").apply {131 initialize(ECGenParameterSpec("secp256r1"))132 }.generateKeyPair()133 val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"134 val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"135 val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(136 passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),137 )138 val request = """{"challenge":"c","rpId":"example.com"}"""139 assertNotNull(PasskeyAssertion.build(bound, request, appA))140 assertEquals(null, PasskeyAssertion.build(bound, request, appB))141 142 assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))143 }144145 @Test fun legacy_credential_stays_hidden_from_native_app_callers() {146 val keyPair = KeyPairGenerator.getInstance("EC").apply {147 initialize(ECGenParameterSpec("secp256r1"))148 }.generateKeyPair()149 val legacy = passkeyEntry(keyPair.private as ECPrivateKey)150 assertEquals(151 null,152 PasskeyAssertion.build(153 legacy,154 """{"challenge":"c","rpId":"example.com"}""",155 "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",156 ),157 )158 159 assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))160 }161162 @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {163 164 165 166 val keyPair = KeyPairGenerator.getInstance("EC").apply {167 initialize(ECGenParameterSpec("secp256r1"))168 }.generateKeyPair()169 val privKey = keyPair.private as ECPrivateKey170 val backupEntry = passkeyEntry(privKey).copy(171 passkey = passkeyEntry(privKey).passkey!!.copy(172 backupEligible = true,173 backupState = false,174 ),175 )176177 val verified = PasskeyAssertion.build(178 entry = backupEntry,179 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",180 origin = "https://example.com",181 userVerified = true,182 )183 assertNotNull(verified)184 assertEquals(0x0d, extractFlags(verified!!.responseJson))185186 187 val unverified = PasskeyAssertion.build(188 entry = backupEntry,189 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",190 origin = "https://example.com",191 userVerified = false,192 )193 assertEquals(0x09, extractFlags(unverified!!.responseJson))194195 196 val plainEntry = passkeyEntry(privKey).copy(197 passkey = passkeyEntry(privKey).passkey!!.copy(198 backupEligible = false,199 backupState = false,200 ),201 )202 val plain = PasskeyAssertion.build(203 entry = plainEntry,204 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",205 origin = "https://example.com",206 userVerified = false,207 )208 assertEquals(0x01, extractFlags(plain!!.responseJson))209 210 assertEquals(0, 0x40 and extractFlags(plain.responseJson))211 }212213 private fun extractFlags(responseJson: String): Int {214 val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject215 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)216 return authData[32].toInt() and 0xff217 }218219 private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(220 id = "entry-1",221 type = "passkey",222 title = "Example",223 passkey = PasskeyData(224 credentialId = "cred-1",225 publicKeyCose = "cose",226 privateKeyJwk = privateKey?.let(::privateJwk),227 rpId = "example.com",228 userHandle = "user-handle",229 userName = "june",230 userDisplayName = "June",231 algorithm = -7,232 signCount = 41,233 ),234 createdAt = 1,235 modifiedAt = 1,236 )237238 private fun privateJwk(key: ECPrivateKey): String =239 """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""240241 private fun BigInteger.toFixed32(): ByteArray {242 val raw = toByteArray()243 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw244 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }245 }246247 private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)248 private fun b64Decode(value: String): ByteArray =249 Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))250}