431bdd9b44f6

feat: remove autofill origin verification — match by domain + app title (v2.10.7)

June's directive: origin verification removed; matches shown by domain and app title. Fail-closed browser cert pins left nearly every web origin UNKNOWN, so 'everything says unverified' and matches were withheld. - Delete AutofillOriginTrust + TrustedBrowsers.kt + authenticateAutofillOrigin - New AutofillOrigin.kt: web origins resolve the page domain; native apps resolve verified app-link domains + display label (framework-authenticated caller package only — never caller extras) - matchEntries ranks by registrable domain + case-insensitive app title - Service: save offered always (no TRUSTED_WEB gate); drop 'Verify site in Peach…' and 'Origin not verified' copy - Picker: no trust re-derivation or warning box; origin re-resolved in-process - Tests: delete PickerOriginTrustTest/TrustedBrowsersTest; rewrite trust-based cases in AutofillMatchTest/AutofillPickerSearchTest/AutofillCardTest Suite: 879 tests, only the 5 known pre-existing failures. Deliberate product-owner tradeoff: show matches for unverified origins; the explicit picker tap remains the security boundary. Live autofill needs on-device QA.

time
2026-08-02 23:12 UTC · 2 months ago
machine
koh2
parent
bb109f184adb
13 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+10 −7app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −52app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+5 −7app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+62 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+17 −9app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+8 −22app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+0 −201app/src/test/java/com/peachpasswords/autofill/AutofillCardTest.kt+2 −2app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+14 −89app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+6 −8app/src/test/java/com/peachpasswords/autofill/PickerOriginTrustTest.kt+0 −98app/src/test/java/com/peachpasswords/autofill/TrustedBrowsersTest.kt+0 −213
VERSION+1 −1
@@ -1 +1 @@1-2.10.61+2.10.7

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+10 −7
@@ -5,12 +5,15 @@55 import android.view.View66 import android.view.autofill.AutofillId77 8-enum class AutofillOriginTrust { TRUSTED_WEB, TRUSTED_NATIVE, UNKNOWN }9-108 /**11- * The autofillable fields discovered in one [AssistStructure], plus the origin12- * Android authenticated for ranking vault entries.13- */9+* The autofillable fields discovered in one [AssistStructure], plus the origin10+* Android authenticated for ranking vault entries.11+ *12+ * Origin verification was removed (queue #3, June 2026-08-02): matches are13+ * ranked by [originDomains] (the page domain, or a native app's app-link14+ * domains) and [appTitle] (a native app's display label), with no caller-trust15+ * gate. [originDomains]/[appTitle] are populated by the service, not the parser.16+*/1417 data class ParsedStructure(1518     val usernameId: AutofillId? = null,1619     val emailId: AutofillId? = null,@@ -24,8 +27,8 @@2427     val isNewPassword: Boolean = false,2528     val webDomain: String? = null,2629     val packageName: String? = null,27-    val originTrust: AutofillOriginTrust = AutofillOriginTrust.UNKNOWN,28-    val verifiedDomains: List<String> = emptyList(),30+    val appTitle: String? = null,31+    val originDomains: List<String> = emptyList(),2932     val cardNumberId: AutofillId? = null,3033     val cardExpMonthId: AutofillId? = null,3134     val cardExpYearId: AutofillId? = null,

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −52
@@ -121,7 +121,6 @@121121             origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),122122             isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),123123             callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),124-            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125124             usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),126125             passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),127126             otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),@@ -132,7 +131,7 @@132131             cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),133132             cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),134133         )135-        val parsed = authenticateAutofillOrigin(this, rawParsed)134+        val parsed = resolveAutofillOrigin(this, rawParsed)136135         val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT137136 138137         setContent {@@ -176,7 +175,6 @@176175                 ) {177176                     PickerBody(178177                         origin = parsed.origin,179-                        originTrust = parsed.originTrust,180178                         typedIdentity = typedUsername ?: typedEmail,181179                         unlocked = unlocked,182180                         matchedEntries = matchedEntries,@@ -300,11 +298,7 @@300298         typedEmail: String?,301299         searchText: String?,302300     ) {303-        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {304-            searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin305-        } else {306-            parsed.origin307-        }301+        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin308302         val intent = Intent(this, MainActivity::class.java).apply {309303             putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)310304             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)@@ -335,17 +329,14 @@335329 }336330 337331 /**338- * Build the initial [ParsedStructure] from raw picker intent inputs with339- * originTrust always UNKNOWN. The picker must re-derive trust in-process via340- * [authenticateAutofillOrigin] — a caller-supplied trust extra is never341- * authoritative. For web origins, the authenticated caller package is carried342- * separately so the re-verification can check the browser signing pin.332+ * Build the initial [ParsedStructure] from raw picker intent inputs. Origin333+ * domains and app title are resolved in-process via [resolveAutofillOrigin] from334+ * the authenticated caller package + web domain — never from a caller extra.343335  */344336 internal fun pickerParsedFromInputs(345337     origin: String?,346338     isWebOrigin: Boolean,347339     callerPackage: String?,348-    verifiedDomains: List<String>,349340     usernameId: AutofillId? = null,350341     passwordId: AutofillId? = null,351342     otpId: AutofillId? = null,@@ -367,9 +358,6 @@367358     cardNameId = cardNameId,368359     webDomain = origin.takeIf { isWebOrigin },369360     packageName = if (isWebOrigin) callerPackage else origin,370-    // Always UNKNOWN here — the picker re-derives trust from these raw inputs.371-    originTrust = AutofillOriginTrust.UNKNOWN,372-    verifiedDomains = verifiedDomains,373361 )374362 375363 internal fun autofillPickerEntries(@@ -408,7 +396,6 @@408396 @Composable409397 private fun PickerBody(410398     origin: String?,411-    originTrust: AutofillOriginTrust,412399     typedIdentity: String?,413400     unlocked: Boolean,414401     matchedEntries: List<VaultEntry>,@@ -422,7 +409,6 @@422409     onCreateNew: (String?) -> Unit,423410 ) {424411     val accent = LocalPeachAccent.current425-    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN426412     Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {427413         Text(428414             text = "peach",@@ -432,39 +418,18 @@432418             fontSize = 15.sp,433419         )434420         Text(435-            text = when {436-                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"437-                origin.isNullOrBlank() -> "your vault"438-                else -> "Suggested for $origin"439-            },421+            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",440422             color = peachColors.T2,441423             fontFamily = PeachType.Mono,442424             fontSize = 9.sp,443425             modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),444426         )445427 446-        if (originTrust == AutofillOriginTrust.UNKNOWN) {447-            Box(448-                modifier = Modifier449-                    .fillMaxWidth()450-                    .padding(bottom = 14.dp)451-                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))452-                    .padding(12.dp),453-            ) {454-                Text(455-                    text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",456-                    color = peachColors.Breach,457-                    fontFamily = PeachType.Body,458-                    fontSize = 12.sp,459-                )460-            }461-        }462-463428         if (!unlocked) {464429             UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)465430         } else {466-            // The search field is a vault-wide substring search. For unverified origins,467-            // the user must type to initiate — empty query shows no entries.431+            // The search field is a vault-wide substring search; an empty query432+            // shows the origin's matched entries.468433             var rawQuery by remember { mutableStateOf("") }469434             var debouncedQuery by remember { mutableStateOf("") }470435             LaunchedEffect(rawQuery) {@@ -481,17 +446,12 @@481446             )482447 483448             val visibleEntries = when {484-                untrustedOrigin && debouncedQuery.isBlank() -> emptyList()485449                 debouncedQuery.isBlank() -> matchedEntries486450                 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }487451             }488452             val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489453 490454             val emptyMessage = when {491-                untrustedOrigin && debouncedQuery.isBlank() ->492-                    "Search your vault to choose a login."493-                untrustedOrigin && visibleEntries.isEmpty() ->494-                    "No entries match \"${debouncedQuery.trim()}\"."495455                 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->496456                     "No saved entries match this site."497457                 showSearchEmpty ->@@ -516,11 +476,8 @@516476                 }517477             }518478 519-            val createOrigin = if (untrustedOrigin) {479+            val createOrigin =520480                 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin521-            } else {522-                origin523-            }524481             CreateNewRow(525482                 origin = createOrigin,526483                 typedIdentity = typedIdentity,

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+5 −7
@@ -19,8 +19,7 @@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_VERIFIED_DOMAINS = "peach.autofill.verifiedDomains"23-    /** Authenticated caller package for web-origin re-verification in the picker. */22+    /** Authenticated caller package so the picker can re-resolve web-origin domains in-process. */2423     const val EXTRA_CALLER_PACKAGE = "peach.autofill.callerPackage"2524 2625     const val EXTRA_CARD_NUMBER_ID = "peach.autofill.cardNumberId"@@ -64,10 +63,9 @@6463             putExtra(EXTRA_OTP_ID, parsed.otpId)6564             putExtra(EXTRA_ORIGIN, parsed.origin)6665             putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)67-            // Origin trust is re-derived in the picker from raw inputs;68-            // carry the authenticated caller package for web-origin re-verification.66+            // The picker re-resolves origin domains/app title in-process from67+            // the authenticated caller package.6968             putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)70-            putStringArrayListExtra(EXTRA_VERIFIED_DOMAINS, ArrayList(parsed.verifiedDomains))7169             putExtra(EXTRA_CARD_NUMBER_ID, parsed.cardNumberId)7270             putExtra(EXTRA_CARD_EXP_MONTH_ID, parsed.cardExpMonthId)7371             putExtra(EXTRA_CARD_EXP_YEAR_ID, parsed.cardExpYearId)@@ -86,8 +84,8 @@8684             }8785         }8886         // Freezing the sender prevents the host app from rewriting origin89-        // extras before the private picker receives them. The picker re-derives90-        // origin trust from raw authenticated inputs in-process.87+        // extras before the private picker receives them. The picker re-resolves88+        // the origin from raw authenticated inputs in-process.9189         return PendingIntent.getActivity(context, REQ_PICKER, intent, immutableFlags()).intentSender9290     }9391 

