c9594ddcf5d4

H-06: autofill origin auth — pinned signing certs, DAL-verified native, unknown origin gets no preselect

time
2026-07-29 13:42 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
d3ee5564ced4
7 files app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+8 −5app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+113 −49app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+4 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+22 −14app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+23 −23app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+100 −25app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+44 −16
app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+8 −5
@@ -5,9 +5,11 @@55 import android.view.View66 import android.view.autofill.AutofillId77 8+enum class AutofillOriginTrust { TRUSTED_WEB, TRUSTED_NATIVE, UNKNOWN }9+810 /**911  * The autofillable fields discovered in one [AssistStructure], plus the origin10- * (web domain for browsers, package name otherwise) used to rank vault entries.12+ * Android authenticated for ranking vault entries.1113  */1214 data class ParsedStructure(1315     val usernameId: AutofillId? = null,@@ -20,6 +22,8 @@2022     val otpValue: String? = null,2123     val webDomain: String? = null,2224     val packageName: String? = null,25+    val originTrust: AutofillOriginTrust = AutofillOriginTrust.UNKNOWN,26+    val verifiedDomains: List<String> = emptyList(),2327 ) {2428     /** The id we put the account identifier into — email preferred, else username. */2529     val identityId: AutofillId? get() = emailId ?: usernameId@@ -37,7 +41,9 @@3741 object AssistStructureParser {3842 3943     fun parse(structure: AssistStructure): ParsedStructure {40-        var result = ParsedStructure()44+        // View-node package ids are supplied by the foreground app; the framework-owned45+        // activity component is the authenticated caller identity.46+        var result = ParsedStructure(packageName = structure.activityComponent?.packageName)4147         val windows = structure.windowNodeCount4248         for (i in 0 until windows) {4349             val root = structure.getWindowNodeAt(i).rootViewNode@@ -50,9 +56,6 @@5056         var result = acc5157         if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {5258             result = result.copy(webDomain = node.webDomain)53-        }54-        if (result.packageName == null && !node.idPackage.isNullOrBlank()) {55-            result = result.copy(packageName = node.idPackage)5659         }5760 5861         val id = node.autofillId

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+113 −49
@@ -107,6 +107,11 @@107107 108108         val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)109109         val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)110+        val originTrust = runCatching {111+            AutofillOriginTrust.valueOf(112+                intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN_TRUST).orEmpty(),113+            )114+        }.getOrDefault(AutofillOriginTrust.UNKNOWN)110115         val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)111116         val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)112117         val parsed = ParsedStructure(@@ -115,6 +120,8 @@115120             otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),116121             webDomain = origin.takeIf { isWebOrigin },117122             packageName = origin.takeUnless { isWebOrigin },123+            originTrust = originTrust,124+            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),118125         )119126         val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT120127 @@ -126,10 +133,8 @@126133                 var error by remember { mutableStateOf<String?>(null) }127134                 var busy by remember { mutableStateOf(false) }128135 129-                // The picker filters across the entire vault when the user types130-                // a search, so the unlocked entry list is the universe of choices —131-                // not just the matched ones. The header still surfaces the matched132-                // count separately so users know what the search is broadening.136+                // Trusted origins may search the vault manually. Unknown origins stay137+                // empty until the user types a registrable target domain in the picker.133138                 val allEntries = remember(unlocked) {134139                     if (unlocked) vault.getEntries() else emptyList()135140                 }@@ -157,6 +162,7 @@157162                 ) {158163                     PickerBody(159164                         origin = parsed.origin,165+                        originTrust = parsed.originTrust,160166                         typedIdentity = typedUsername ?: typedEmail,161167                         unlocked = unlocked,162168                         matchedEntries = matchedEntries,@@ -177,8 +183,12 @@177183                                 busy = false178184                             }179185                         },180-                        onPick = { returnDataset(parsed, it) },181-                        onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },186+                        onPick = { entry, confirmedTarget ->187+                            returnDataset(parsed, entry, confirmedTarget)188+                        },189+                        onCreateNew = { confirmedTarget ->190+                            launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)191+                        },182192                     )183193                 }184194             }@@ -214,7 +224,18 @@214224         )215225     }216226 217-    private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {227+    private fun returnDataset(228+        parsed: ParsedStructure,229+        entry: VaultEntry,230+        confirmedTarget: String?,231+    ) {232+        if (233+            parsed.originTrust == AutofillOriginTrust.UNKNOWN &&234+            AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()235+        ) {236+            cancel()237+            return238+        }218239         val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)219240         if (dataset == null) { cancel(); return }220241         val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)@@ -228,11 +249,20 @@228249      * prompt, not to fill it — and the launcher extra carries the host's typed229250      * identity and origin so the new-entry editor opens pre-filled.230251      */231-    private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {232-        val typedIdentity = typedUsername ?: typedEmail252+    private fun launchNewEntry(253+        parsed: ParsedStructure,254+        typedUsername: String?,255+        typedEmail: String?,256+        confirmedTarget: String?,257+    ) {258+        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {259+            confirmedTarget?.let(::extractDomain) ?: return260+        } else {261+            parsed.origin262+        }233263         val intent = Intent(this, MainActivity::class.java).apply {234264             putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)235-            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)265+            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)236266             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)237267             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)238268             addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)@@ -263,11 +293,7 @@263293     entries: List<VaultEntry>,264294     parsed: ParsedStructure,265295     limit: Int,266-): List<VaultEntry> = when {267-    parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)268-    parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)269-    else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)270-}296+): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)271297 272298 /**273299  * Case-insensitive substring match across the four user-facing identity columns@@ -299,6 +325,7 @@299325 @Composable300326 private fun PickerBody(301327     origin: String?,328+    originTrust: AutofillOriginTrust,302329     typedIdentity: String?,303330     unlocked: Boolean,304331     matchedEntries: List<VaultEntry>,@@ -308,10 +335,11 @@308335     canBiometric: Boolean,309336     onBiometric: () -> Unit,310337     onUnlock: (String) -> Unit,311-    onPick: (VaultEntry) -> Unit,312-    onCreateNew: () -> Unit,338+    onPick: (VaultEntry, String?) -> Unit,339+    onCreateNew: (String?) -> Unit,313340 ) {314341     val accent = LocalPeachAccent.current342+    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN315343     Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {316344         Text(317345             text = "peach",@@ -321,75 +349,111 @@321349             fontSize = 15.sp,322350         )323351         Text(324-            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",352+            text = when {353+                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"354+                origin.isNullOrBlank() -> "your vault"355+                else -> "Suggested for $origin"356+            },325357             color = peachColors.T2,326358             fontFamily = PeachType.Mono,327359             fontSize = 9.sp,328360             modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),329361         )330362 363+        if (originTrust == AutofillOriginTrust.UNKNOWN) {364+            Box(365+                modifier = Modifier366+                    .fillMaxWidth()367+                    .padding(bottom = 14.dp)368+                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))369+                    .padding(12.dp),370+            ) {371+                Text(372+                    text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",373+                    color = peachColors.Breach,374+                    fontFamily = PeachType.Body,375+                    fontSize = 12.sp,376+                )377+            }378+        }379+331380         if (!unlocked) {332381             UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)333382         } else {334-            // Always-on search field; debounced 200ms so a fast typer doesn't335-            // thrash the filter. Vault-wide (not matched-only) so the user can336-            // reach any entry from here.383+            // For an unverified caller, this field is a domain confirmation boundary,384+            // not a vault-wide search box controlled by attacker-provided origin data.337385             var rawQuery by remember { mutableStateOf("") }338386             var debouncedQuery by remember { mutableStateOf("") }339387             LaunchedEffect(rawQuery) {340388                 delay(SEARCH_DEBOUNCE_MS)341389                 debouncedQuery = rawQuery342390             }391+            val confirmedTarget = if (untrustedOrigin) {392+                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)393+            } else {394+                null395+            }343396             SearchField(344397                 value = rawQuery,345398                 onValueChange = { rawQuery = it },346-                placeholder = if (matchedEntries.isNotEmpty())347-                    "search for another entry…" else "search vault…",399+                placeholder = when {400+                    untrustedOrigin -> "enter site domain, e.g. example.com"401+                    matchedEntries.isNotEmpty() -> "search for another entry…"402+                    else -> "search vault…"403+                },348404             )349405 350-            // No-results-vs-empty-case is a single observable: an empty351-            // entry list with no active query means the vault really had352-            // no matches for this origin; a non-empty query producing an353-            // empty list means the user typed something the vault can't find.354-            // Either way, the picker still offers "+ Create new entry" so the355-            // user can pivot to a save instead of being stranded.356-            val visibleEntries = if (debouncedQuery.isBlank()) {357-                matchedEntries358-            } else {359-                allEntries.filter { matchesPickerSearch(it, debouncedQuery) }406+            val visibleEntries = when {407+                untrustedOrigin && confirmedTarget == null -> emptyList()408+                untrustedOrigin -> AutofillResponseBuilder409+                    .matchEntries(allEntries, confirmedTarget)410+                    .hits411+                debouncedQuery.isBlank() -> matchedEntries412+                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }360413             }361-            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()414+            val showSearchEmpty = debouncedQuery.isNotBlank() &&415+                visibleEntries.isEmpty() &&416+                (!untrustedOrigin || confirmedTarget != null)362417 363-            if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {418+            val emptyMessage = when {419+                untrustedOrigin && debouncedQuery.isBlank() ->420+                    "Type the site domain to choose a saved login."421+                untrustedOrigin && confirmedTarget == null ->422+                    "Enter a full public domain such as example.com."423+                untrustedOrigin && visibleEntries.isEmpty() ->424+                    "No saved entries match $confirmedTarget."425+                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->426+                    "No saved entries match this site."427+                showSearchEmpty ->428+                    "No entries match \"${debouncedQuery.trim()}\"."429+                else -> null430+            }431+            if (emptyMessage != null) {364432                 Text(365-                    text = "No saved entries match this site.",433+                    text = emptyMessage,366434                     color = peachColors.T1,367435                     fontFamily = PeachType.Body,368436                     fontSize = 13.sp,369437                     modifier = Modifier.padding(vertical = 6.dp),370438                 )371-            } else if (showSearchEmpty) {372-                Text(373-                    text = "No entries match \"${debouncedQuery.trim()}\".",374-                    color = peachColors.T1,375-                    fontFamily = PeachType.Body,376-                    fontSize = 13.sp,377-                    modifier = Modifier.padding(vertical = 6.dp),378-                )379439             }380440 381441             if (visibleEntries.isNotEmpty()) {382442                 LazyColumn {383443                     items(visibleEntries, key = { it.id }) { entry ->384-                        PickerRow(entry, onClick = { onPick(entry) })444+                        PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })385445                     }386446                 }387447             }388448 389-            // Header affordance, not buried at the end of the list — mirrors390-            // the inline selector's "+" so the user is never trapped in the391-            // search list when they want to make a new entry.392-            CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)449+            val createOrigin = if (untrustedOrigin) confirmedTarget else origin450+            if (!untrustedOrigin || createOrigin != null) {451+                CreateNewRow(452+                    origin = createOrigin,453+                    typedIdentity = typedIdentity,454+                    onClick = { onCreateNew(confirmedTarget) },455+                )456+            }393457         }394458     }395459 }

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+4 −0
@@ -19,6 +19,8 @@1919     const val EXTRA_OTP_ID = "peach.autofill.otpId"2020     const val EXTRA_ORIGIN = "peach.autofill.origin"2121     const val EXTRA_IS_WEB_ORIGIN = "peach.autofill.isWebOrigin"22+    const val EXTRA_ORIGIN_TRUST = "peach.autofill.originTrust"23+    const val EXTRA_VERIFIED_DOMAINS = "peach.autofill.verifiedDomains"2224 2325     /** Host-typed username captured by the autofill framework — used to prefill the new-entry editor. */2426     const val EXTRA_TYPED_USERNAME = "peach.autofill.typedUsername"@@ -54,6 +56,8 @@5456             putExtra(EXTRA_OTP_ID, parsed.otpId)5557             putExtra(EXTRA_ORIGIN, parsed.origin)5658             putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)59+            putExtra(EXTRA_ORIGIN_TRUST, parsed.originTrust.name)60+            putStringArrayListExtra(EXTRA_VERIFIED_DOMAINS, ArrayList(parsed.verifiedDomains))5761             // Mirror the browser's preUsername/preEmail capture so the "+ Create5862             // new entry" path can hand the typed identity to the new-entry editor.5963             // Empty/blank values are dropped — the picker reads the typed username

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+22 −14
@@ -23,28 +23,36 @@2323  */2424 object AutofillResponseBuilder {2525 26-    /** Max entries shown when there is no web origin to match strictly against. */27-    private const val FALLBACK_LIMIT = 628-2926     /**30-     * Strict origin match (MF-7). [hits] are entries whose registrable domain31-     * (eTLD+1, via the public suffix list) exactly equals the request origin's —32-     * never a loose substring, so `github.com` can't elicit a `notgithub.com`33-     * entry. [rest] is a small fallback shown ONLY when the origin has no34-     * registrable domain (a native package or no origin at all); for a real web35-     * origin it is always empty, so unrelated entries are never offered.27+     * Strict registrable-domain matching. Missing, malformed, local, and IP origins28+     * return no entries; callers must route those requests through the guarded picker.3629      */3730     data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {3831         val all: List<VaultEntry> get() = hits + rest3932     }4033 4134     fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {42-        if (origin.isNullOrBlank()) return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT))43-        val originDomain = extractDomain(origin)44-            ?: return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT))45-        val hits = entries.filter { entry -> entry.matchesOriginDomain(originDomain) }46-        return MatchResult(hits = hits, rest = emptyList())35+        val originDomain = origin?.let(::extractDomain)36+            ?: return MatchResult(emptyList(), emptyList())37+        return matchDomains(entries, setOf(originDomain))4738     }39+40+    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) {41+        AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain)42+        AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains(43+            entries,44+            parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(),45+        )46+        AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList())47+    }48+49+    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =50+        MatchResult(51+            hits = entries.filter { entry ->52+                domains.any { domain -> entry.matchesOriginDomain(domain) }53+            },54+            rest = emptyList(),55+        )4856 4957     private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =5058         sequenceOf(url)

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+23 −23
@@ -42,24 +42,16 @@4242     ) {4343         val context = applicationContext4444         val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }45-        val parsed = AssistStructureParser.parse(structure)45+        val parsed = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))4646         if (!parsed.hasUsableField) { callback.onSuccess(null); return }4747 48-        // MF-7: a web origin (webDomain) is only trustworthy from a known browser.49-        // A non-browser app claiming a web domain is trying to elicit credentials it50-        // does not own — refuse to offer anything at all.51-        if (parsed.webDomain != null) {52-            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName53-            if (!TrustedBrowsers.isTrusted(clientPackage)) { callback.onSuccess(null); return }54-        }55-5648         val inlineSpecs = inlineSpecs(request)5749         val response = FillResponse.Builder()5850         var datasetCount = 05951 6052         if (vault.isUnlocked.value) {6153             val matches = AutofillResponseBuilder62-                .matchEntries(vault.getEntries(), parsed.origin)54+                .matchEntries(vault.getEntries(), parsed)6355                 .all.take(MAX_DATASETS)6456             matches.forEachIndexed { index, entry ->6557                 val spec = inlineSpecs.getOrNull(index)@@ -71,10 +63,14 @@7163         }7264 7365         // Always offer the branded picker as the last row (search / unlock / no-match).74-        val pickerLabel = autofillPickerLabel(75-            unlocked = vault.isUnlocked.value,76-            datasetCount = datasetCount,77-        )66+        val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {67+            "Verify site in Peach…"68+        } else {69+            autofillPickerLabel(70+                unlocked = vault.isUnlocked.value,71+                datasetCount = datasetCount,72+            )73+        }7874         runCatching {7975             response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))8076             datasetCount++@@ -84,7 +80,9 @@8480             callback.onSuccess(null)8581         } else {8682             ignoreUnfilledIds(response, parsed)87-            addSaveInfo(response, parsed)83+            if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {84+                addSaveInfo(response, parsed)85+            }8886             callback.onSuccess(response.build())8987         }9088     }@@ -95,7 +93,12 @@9593         label: String,9694         inlineSpec: android.widget.inline.InlinePresentationSpec?,9795     ): android.service.autofill.Dataset {98-        val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "")96+        val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {97+            "Origin not verified"98+        } else {99+            parsed.origin.orEmpty()100+        }101+        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)99102         val builder = android.service.autofill.Dataset.Builder(menu)100103         // A placeholder value per field keeps the dataset valid; the real values101104         // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.@@ -138,13 +141,10 @@138141             callback.onSuccess()139142             return140143         }141-        val parsed = AssistStructureParser.parse(structure)142-        if (parsed.webDomain != null) {143-            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName144-            if (!TrustedBrowsers.isTrusted(clientPackage)) {145-                callback.onSuccess()146-                return147-            }144+        val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))145+        if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {146+            callback.onSuccess()147+            return148148         }149149         val draft = autofillSaveDraft(parsed)150150         if (draft == null) {

