960b4a5c107a

feat: Credential Manager password rows in the system credential picker (v2.11.0)

Begin-get now surfaces matching vault passwords alongside passkeys on Android 14+ Credential Manager (Gboard/Samsung keyboard row and the system bottom sheet). - PasswordCredentialPlanner: content-based matching mirroring autofill — registrable domain for privileged-browser callers, app-link domains and app title for native callers; honors BeginGetPasswordOption.allowedUserIds - PasswordCredentialSelectionActivity: releases the credential through PendingIntentHandler with a framework-verified caller re-check (fail closed), secure-window parity with passkey selection, lastUsedAt bump - PeachCredentialProviderService: get path split into passkeyEntries / passwordEntries; locked vault always offers the unlock action now that passwords are always eligible; passkeys keep their Pro gate and toggle - 12 new planner unit tests (domain, subdomain, lookalike, title, allowedUserIds, ranking, edge entries) — all green with the existing autofill/passkey suites

time
2026-08-04 21:52 UTC · 2 months ago
machine
koh2
parent
126c11adcbea
6 files VERSION+1 −1app/src/main/AndroidManifest.xml+6 −0app/src/main/java/com/peachpasswords/credentials/PasswordCredentialPlanner.kt+69 −0app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+130 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+79 −20app/src/test/java/com/peachpasswords/credentials/PasswordCredentialPlannerTest.kt+109 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.231+2.11.0

view file

app/src/main/AndroidManifest.xml+6 −0
@@ -75,6 +75,12 @@7575             android:excludeFromRecents="true"7676             android:theme="@style/Theme.Peach.Transparent" />7777 78+        <activity79+            android:name=".credentials.PasswordCredentialSelectionActivity"80+            android:exported="false"81+            android:excludeFromRecents="true"82+            android:theme="@style/Theme.Peach.Transparent" />83+7884         <!-- WorkManager is initialized on demand by PeachApplication (Configuration.Provider)7985              so the update worker can be Hilt-injected; its default auto-initializer would8086              race that and build a factory-less WorkManager first. -->

view file

app/src/main/java/com/peachpasswords/credentials/PasswordCredentialPlanner.kt+69 −0
@@ -0,0 +1,69 @@1+package com.peachpasswords.credentials2+3+import com.peachpasswords.shared.extractDomain4+import com.peachpasswords.vault.VaultEntry5+6+data class PasswordCredentialCandidate(7+    val entryId: String,8+    val title: String,9+    val username: String,10+    val lastUsedAt: Long?,11+)12+13+/**14+ * Content-based matching for Credential Manager password rows — the same15+ * stance as autofill (AutofillResponseBuilder): registrable-domain matching16+ * for web callers, app-link-domain / app-title matching for native callers.17+ * No caller-trust gate (removed queue #3); the caller package itself is the18+ * framework-authenticated identity.19+ */20+object PasswordCredentialPlanner {21+22+    fun candidates(23+        entries: List<VaultEntry>,24+        domains: Set<String>,25+        appTitle: String?,26+        allowedUserIds: Set<String>? = null,27+    ): List<PasswordCredentialCandidate> {28+        val eligible = entries.filter { it.fillable() }29+        val byDomain = if (domains.isEmpty()) {30+            emptyList()31+        } else {32+            eligible.filter { entry -> domains.any { entry.matchesDomain(it) } }33+        }34+        val byTitle = if (appTitle.isNullOrBlank()) {35+            emptyList()36+        } else {37+            eligible.filter { it.title.trim().equals(appTitle, ignoreCase = true) }38+        }39+        return (byDomain + byTitle)40+            .distinctBy { it.id }41+            .mapNotNull { entry ->42+                val username = entry.username ?: entry.email ?: return@mapNotNull null43+                if (!allowedUserIds.isNullOrEmpty() && username !in allowedUserIds) {44+                    return@mapNotNull null45+                }46+                PasswordCredentialCandidate(47+                    entryId = entry.id,48+                    title = entry.title,49+                    username = username,50+                    lastUsedAt = entry.lastUsedAt,51+                )52+            }53+            .sortedWith(54+                compareByDescending<PasswordCredentialCandidate> { it.lastUsedAt ?: Long.MIN_VALUE }55+                    .thenBy { it.username.lowercase() },56+            )57+    }58+59+    /** Entries that can actually back a password credential row. */60+    private fun VaultEntry.fillable(): Boolean =61+        deletedAt == null && !password.isNullOrBlank()62+63+    private fun VaultEntry.matchesDomain(domain: String): Boolean =64+        sequenceOf(url)65+            .plus(additionalUrls.orEmpty().asSequence())66+            .filterNotNull()67+            .mapNotNull { extractDomain(it) }68+            .any { it == domain }69+}

