PasskeyAssertionTest.kt

4.2 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.Test2122class PasskeyAssertionTest {23    @Test fun builds_es256_assertion_and_increments_counter() {24        val keyPair = KeyPairGenerator.getInstance("EC").apply {25            initialize(ECGenParameterSpec("secp256r1"))26        }.generateKeyPair()27        val entry = passkeyEntry(keyPair.private as ECPrivateKey)2829        val result = PasskeyAssertion.build(30            entry = entry,31            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",32            origin = "https://login.example.com",33        )3435        assertNotNull(result)36        result!!37        assertEquals(42, result.nextSignCount)38        val response = Json.parseToJsonElement(result.responseJson).jsonObject39        assertEquals("cred-1", response["id"]?.jsonPrimitive?.contentOrNull)40        val payload = response["response"]!!.jsonObject41        val clientDataBytes = b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content)42        val clientData = String(clientDataBytes, Charsets.UTF_8)43        val client = Json.parseToJsonElement(clientData).jsonObject44        assertEquals("webauthn.get", client["type"]?.jsonPrimitive?.contentOrNull)45        assertEquals("https://login.example.com", client["origin"]?.jsonPrimitive?.contentOrNull)46        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)47        assertEquals(37, authData.size)48        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))4950        val verifier = Signature.getInstance("SHA256withECDSA")51        verifier.initVerify(keyPair.public as ECPublicKey)52        verifier.update(authData + MessageDigest.getInstance("SHA-256").digest(clientDataBytes))53        assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))54    }5556    @Test fun returns_null_for_non_es256_or_missing_private_key() {57        val entry = passkeyEntry(null)58        assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))59        val eddsa = entry.copy(passkey = entry.passkey!!.copy(privateKeyJwk = """{"kty":"OKP","crv":"Ed25519","d":"x","x":"y"}""", algorithm = -8))60        assertEquals(null, PasskeyAssertion.build(eddsa, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))61    }6263    private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(64        id = "entry-1",65        type = "passkey",66        title = "Example",67        passkey = PasskeyData(68            credentialId = "cred-1",69            publicKeyCose = "cose",70            privateKeyJwk = privateKey?.let(::privateJwk),71            rpId = "example.com",72            userHandle = "user-handle",73            userName = "june",74            userDisplayName = "June",75            algorithm = -7,76            signCount = 41,77        ),78        createdAt = 1,79        modifiedAt = 1,80    )8182    private fun privateJwk(key: ECPrivateKey): String =83        """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""8485    private fun BigInteger.toFixed32(): ByteArray {86        val raw = toByteArray()87        val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw88        return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }89    }9091    private fun b64(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)92    private fun b64Decode(value: String): ByteArray =93        Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))94}