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.assertNotNull19import org.junit.Assert.assertTrue20import org.junit.Test21import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters22import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters23import org.bouncycastle.crypto.signers.Ed25519Signer2425class PasskeyAssertionTest {26 @Test fun builds_es256_assertion_and_increments_counter() {27 val keyPair = KeyPairGenerator.getInstance("EC").apply {28 initialize(ECGenParameterSpec("secp256r1"))29 }.generateKeyPair()30 val entry = passkeyEntry(keyPair.private as ECPrivateKey)3132 val result = PasskeyAssertion.build(33 entry = entry,34 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",35 origin = "https://login.example.com",36 )3738 assertNotNull(result)39 result!!40 assertEquals(42, result.nextSignCount)41 val response = Json.parseToJsonElement(result.responseJson).jsonObject42 assertEquals("cred-1", response["id"]?.jsonPrimitive?.contentOrNull)43 val payload = response["response"]!!.jsonObject44 val clientDataBytes = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)45 val clientData = String(clientDataBytes, Charsets.UTF_8)46 val client = Json.parseToJsonElement(clientData).jsonObject47 assertEquals("webauthn.get", client["type"]?.jsonPrimitive?.contentOrNull)48 assertEquals("https://login.example.com", client["origin"]?.jsonPrimitive?.contentOrNull)49 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)50 assertEquals(37, authData.size)51 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))5253 val verifier = Signature.getInstance("SHA256withECDSA")54 verifier.initVerify(keyPair.public as ECPublicKey)55 verifier.update(authData + MessageDigest.getInstance("SHA-256").digest(clientDataBytes))56 assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))57 }5859 @Test fun builds_ed25519_assertion() {60 val seed = ByteArray(32) { (it + 1).toByte() }61 val privateKey = Ed25519PrivateKeyParameters(seed, 0)62 val publicKey = privateKey.generatePublicKey().encoded63 val entry = passkeyEntry(null).copy(64 passkey = passkeyEntry(null).passkey!!.copy(65 privateKeyJwk = """{"kty":"OKP","crv":"Ed25519","d":"${b64(seed)}","x":"${b64(publicKey)}"}""",66 algorithm = -8,67 ),68 )6970 val result = PasskeyAssertion.build(71 entry = entry,72 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",73 origin = "https://example.com",74 )7576 assertNotNull(result)77 result!!78 val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject79 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)80 val clientData = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)81 val message = authData + MessageDigest.getInstance("SHA-256").digest(clientData)82 val signature = b64Decode(payload["signature"]!!.jsonPrimitive.content)83 assertEquals(64, signature.size)84 val verifier = Ed25519Signer()85 verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))86 verifier.update(message, 0, message.size)87 assertTrue(verifier.verifySignature(signature))88 }8990 @Test fun returns_null_for_missing_private_key() {91 val entry = passkeyEntry(null)92 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))93 }9495 @Test fun rejects_missing_or_blank_origin() {96 val keyPair = KeyPairGenerator.getInstance("EC").apply {97 initialize(ECGenParameterSpec("secp256r1"))98 }.generateKeyPair()99 val entry = passkeyEntry(keyPair.private as ECPrivateKey)100 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", null))101 assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", " "))102 }103104 @Test fun bound_credential_refuses_every_other_caller() {105 val keyPair = KeyPairGenerator.getInstance("EC").apply {106 initialize(ECGenParameterSpec("secp256r1"))107 }.generateKeyPair()108 val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"109 val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"110 val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(111 passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),112 )113 val request = """{"challenge":"c","rpId":"example.com"}"""114 assertNotNull(PasskeyAssertion.build(bound, request, appA))115 assertEquals(null, PasskeyAssertion.build(bound, request, appB))116 117 assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))118 }119120 @Test fun legacy_credential_stays_hidden_from_native_app_callers() {121 val keyPair = KeyPairGenerator.getInstance("EC").apply {122 initialize(ECGenParameterSpec("secp256r1"))123 }.generateKeyPair()124 val legacy = passkeyEntry(keyPair.private as ECPrivateKey)125 assertEquals(126 null,127 PasskeyAssertion.build(128 legacy,129 """{"challenge":"c","rpId":"example.com"}""",130 "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",131 ),132 )133 134 assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))135 }136137 @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {138 139 140 141 val keyPair = KeyPairGenerator.getInstance("EC").apply {142 initialize(ECGenParameterSpec("secp256r1"))143 }.generateKeyPair()144 val privKey = keyPair.private as ECPrivateKey145 val backupEntry = passkeyEntry(privKey).copy(146 passkey = passkeyEntry(privKey).passkey!!.copy(147 backupEligible = true,148 backupState = false,149 ),150 )151152 val verified = PasskeyAssertion.build(153 entry = backupEntry,154 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",155 origin = "https://example.com",156 userVerified = true,157 )158 assertNotNull(verified)159 assertEquals(0x0d, extractFlags(verified!!.responseJson))160161 162 val unverified = PasskeyAssertion.build(163 entry = backupEntry,164 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",165 origin = "https://example.com",166 userVerified = false,167 )168 assertEquals(0x09, extractFlags(unverified!!.responseJson))169170 171 val plainEntry = passkeyEntry(privKey).copy(172 passkey = passkeyEntry(privKey).passkey!!.copy(173 backupEligible = false,174 backupState = false,175 ),176 )177 val plain = PasskeyAssertion.build(178 entry = plainEntry,179 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",180 origin = "https://example.com",181 userVerified = false,182 )183 assertEquals(0x01, extractFlags(plain!!.responseJson))184 185 assertEquals(0, 0x40 and extractFlags(plain.responseJson))186 }187188 private fun extractFlags(responseJson: String): Int {189 val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject190 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)191 return authData[32].toInt() and 0xff192 }193194 private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(195 id = "entry-1",196 type = "passkey",197 title = "Example",198 passkey = PasskeyData(199 credentialId = "cred-1",200 publicKeyCose = "cose",201 privateKeyJwk = privateKey?.let(::privateJwk),202 rpId = "example.com",203 userHandle = "user-handle",204 userName = "june",205 userDisplayName = "June",206 algorithm = -7,207 signCount = 41,208 ),209 createdAt = 1,210 modifiedAt = 1,211 )212213 private fun privateJwk(key: ECPrivateKey): String =214 """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""215216 private fun BigInteger.toFixed32(): ByteArray {217 val raw = toByteArray()218 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw219 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }220 }221222 private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)223 private fun b64Decode(value: String): ByteArray =224 Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))225}