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
↓ peach-android-c9594ddc.tar.gzatlas at this save
files changed7 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 −16app/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.autofillIdapp/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 }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 usernameapp/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)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) {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 }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 }