363a93112668
feat: Add EdDSA passkey crypto (v2.3.45)
- time
- 2026-06-26 13:52 UTC · 4 months ago
- machine
- koh2
- parent
- 142232e24288
↓ peach-android-363a9311.tar.gzatlas at this save
files changed5 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 −2app/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 {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 {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 {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(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)