933dfea7fdd0

feat: Let users fill any entry from an unverified origin via explicit picker choice (v2.8.7)

Product decision: autofill is always an explicit user action — Peach never fills on its own. The manual picker therefore lets the user fill any vault entry they deliberately tap, even for an unverified origin (native apps without App Links, unknown apps, browsers with unconfirmed pins). The prior exact-domain-confirmation gate blocked native apps entirely (no domain to type) and protected nothing the user's explicit tap doesn't already cover. Picker (AutofillEntryPickerActivity): - returnDataset fills any explicitly-picked entry; removed the UNKNOWN-origin domain-match gate and the confirmedTarget concept. - Unverified-origin search is now the same full-vault substring search (matchesPickerSearch over title/username/email/url) as trusted origins; it shows nothing until the user types, so the vault is never pre-listed for an unknown caller. - Warning copy: 'Peach couldn't verify this app or site. Make sure you trust it before filling a login.' Create-new falls back to the raw origin (package name for native apps) when the search text is not a domain. Security boundary preserved (verified): - AutofillResponseBuilder.matchEntries(parsed) still returns ZERO entries for AutofillOriginTrust.UNKNOWN — the system dropdown never preselects credentials for an unverified origin. That is the only place Peach acts without a deliberate choice. - Picker origin-trust re-derivation and the fail-closed signer-set are unchanged. - Filling a knowingly-trusted malicious app is the user's informed decision (out of scope). Gate: 813 tests 0 failures/errors/skips (114 suites); lintDebug 0 errors; assembleRelease.

