363a93112668

feat: Add EdDSA passkey crypto (v2.3.45)

- Prefer Ed25519 for passkey creation when the relying party offers alg -8 - Sign EdDSA get assertions from stored OKP private JWKs - Verify Ed25519 assertion signatures in passkey tests

time
2026-06-26 13:52 UTC · 4 months ago
machine
koh2
parent
142232e24288
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+22 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+78 −23app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+35 −3app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+18 −2
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23440020-        versionName "2.3.44"19+        versionCode 23450020+        versionName "2.3.45"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+22 −2
@@ -11,6 +11,8 @@1111 import java.security.spec.ECParameterSpec1212 import java.security.spec.ECPrivateKeySpec1313 import java.util.Base6414+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters15+import org.bouncycastle.crypto.signers.Ed25519Signer1416 import kotlinx.serialization.json.Json1517 import kotlinx.serialization.json.JsonObject1618 import kotlinx.serialization.json.JsonPrimitive@@ -38,7 +40,6 @@3840         clientDataHashOverride: ByteArray? = null,3941     ): PasskeyAssertionResult? {4042         val passkey = entry.passkey ?: return null41-        if (passkey.algorithm != -7) return null4243         val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null4344         val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null4445         val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId@@ -53,7 +54,7 @@5354             origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),5455         )5556         val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)56-        val signature = signEs256(privateKeyJwk, authData + clientDataHash) ?: return null57+        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash) ?: return null5758 5859         val responseJson = json.encodeToString(5960             JsonObject.serializer(),@@ -96,6 +97,13 @@9697         return out9798     }9899 100+    private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =101+        when (algorithm) {102+            -7 -> signEs256(privateKeyJwk, message)103+            -8 -> signEd25519(privateKeyJwk, message)104+            else -> null105+        }106+99107     private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {100108         val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null101109         if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null@@ -106,6 +114,18 @@106114         signer.initSign(privateKey)107115         signer.update(message)108116         return signer.sign()117+    }118+119+    private fun signEd25519(privateKeyJwk: String, message: ByteArray): ByteArray? {120+        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null121+        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "OKP") return null122+        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "Ed25519") return null123+        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null124+        if (d.size != 32) return null125+        val signer = Ed25519Signer()126+        signer.init(true, Ed25519PrivateKeyParameters(d, 0))127+        signer.update(message, 0, message.size)128+        return signer.generateSignature()109129     }110130 111131     private fun ecPrivateKey(d: ByteArray): ECPrivateKey {

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+78 −23
@@ -8,8 +8,8 @@88 import java.security.interfaces.ECPublicKey99 import java.security.spec.ECGenParameterSpec1010 import java.util.Base6411+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters1112 import kotlinx.serialization.json.Json12-import kotlinx.serialization.json.JsonArray1313 import kotlinx.serialization.json.JsonObject1414 import kotlinx.serialization.json.JsonPrimitive1515 import kotlinx.serialization.json.buildJsonArray@@ -41,21 +41,11 @@4141         val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null4242         val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName4343         val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null44-        if (!offersEs256(request["pubKeyCredParams"])) return null44+        val algorithm = pickAlgorithm(request["pubKeyCredParams"]) ?: return null4545 46-        val keyPair = KeyPairGenerator.getInstance("EC").apply {47-            initialize(ECGenParameterSpec("secp256r1"))48-        }.generateKeyPair()49-        val privateKey = keyPair.private as ECPrivateKey50-        val publicKey = keyPair.public as ECPublicKey46+        val keyMaterial = generateKeyMaterial(algorithm)5147         val credentialId = ByteArray(32).also(random::nextBytes)52-        val publicKeyCose = Cbor.map(53-            1 to Cbor.uint(2),54-            3 to Cbor.nint(-7),55-            -1 to Cbor.uint(1),56-            -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),57-            -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),58-        )48+        val publicKeyCose = keyMaterial.publicKeyCose5949         val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)6050         val attestationObject = Cbor.map(6151             "fmt" to Cbor.text("none"),@@ -66,13 +56,13 @@6656         val passkey = PasskeyData(6757             credentialId = b64(credentialId),6858             publicKeyCose = b64(publicKeyCose),69-            privateKeyJwk = privateJwk(privateKey, publicKey),59+            privateKeyJwk = keyMaterial.privateKeyJwk,7060             rpId = rpId,7161             rpName = rpName,7262             userHandle = userHandle,7363             userName = userName,7464             userDisplayName = userDisplayName,75-            algorithm = -7,65+            algorithm = algorithm,7666             signCount = 0,7767             aaguid = b64(ByteArray(16)),7868             backupEligible = false,@@ -101,8 +91,8 @@10191                     put("authenticatorData", b64(authData))10292                     put("attestationObject", b64(attestationObject))10393                     put("transports", buildJsonArray { add(JsonPrimitive("internal")) })104-                    put("publicKey", b64(publicKey.encoded))105-                    put("publicKeyAlgorithm", -7)94+                    put("publicKey", b64(keyMaterial.publicKeySpki))95+                    put("publicKeyAlgorithm", algorithm)10696                 })10797                 put("authenticatorAttachment", "platform")10898                 put("clientExtensionResults", buildJsonObject {})@@ -111,13 +101,73 @@111101         return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)112102     }113103 114-    private fun offersEs256(value: kotlinx.serialization.json.JsonElement?): Boolean {115-        val params = runCatching { value?.jsonArray }.getOrNull() ?: return true116-        return params.any { item ->117-            runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull == -7 }.getOrDefault(false)104+    private data class KeyMaterial(105+        val publicKeyCose: ByteArray,106+        val publicKeySpki: ByteArray,107+        val privateKeyJwk: String,108+    )109+110+    private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? {111+        val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7112+        val offered = params.mapNotNull { item ->113+            runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull()114+        }.toSet()115+        return when {116+            -8 in offered -> -8117+            -7 in offered -> -7118+            else -> null118119         }119120     }120121 122+    private fun generateKeyMaterial(algorithm: Int): KeyMaterial =123+        if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial()124+125+    private fun generateEs256KeyMaterial(): KeyMaterial {126+        val keyPair = KeyPairGenerator.getInstance("EC").apply {127+            initialize(ECGenParameterSpec("secp256r1"))128+        }.generateKeyPair()129+        val privateKey = keyPair.private as ECPrivateKey130+        val publicKey = keyPair.public as ECPublicKey131+        val publicKeyCose = Cbor.map(132+            1 to Cbor.uint(2),133+            3 to Cbor.nint(-7),134+            -1 to Cbor.uint(1),135+            -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),136+            -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),137+        )138+        return KeyMaterial(139+            publicKeyCose = publicKeyCose,140+            publicKeySpki = publicKey.encoded,141+            privateKeyJwk = privateEs256Jwk(privateKey, publicKey),142+        )143+    }144+145+    private fun generateEd25519KeyMaterial(): KeyMaterial {146+        val privateKey = Ed25519PrivateKeyParameters(random)147+        val publicKey = privateKey.generatePublicKey().encoded148+        val seed = privateKey.encoded149+        val publicKeyCose = Cbor.map(150+            1 to Cbor.uint(1),151+            3 to Cbor.nint(-8),152+            -1 to Cbor.uint(6),153+            -2 to Cbor.bytes(publicKey),154+        )155+        val privateKeyJwk = json.encodeToString(156+            JsonObject.serializer(),157+            buildJsonObject {158+                put("kty", "OKP")159+                put("crv", "Ed25519")160+                put("d", b64(seed))161+                put("x", b64(publicKey))162+            },163+        )164+        return KeyMaterial(165+            publicKeyCose = publicKeyCose,166+            publicKeySpki = ed25519Spki(publicKey),167+            privateKeyJwk = privateKeyJwk,168+        )169+    }170+121171     private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {122172         val base = ByteArray(37)123173         java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)@@ -137,7 +187,7 @@137187             },138188         ).toByteArray(Charsets.UTF_8)139189 140-    private fun privateJwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =190+    private fun privateEs256Jwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =141191         json.encodeToString(142192             JsonObject.serializer(),143193             buildJsonObject {@@ -153,6 +203,11 @@153203         if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"154204 155205     private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)206+207+    private fun ed25519Spki(publicKey: ByteArray): ByteArray =208+        byteArrayOf(209+            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,210+        ) + publicKey156211 }157212 158213 private object Cbor {

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+35 −3
@@ -18,6 +18,9 @@1818 import org.junit.Assert.assertNotNull1919 import org.junit.Assert.assertTrue2020 import org.junit.Test21+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters22+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters23+import org.bouncycastle.crypto.signers.Ed25519Signer2124 2225 class PasskeyAssertionTest {2326     @Test fun builds_es256_assertion_and_increments_counter() {@@ -53,11 +56,40 @@5356         assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))5457     }5558 56-    @Test fun returns_null_for_non_es256_or_missing_private_key() {59+    @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+        )69+70+        val result = PasskeyAssertion.build(71+            entry = entry,72+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",73+            origin = "https://example.com",74+        )75+76+        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+    }89+90+    @Test fun returns_null_for_missing_private_key() {5791         val entry = passkeyEntry(null)5892         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"))6193     }6294 6395     private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+18 −2
@@ -44,9 +44,25 @@4444         assertTrue(b64Decode(payload["attestationObject"]!!.jsonPrimitive.content).isNotEmpty())4545     }4646 47-    @Test fun rejects_requests_without_es256() {47+    @Test fun prefers_eddsa_registration_when_offered() {4848         val result = PasskeyRegistration.build(49-            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-8}]}""",49+            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-8},{"alg":-7}]}""",50+            "https://example.com",51+        )52+53+        assertNotNull(result)54+        result!!55+        assertEquals(-8, result.entryDraft.passkey?.algorithm)56+        val jwk = Json.parseToJsonElement(result.entryDraft.passkey!!.privateKeyJwk!!).jsonObject57+        assertEquals("OKP", jwk["kty"]?.jsonPrimitive?.contentOrNull)58+        assertEquals("Ed25519", jwk["crv"]?.jsonPrimitive?.contentOrNull)59+        val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject60+        assertEquals(-8, payload["publicKeyAlgorithm"]?.jsonPrimitive?.int)61+    }62+63+    @Test fun rejects_requests_without_supported_algorithms() {64+        val result = PasskeyRegistration.build(65+            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-257}]}""",5066             "https://example.com",5167         )5268         assertEquals(null, result)

view file