44316f00e2ed

feat: Sign ES256 passkey assertions (v2.3.43)

- Build Android Credential Manager public-key get responses for ES256 passkeys - Persist incremented sign counters before returning successful assertions - Add signature verification coverage for generated P-256 passkey assertions

time
2026-06-26 13:44 UTC · 4 months ago
machine
koh2
parent
c3d88387bfd8
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+136 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+77 −8app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+5 −1app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+94 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23420020-        versionName "2.3.42"19+        versionCode 23430020+        versionName "2.3.43"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+136 −0
@@ -0,0 +1,136 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.VaultEntry4+import java.math.BigInteger5+import java.security.AlgorithmParameters6+import java.security.KeyFactory7+import java.security.MessageDigest8+import java.security.Signature9+import java.security.interfaces.ECPrivateKey10+import java.security.spec.ECGenParameterSpec11+import java.security.spec.ECParameterSpec12+import java.security.spec.ECPrivateKeySpec13+import java.util.Base6414+import kotlinx.serialization.json.Json15+import kotlinx.serialization.json.JsonObject16+import kotlinx.serialization.json.JsonPrimitive17+import kotlinx.serialization.json.buildJsonObject18+import kotlinx.serialization.json.contentOrNull19+import kotlinx.serialization.json.intOrNull20+import kotlinx.serialization.json.jsonObject21+import kotlinx.serialization.json.jsonPrimitive22+import kotlinx.serialization.json.put23+24+data class PasskeyAssertionResult(25+    val responseJson: String,26+    val nextSignCount: Int,27+)28+29+object PasskeyAssertion {30+    private val json = Json { ignoreUnknownKeys = true }31+    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()32+    private val urlDecoder = Base64.getUrlDecoder()33+34+    fun build(35+        entry: VaultEntry,36+        requestJson: String,37+        origin: String?,38+        clientDataHashOverride: ByteArray? = null,39+    ): PasskeyAssertionResult? {40+        val passkey = entry.passkey ?: return null41+        if (passkey.algorithm != -7) return null42+        val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null43+        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null44+        val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId45+        if (rpId != passkey.rpId) return null46+        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null47+48+        val nextSignCount = passkey.signCount + 149+        val flags = 0x05 or (if (passkey.backupEligible) 0x08 else 0) or (if (passkey.backupState) 0x10 else 0)50+        val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)51+        val clientDataJson = buildClientDataJson(52+            challenge = challenge,53+            origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),54+        )55+        val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)56+        val signature = signEs256(privateKeyJwk, authData + clientDataHash) ?: return null57+58+        val responseJson = json.encodeToString(59+            JsonObject.serializer(),60+            buildJsonObject {61+                put("id", passkey.credentialId)62+                put("rawId", passkey.credentialId)63+                put("type", "public-key")64+                put("response", buildJsonObject {65+                    put("clientDataJSON", b64(clientDataJson))66+                    put("authenticatorData", b64(authData))67+                    put("signature", b64(signature))68+                    put("userHandle", passkey.userHandle)69+                })70+                put("authenticatorAttachment", "platform")71+                put("clientExtensionResults", buildJsonObject {})72+            },73+        )74+        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)75+    }76+77+    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =78+        json.encodeToString(79+            JsonObject.serializer(),80+            buildJsonObject {81+                put("type", "webauthn.get")82+                put("challenge", challenge)83+                put("origin", origin)84+                put("crossOrigin", JsonPrimitive(false))85+            },86+        ).toByteArray(Charsets.UTF_8)87+88+    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {89+        val out = ByteArray(37)90+        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)91+        out[32] = flags.toByte()92+        out[33] = ((signCount ushr 24) and 0xff).toByte()93+        out[34] = ((signCount ushr 16) and 0xff).toByte()94+        out[35] = ((signCount ushr 8) and 0xff).toByte()95+        out[36] = (signCount and 0xff).toByte()96+        return out97+    }98+99+    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {100+        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null101+        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null102+        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null103+        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null104+        val privateKey = ecPrivateKey(d)105+        val signer = Signature.getInstance("SHA256withECDSA")106+        signer.initSign(privateKey)107+        signer.update(message)108+        return signer.sign()109+    }110+111+    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {112+        val keyFactory = KeyFactory.getInstance("EC")113+        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())114+        return keyFactory.generatePrivate(spec) as ECPrivateKey115+    }116+117+    private fun ecP256Spec(): ECParameterSpec {118+        val params = AlgorithmParameters.getInstance("EC")119+        params.init(ECGenParameterSpec("secp256r1"))120+        return params.getParameterSpec(ECParameterSpec::class.java)121+    }122+123+    private fun originFromRpId(rpId: String): String =124+        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") {125+            "http://$rpId"126+        } else {127+            "https://$rpId"128+        }129+130+    private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)131+    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)132+    private fun b64Decode(value: String): ByteArray {133+        val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')134+        return urlDecoder.decode(padded)135+    }136+}

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+77 −8
@@ -1,36 +1,105 @@11 package com.peachpasswords.passkeys22 3-import android.app.Activity43 import android.app.PendingIntent54 import android.content.Context65 import android.content.Intent76 import android.os.Build87 import android.os.Bundle8+import androidx.activity.ComponentActivity9+import androidx.credentials.GetCredentialResponse10+import androidx.credentials.GetPublicKeyCredentialOption11+import androidx.credentials.PublicKeyCredential912 import androidx.credentials.exceptions.GetCredentialUnsupportedException1013 import androidx.credentials.provider.PendingIntentHandler14+import androidx.lifecycle.lifecycleScope15+import com.peachpasswords.vault.VaultRepository16+import dagger.hilt.android.AndroidEntryPoint17+import javax.inject.Inject18+import kotlinx.coroutines.launch1119 12-class PasskeyCredentialSelectionActivity : Activity() {20+@AndroidEntryPoint21+class PasskeyCredentialSelectionActivity : ComponentActivity() {22+    @Inject lateinit var vault: VaultRepository23+1324     override fun onCreate(savedInstanceState: Bundle?) {1425         super.onCreate(savedInstanceState)26+        lifecycleScope.launch { completeSelection() }27+    }28+29+    private suspend fun completeSelection() {1530         val result = Intent()16-        PendingIntentHandler.setGetCredentialException(17-            result,18-            GetCredentialUnsupportedException("Peach passkey signing is not available yet."),19-        )31+        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)32+        val providerRequest = runCatching {33+            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)34+        }.getOrNull()35+        val option = providerRequest?.credentialOptions36+            ?.filterIsInstance<GetPublicKeyCredentialOption>()37+            ?.firstOrNull()38+        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)39+        val entry = entryId?.let(vault::getEntry)40+        val origin = providerRequest?.callingAppInfo?.let { info ->41+            runCatching { info.getOrigin(info.packageName) }.getOrNull()42+        }43+        val assertion = if (entry != null && requestJson != null) {44+            PasskeyAssertion.build(45+                entry = entry,46+                requestJson = requestJson,47+                origin = origin,48+                clientDataHashOverride = option?.clientDataHash,49+            )50+        } else {51+            null52+        }53+54+        if (entry != null && assertion != null) {55+            val passkey = entry.passkey56+            val persisted = if (passkey != null) {57+                val now = System.currentTimeMillis()58+                runCatching {59+                    vault.updateEntry(60+                        entry.copy(61+                            passkey = passkey.copy(signCount = assertion.nextSignCount),62+                            signCount = assertion.nextSignCount,63+                            lastUsedAt = now,64+                        ),65+                    )66+                }.isSuccess67+            } else {68+                false69+            }70+            if (persisted) {71+                PendingIntentHandler.setGetCredentialResponse(72+                    result,73+                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),74+                )75+            } else {76+                PendingIntentHandler.setGetCredentialException(77+                    result,78+                    GetCredentialUnsupportedException("Peach could not update this passkey."),79+                )80+            }81+        } else {82+            PendingIntentHandler.setGetCredentialException(83+                result,84+                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),85+            )86+        }2087         setResult(RESULT_OK, result)2188         finish()2289     }2390 2491     companion object {2592         private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"93+        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"2694 27-        fun pendingIntent(context: Context, entryId: String): PendingIntent {95+        fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {2896             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)2997                 .putExtra(EXTRA_ENTRY_ID, entryId)98+                .putExtra(EXTRA_REQUEST_JSON, requestJson)3099             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 031100             return PendingIntent.getActivity(32101                 context,33-                entryId.hashCode(),102+                "$entryId:$requestJson".hashCode(),34103                 intent,35104                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,36105             )

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+5 −1
@@ -62,7 +62,11 @@6262                 PublicKeyCredentialEntry(6363                     context = this,6464                     username = candidate.username,65-                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(this, candidate.entryId),65+                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(66+                        this,67+                        candidate.entryId,68+                        option.requestJson,69+                    ),6670                     beginGetPublicKeyCredentialOption = option,6771                     displayName = candidate.displayName,6872                     lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+94 −0
@@ -0,0 +1,94 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import java.math.BigInteger6+import java.security.MessageDigest7+import java.security.KeyPairGenerator8+import java.security.Signature9+import java.security.interfaces.ECPrivateKey10+import java.security.interfaces.ECPublicKey11+import java.security.spec.ECGenParameterSpec12+import java.util.Base6413+import kotlinx.serialization.json.Json14+import kotlinx.serialization.json.contentOrNull15+import kotlinx.serialization.json.jsonObject16+import kotlinx.serialization.json.jsonPrimitive17+import org.junit.Assert.assertEquals18+import org.junit.Assert.assertNotNull19+import org.junit.Assert.assertTrue20+import org.junit.Test21+22+class 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)28+29+        val result = PasskeyAssertion.build(30+            entry = entry,31+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",32+            origin = "https://login.example.com",33+        )34+35+        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))49+50+        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+    }55+56+    @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+    }62+63+    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+    )81+82+    private fun privateJwk(key: ECPrivateKey): String =83+        """{"kty":"EC","crv":"P-256","d":"${b64(key.s.toFixed32())}"}"""84+85+    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+    }90+91+    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+}

view file