time
2026-07-29 23:40 UTC · 2 months ago
machine
koh2
parent
3b3fd73ab28f
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+24 −43app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+18 −0
VERSION+1 −1
@@ -1 +1 @@1-2.8.61+2.8.7

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+24 −43
@@ -201,16 +201,16 @@201201                                 busy = false202202                             }203203                         },204-                        onPick = { entry, confirmedTarget ->204+                        onPick = { entry ->205205                             val epoch = unlockedEpoch206206                             if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {207207                                 cancel()208208                             } else {209-                                returnDataset(parsed, entry, confirmedTarget)209+                                returnDataset(parsed, entry)210210                             }211211                         },212-                        onCreateNew = { confirmedTarget ->213-                            launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)212+                        onCreateNew = { searchText ->213+                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)214214                         },215215                     )216216                 }@@ -269,15 +269,7 @@269269     private fun returnDataset(270270         parsed: ParsedStructure,271271         entry: VaultEntry,272-        confirmedTarget: String?,273272     ) {274-        if (275-            parsed.originTrust == AutofillOriginTrust.UNKNOWN &&276-            AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()277-        ) {278-            cancel()279-            return280-        }281273         val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)282274         if (dataset == null) { cancel(); return }283275         val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)@@ -295,10 +287,10 @@295287         parsed: ParsedStructure,296288         typedUsername: String?,297289         typedEmail: String?,298-        confirmedTarget: String?,290+        searchText: String?,299291     ) {300292         val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {301-            confirmedTarget?.let(::extractDomain) ?: return293+            searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin302294         } else {303295             parsed.origin304296         }@@ -403,7 +395,7 @@403395     canBiometric: Boolean,404396     onBiometric: () -> Unit,405397     onUnlock: (String) -> Unit,406-    onPick: (VaultEntry, String?) -> Unit,398+    onPick: (VaultEntry) -> Unit,407399     onCreateNew: (String?) -> Unit,408400 ) {409401     val accent = LocalPeachAccent.current@@ -437,7 +429,7 @@437429                     .padding(12.dp),438430             ) {439431                 Text(440-                    text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",432+                    text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",441433                     color = peachColors.Breach,442434                     fontFamily = PeachType.Body,443435                     fontSize = 12.sp,@@ -448,48 +440,35 @@448440         if (!unlocked) {449441             UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)450442         } else {451-            // For an unverified caller, this field is a domain confirmation boundary,452-            // not a vault-wide search box controlled by attacker-provided origin data.443+            // The search field is a vault-wide substring search. For unverified origins,444+            // the user must type to initiate — empty query shows no entries.453445             var rawQuery by remember { mutableStateOf("") }454446             var debouncedQuery by remember { mutableStateOf("") }455447             LaunchedEffect(rawQuery) {456448                 delay(SEARCH_DEBOUNCE_MS)457449                 debouncedQuery = rawQuery458450             }459-            val confirmedTarget = if (untrustedOrigin) {460-                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)461-            } else {462-                null463-            }464451             SearchField(465452                 value = rawQuery,466453                 onValueChange = { rawQuery = it },467454                 placeholder = when {468-                    untrustedOrigin -> "enter site domain, e.g. example.com"469455                     matchedEntries.isNotEmpty() -> "search for another entry…"470456                     else -> "search vault…"471457                 },472458             )473459 474460             val visibleEntries = when {475-                untrustedOrigin && confirmedTarget == null -> emptyList()476-                untrustedOrigin -> AutofillResponseBuilder477-                    .matchEntries(allEntries, confirmedTarget)478-                    .hits461+                untrustedOrigin && debouncedQuery.isBlank() -> emptyList()479462                 debouncedQuery.isBlank() -> matchedEntries480463                 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }481464             }482-            val showSearchEmpty = debouncedQuery.isNotBlank() &&483-                visibleEntries.isEmpty() &&484-                (!untrustedOrigin || confirmedTarget != null)465+            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()485466 486467             val emptyMessage = when {487468                 untrustedOrigin && debouncedQuery.isBlank() ->488-                    "Type the site domain to choose a saved login."489-                untrustedOrigin && confirmedTarget == null ->490-                    "Enter a full public domain such as example.com."469+                    "Search your vault to choose a login."491470                 untrustedOrigin && visibleEntries.isEmpty() ->492-                    "No saved entries match $confirmedTarget."471+                    "No entries match \"${debouncedQuery.trim()}\"."493472                 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->494473                     "No saved entries match this site."495474                 showSearchEmpty ->@@ -509,19 +488,21 @@509488             if (visibleEntries.isNotEmpty()) {510489                 LazyColumn {511490                     items(visibleEntries, key = { it.id }) { entry ->512-                        PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })491+                        PickerRow(entry, onClick = { onPick(entry) })513492                     }514493                 }515494             }516495 517-            val createOrigin = if (untrustedOrigin) confirmedTarget else origin518-            if (!untrustedOrigin || createOrigin != null) {519-                CreateNewRow(520-                    origin = createOrigin,521-                    typedIdentity = typedIdentity,522-                    onClick = { onCreateNew(confirmedTarget) },523-                )496+            val createOrigin = if (untrustedOrigin) {497+                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin498+            } else {499+                origin524500             }501+            CreateNewRow(502+                origin = createOrigin,503+                typedIdentity = typedIdentity,504+                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },505+            )525506         }526507     }527508 }

view file

app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+18 −0
@@ -109,4 +109,22 @@109109         )110110         assertTrue(autofillPickerEntries(listOf(entry), parsed, limit = 12).isEmpty())111111     }112+113+    @Test fun unverified_origin_user_can_locate_any_entry_via_substring_search() {114+        // For unverified origins, the picker now uses the same substring search115+        // as trusted origins. An unverified-origin user can locate any entry by116+        // typing a substring of title/username/email/url.117+        val entries = listOf(118+            entry("GitHub", username = "june", url = "https://github.com"),119+            entry("Fastmail", email = "[email protected]"),120+            entry("Peach", url = "https://peachpasswords.com"),121+        )122+        assertEquals(listOf("GitHub"), entries.filter { matchesPickerSearch(it, "git") }.map { it.title })123+        assertEquals(listOf("Fastmail"), entries.filter { matchesPickerSearch(it, "fastmail") }.map { it.title })124+        assertEquals(listOf("Peach"), entries.filter { matchesPickerSearch(it, "peach") }.map { it.title })125+        assertEquals(126+            listOf("GitHub", "Fastmail"),127+            entries.filter { matchesPickerSearch(it, "june") }.map { it.title },128+        )129+    }112130 }

view file