PasskeyAssertionTest.kt

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