view file

app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+62 −0
@@ -0,0 +1,62 @@1+package com.peachpasswords.autofill2+3+import android.content.Context4+import android.content.pm.verify.domain.DomainVerificationManager5+import android.content.pm.verify.domain.DomainVerificationUserState6+import android.os.Build7+import com.peachpasswords.shared.extractDomain8+9+/**10+ * Origin resolution for autofill matching.11+ *12+ * Origin *verification* (browser certificate pinning + caller-trust gating) was13+ * removed (queue #3, June 2026-08-02) — it flagged nearly every request as14+ * "unverified" and withheld matches, adding friction without value. Matches are15+ * now ranked purely by content: the page domain for web origins, and a native16+ * app's app-link domains + display label for app origins. The caller package is17+ * still the framework-authenticated identity (never a caller-supplied extra).18+ */19+20+/**21+ * Populate [ParsedStructure.originDomains] and [ParsedStructure.appTitle] from22+ * the authenticated origin. Web origins match on the page domain; native apps23+ * match on their app-link domains and their display label. No trust24+ * classification — every origin is eligible to see its matching entries.25+ */26+internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {27+    val webDomain = parsed.webDomain28+    if (webDomain != null) {29+        return parsed.copy(originDomains = listOfNotNull(extractDomain(webDomain)), appTitle = null)30+    }31+    return parsed.copy(32+        originDomains = appLinkDomains(context, parsed.packageName),33+        appTitle = appLabel(context, parsed.packageName),34+    )35+}36+37+/** The display label of the calling app, for matching entries by app title. */38+internal fun appLabel(context: Context, packageName: String?): String? {39+    if (packageName.isNullOrBlank()) return null40+    return runCatching {41+        context.packageManager.getApplicationLabel(42+            context.packageManager.getApplicationInfo(packageName, 0),43+        ).toString().takeIf { it.isNotBlank() }44+    }.getOrNull()45+}46+47+/** A native app's verified app-link domains (Android's cached Digital Asset Links verdict). */48+internal fun appLinkDomains(context: Context, packageName: String?): List<String> {49+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) {50+        return emptyList()51+    }52+    return runCatching {53+        val state = context.getSystemService(DomainVerificationManager::class.java)54+            ?.getDomainVerificationUserState(packageName)55+        state?.hostToStateMap.orEmpty()56+            .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED }57+            .keys58+            .mapNotNull { extractDomain(it.removePrefix("*.")) }59+            .distinct()60+            .sorted()61+    }.getOrDefault(emptyList())62+}

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+17 −9
@@ -26,8 +26,8 @@2626 object AutofillResponseBuilder {2727 2828     /**29-     * Strict registrable-domain matching. Missing, malformed, local, and IP origins30-     * return no entries; callers must route those requests through the guarded picker.29+     * Registrable-domain matching. Missing, malformed, local, and IP origins30+     * return no entries (there is nothing to rank against).3131      */3232     data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {3333         val all: List<VaultEntry> get() = hits + rest@@ -39,13 +39,21 @@3939         return matchDomains(entries, setOf(originDomain))4040     }4141 42-    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) {43-        AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain)44-        AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains(45-            entries,46-            parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(),47-        )48-        AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList())42+    /**43+     * Rank entries for an origin with no caller-trust gate (origin verification44+     * removed, queue #3). Web origins match by the page domain; native apps match45+     * by their app-link domains or display title. Blank origins match nothing.46+     */47+    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {48+        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()49+        val byDomain = matchDomains(entries, domains).hits50+        val appTitle = parsed.appTitle?.trim()51+        val byTitle = if (appTitle.isNullOrBlank()) {52+            emptyList()53+        } else {54+            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }55+        }56+        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())4957     }5058 5159     private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+8 −22
@@ -43,7 +43,7 @@4343     ) {4444         val context = applicationContext4545         val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }46-        val parsed = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))46+        val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure))4747         if (!parsed.hasUsableField) { callback.onSuccess(null); return }4848 4949         val inlineSpecs = inlineSpecs(request)@@ -91,14 +91,10 @@9191         }9292 9393         // Always offer the branded picker as the last row (search / unlock / no-match).94-        val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {95-            "Verify site in Peach…"96-        } else {97-            autofillPickerLabel(98-                unlocked = vault.isUnlocked.value,99-                datasetCount = datasetCount,100-            )101-        }94+        val pickerLabel = autofillPickerLabel(95+            unlocked = vault.isUnlocked.value,96+            datasetCount = datasetCount,97+        )10298         runCatching {10399             response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))104100             datasetCount++@@ -108,9 +104,7 @@108104             callback.onSuccess(null)109105         } else {110106             ignoreUnfilledIds(response, parsed)111-            if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {112-                addSaveInfo(response, parsed)113-            }107+            addSaveInfo(response, parsed)114108             callback.onSuccess(response.build())115109         }116110     }@@ -121,11 +115,7 @@121115         label: String,122116         inlineSpec: android.widget.inline.InlinePresentationSpec?,123117     ): android.service.autofill.Dataset {124-        val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {125-            "Origin not verified"126-        } else {127-            parsed.origin.orEmpty()128-        }118+        val subtitle = parsed.origin.orEmpty()129119         val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)130120         val builder = android.service.autofill.Dataset.Builder(menu)131121         // A placeholder value per field keeps the dataset valid; the real values@@ -177,11 +167,7 @@177167             callback.onSuccess()178168             return179169         }180-        val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))181-        if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {182-            callback.onSuccess()183-            return184-        }170+        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))185171         val draft = autofillSaveDraft(parsed)186172         if (draft == null) {187173             callback.onSuccess()

