PasskeyAssertionTest.kt

12.7 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        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 rejects_plaintext_http_origin() {156        val keyPair = KeyPairGenerator.getInstance("EC").apply {157            initialize(ECGenParameterSpec("secp256r1"))158        }.generateKeyPair()159        assertEquals(160            null,161            PasskeyAssertion.build(162                passkeyEntry(keyPair.private as ECPrivateKey),163                """{"challenge":"c","rpId":"example.com"}""",164                "http://example.com",165            ),166        )167    }168169    @Test fun bound_credential_refuses_every_other_caller() {170        val keyPair = KeyPairGenerator.getInstance("EC").apply {171            initialize(ECGenParameterSpec("secp256r1"))172        }.generateKeyPair()173        val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"174        val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"175        val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(176            passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),177        )178        val request = """{"challenge":"c","rpId":"example.com"}"""179        assertNotNull(PasskeyAssertion.build(bound, request, appA))180        assertEquals(null, PasskeyAssertion.build(bound, request, appB))181        // A web caller cannot use a credential bound to a native app either.182        assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))183    }184185    @Test fun legacy_credential_stays_hidden_from_native_app_callers() {186        val keyPair = KeyPairGenerator.getInstance("EC").apply {187            initialize(ECGenParameterSpec("secp256r1"))188        }.generateKeyPair()189        val legacy = passkeyEntry(keyPair.private as ECPrivateKey)190        assertEquals(191            null,192            PasskeyAssertion.build(193                legacy,194                """{"challenge":"c","rpId":"example.com"}""",195                "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",196            ),197        )198        // ...but a verified web caller inside the rpId domain still works.199        assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))200    }201202    @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {203        // Backup-eligible + UV performed → UP | UV | BE = 0x0d. AT (0x40) must204        // NOT be set on an assertion (it's registration-only). BS depends on205        // passkey.backupState which is false here.206        val keyPair = KeyPairGenerator.getInstance("EC").apply {207            initialize(ECGenParameterSpec("secp256r1"))208        }.generateKeyPair()209        val privKey = keyPair.private as ECPrivateKey210        val backupEntry = passkeyEntry(privKey).copy(211            passkey = passkeyEntry(privKey).passkey!!.copy(212                backupEligible = true,213                backupState = false,214            ),215        )216217        val verified = PasskeyAssertion.build(218            entry = backupEntry,219            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",220            origin = "https://example.com",221            userVerified = true,222        )223        assertNotNull(verified)224        assertEquals(0x0d, extractFlags(verified!!.responseJson))225226        // Same entry, no UV performed: UP | BE only = 0x09.227        val unverified = PasskeyAssertion.build(228            entry = backupEntry,229            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",230            origin = "https://example.com",231            userVerified = false,232        )233        assertEquals(0x09, extractFlags(unverified!!.responseJson))234235        // No backup eligibility, no UV: just UP = 0x01.236        val plainEntry = passkeyEntry(privKey).copy(237            passkey = passkeyEntry(privKey).passkey!!.copy(238                backupEligible = false,239                backupState = false,240            ),241        )242        val plain = PasskeyAssertion.build(243            entry = plainEntry,244            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",245            origin = "https://example.com",246            userVerified = false,247        )248        assertEquals(0x01, extractFlags(plain!!.responseJson))249        // AT (0x40) is never set on assertions regardless of the other bits.250        assertEquals(0, 0x40 and extractFlags(plain.responseJson))251    }252253    private fun extractFlags(responseJson: String): Int {254        val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject255        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)256        return authData[32].toInt() and 0xff257    }258259    private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(260        id = "entry-1",261        type = "passkey",262        title = "Example",263        passkey = PasskeyData(264            credentialId = "cred-1",265            publicKeyCose = "cose",266            privateKeyJwk = privateKey?.let(::privateJwk),267            rpId = "example.com",268            userHandle = "user-handle",269            userName = "june",270            userDisplayName = "June",271            algorithm = -7,272            signCount = 41,273        ),274        createdAt = 1,275        modifiedAt = 1,276    )277278    private fun privateJwk(key: ECPrivateKey): String =279        """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""280281    private fun BigInteger.toFixed32(): ByteArray {282        val raw = toByteArray()283        val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw284        return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }285    }286287    private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)288    private fun b64Decode(value: String): ByteArray =289        Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))290}