view file

app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+100 −25
@@ -1,31 +1,106 @@11 package com.peachpasswords.autofill22 3-/**4- * Conservative allowlist of apps permitted to request autofill for a *web* origin5- * (MF-7). When an autofill request carries a webDomain, the providing app must be a6- * known browser — otherwise a malicious app could claim `bank.com` and harvest the7- * credentials it does not own. Native-app autofill (no webDomain) is unaffected.8- *9- * [NEEDS EXPANSION] — add browsers as needed; an unknown browser simply gets no10- * web autofill until it is listed (it can still be used via the manual picker).11- */3+import android.content.Context4+import android.content.pm.PackageManager5+import android.content.pm.verify.domain.DomainVerificationManager6+import android.content.pm.verify.domain.DomainVerificationUserState7+import android.os.Build8+import com.peachpasswords.shared.extractDomain9+import java.security.MessageDigest10+11+/** Browsers allowed to assert a web origin, pinned to their release certificates. */1212 object TrustedBrowsers {13-    private val KNOWN_BROWSERS = setOf(14-        "com.android.chrome",15-        "com.chrome.beta",16-        "com.chrome.dev",17-        "org.mozilla.firefox",18-        "org.mozilla.firefox_beta",19-        "com.brave.browser",20-        "com.microsoft.emmx",        // Edge21-        "com.opera.browser",22-        "com.opera.mini.native",23-        "com.vivaldi.browser",24-        "com.kiwibrowser.browser",25-        "com.duckduckgo.mobile.android",26-        "com.sec.android.app.sbrowser", // Samsung Internet27-        "com.android.browser",13+    private const val GOOGLE_RELEASE_CERT =14+        "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83"15+    private const val MOZILLA_RELEASE_CERT =16+        "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04"17+    private const val BRAVE_RELEASE_CERT =18+        "9c2db70513515fdbfbbc585b3edf3d7123d4dc67c94ffd306361c1d79bbf18ac"19+20+    // Unknown browsers still reach the guarded manual picker; they never receive21+    // credentials merely because their package name resembles a supported browser.22+    private val EXPECTED_CERTIFICATES = mapOf(23+        "com.android.chrome" to setOf(GOOGLE_RELEASE_CERT),24+        "com.chrome.beta" to setOf(GOOGLE_RELEASE_CERT),25+        "com.chrome.dev" to setOf(GOOGLE_RELEASE_CERT),26+        "org.mozilla.firefox" to setOf(MOZILLA_RELEASE_CERT),27+        "org.mozilla.firefox_beta" to setOf(MOZILLA_RELEASE_CERT),28+        "com.brave.browser" to setOf(BRAVE_RELEASE_CERT),2829     )2930 30-    fun isTrusted(packageName: String?): Boolean = packageName in KNOWN_BROWSERS31+    @Suppress("DEPRECATION")32+    fun isTrusted(context: Context, packageName: String?): Boolean {33+        if (packageName !in EXPECTED_CERTIFICATES) return false34+        return runCatching {35+            context.packageManager36+                .getPackageInfo(packageName!!, PackageManager.GET_SIGNATURES)37+                .signatures38+                .orEmpty()39+                .any { hasExpectedCertificate(packageName, sha256(it.toByteArray())) }40+        }.getOrDefault(false)41+    }42+43+    internal fun hasExpectedCertificate(packageName: String?, certificateSha256: String): Boolean =44+        certificateSha256.lowercase() in EXPECTED_CERTIFICATES[packageName].orEmpty()45+46+    private fun sha256(bytes: ByteArray): String =47+        MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") {48+            (it.toInt() and 0xff).toString(16).padStart(2, '0')49+        }50+}51+52+internal fun authenticateAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {53+    val claimedWebDomain = parsed.webDomain54+    if (claimedWebDomain != null) {55+        val domain = extractDomain(claimedWebDomain)56+            ?: return parsed.copy(57+                originTrust = AutofillOriginTrust.UNKNOWN,58+                verifiedDomains = emptyList(),59+            )60+        if (TrustedBrowsers.isTrusted(context, parsed.packageName)) {61+            return parsed.copy(62+                originTrust = AutofillOriginTrust.TRUSTED_WEB,63+                verifiedDomains = listOf(domain),64+            )65+        }66+        val appDomains = verifiedAppLinkDomains(context, parsed.packageName)67+        return if (domain in appDomains) {68+            parsed.copy(69+                originTrust = AutofillOriginTrust.TRUSTED_NATIVE,70+                verifiedDomains = listOf(domain),71+            )72+        } else {73+            parsed.copy(74+                originTrust = AutofillOriginTrust.UNKNOWN,75+                verifiedDomains = emptyList(),76+            )77+        }78+    }79+80+    val appDomains = verifiedAppLinkDomains(context, parsed.packageName)81+    return if (appDomains.isEmpty()) {82+        parsed.copy(originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = emptyList())83+    } else {84+        parsed.copy(85+            originTrust = AutofillOriginTrust.TRUSTED_NATIVE,86+            verifiedDomains = appDomains,87+        )88+    }89+}90+91+/** Uses Android's cached Digital Asset Links verdict; older Android versions fail closed. */92+internal fun verifiedAppLinkDomains(context: Context, packageName: String?): List<String> {93+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) {94+        return emptyList()95+    }96+    return runCatching {97+        val state = context.getSystemService(DomainVerificationManager::class.java)98+            ?.getDomainVerificationUserState(packageName)99+        state?.hostToStateMap.orEmpty()100+            .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED }101+            .keys102+            .mapNotNull { extractDomain(it.removePrefix("*.")) }103+            .distinct()104+            .sorted()105+    }.getOrDefault(emptyList())31106 }

view file

app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+44 −16
@@ -48,20 +48,27 @@4848 4949     @Test fun picker_web_origin_uses_domain_matching() {5050         val peach = entry("Peach", "https://peachpasswords.com")51-        val parsed = ParsedStructure(webDomain = "app.peachpasswords.com")51+        val parsed = ParsedStructure(52+            webDomain = "app.peachpasswords.com",53+            originTrust = AutofillOriginTrust.TRUSTED_WEB,54+        )5255         assertEquals(listOf(peach), autofillPickerEntries(listOf(peach), parsed, limit = 12))5356     }5457 55-    @Test fun picker_native_origin_requires_exact_package_match() {56-        val app = entry("Native", "com.example.app")57-        val other = entry("Other", "com.example.other")58-        val parsed = ParsedStructure(packageName = "com.example.app")58+    @Test fun picker_native_origin_uses_verified_app_link_domains() {59+        val app = entry("Native", "https://example.com/login")60+        val other = entry("Other", "https://other.example.net")61+        val parsed = ParsedStructure(62+            packageName = "com.example.app",63+            originTrust = AutofillOriginTrust.TRUSTED_NATIVE,64+            verifiedDomains = listOf("example.com"),65+        )5966         assertEquals(listOf(app), autofillPickerEntries(listOf(app, other), parsed, limit = 12))6067     }6168 62-    @Test fun picker_native_origin_rejects_package_substrings() {63-        val app = entry("Native", "com.example.app")64-        val parsed = ParsedStructure(packageName = "evil.com.example.app")69+    @Test fun unknown_native_origin_has_no_matches() {70+        val app = entry("Native", "https://example.com/login")71+        val parsed = ParsedStructure(packageName = "com.example.app")6572         assertTrue(autofillPickerEntries(listOf(app), parsed, limit = 12).isEmpty())6673     }6774 @@ -77,18 +84,39 @@7784         assertEquals("Search Peach…", autofillPickerLabel(unlocked = true, datasetCount = 1))7885     }7986 80-    @Test fun null_origin_yields_no_hits_only_fallback() {87+    @Test fun null_origin_yields_no_entries() {8188         val a = entry("A", "https://a.com")8289         val b = entry("B", "https://b.com")8390         val result = AutofillResponseBuilder.matchEntries(listOf(a, b), null)84-        assertTrue(result.hits.isEmpty())85-        assertEquals(listOf(a, b), result.rest)91+        assertTrue(result.all.isEmpty())8692     }8793 88-    @Test fun trusted_browser_allowlist() {89-        assertTrue(TrustedBrowsers.isTrusted("com.android.chrome"))90-        assertTrue(TrustedBrowsers.isTrusted("org.mozilla.firefox"))91-        assertFalse(TrustedBrowsers.isTrusted("com.evil.phisher"))92-        assertFalse(TrustedBrowsers.isTrusted(null))94+    @Test fun unknown_origin_returns_no_preselected_credentials() {95+        val bank = entry("Bank", "https://bank.example.com")96+        val parsed = ParsedStructure(97+            webDomain = "bank.example.com",98+            packageName = "com.evil.phisher",99+            originTrust = AutofillOriginTrust.UNKNOWN,100+        )101+102+        assertTrue(AutofillResponseBuilder.matchEntries(listOf(bank), parsed).all.isEmpty())103+    }104+105+    @Test fun trusted_browser_certificate_pins() {106+        assertTrue(107+            TrustedBrowsers.hasExpectedCertificate(108+                "com.android.chrome",109+                "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83",110+            ),111+        )112+        assertTrue(113+            TrustedBrowsers.hasExpectedCertificate(114+                "org.mozilla.firefox",115+                "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04",116+            ),117+        )118+        assertFalse(TrustedBrowsers.hasExpectedCertificate("com.android.chrome", "00".repeat(32)))119+        assertFalse(TrustedBrowsers.hasExpectedCertificate("com.evil.phisher", "00".repeat(32)))120+        assertFalse(TrustedBrowsers.hasExpectedCertificate(null, "00".repeat(32)))93121     }94122 }

view file