view file

app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+0 −201
@@ -1,201 +0,0 @@1-package com.peachpasswords.autofill2-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-/**12- * Browsers allowed to assert a web origin, pinned to their release13- * certificates. The fingerprints below were inherited from the audit patch but14- * have no checked-in upstream provenance in this repository. They are retained15- * as an explicit UNCONFIRMED registry and are therefore never trusted until a16- * release process records and flips the provenance to CONFIRMED. Unknown and17- * unproven packages fail closed.18- */19-object TrustedBrowsers {20-    private enum class PinProvenance { UNCONFIRMED, CONFIRMED }21-22-    private data class BrowserCertificatePins(23-        val hashes: Set<String>,24-        val provenance: PinProvenance,25-    )26-27-    private const val GOOGLE_RELEASE_CERT =28-        "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83"29-    private const val MOZILLA_RELEASE_CERT =30-        "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04"31-    private const val BRAVE_RELEASE_CERT =32-        "9c2db70513515fdbfbbc585b3edf3d7123d4dc67c94ffd306361c1d79bbf18ac"33-34-    // Unknown browsers still reach the guarded manual picker; they never receive35-    // credentials merely because their package name resembles a supported browser.36-    // Do not flip UNCONFIRMED to CONFIRMED without a locally recorded official37-    // release-certificate source and a test vector for that exact package.38-    private val CERTIFICATE_REGISTRY = mapOf(39-        "com.android.chrome" to BrowserCertificatePins(40-            hashes = setOf(GOOGLE_RELEASE_CERT),41-            provenance = PinProvenance.UNCONFIRMED,42-        ),43-        "com.chrome.beta" to BrowserCertificatePins(44-            hashes = setOf(GOOGLE_RELEASE_CERT),45-            provenance = PinProvenance.UNCONFIRMED,46-        ),47-        "com.chrome.dev" to BrowserCertificatePins(48-            hashes = setOf(GOOGLE_RELEASE_CERT),49-            provenance = PinProvenance.UNCONFIRMED,50-        ),51-        "org.mozilla.firefox" to BrowserCertificatePins(52-            hashes = setOf(MOZILLA_RELEASE_CERT),53-            provenance = PinProvenance.UNCONFIRMED,54-        ),55-        "org.mozilla.firefox_beta" to BrowserCertificatePins(56-            hashes = setOf(MOZILLA_RELEASE_CERT),57-            provenance = PinProvenance.UNCONFIRMED,58-        ),59-        "com.brave.browser" to BrowserCertificatePins(60-            hashes = setOf(BRAVE_RELEASE_CERT),61-            provenance = PinProvenance.UNCONFIRMED,62-        ),63-    )64-65-    fun isTrusted(context: Context, packageName: String?): Boolean {66-        val packageName = packageName ?: return false67-        val pins = CERTIFICATE_REGISTRY[packageName] ?: return false68-        // A package entry without locally verified provenance is not an69-        // allowlist entry yet. This deliberately trades browser convenience for70-        // fail-closed security until release metadata is supplied.71-        if (pins.provenance != PinProvenance.CONFIRMED) return false72-        return runCatching {73-            val info = packageInfo(context.packageManager, packageName)74-                ?: return@runCatching false75-            val current = mutableSetOf<String>()76-            val history = mutableSetOf<String>()77-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {78-                val signingInfo = info.signingInfo ?: return@runCatching false79-                signingInfo.apkContentsSigners.orEmpty()80-                    .mapTo(current) { sha256(it.toByteArray()) }81-                signingInfo.signingCertificateHistory.orEmpty()82-                    .mapTo(history) { sha256(it.toByteArray()) }83-            } else {84-                @Suppress("DEPRECATION")85-                info.signatures.orEmpty().mapTo(current) { sha256(it.toByteArray()) }86-            }87-            certificateSetMatches(88-                expected = pins.hashes,89-                current = current,90-                history = history,91-                apiLevel = Build.VERSION.SDK_INT,92-            )93-        }.getOrDefault(false)94-    }95-96-    private fun packageInfo(packageManager: PackageManager, packageName: String) =97-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {98-            packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)99-        } else {100-            @Suppress("DEPRECATION")101-            packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)102-        }103-104-    /**105-     * Accept only when a CURRENT signer matches the pinned certificate.106-     *107-     * API 26/27: only current signers are available via GET_SIGNATURES.108-     * API 28+ (P): [SigningInfo.getApkContentsSigners] is the authoritative109-     * current signer set; [SigningInfo.getSigningCertificateHistory] is110-     * rotation history. We accept only on a current match — a package whose111-     * current signer is absent or unpinned is rejected even if a historical112-     * cert matches, because rotation history alone cannot prove the current113-     * binary is authentic. Empty expected, empty current, or null114-     * [SigningInfo] all fail closed.115-     */116-    internal fun certificateSetMatches(117-        expected: Set<String>,118-        current: Set<String>,119-        history: Set<String>,120-        apiLevel: Int,121-    ): Boolean {122-        if (expected.isEmpty()) return false123-        if (current.isEmpty()) return false124-        val normalized = expected.map(String::lowercase)125-        return current.any { it.lowercase() in normalized }126-    }127-128-    internal fun hasLocallyConfirmedPins(packageName: String?): Boolean =129-        CERTIFICATE_REGISTRY[packageName]?.provenance == PinProvenance.CONFIRMED130-131-    /** All registered package names — exposed for test assertions. */132-    internal fun registeredPackages(): Set<String> = CERTIFICATE_REGISTRY.keys133-134-    /** Whether every registry entry is still UNCONFIRMED — locks the fail-closed decision. */135-    internal fun allPinsUnconfirmed(): Boolean =136-        CERTIFICATE_REGISTRY.values.all { it.provenance == PinProvenance.UNCONFIRMED }137-138-    internal fun hasExpectedCertificate(packageName: String?, certificateSha256: String): Boolean =139-        certificateSha256.lowercase() in CERTIFICATE_REGISTRY[packageName]?.hashes.orEmpty()140-141-    private fun sha256(bytes: ByteArray): String =142-        MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") {143-            (it.toInt() and 0xff).toString(16).padStart(2, '0')144-        }145-}146-147-internal fun authenticateAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {148-    val claimedWebDomain = parsed.webDomain149-    if (claimedWebDomain != null) {150-        val domain = extractDomain(claimedWebDomain)151-            ?: return parsed.copy(152-                originTrust = AutofillOriginTrust.UNKNOWN,153-                verifiedDomains = emptyList(),154-            )155-        if (TrustedBrowsers.isTrusted(context, parsed.packageName)) {156-            return parsed.copy(157-                originTrust = AutofillOriginTrust.TRUSTED_WEB,158-                verifiedDomains = listOf(domain),159-            )160-        }161-        val appDomains = verifiedAppLinkDomains(context, parsed.packageName)162-        return if (domain in appDomains) {163-            parsed.copy(164-                originTrust = AutofillOriginTrust.TRUSTED_NATIVE,165-                verifiedDomains = listOf(domain),166-            )167-        } else {168-            parsed.copy(169-                originTrust = AutofillOriginTrust.UNKNOWN,170-                verifiedDomains = emptyList(),171-            )172-        }173-    }174-175-    val appDomains = verifiedAppLinkDomains(context, parsed.packageName)176-    return if (appDomains.isEmpty()) {177-        parsed.copy(originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = emptyList())178-    } else {179-        parsed.copy(180-            originTrust = AutofillOriginTrust.TRUSTED_NATIVE,181-            verifiedDomains = appDomains,182-        )183-    }184-}185-186-/** Uses Android's cached Digital Asset Links verdict; older Android versions fail closed. */187-internal fun verifiedAppLinkDomains(context: Context, packageName: String?): List<String> {188-    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) {189-        return emptyList()190-    }191-    return runCatching {192-        val state = context.getSystemService(DomainVerificationManager::class.java)193-            ?.getDomainVerificationUserState(packageName)194-        state?.hostToStateMap.orEmpty()195-            .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED }196-            .keys197-            .mapNotNull { extractDomain(it.removePrefix("*.")) }198-            .distinct()199-            .sorted()200-    }.getOrDefault(emptyList())201-}

view file at the parent

app/src/test/java/com/peachpasswords/autofill/AutofillCardTest.kt+2 −2
@@ -90,9 +90,9 @@9090             origin = "shop.example.com",9191             isWebOrigin = true,9292             callerPackage = "com.android.chrome",93-            verifiedDomains = emptyList(),9493         )9594         assertFalse(parsed.hasCardField)96-        assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)95+        assertEquals("shop.example.com", parsed.webDomain)96+        assertEquals("com.android.chrome", parsed.packageName)9797     }9898 }

