c3d88387bfd8

feat: Surface passkeys in Credential Provider (v2.3.42)

- Match public-key get requests by RP ID and allowCredentials - Return Android Credential Manager passkey rows when the vault is unlocked and provider toggle is enabled - Add a selection bridge that returns an explicit unsupported result until signing lands

time
2026-06-26 13:39 UTC · 4 months ago
machine
koh2
parent
93c2fbea8f2c
6 files app/build.gradle+2 −2app/src/main/AndroidManifest.xml+6 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+59 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+39 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+42 −7app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+62 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23410020-        versionName "2.3.41"19+        versionCode 23420020+        versionName "2.3.42"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/AndroidManifest.xml+6 −0
@@ -58,6 +58,12 @@5858             android:excludeFromRecents="true"5959             android:theme="@style/Theme.Peach.Transparent" />6060 61+        <activity62+            android:name=".passkeys.PasskeyCredentialSelectionActivity"63+            android:exported="false"64+            android:excludeFromRecents="true"65+            android:theme="@style/Theme.Peach.Transparent" />66+6167         <provider6268             android:name="androidx.core.content.FileProvider"6369             android:authorities="${applicationId}.fileprovider"

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+59 −0
@@ -0,0 +1,59 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.VaultEntry4+import kotlinx.serialization.json.Json5+import kotlinx.serialization.json.contentOrNull6+import kotlinx.serialization.json.jsonArray7+import kotlinx.serialization.json.jsonObject8+import kotlinx.serialization.json.jsonPrimitive9+10+data class PasskeyCredentialCandidate(11+    val entryId: String,12+    val title: String,13+    val rpId: String,14+    val credentialId: String,15+    val username: String,16+    val displayName: String,17+    val lastUsedAt: Long?,18+)19+20+object PasskeyCredentialPlanner {21+    private val json = Json { ignoreUnknownKeys = true }22+23+    fun candidates(entries: List<VaultEntry>, requestJson: String): List<PasskeyCredentialCandidate> {24+        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return emptyList()25+        val requestedRpId = request["rpId"]?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return emptyList()26+        val allowedIds = runCatching { request["allowCredentials"]?.jsonArray }.getOrNull()27+            ?.mapNotNull { item ->28+                runCatching { item.jsonObject["id"]?.jsonPrimitive?.contentOrNull }.getOrNull()29+            }30+            ?.filter { it.isNotBlank() }31+            ?.toSet()32+33+        return entries.asSequence()34+            .filter { it.deletedAt == null && it.passkey != null }35+            .mapNotNull { entry ->36+                val passkey = entry.passkey ?: return@mapNotNull null37+                val rpId = passkey.rpId.trim().lowercase()38+                val credentialId = passkey.credentialId39+                if (rpId != requestedRpId) return@mapNotNull null40+                if (!allowedIds.isNullOrEmpty() && credentialId !in allowedIds) return@mapNotNull null41+                val username = passkey.userName.ifBlank { entry.username ?: entry.email ?: entry.title }42+                PasskeyCredentialCandidate(43+                    entryId = entry.id,44+                    title = entry.title,45+                    rpId = passkey.rpId,46+                    credentialId = credentialId,47+                    username = username,48+                    displayName = passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: entry.title,49+                    lastUsedAt = entry.lastUsedAt,50+                )51+            }52+            .sortedWith(53+                compareByDescending<PasskeyCredentialCandidate> { it.lastUsedAt ?: Long.MIN_VALUE }54+                    .thenBy { it.displayName.lowercase() }55+                    .thenBy { it.username.lowercase() },56+            )57+            .toList()58+    }59+}

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+39 −0
@@ -0,0 +1,39 @@1+package com.peachpasswords.passkeys2+3+import android.app.Activity4+import android.app.PendingIntent5+import android.content.Context6+import android.content.Intent7+import android.os.Build8+import android.os.Bundle9+import androidx.credentials.exceptions.GetCredentialUnsupportedException10+import androidx.credentials.provider.PendingIntentHandler11+12+class PasskeyCredentialSelectionActivity : Activity() {13+    override fun onCreate(savedInstanceState: Bundle?) {14+        super.onCreate(savedInstanceState)15+        val result = Intent()16+        PendingIntentHandler.setGetCredentialException(17+            result,18+            GetCredentialUnsupportedException("Peach passkey signing is not available yet."),19+        )20+        setResult(RESULT_OK, result)21+        finish()22+    }23+24+    companion object {25+        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"26+27+        fun pendingIntent(context: Context, entryId: String): PendingIntent {28+            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)29+                .putExtra(EXTRA_ENTRY_ID, entryId)30+            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 031+            return PendingIntent.getActivity(32+                context,33+                entryId.hashCode(),34+                intent,35+                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,36+            )37+        }38+    }39+}

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+42 −7
@@ -2,17 +2,25 @@22 33 import android.os.Build44 import android.os.CancellationSignal5+import android.graphics.drawable.Icon56 import androidx.credentials.provider.BeginCreateCredentialRequest67 import androidx.credentials.provider.BeginCreateCredentialResponse78 import androidx.credentials.provider.BeginGetCredentialRequest89 import androidx.credentials.provider.BeginGetCredentialResponse10+import androidx.credentials.provider.BeginGetPublicKeyCredentialOption911 import androidx.credentials.provider.CredentialProviderService1012 import androidx.credentials.provider.ProviderClearCredentialStateRequest13+import androidx.credentials.provider.PublicKeyCredentialEntry1114 import android.os.OutcomeReceiver1215 import androidx.credentials.exceptions.ClearCredentialException1316 import androidx.credentials.exceptions.CreateCredentialException1417 import androidx.credentials.exceptions.GetCredentialException1518 import androidx.annotation.RequiresApi19+import com.peachpasswords.R20+import com.peachpasswords.vault.VaultRepository21+import dagger.hilt.android.AndroidEntryPoint22+import java.time.Instant23+import javax.inject.Inject1624 1725 /**1826  * Android 14+ credential provider surface for Peach passkeys.@@ -22,21 +30,48 @@2230  * version always completes the callbacks with a valid (currently empty)2331  * response, so Peach registers cleanly as a provider without erroring.2432  *25- * Surfacing real passkey entries and running the WebAuthn ceremony is gated on26- * one data-model addition: [com.peachpasswords.vault.PasskeyData] stores only27- * the public COSE key, so an assertion (get) cannot be signed yet. Completing28- * passkeys means persisting the per-credential ES256 private key in the vault29- * envelope, then emitting PublicKeyCredentialEntry rows here with a30- * PendingIntent into the same branded sheet the autofill picker uses.33+ * Begin-get now surfaces unlocked, matching passkeys as system credential rows.34+ * The selected pending intent still returns an explicit unsupported result until35+ * the ES256/WebAuthn assertion ceremony lands.3136  */37+@AndroidEntryPoint3238 @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)3339 class PeachCredentialProviderService : CredentialProviderService() {40+41+    @Inject lateinit var vault: VaultRepository42+3443     override fun onBeginGetCredentialRequest(3544         request: BeginGetCredentialRequest,3645         cancellationSignal: CancellationSignal,3746         callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,3847     ) {39-        callback.onResult(BeginGetCredentialResponse())48+        if (!vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {49+            callback.onResult(BeginGetCredentialResponse())50+            return51+        }52+        val passkeyOptions = request.beginGetCredentialOptions53+            .filterIsInstance<BeginGetPublicKeyCredentialOption>()54+        if (passkeyOptions.isEmpty()) {55+            callback.onResult(BeginGetCredentialResponse())56+            return57+        }58+59+        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)60+        val entries = passkeyOptions.flatMap { option ->61+            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson).map { candidate ->62+                PublicKeyCredentialEntry(63+                    context = this,64+                    username = candidate.username,65+                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(this, candidate.entryId),66+                    beginGetPublicKeyCredentialOption = option,67+                    displayName = candidate.displayName,68+                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),69+                    icon = icon,70+                    isAutoSelectAllowed = false,71+                )72+            }73+        }74+        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))4075     }4176 4277     override fun onBeginCreateCredentialRequest(

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+62 −0
@@ -0,0 +1,62 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+class PasskeyCredentialPlannerTest {10+    @Test fun matches_passkeys_by_exact_rp_id() {11+        val matches = PasskeyCredentialPlanner.candidates(12+            listOf(13+                passkey("a", rpId = "example.com", username = "june"),14+                passkey("b", rpId = "evil-example.com", username = "lookalike"),15+            ),16+            """{"challenge":"c","rpId":"example.com"}""",17+        )18+19+        assertEquals(listOf("a"), matches.map { it.entryId })20+        assertEquals("june", matches.single().username)21+    }22+23+    @Test fun narrows_to_allow_credentials_when_present() {24+        val matches = PasskeyCredentialPlanner.candidates(25+            listOf(26+                passkey("a", credentialId = "cred-a", lastUsedAt = 10),27+                passkey("b", credentialId = "cred-b", lastUsedAt = 20),28+            ),29+            """{"challenge":"c","rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cred-a"}]}""",30+        )31+32+        assertEquals(listOf("a"), matches.map { it.entryId })33+    }34+35+    @Test fun empty_or_invalid_requests_offer_no_credentials() {36+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""").isEmpty())37+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json").isEmpty())38+    }39+40+    private fun passkey(41+        id: String,42+        rpId: String = "example.com",43+        credentialId: String = "cred-$id",44+        username: String = "user-$id",45+        lastUsedAt: Long? = null,46+    ) = VaultEntry(47+        id = id,48+        type = "passkey",49+        title = "Passkey $id",50+        passkey = PasskeyData(51+            credentialId = credentialId,52+            publicKeyCose = "cose-$id",53+            rpId = rpId,54+            userHandle = "handle-$id",55+            userName = username,56+            userDisplayName = "User $id",57+        ),58+        lastUsedAt = lastUsedAt,59+        createdAt = 1,60+        modifiedAt = 1,61+    )62+}

view file