PasskeyAssertionTest.kt

11.4 KB · raw

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        // A web caller cannot use a credential bound to a native app either.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        // ...but a verified web caller inside the rpId domain still works.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        // Backup-eligible + UV performed → UP | UV | BE = 0x0d. AT (0x40) must164        // NOT be set on an assertion (it's registration-only). BS depends on165        // passkey.backupState which is false here.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        // Same entry, no UV performed: UP | BE only = 0x09.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        // No backup eligibility, no UV: just UP = 0x01.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        // AT (0x40) is never set on assertions regardless of the other bits.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}