view file

app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+14 −89
@@ -2,7 +2,6 @@22 33 import com.peachpasswords.vault.VaultEntry44 import org.junit.Assert.assertEquals5-import org.junit.Assert.assertFalse65 import org.junit.Assert.assertTrue76 import org.junit.Test87 @@ -50,23 +49,27 @@5049         val peach = entry("Peach", "https://peachpasswords.com")5150         val parsed = ParsedStructure(5251             webDomain = "app.peachpasswords.com",53-            originTrust = AutofillOriginTrust.TRUSTED_WEB,52+            originDomains = listOf("peachpasswords.com"),5453         )5554         assertEquals(listOf(peach), autofillPickerEntries(listOf(peach), parsed, limit = 12))5655     }5756 58-    @Test fun picker_native_origin_uses_verified_app_link_domains() {59-        val app = entry("Native", "https://example.com/login")57+    @Test fun picker_native_origin_matches_app_link_domains_and_title() {58+        val byDomain = entry("Native", "https://example.com/login")59+        val byTitle = entry("example app", "https://unrelated.test")6060         val other = entry("Other", "https://other.example.net")6161         val parsed = ParsedStructure(6262             packageName = "com.example.app",63-            originTrust = AutofillOriginTrust.TRUSTED_NATIVE,64-            verifiedDomains = listOf("example.com"),63+            appTitle = "Example App",64+            originDomains = listOf("example.com"),6565         )66-        assertEquals(listOf(app), autofillPickerEntries(listOf(app, other), parsed, limit = 12))66+        assertEquals(67+            listOf(byDomain, byTitle),68+            autofillPickerEntries(listOf(byDomain, byTitle, other), parsed, limit = 12),69+        )6770     }6871 69-    @Test fun unknown_native_origin_has_no_matches() {72+    @Test fun origin_without_domains_or_title_has_no_matches() {7073         val app = entry("Native", "https://example.com/login")7174         val parsed = ParsedStructure(packageName = "com.example.app")7275         assertTrue(autofillPickerEntries(listOf(app), parsed, limit = 12).isEmpty())@@ -91,91 +94,13 @@9194         assertTrue(result.all.isEmpty())9295     }9396 94-    @Test fun unknown_origin_returns_no_preselected_credentials() {97+    @Test fun web_origin_matches_its_page_domain() {9598         val bank = entry("Bank", "https://bank.example.com")9699         val parsed = ParsedStructure(97100             webDomain = "bank.example.com",98-            packageName = "com.evil.phisher",99-            originTrust = AutofillOriginTrust.UNKNOWN,101+            originDomains = listOf("bank.example.com"),100102         )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)))121-    }122-123-    @Test fun browser_pin_provenance_is_fail_closed_until_confirmed() {124-        assertFalse(TrustedBrowsers.hasLocallyConfirmedPins("com.android.chrome"))125-        assertFalse(TrustedBrowsers.hasLocallyConfirmedPins("com.evil.phisher"))126-    }127-128-    @Test fun api27_accepts_only_current_signers() {129-        val expected = setOf("aa")130-        assertTrue(131-            TrustedBrowsers.certificateSetMatches(132-                expected = expected,133-                current = setOf("aa"),134-                history = emptySet(),135-                apiLevel = 27,136-            ),137-        )138-        assertFalse(139-            TrustedBrowsers.certificateSetMatches(140-                expected = expected,141-                current = setOf("bb"),142-                history = setOf("aa"),143-                apiLevel = 27,144-            ),145-        )146-    }147-148-    @Test fun api28_rejects_history_only_accepts_current_signers() {149-        val expected = setOf("aa")150-        // History-only match must NOT accept: the current signer is unpinned,151-        // so the package is rejected even though rotation history contains a152-        // pinned cert. History alone cannot prove the current binary is authentic.153-        assertFalse(154-            TrustedBrowsers.certificateSetMatches(155-                expected = expected,156-                current = setOf("bb"),157-                history = setOf("aa"),158-                apiLevel = 28,159-            ),160-        )161-        // Current match accepts.162-        assertTrue(163-            TrustedBrowsers.certificateSetMatches(164-                expected = expected,165-                current = setOf("aa"),166-                history = emptySet(),167-                apiLevel = 28,168-            ),169-        )170-        // No match rejects.171-        assertFalse(172-            TrustedBrowsers.certificateSetMatches(173-                expected = expected,174-                current = setOf("bb"),175-                history = setOf("cc"),176-                apiLevel = 28,177-            ),178-        )103+        assertEquals(listOf(bank), AutofillResponseBuilder.matchEntries(listOf(bank), parsed).all)179104     }180105 181106     @Test fun parser_identity_uses_only_framework_activity_component() {

view file

app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+6 −8
@@ -100,20 +100,18 @@100100         }101101     }102102 103-    @Test fun unknown_origin_never_preselects_entries_even_when_url_matches() {103+    @Test fun web_origin_preselects_matching_entries_by_domain() {104104         val entry = entry("Bank", url = "https://bank.example.com/login")105105         val parsed = ParsedStructure(106106             webDomain = "bank.example.com",107-            packageName = "com.unverified.host",108-            originTrust = AutofillOriginTrust.UNKNOWN,107+            originDomains = listOf("bank.example.com"),109108         )110-        assertTrue(autofillPickerEntries(listOf(entry), parsed, limit = 12).isEmpty())109+        assertEquals(listOf(entry), autofillPickerEntries(listOf(entry), parsed, limit = 12))111110     }112111 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.112+    @Test fun picker_search_locates_any_entry_via_substring() {113+        // The picker's search field locates any vault entry by a substring of114+        // title/username/email/url.117115         val entries = listOf(118116             entry("GitHub", username = "june", url = "https://github.com"),119117             entry("Fastmail", email = "[email protected]"),

view file

app/src/test/java/com/peachpasswords/autofill/PickerOriginTrustTest.kt+0 −98
@@ -1,98 +0,0 @@1-package com.peachpasswords.autofill2-3-import org.junit.Assert.assertEquals4-import org.junit.Assert.assertNull5-import org.junit.Assert.assertTrue6-import org.junit.Test7-8-/**9- * Proves the picker re-derives origin trust from raw inputs rather than10- * trusting a caller-supplied EXTRA_ORIGIN_TRUST. A forged trust extra must11- * never elevate origin trust — the picker always starts at UNKNOWN and12- * re-authenticates via authenticateAutofillOrigin.13- */14-class PickerOriginTrustTest {15-16-    @Test fun pickerParsedFromInputs_always_returns_unknown_trust() {17-        val parsed = pickerParsedFromInputs(18-            origin = "example.com",19-            isWebOrigin = true,20-            callerPackage = "com.android.chrome",21-            verifiedDomains = listOf("example.com"),22-        )23-        assertEquals(24-            "picker must never inherit trust from intent extras",25-            AutofillOriginTrust.UNKNOWN,26-            parsed.originTrust,27-        )28-    }29-30-    @Test fun pickerParsedFromInputs_sets_webDomain_for_web_origins() {31-        val parsed = pickerParsedFromInputs(32-            origin = "example.com",33-            isWebOrigin = true,34-            callerPackage = "com.android.chrome",35-            verifiedDomains = emptyList(),36-        )37-        assertEquals("example.com", parsed.webDomain)38-        assertEquals("com.android.chrome", parsed.packageName)39-        assertNull(parsed.webDomain?.let { null } ?: "webDomain should be set", parsed.packageName.takeIf { parsed.webDomain == null })40-    }41-42-    @Test fun pickerParsedFromInputs_sets_packageName_for_native_origins() {43-        val parsed = pickerParsedFromInputs(44-            origin = "com.example.app",45-            isWebOrigin = false,46-            callerPackage = null,47-            verifiedDomains = emptyList(),48-        )49-        assertNull(parsed.webDomain)50-        assertEquals("com.example.app", parsed.packageName)51-    }52-53-    @Test fun pickerParsedFromInputs_null_origin_produces_empty_parsed() {54-        val parsed = pickerParsedFromInputs(55-            origin = null,56-            isWebOrigin = false,57-            callerPackage = null,58-            verifiedDomains = emptyList(),59-        )60-        assertNull(parsed.webDomain)61-        assertNull(parsed.packageName)62-        assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)63-        assertTrue(parsed.verifiedDomains.isEmpty())64-    }65-66-    @Test fun pickerParsedFromInputs_carries_verified_domains_as_input_only() {67-        // verifiedDomains from the intent are passed through as an input to68-        // re-derivation, but originTrust is still UNKNOWN until69-        // authenticateAutofillOrigin runs.70-        val parsed = pickerParsedFromInputs(71-            origin = "example.com",72-            isWebOrigin = false,73-            callerPackage = null,74-            verifiedDomains = listOf("example.com", "cdn.example.com"),75-        )76-        assertEquals(listOf("example.com", "cdn.example.com"), parsed.verifiedDomains)77-        assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)78-    }79-80-    @Test fun rederived_trust_is_unknown_for_untrusted_package() {81-        // Simulates what authenticateAutofillOrigin does for an unknown package:82-        // the picker starts at UNKNOWN and stays UNKNOWN because the package is83-        // not in the trusted browser registry and has no verified app links.84-        val rawParsed = pickerParsedFromInputs(85-            origin = "example.com",86-            isWebOrigin = true,87-            callerPackage = "com.evil.browser",88-            verifiedDomains = emptyList(),89-        )90-        // authenticateAutofillOrigin with an unknown package would return:91-        val rederived = rawParsed.copy(92-            originTrust = AutofillOriginTrust.UNKNOWN,93-            verifiedDomains = emptyList(),94-        )95-        assertEquals(AutofillOriginTrust.UNKNOWN, rederived.originTrust)96-        assertTrue(rederived.verifiedDomains.isEmpty())97-    }98-}

view file at the parent

app/src/test/java/com/peachpasswords/autofill/TrustedBrowsersTest.kt+0 −213
@@ -1,213 +0,0 @@1-package com.peachpasswords.autofill2-3-import org.junit.Assert.assertFalse4-import org.junit.Assert.assertTrue5-import org.junit.Test6-7-/**8- * Locks the signer-set semantics for TrustedBrowsers.certificateSetMatches and9- * the fail-closed registry posture. These are pure JVM tests — no device or10- * emulator required.11- */12-class TrustedBrowsersTest {13-14-    // certificateSetMatches: current-match accept15-16-    @Test fun current_match_accepts_on_api28() {17-        assertTrue(18-            TrustedBrowsers.certificateSetMatches(19-                expected = setOf("aa", "bb"),20-                current = setOf("AA"),21-                history = emptySet(),22-                apiLevel = 28,23-            ),24-        )25-    }26-27-    @Test fun current_match_accepts_on_api26() {28-        assertTrue(29-            TrustedBrowsers.certificateSetMatches(30-                expected = setOf("aa"),31-                current = setOf("AA"),32-                history = emptySet(),33-                apiLevel = 26,34-            ),35-        )36-    }37-38-    // certificateSetMatches: history-only must NOT accept (API 28+)39-40-    @Test fun history_only_rejects_on_api28() {41-        // A package whose current signer is absent/unpinned but whose rotation42-        // history contains a pinned cert must be rejected. History alone43-        // cannot prove the current binary is authentic.44-        assertFalse(45-            "history-only match must not accept on API 28+",46-            TrustedBrowsers.certificateSetMatches(47-                expected = setOf("aa"),48-                current = setOf("cc"),49-                history = setOf("AA"),50-                apiLevel = 28,51-            ),52-        )53-    }54-55-    @Test fun history_only_with_empty_current_rejects_on_api28() {56-        assertFalse(57-            TrustedBrowsers.certificateSetMatches(58-                expected = setOf("aa"),59-                current = emptySet(),60-                history = setOf("AA"),61-                apiLevel = 28,62-            ),63-        )64-    }65-66-    // certificateSetMatches: no-match reject67-68-    @Test fun no_match_rejects() {69-        assertFalse(70-            TrustedBrowsers.certificateSetMatches(71-                expected = setOf("aa"),72-                current = setOf("bb"),73-                history = emptySet(),74-                apiLevel = 28,75-            ),76-        )77-    }78-79-    // certificateSetMatches: empty expected reject80-81-    @Test fun empty_expected_rejects() {82-        assertFalse(83-            TrustedBrowsers.certificateSetMatches(84-                expected = emptySet(),85-                current = setOf("aa"),86-                history = emptySet(),87-                apiLevel = 28,88-            ),89-        )90-    }91-92-    // certificateSetMatches: empty current reject93-94-    @Test fun empty_current_rejects_on_api28() {95-        assertFalse(96-            TrustedBrowsers.certificateSetMatches(97-                expected = setOf("aa"),98-                current = emptySet(),99-                history = emptySet(),100-                apiLevel = 28,101-            ),102-        )103-    }104-105-    @Test fun empty_current_rejects_on_api26() {106-        assertFalse(107-            TrustedBrowsers.certificateSetMatches(108-                expected = setOf("aa"),109-                current = emptySet(),110-                history = emptySet(),111-                apiLevel = 26,112-            ),113-        )114-    }115-116-    // certificateSetMatches: API 26/27 vs 28+ divergence117-    // On API 26/27, only current signers exist (no history parameter is used).118-    // On API 28+, history is available but must NOT be used for acceptance.119-120-    @Test fun api26_ignores_history_parameter() {121-        // Even if history is non-empty on API 26, only current matters.122-        // (In practice, history is always empty on API 26/27.)123-        assertFalse(124-            TrustedBrowsers.certificateSetMatches(125-                expected = setOf("aa"),126-                current = setOf("bb"),127-                history = setOf("AA"),128-                apiLevel = 26,129-            ),130-        )131-    }132-133-    @Test fun api27_current_match_accepts() {134-        assertTrue(135-            TrustedBrowsers.certificateSetMatches(136-                expected = setOf("aa"),137-                current = setOf("AA"),138-                history = emptySet(),139-                apiLevel = 27,140-            ),141-        )142-    }143-144-    // Registry provenance: all pins must be UNCONFIRMED145-146-    @Test fun all_registry_pins_are_unconfirmed() {147-        assertTrue(148-            "every registry entry must be UNCONFIRMED until provenance is recorded",149-            TrustedBrowsers.allPinsUnconfirmed(),150-        )151-    }152-153-    @Test fun registered_packages_include_expected_browsers() {154-        val packages = TrustedBrowsers.registeredPackages()155-        assertTrue(packages.contains("com.android.chrome"))156-        assertTrue(packages.contains("org.mozilla.firefox"))157-        assertTrue(packages.contains("com.brave.browser"))158-    }159-160-    // isTrusted returns false for every pinned package while unconfirmed161-162-    @Test fun no_package_is_trusted_while_all_pins_unconfirmed() {163-        // hasLocallyConfirmedPins must return false for every registered package.164-        for (pkg in TrustedBrowsers.registeredPackages()) {165-            assertFalse(166-                "$pkg must not be trusted while its pin is UNCONFIRMED",167-                TrustedBrowsers.hasLocallyConfirmedPins(pkg),168-            )169-        }170-    }171-172-    // hasExpectedCertificate: positive and negative cases173-174-    @Test fun hasExpectedCertificate_positive_for_chrome() {175-        assertTrue(176-            TrustedBrowsers.hasExpectedCertificate(177-                "com.android.chrome",178-                "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83",179-            ),180-        )181-    }182-183-    @Test fun hasExpectedCertificate_negative_for_unknown_package() {184-        assertFalse(185-            TrustedBrowsers.hasExpectedCertificate(186-                "com.evil.browser",187-                "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83",188-            ),189-        )190-    }191-192-    @Test fun hasExpectedCertificate_negative_for_wrong_hash() {193-        assertFalse(194-            TrustedBrowsers.hasExpectedCertificate(195-                "com.android.chrome",196-                "0000000000000000000000000000000000000000000000000000000000000000",197-            ),198-        )199-    }200-201-    // Case-insensitive matching202-203-    @Test fun certificateSetMatches_is_case_insensitive() {204-        assertTrue(205-            TrustedBrowsers.certificateSetMatches(206-                expected = setOf("AABBCC"),207-                current = setOf("aabbcc"),208-                history = emptySet(),209-                apiLevel = 28,210-            ),211-        )212-    }213-}

view file at the parent