view file

app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+130 −0
@@ -0,0 +1,130 @@1+package com.peachpasswords.credentials2+3+import android.app.PendingIntent4+import android.content.Context5+import android.content.Intent6+import android.os.Build7+import android.os.Bundle8+import androidx.credentials.GetCredentialResponse9+import androidx.credentials.PasswordCredential10+import androidx.credentials.exceptions.GetCredentialUnsupportedException11+import androidx.credentials.provider.PendingIntentHandler12+import androidx.fragment.app.FragmentActivity13+import androidx.lifecycle.lifecycleScope14+import com.peachpasswords.BuildConfig15+import com.peachpasswords.autofill.appLabel16+import com.peachpasswords.autofill.appLinkDomains17+import com.peachpasswords.passkeys.PasskeyCallerOrigin18+import com.peachpasswords.shared.extractDomain19+import com.peachpasswords.ui.components.QaFlags20+import com.peachpasswords.ui.components.SecureWindowPolicy21+import com.peachpasswords.vault.VaultRepository22+import dagger.hilt.android.AndroidEntryPoint23+import javax.inject.Inject24+import kotlinx.coroutines.launch25+26+/**27+ * Completes a Credential Manager password selection. The vault was already28+ * unlocked when the row was offered (the provider never emits rows while29+ * locked), and the tap is the release boundary — the same posture as autofill30+ * (PEACH-AND-01-002 risk acceptance).31+ *32+ * Defense in depth: the framework-verified caller is re-checked against the33+ * entry before release, so a stale or replayed pending intent cannot pull a34+ * credential for a different caller. Fails closed on any doubt.35+ */36+@AndroidEntryPoint37+class PasswordCredentialSelectionActivity : FragmentActivity() {38+    @Inject lateinit var vault: VaultRepository39+    private var debugSecureLease = false40+41+    override fun onCreate(savedInstanceState: Bundle?) {42+        super.onCreate(savedInstanceState)43+        // This window briefly holds credential material on screen. Block44+        // screenshots / recents exactly like the passkey selection surface.45+        if (!BuildConfig.DEBUG) {46+            SecureWindowPolicy.setUserSecure(window, true)47+        } else if (!qaSecureCaptureBypass()) {48+            SecureWindowPolicy.acquire(window)49+            debugSecureLease = true50+        }51+        lifecycleScope.launch { completeSelection() }52+    }53+54+    override fun onDestroy() {55+        if (debugSecureLease) {56+            SecureWindowPolicy.release(window)57+            debugSecureLease = false58+        }59+        super.onDestroy()60+    }61+62+    private fun qaSecureCaptureBypass(): Boolean =63+        BuildConfig.DEBUG && (64+            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||65+                QaFlags.disableSecureCaptureForQa66+            )67+68+    private suspend fun completeSelection() {69+        val result = Intent()70+        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)71+        val entry = entryId?.let(vault::getEntry)72+        val providerRequest = runCatching {73+            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)74+        }.getOrNull()75+        val callerMatches = providerRequest?.callingAppInfo?.let { info ->76+            if (entry == null) {77+                false78+            } else {79+                val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)80+                val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()81+                if (!webOrigin.isNullOrBlank()) {82+                    val domain = extractDomain(webOrigin)83+                    domain != null && PasswordCredentialPlanner84+                        .candidates(listOf(entry), setOf(domain), null)85+                        .isNotEmpty()86+                } else {87+                    PasswordCredentialPlanner.candidates(88+                        listOf(entry),89+                        appLinkDomains(this, info.packageName).toSet(),90+                        appLabel(this, info.packageName),91+                    ).isNotEmpty()92+                }93+            }94+        } ?: false95+        val username = entry?.username ?: entry?.email96+        val password = entry?.password97+        if (entry == null || entry.deletedAt != null || !callerMatches ||98+            username.isNullOrBlank() || password.isNullOrEmpty()99+        ) {100+            PendingIntentHandler.setGetCredentialException(101+                result,102+                GetCredentialUnsupportedException("Peach cannot fill this login."),103+            )104+        } else {105+            runCatching { vault.updateEntry(entry.copy(lastUsedAt = System.currentTimeMillis())) }106+            PendingIntentHandler.setGetCredentialResponse(107+                result,108+                GetCredentialResponse(PasswordCredential(username, password)),109+            )110+        }111+        setResult(RESULT_OK, result)112+        finish()113+    }114+115+    companion object {116+        private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID"117+118+        fun pendingIntent(context: Context, entryId: String): PendingIntent {119+            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)120+                .putExtra(EXTRA_ENTRY_ID, entryId)121+            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0122+            return PendingIntent.getActivity(123+                context,124+                "cm-password:$entryId".hashCode(),125+                intent,126+                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,127+            )128+        }129+    }130+}

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+79 −20
@@ -8,37 +8,48 @@88 import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest99 import androidx.credentials.provider.BeginGetCredentialRequest1010 import androidx.credentials.provider.BeginGetCredentialResponse11+import androidx.credentials.provider.BeginGetPasswordOption1112 import androidx.credentials.provider.BeginGetPublicKeyCredentialOption13+import androidx.credentials.provider.CredentialEntry1214 import androidx.credentials.provider.CreateEntry1315 import androidx.credentials.provider.AuthenticationAction1416 import androidx.credentials.provider.CredentialProviderService1517 import androidx.credentials.provider.ProviderClearCredentialStateRequest1618 import androidx.credentials.provider.PublicKeyCredentialEntry19+import androidx.credentials.provider.PasswordCredentialEntry1720 import android.os.OutcomeReceiver1821 import androidx.credentials.exceptions.ClearCredentialException1922 import androidx.credentials.exceptions.CreateCredentialException2023 import androidx.credentials.exceptions.GetCredentialException2124 import androidx.annotation.RequiresApi2225 import com.peachpasswords.R26+import com.peachpasswords.autofill.appLabel27+import com.peachpasswords.autofill.appLinkDomains28+import com.peachpasswords.credentials.PasswordCredentialPlanner29+import com.peachpasswords.credentials.PasswordCredentialSelectionActivity2330 import com.peachpasswords.license.LicenseRepository2431 import com.peachpasswords.license.ProFeature2532 import com.peachpasswords.license.ProNoticeStore33+import com.peachpasswords.shared.extractDomain2634 import com.peachpasswords.vault.VaultRepository2735 import dagger.hilt.android.AndroidEntryPoint2836 import java.time.Instant2937 import javax.inject.Inject3038 3139 /**32- * Android 14+ credential provider surface for Peach passkeys.40+ * Android 14+ credential provider surface for Peach.3341  *3442  * The Begin* callbacks MUST be answered or the Credential Manager times out and3543  * reports a provider error; the previous scaffold left them uninvoked. This36- * version always completes the callbacks with a valid (currently empty)44+ * version always completes the callbacks with a valid (possibly empty)3745  * response, so Peach registers cleanly as a provider without erroring.3846  *39- * Begin-get surfaces unlocked, matching passkeys as system credential rows.40- * Selection completes the full ES256/Ed25519 assertion or registration ceremony41- * via PasskeyCredentialSelectionActivity.47+ * Begin-get surfaces two independent families:48+ * - passkeys (Pro-gated, opt-in toggle, fail-closed caller binding) —49+ *   selection completes the full ES256/Ed25519 assertion or registration50+ *   ceremony via PasskeyCredentialSelectionActivity;51+ * - passwords (always offered, content-matched like autofill) — selection52+ *   releases the credential via PasswordCredentialSelectionActivity.4253  */4354 @AndroidEntryPoint4455 @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)@@ -53,16 +64,9 @@5364         cancellationSignal: CancellationSignal,5465         callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,5566     ) {56-        if (!vault.settings.value.passkeyProviderEnabled) {57-            callback.onResult(BeginGetCredentialResponse())58-            return59-        }60-        if (!license.hasFeature(ProFeature.Passkeys)) {61-            proNoticeStore.record(ProFeature.Passkeys)62-            callback.onResult(BeginGetCredentialResponse())63-            return64-        }6567         if (!vault.isUnlocked.value) {68+            // Password rows are always eligible, so a locked vault always gets69+            // the unlock action regardless of the passkey toggle.6670             callback.onResult(6771                 BeginGetCredentialResponse(6872                     authenticationActions = listOf(@@ -75,12 +79,19 @@7579             )7680             return7781         }82+        val entries = passkeyEntries(request) + passwordEntries(request)83+        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))84+    }85+86+    private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {87+        if (!vault.settings.value.passkeyProviderEnabled) return emptyList()88+        if (!license.hasFeature(ProFeature.Passkeys)) {89+            proNoticeStore.record(ProFeature.Passkeys)90+            return emptyList()91+        }7892         val passkeyOptions = request.beginGetCredentialOptions7993             .filterIsInstance<BeginGetPublicKeyCredentialOption>()80-        if (passkeyOptions.isEmpty()) {81-            callback.onResult(BeginGetCredentialResponse())82-            return83-        }94+        if (passkeyOptions.isEmpty()) return emptyList()8495 8596         val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)8697         // F1: resolve the framework-verified caller origin once (privileged@@ -88,7 +99,7 @@8899         val callerOrigin = request.callingAppInfo?.let {89100             PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))90101         }91-        val entries = passkeyOptions.flatMap { option ->102+        return passkeyOptions.flatMap { option ->92103             PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->93104                 PublicKeyCredentialEntry(94105                     context = this,@@ -106,7 +117,55 @@106117                 )107118             }108119         }109-        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))120+    }121+122+    /**123+     * Password rows for the system credential picker. Matching mirrors124+     * autofill: privileged-browser callers match on the web origin's125+     * registrable domain; native callers match on their app-link domains and126+     * display label. No trust gate — the caller package is framework-127+     * authenticated (queue #3 stance).128+     */129+    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {130+        val option = request.beginGetCredentialOptions131+            .filterIsInstance<BeginGetPasswordOption>()132+            .firstOrNull() ?: return emptyList()133+        val info = request.callingAppInfo ?: return emptyList()134+135+        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)136+        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()137+        val domains: Set<String>138+        val appTitle: String?139+        if (!webOrigin.isNullOrBlank()) {140+            domains = setOfNotNull(extractDomain(webOrigin))141+            appTitle = null142+        } else {143+            domains = appLinkDomains(this, info.packageName).toSet()144+            appTitle = appLabel(this, info.packageName)145+        }146+147+        val candidates = PasswordCredentialPlanner.candidates(148+            vault.getEntries(),149+            domains,150+            appTitle,151+            option.allowedUserIds,152+        )153+        if (candidates.isEmpty()) return emptyList()154+155+        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)156+        return candidates.map { candidate ->157+            PasswordCredentialEntry.Builder(158+                this,159+                candidate.username,160+                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),161+                option,162+            )163+                .setDisplayName(candidate.title)164+                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))165+                .setIcon(icon)166+                .setAutoSelectAllowed(false)167+                .build()168+        }110169     }111170 112171     override fun onBeginCreateCredentialRequest(

view file

app/src/test/java/com/peachpasswords/credentials/PasswordCredentialPlannerTest.kt+109 −0
@@ -0,0 +1,109 @@1+package com.peachpasswords.credentials2+3+import com.peachpasswords.vault.VaultEntry4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class PasswordCredentialPlannerTest {9+    private fun entry(10+        title: String,11+        url: String?,12+        username: String? = "[email protected]",13+        password: String? = "correct-horse-battery",14+        lastUsedAt: Long? = null,15+    ) = VaultEntry(16+        id = title,17+        title = title,18+        url = url,19+        username = username,20+        password = password,21+        createdAt = 0,22+        modifiedAt = 0,23+        lastUsedAt = lastUsedAt,24+    )25+26+    @Test fun web_caller_matches_entry_on_same_registrable_domain() {27+        val peach = entry("Peach", "https://peachpasswords.com/login")28+        val mail = entry("Fastmail", "https://fastmail.com")29+        val hits = PasswordCredentialPlanner.candidates(listOf(mail, peach), setOf("peachpasswords.com"), null)30+        assertEquals(listOf("Peach"), hits.map { it.title })31+    }32+33+    @Test fun subdomain_entry_matches_registrable_caller_domain() {34+        val peach = entry("Peach", "https://app.peachpasswords.com")35+        val hits = PasswordCredentialPlanner.candidates(listOf(peach), setOf("peachpasswords.com"), null)36+        assertEquals(1, hits.size)37+    }38+39+    @Test fun no_false_positive_on_substring_lookalike() {40+        val gh = entry("GitHub", "https://github.com/login")41+        val hits = PasswordCredentialPlanner.candidates(listOf(gh), setOf("notgithub.com"), null)42+        assertTrue("github.com must not match notgithub.com", hits.isEmpty())43+    }44+45+    @Test fun additional_urls_match_registrable_domain() {46+        val peach = entry("Peach", "https://peachpasswords.com")47+            .copy(additionalUrls = listOf("https://accounts.peachpasswords.co.uk/login"))48+        val hits = PasswordCredentialPlanner.candidates(listOf(peach), setOf("peachpasswords.co.uk"), null)49+        assertEquals(1, hits.size)50+    }51+52+    @Test fun native_caller_matches_by_app_title() {53+        val netflix = entry("Netflix", null)54+        val hits = PasswordCredentialPlanner.candidates(listOf(netflix), emptySet(), "Netflix")55+        assertEquals(listOf("Netflix"), hits.map { it.title })56+    }57+58+    @Test fun entries_without_password_are_never_offered() {59+        val notes = entry("Notes", "https://example.com", password = null)60+        val blank = entry("Blank", "https://example.com", password = "  ")61+        val hits = PasswordCredentialPlanner.candidates(listOf(notes, blank), setOf("example.com"), null)62+        assertTrue(hits.isEmpty())63+    }64+65+    @Test fun deleted_entries_are_never_offered() {66+        val gone = entry("Gone", "https://example.com").copy(deletedAt = 1L)67+        val hits = PasswordCredentialPlanner.candidates(listOf(gone), setOf("example.com"), null)68+        assertTrue(hits.isEmpty())69+    }70+71+    @Test fun entries_without_any_username_are_skipped() {72+        // Credential Manager requires an id; an entry with neither username nor73+        // email cannot produce a PasswordCredential.74+        val nameless = entry("Nameless", "https://example.com", username = null)75+        val hits = PasswordCredentialPlanner.candidates(listOf(nameless), setOf("example.com"), null)76+        assertTrue(hits.isEmpty())77+    }78+79+    @Test fun email_is_used_when_username_is_missing() {80+        val emailOnly = entry("Mail", "https://example.com", username = null)81+            .copy(email = "[email protected]")82+        val hits = PasswordCredentialPlanner.candidates(listOf(emailOnly), setOf("example.com"), null)83+        assertEquals(listOf("[email protected]"), hits.map { it.username })84+    }85+86+    @Test fun allowed_user_ids_filter_restricts_rows() {87+        val a = entry("A", "https://example.com", username = "alice")88+        val b = entry("B", "https://example.com", username = "bob")89+        val hits = PasswordCredentialPlanner.candidates(90+            listOf(a, b), setOf("example.com"), null, allowedUserIds = setOf("bob"),91+        )92+        assertEquals(listOf("bob"), hits.map { it.username })93+    }94+95+    @Test fun most_recently_used_ranks_first() {96+        val old = entry("Old", "https://example.com", username = "old", lastUsedAt = 100L)97+        val fresh = entry("Fresh", "https://example.com", username = "fresh", lastUsedAt = 200L)98+        val hits = PasswordCredentialPlanner.candidates(listOf(old, fresh), setOf("example.com"), null)99+        assertEquals(listOf("fresh", "old"), hits.map { it.username })100+    }101+102+    @Test fun domain_and_title_matches_deduplicate_to_one_row() {103+        val peach = entry("Peach", "https://peachpasswords.com")104+        val hits = PasswordCredentialPlanner.candidates(105+            listOf(peach), setOf("peachpasswords.com"), "Peach",106+        )107+        assertEquals(1, hits.size)108+    }109+}

view file