431bdd9b44f6
feat: remove autofill origin verification — match by domain + app title (v2.10.7)
- time
- 2026-08-02 23:12 UTC · 2 months ago
- machine
- koh2
- parent
- bb109f184adb
↓ peach-android-431bdd9b.tar.gzatlas at this save
files changed13 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 −213app/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,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,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 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+}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 =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()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-}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 }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() {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]"),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-}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-}