960b4a5c107a
feat: Credential Manager password rows in the system credential picker (v2.11.0)
- time
- 2026-08-04 21:52 UTC · 2 months ago
- machine
- koh2
- parent
- 126c11adcbea
↓ peach-android-960b4a5c.tar.gzatlas at this save
files changed6 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 −0app/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. -->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+}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+}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(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+}