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 bound_credential_refuses_every_other_caller() {156 val keyPair = KeyPairGenerator.getInstance("EC").apply {157 initialize(ECGenParameterSpec("secp256r1"))158 }.generateKeyPair()159 val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"160 val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"161 val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(162 passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),163 )164 val request = """{"challenge":"c","rpId":"example.com"}"""165 assertNotNull(PasskeyAssertion.build(bound, request, appA))166 assertEquals(null, PasskeyAssertion.build(bound, request, appB))167 168 assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))169 }170171 @Test fun legacy_credential_stays_hidden_from_native_app_callers() {172 val keyPair = KeyPairGenerator.getInstance("EC").apply {173 initialize(ECGenParameterSpec("secp256r1"))174 }.generateKeyPair()175 val legacy = passkeyEntry(keyPair.private as ECPrivateKey)176 assertEquals(177 null,178 PasskeyAssertion.build(179 legacy,180 """{"challenge":"c","rpId":"example.com"}""",181 "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",182 ),183 )184 185 assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))186 }187188 @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {189 190 191 192 val keyPair = KeyPairGenerator.getInstance("EC").apply {193 initialize(ECGenParameterSpec("secp256r1"))194 }.generateKeyPair()195 val privKey = keyPair.private as ECPrivateKey196 val backupEntry = passkeyEntry(privKey).copy(197 passkey = passkeyEntry(privKey).passkey!!.copy(198 backupEligible = true,199 backupState = false,200 ),201 )202203 val verified = PasskeyAssertion.build(204 entry = backupEntry,205 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",206 origin = "https://example.com",207 userVerified = true,208 )209 assertNotNull(verified)210 assertEquals(0x0d, extractFlags(verified!!.responseJson))211212 213 val unverified = PasskeyAssertion.build(214 entry = backupEntry,215 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",216 origin = "https://example.com",217 userVerified = false,218 )219 assertEquals(0x09, extractFlags(unverified!!.responseJson))220221 222 val plainEntry = passkeyEntry(privKey).copy(223 passkey = passkeyEntry(privKey).passkey!!.copy(224 backupEligible = false,225 backupState = false,226 ),227 )228 val plain = PasskeyAssertion.build(229 entry = plainEntry,230 requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",231 origin = "https://example.com",232 userVerified = false,233 )234 assertEquals(0x01, extractFlags(plain!!.responseJson))235 236 assertEquals(0, 0x40 and extractFlags(plain.responseJson))237 }238239 private fun extractFlags(responseJson: String): Int {240 val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject241 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)242 return authData[32].toInt() and 0xff243 }244245 private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(246 id = "entry-1",247 type = "passkey",248 title = "Example",249 passkey = PasskeyData(250 credentialId = "cred-1",251 publicKeyCose = "cose",252 privateKeyJwk = privateKey?.let(::privateJwk),253 rpId = "example.com",254 userHandle = "user-handle",255 userName = "june",256 userDisplayName = "June",257 algorithm = -7,258 signCount = 41,259 ),260 createdAt = 1,261 modifiedAt = 1,262 )263264 private fun privateJwk(key: ECPrivateKey): String =265 """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""266267 private fun BigInteger.toFixed32(): ByteArray {268 val raw = toByteArray()269 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw270 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }271 }272273 private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)274 private fun b64Decode(value: String): ByteArray =275 Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))276}