142232e24288

feat: Add ES256 passkey creation (v2.3.44)

- Offer Credential Manager create rows when Peach is unlocked and passkeys are enabled - Generate P-256 passkeys with portable private JWK storage and attestation JSON - Attach new passkeys to existing same-domain login entries when possible

time
2026-06-26 13:49 UTC · 4 months ago
machine
koh2
parent
44316f00e2ed
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+74 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+191 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+20 −1app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+57 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23430020-        versionName "2.3.43"19+        versionCode 23440020+        versionName "2.3.44"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+74 −0
@@ -8,11 +8,16 @@88 import androidx.activity.ComponentActivity99 import androidx.credentials.GetCredentialResponse1010 import androidx.credentials.GetPublicKeyCredentialOption11+import androidx.credentials.CreatePublicKeyCredentialRequest12+import androidx.credentials.CreatePublicKeyCredentialResponse1113 import androidx.credentials.PublicKeyCredential14+import androidx.credentials.exceptions.CreateCredentialUnsupportedException1215 import androidx.credentials.exceptions.GetCredentialUnsupportedException1316 import androidx.credentials.provider.PendingIntentHandler17+import com.peachpasswords.shared.extractDomain1418 import androidx.lifecycle.lifecycleScope1519 import com.peachpasswords.vault.VaultRepository20+import com.peachpasswords.vault.isLogin1621 import dagger.hilt.android.AndroidEntryPoint1722 import javax.inject.Inject1823 import kotlinx.coroutines.launch@@ -27,6 +32,10 @@2732     }2833 2934     private suspend fun completeSelection() {35+        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {36+            completeCreate()37+            return38+        }3039         val result = Intent()3140         val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)3241         val providerRequest = runCatching {@@ -88,9 +97,61 @@8897         finish()8998     }9099 100+    private suspend fun completeCreate() {101+        val result = Intent()102+        val providerRequest = runCatching {103+            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)104+        }.getOrNull()105+        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest106+        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)107+        val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->108+            runCatching { info.getOrigin(info.packageName) }.getOrNull()109+        }110+        val registration = requestJson?.let { PasskeyRegistration.build(it, origin) }111+        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false112+        if (registration != null && stored) {113+            PendingIntentHandler.setCreateCredentialResponse(114+                result,115+                CreatePublicKeyCredentialResponse(registration.responseJson),116+            )117+        } else {118+            PendingIntentHandler.setCreateCredentialException(119+                result,120+                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),121+            )122+        }123+        setResult(RESULT_OK, result)124+        finish()125+    }126+127+    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {128+        val rpId = draft.passkey?.rpId ?: return false129+        val target = vault.getEntries().firstOrNull { entry ->130+                entry.deletedAt == null &&131+                entry.passkey == null &&132+                entry.isLogin() &&133+                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())134+                    .any { extractDomain(it) == rpId }135+        }136+        return runCatching {137+            if (target != null) {138+                vault.updateEntry(139+                    target.copy(140+                        passkey = draft.passkey,141+                        username = target.username ?: draft.username,142+                    ),143+                )144+            } else {145+                vault.addEntry(draft)146+            }147+        }.isSuccess148+    }149+91150     companion object {92151         private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"93152         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"153+        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"154+        private const val MODE_CREATE = "create"94155 95156         fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {96157             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)@@ -100,6 +161,19 @@100161             return PendingIntent.getActivity(101162                 context,102163                 "$entryId:$requestJson".hashCode(),164+                intent,165+                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,166+            )167+        }168+169+        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {170+            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)171+                .putExtra(EXTRA_MODE, MODE_CREATE)172+                .putExtra(EXTRA_REQUEST_JSON, requestJson)173+            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0174+            return PendingIntent.getActivity(175+                context,176+                requestJson.hashCode(),103177                 intent,104178                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,105179             )

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+191 −0
@@ -0,0 +1,191 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import java.security.KeyPairGenerator6+import java.security.SecureRandom7+import java.security.interfaces.ECPrivateKey8+import java.security.interfaces.ECPublicKey9+import java.security.spec.ECGenParameterSpec10+import java.util.Base6411+import kotlinx.serialization.json.Json12+import kotlinx.serialization.json.JsonArray13+import kotlinx.serialization.json.JsonObject14+import kotlinx.serialization.json.JsonPrimitive15+import kotlinx.serialization.json.buildJsonArray16+import kotlinx.serialization.json.buildJsonObject17+import kotlinx.serialization.json.contentOrNull18+import kotlinx.serialization.json.intOrNull19+import kotlinx.serialization.json.jsonArray20+import kotlinx.serialization.json.jsonObject21+import kotlinx.serialization.json.jsonPrimitive22+import kotlinx.serialization.json.put23+24+data class PasskeyRegistrationResult(25+    val entryDraft: VaultEntry,26+    val responseJson: String,27+)28+29+object PasskeyRegistration {30+    private val json = Json { ignoreUnknownKeys = true }31+    private val random = SecureRandom()32+    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()33+34+    fun build(requestJson: String, origin: String?, now: Long = System.currentTimeMillis()): PasskeyRegistrationResult? {35+        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null36+        val rp = request["rp"]?.jsonObject ?: return null37+        val user = request["user"]?.jsonObject ?: return null38+        val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null39+        val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId40+        val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null41+        val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null42+        val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName43+        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null44+        if (!offersEs256(request["pubKeyCredParams"])) return null45+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 ECPublicKey51+        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+        )59+        val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)60+        val attestationObject = Cbor.map(61+            "fmt" to Cbor.text("none"),62+            "attStmt" to Cbor.map(),63+            "authData" to Cbor.bytes(authData),64+        )65+        val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))66+        val passkey = PasskeyData(67+            credentialId = b64(credentialId),68+            publicKeyCose = b64(publicKeyCose),69+            privateKeyJwk = privateJwk(privateKey, publicKey),70+            rpId = rpId,71+            rpName = rpName,72+            userHandle = userHandle,73+            userName = userName,74+            userDisplayName = userDisplayName,75+            algorithm = -7,76+            signCount = 0,77+            aaguid = b64(ByteArray(16)),78+            backupEligible = false,79+            backupState = false,80+            transports = listOf("internal"),81+            createdAt = now,82+        )83+        val entry = VaultEntry(84+            id = "",85+            type = "login",86+            title = rpName,87+            username = userName,88+            url = "https://$rpId",89+            passkey = passkey,90+            createdAt = 0,91+            modifiedAt = 0,92+        )93+        val responseJson = json.encodeToString(94+            JsonObject.serializer(),95+            buildJsonObject {96+                put("id", passkey.credentialId)97+                put("rawId", passkey.credentialId)98+                put("type", "public-key")99+                put("response", buildJsonObject {100+                    put("clientDataJSON", b64(clientDataJson))101+                    put("authenticatorData", b64(authData))102+                    put("attestationObject", b64(attestationObject))103+                    put("transports", buildJsonArray { add(JsonPrimitive("internal")) })104+                    put("publicKey", b64(publicKey.encoded))105+                    put("publicKeyAlgorithm", -7)106+                })107+                put("authenticatorAttachment", "platform")108+                put("clientExtensionResults", buildJsonObject {})109+            },110+        )111+        return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)112+    }113+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)118+        }119+    }120+121+    private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {122+        val base = ByteArray(37)123+        java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)124+        base[32] = 0x4d125+        val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())126+        return base + ByteArray(16) + length + credentialId + publicKeyCose127+    }128+129+    private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =130+        json.encodeToString(131+            JsonObject.serializer(),132+            buildJsonObject {133+                put("type", type)134+                put("challenge", challenge)135+                put("origin", origin)136+                put("crossOrigin", JsonPrimitive(false))137+            },138+        ).toByteArray(Charsets.UTF_8)139+140+    private fun privateJwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =141+        json.encodeToString(142+            JsonObject.serializer(),143+            buildJsonObject {144+                put("kty", "EC")145+                put("crv", "P-256")146+                put("d", b64(privateKey.s.toFixed32()))147+                put("x", b64(publicKey.w.affineX.toFixed32()))148+                put("y", b64(publicKey.w.affineY.toFixed32()))149+            },150+        )151+152+    private fun originFromRpId(rpId: String): String =153+        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"154+155+    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)156+}157+158+private object Cbor {159+    fun uint(value: Int) = integer(value)160+    fun nint(value: Int) = integer(value)161+    fun bytes(value: ByteArray) = major(2, value.size) + value162+    fun text(value: String): ByteArray {163+        val bytes = value.toByteArray(Charsets.UTF_8)164+        return major(3, bytes.size) + bytes165+    }166+    fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {167+        val body = entries.flatMap { (key, value) ->168+            val encodedKey = when (key) {169+                is Int -> integer(key)170+                is String -> text(key)171+                else -> error("unsupported cbor key")172+            }173+            listOf(encodedKey, value)174+        }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }175+        return major(5, entries.size) + body176+    }177+    private fun integer(value: Int): ByteArray =178+        if (value >= 0) major(0, value) else major(1, -1 - value)179+    private fun major(type: Int, value: Int): ByteArray = when {180+        value < 24 -> byteArrayOf(((type shl 5) or value).toByte())181+        value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())182+        value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())183+        else -> error("cbor length too large")184+    }185+}186+187+private fun java.math.BigInteger.toFixed32(): ByteArray {188+    val raw = toByteArray()189+    val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw190+    return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }191+}

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+20 −1
@@ -5,9 +5,11 @@55 import android.graphics.drawable.Icon66 import androidx.credentials.provider.BeginCreateCredentialRequest77 import androidx.credentials.provider.BeginCreateCredentialResponse8+import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest89 import androidx.credentials.provider.BeginGetCredentialRequest910 import androidx.credentials.provider.BeginGetCredentialResponse1011 import androidx.credentials.provider.BeginGetPublicKeyCredentialOption12+import androidx.credentials.provider.CreateEntry1113 import androidx.credentials.provider.CredentialProviderService1214 import androidx.credentials.provider.ProviderClearCredentialStateRequest1315 import androidx.credentials.provider.PublicKeyCredentialEntry@@ -83,7 +85,24 @@8385         cancellationSignal: CancellationSignal,8486         callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,8587     ) {86-        callback.onResult(BeginCreateCredentialResponse())88+        val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest89+        if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {90+            callback.onResult(BeginCreateCredentialResponse())91+            return92+        }93+        val count = vault.getEntries().count { it.passkey != null }94+        val entry = CreateEntry(95+            accountName = "Peach",96+            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),97+            description = "Save passkey to Peach",98+            lastUsedTime = Instant.now(),99+            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),100+            passwordCredentialCount = null,101+            publicKeyCredentialCount = count,102+            totalCredentialCount = vault.getEntries().size,103+            isAutoSelectAllowed = false,104+        )105+        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))87106     }88107 89108     override fun onClearCredentialStateRequest(

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+57 −0
@@ -0,0 +1,57 @@1+package com.peachpasswords.passkeys2+3+import kotlinx.serialization.json.Json4+import kotlinx.serialization.json.contentOrNull5+import kotlinx.serialization.json.int6+import kotlinx.serialization.json.jsonArray7+import kotlinx.serialization.json.jsonObject8+import kotlinx.serialization.json.jsonPrimitive9+import org.junit.Assert.assertEquals10+import org.junit.Assert.assertNotNull11+import org.junit.Assert.assertTrue12+import org.junit.Test13+import java.util.Base6414+15+class PasskeyRegistrationTest {16+    @Test fun builds_es256_registration_response_and_entry() {17+        val result = PasskeyRegistration.build(18+            requestJson = """19+                {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}20+            """.trimIndent(),21+            origin = "https://login.example.com",22+            now = 123,23+        )24+25+        assertNotNull(result)26+        result!!27+        val passkey = result.entryDraft.passkey!!28+        assertEquals("example.com", passkey.rpId)29+        assertEquals("june", passkey.userName)30+        assertEquals(-7, passkey.algorithm)31+        assertEquals(123, passkey.createdAt)32+        assertTrue(passkey.privateKeyJwk!!.contains("\"d\""))33+        assertTrue(b64Decode(passkey.credentialId).size == 32)34+        assertTrue(b64Decode(passkey.publicKeyCose).isNotEmpty())35+36+        val response = Json.parseToJsonElement(result.responseJson).jsonObject37+        assertEquals(passkey.credentialId, response["id"]?.jsonPrimitive?.contentOrNull)38+        val payload = response["response"]!!.jsonObject39+        assertEquals(-7, payload["publicKeyAlgorithm"]?.jsonPrimitive?.int)40+        assertEquals("internal", payload["transports"]?.jsonArray?.single()?.jsonPrimitive?.contentOrNull)41+        val clientData = Json.parseToJsonElement(String(b64Decode(payload["clientDataJSON"]!!.jsonPrimitive.content), Charsets.UTF_8)).jsonObject42+        assertEquals("webauthn.create", clientData["type"]?.jsonPrimitive?.contentOrNull)43+        assertEquals("https://login.example.com", clientData["origin"]?.jsonPrimitive?.contentOrNull)44+        assertTrue(b64Decode(payload["attestationObject"]!!.jsonPrimitive.content).isNotEmpty())45+    }46+47+    @Test fun rejects_requests_without_es256() {48+        val result = PasskeyRegistration.build(49+            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-8}]}""",50+            "https://example.com",51+        )52+        assertEquals(null, result)53+    }54+55+    private fun b64Decode(value: String): ByteArray =56+        Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))57+}

view file