899b79c9c90a

remove mutable app-label credential matching and bind native callers to package name (v2.14.80)

PAI-2026-001: an Android display label is presentation a developer chooses freely, so any sideloaded app can name itself after a vault entry and be offered it by Autofill and Credential Manager. Both automatic candidate paths (AutofillResponseBuilder.matchEntries and PasswordCredentialPlanner.candidates) and the Credential Manager revalidation gate now rank native callers on verified app-link domains plus an exact package-name association, never on the label. ParsedStructure.appTitle and AutofillOrigin.appLabel are gone. Package association is an exact url match, not domain matching: com.a.example.app and com.b.example.app both reduce to example.app under the public suffix list, so anything weaker would reintroduce the confusion. The Credential Manager create path now saves against the package name instead of the label, matching what the autofill save path already stored and keeping saved credentials reachable. Verification: four new regressions (label collision returns no row, exact-package match, package-vs-registrable-domain discrimination, duplicate rows collapse to one); full JVM suite 1022 tests / 0 failures / 0 errors / 2 skipped, up from 1018; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug and debug androidTest sources compile.

time
2026-09-01 00:05 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
resolve independent audit findings
parent
d382f5081a3d
9 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+1 −2app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+10 −20app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+22 −10app/src/main/java/com/peachpasswords/credentials/PasswordCredentialPlanner.kt+7 −5app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+7 −6app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+8 −13app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+24 −5app/src/test/java/com/peachpasswords/credentials/PasswordCredentialPlannerTest.kt+18 −4
VERSION+1 −1
@@ -1 +1 @@1-2.14.791+2.14.80\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+1 −2
@@ -11,7 +11,7 @@1111  *1212  * [webDomain] is untrusted form metadata. The service may use it only after1313  * verifying the framework-authenticated package and its signing identity.14- * [originDomains]/[appTitle] are populated by the service, not the parser.14+ * [originDomains] is populated by the service, not the parser.1515 */1616 data class ParsedStructure(1717     val usernameId: AutofillId? = null,@@ -28,7 +28,6 @@2828     val isNewPassword: Boolean = false,2929     val webDomain: String? = null,3030     val packageName: String? = null,31-    val appTitle: String? = null,3231     val originDomains: List<String> = emptyList(),3332     /** False when an app claimed a web origin without a verified browser identity. */3433     val credentialReleaseAllowed: Boolean = true,

view file

app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+10 −20
@@ -17,10 +17,15 @@1717  */1818 1919 /**20- * Populate [ParsedStructure.originDomains] and [ParsedStructure.appTitle] from21- * the authenticated origin. Verified browsers match on the page domain; native22- * apps match on their app-link domains and display label. Unverified web claims23- * produce no origin matches.20+ * Populate [ParsedStructure.originDomains] from the authenticated origin.21+ * Verified browsers match on the page domain; native apps match on their22+ * verified app-link domains. Unverified web claims produce no origin matches.23+ *24+ * An app's display label is never an origin: it is user-visible presentation a25+ * developer chooses freely, so any sideloaded app can claim the label of a26+ * vault entry (PAI-2026-001). Native apps without verified app-link domains27+ * therefore get no automatic rows and reach the vault through the picker's28+ * explicit search.2429  */2530 internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {2631     if (parsed.webDomain != null) {@@ -29,10 +34,7 @@2934             PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName),3035         )3136     }32-    return parsed.copy(33-        originDomains = appLinkDomains(context, parsed.packageName),34-        appTitle = appLabel(context, parsed.packageName),35-    )37+    return parsed.copy(originDomains = appLinkDomains(context, parsed.packageName))3638 }3739 3840 /** Preserve an unverified web claim as a deny decision instead of reinterpreting it as native. */@@ -44,27 +46,15 @@4446     return if (verifiedBrowser) {4547         parsed.copy(4648             originDomains = listOfNotNull(extractDomain(webDomain)),47-            appTitle = null,4849             credentialReleaseAllowed = true,4950         )5051     } else {5152         parsed.copy(5253             webDomain = null,5354             originDomains = emptyList(),54-            appTitle = null,5555             credentialReleaseAllowed = false,5656         )5757     }58-}59-60-/** The display label of the calling app, for matching entries by app title. */61-internal fun appLabel(context: Context, packageName: String?): String? {62-    if (packageName.isNullOrBlank()) return null63-    return runCatching {64-        context.packageManager.getApplicationLabel(65-            context.packageManager.getApplicationInfo(packageName, 0),66-        ).toString().takeIf { it.isNotBlank() }67-    }.getOrNull()6858 }6959 7060 /** A native app's verified app-link domains (Android's cached Digital Asset Links verdict). */

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+22 −10
@@ -47,19 +47,31 @@4747 4848     /**4949      * Rank entries for the already-resolved origin. The service verifies web50-     * caller identity before populating web domains; native apps use their51-     * verified app-link domains or display title. Blank origins match nothing.50+     * caller identity before populating web domains. Native callers match on51+     * their verified app-link domains plus an explicit package-name52+     * association — never on their display label, which any app can choose53+     * freely (PAI-2026-001). Blank origins match nothing.5254      */5355     fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {54-        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()55-        val byDomain = matchDomains(entries, domains).hits56-        val appTitle = parsed.appTitle?.trim()57-        val byTitle = if (appTitle.isNullOrBlank()) {58-            emptyList()59-        } else {60-            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }56+        val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits57+        val byPackage = nativePackageMatches(entries, parsed)58+        return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList())59+    }60+61+    /**62+     * An entry the user associated with this exact package — the save paths63+     * store the calling package as the entry's url. Exact match, not domain64+     * matching: two packages can share a registrable domain under the public65+     * suffix list (com.a.example.app and com.b.example.app both reduce to66+     * example.app), so anything weaker would re-open the confusion the label67+     * match had.68+     */69+    private fun nativePackageMatches(entries: List<VaultEntry>, parsed: ParsedStructure): List<VaultEntry> {70+        val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList()71+        if (parsed.webDomain != null) return emptyList()72+        return entries.filter { entry ->73+            entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName }6174         }62-        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())6375     }6476 6577     private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =

view file

app/src/main/java/com/peachpasswords/credentials/PasswordCredentialPlanner.kt+7 −5
@@ -13,7 +13,9 @@1313 /**1414  * Content-based matching for Credential Manager password rows — the same1515  * stance as autofill (AutofillResponseBuilder): registrable-domain matching16- * for web callers, app-link-domain / app-title matching for native callers.16+ * for web callers, verified app-link domains for native callers, and an exact17+ * package-name association for entries the user saved from this app. A native18+ * app's display label is never a match key (PAI-2026-001).1719  * No caller-trust gate (removed queue #3); the caller package itself is the1820  * framework-authenticated identity.1921  */@@ -22,7 +24,7 @@2224     fun candidates(2325         entries: List<VaultEntry>,2426         domains: Set<String>,25-        appTitle: String?,27+        packageName: String? = null,2628         allowedUserIds: Set<String>? = null,2729     ): List<PasswordCredentialCandidate> {2830         val eligible = entries.filter { it.fillable() }@@ -31,12 +33,12 @@3133         } else {3234             eligible.filter { entry -> domains.any { entry.matchesDomain(it) } }3335         }34-        val byTitle = if (appTitle.isNullOrBlank()) {36+        val byPackage = if (packageName.isNullOrBlank()) {3537             emptyList()3638         } else {37-            eligible.filter { it.title.trim().equals(appTitle, ignoreCase = true) }39+            eligible.filter { it.url == packageName || it.additionalUrls.orEmpty().any { url -> url == packageName } }3840         }39-        return (byDomain + byTitle)41+        return (byDomain + byPackage)4042             .distinctBy { it.id }4143             .mapNotNull { entry ->4244                 val username = entry.username ?: entry.email ?: return@mapNotNull null

view file

app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+7 −6
@@ -17,7 +17,6 @@1717 import com.peachpasswords.BuildConfig1818 import com.peachpasswords.autofill.AutofillSaveDraft1919 import com.peachpasswords.autofill.AutofillSavePlan20-import com.peachpasswords.autofill.appLabel2120 import com.peachpasswords.autofill.appLinkDomains2221 import com.peachpasswords.autofill.planAutofillSave2322 import com.peachpasswords.passkeys.PasskeyCallerOrigin@@ -99,7 +98,7 @@9998                     PasswordCredentialPlanner.candidates(10099                         listOf(entry),101100                         appLinkDomains(this, info.packageName).toSet(),102-                        appLabel(this, info.packageName),101+                        packageName = info.packageName,103102                     ).isNotEmpty()104103                 }105104             }@@ -159,8 +158,10 @@159158 160159     /**161160      * The save draft for a CM create request. Web callers save against their162-     * verified origin; native callers save against their display label — the163-     * same origin model the autofill save flow uses. No caller info -> null:161+     * verified origin; native callers save against their package name — the162+     * same origin model the autofill save flow uses, so a credential saved163+     * here is attributable to an immutable identity rather than to a display164+     * label any app can choose (PAI-2026-001). No caller info -> null:164165      * a credential with no attributable origin would be unmatchable filler.165166      */166167     private fun buildSaveDraft(info: CallingAppInfo?, id: String?, password: String?): AutofillSaveDraft? {@@ -172,8 +173,8 @@172173         if (!webOrigin.isNullOrBlank()) {173174             return AutofillSaveDraft(webOrigin, true, identity, identityIsEmail, password, null)174175         }175-        val title = appLabel(this, info.packageName) ?: return null176-        return AutofillSaveDraft(title, false, identity, identityIsEmail, password, null)176+        val packageName = info.packageName?.trim().takeUnless { it.isNullOrEmpty() } ?: return null177+        return AutofillSaveDraft(packageName, false, identity, identityIsEmail, password, null)177178     }178179 179180     companion object {

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+8 −13
@@ -24,7 +24,6 @@2424 import androidx.credentials.exceptions.GetCredentialException2525 import androidx.annotation.RequiresApi2626 import com.peachpasswords.R27-import com.peachpasswords.autofill.appLabel2827 import com.peachpasswords.autofill.appLinkDomains2928 import com.peachpasswords.credentials.PasswordCredentialPlanner3029 import com.peachpasswords.credentials.PasswordCredentialSelectionActivity@@ -123,9 +122,9 @@123122     /**124123      * Password rows for the system credential picker. Matching mirrors125124      * autofill: privileged-browser callers match on the web origin's126-     * registrable domain; native callers match on their app-link domains and127-     * display label. No trust gate — the caller package is framework-128-     * authenticated (queue #3 stance).125+     * registrable domain; native callers match on their verified app-link126+     * domains. No trust gate — the caller package is framework-authenticated127+     * (queue #3 stance).129128      */130129     private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {131130         val option = request.beginGetCredentialOptions@@ -135,21 +134,17 @@135134 136135         val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)137136         val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()138-        val domains: Set<String>139-        val appTitle: String?140-        if (!webOrigin.isNullOrBlank()) {141-            domains = setOfNotNull(extractDomain(webOrigin))142-            appTitle = null137+        val domains = if (!webOrigin.isNullOrBlank()) {138+            setOfNotNull(extractDomain(webOrigin))143139         } else {144-            domains = appLinkDomains(this, info.packageName).toSet()145-            appTitle = appLabel(this, info.packageName)140+            appLinkDomains(this, info.packageName).toSet()146141         }147142 148143         val candidates = PasswordCredentialPlanner.candidates(149144             vault.getEntries(),150145             domains,151-            appTitle,152-            option.allowedUserIds,146+            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },147+            allowedUserIds = option.allowedUserIds,153148         )154149         if (candidates.isEmpty()) return emptyList()155150 

view file

app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+24 −5
@@ -54,18 +54,37 @@5454         assertEquals(listOf(peach), autofillPickerEntries(listOf(peach), parsed, limit = 12))5555     }5656 57-    @Test fun picker_native_origin_matches_app_link_domains_and_title() {57+    @Test fun picker_native_origin_matches_app_link_domains_and_associated_package() {5858         val byDomain = entry("Native", "https://example.com/login")59-        val byTitle = entry("example app", "https://unrelated.test")59+        val associated = entry("Saved here", "com.example.app")6060         val other = entry("Other", "https://other.example.net")6161         val parsed = ParsedStructure(6262             packageName = "com.example.app",63-            appTitle = "Example App",6463             originDomains = listOf("example.com"),6564         )6665         assertEquals(67-            listOf(byDomain, byTitle),68-            autofillPickerEntries(listOf(byDomain, byTitle, other), parsed, limit = 12),66+            listOf(byDomain, associated),67+            autofillPickerEntries(listOf(byDomain, associated, other), parsed, limit = 12),68+        )69+    }70+71+    /** PAI-2026-001: a display label is presentation, not identity. */72+    @Test fun native_app_label_never_selects_a_credential() {73+        val bank = entry("Bank", "https://real-bank.test/login")74+        val parsed = ParsedStructure(packageName = "com.attacker.app")75+        assertTrue(76+            "a sideloaded app named after a vault entry must not be offered it",77+            autofillPickerEntries(listOf(bank), parsed, limit = 12).isEmpty(),78+        )79+    }80+81+    @Test fun associated_package_match_is_exact_not_registrable_domain() {82+        val mine = entry("Mine", "com.a.example.app")83+        val theirs = entry("Theirs", "com.b.example.app")84+        val parsed = ParsedStructure(packageName = "com.a.example.app")85+        assertEquals(86+            listOf(mine),87+            autofillPickerEntries(listOf(mine, theirs), parsed, limit = 12),6988         )7089     }7190 

view file

app/src/test/java/com/peachpasswords/credentials/PasswordCredentialPlannerTest.kt+18 −4
@@ -49,12 +49,26 @@4949         assertEquals(1, hits.size)5050     }5151 52-    @Test fun native_caller_matches_by_app_title() {52+    /** PAI-2026-001: an app's display label is not a credential origin. */53+    @Test fun native_caller_does_not_match_by_app_title() {5354         val netflix = entry("Netflix", null)5455         val hits = PasswordCredentialPlanner.candidates(listOf(netflix), emptySet(), "Netflix")55-        assertEquals(listOf("Netflix"), hits.map { it.title })56+        assertTrue("a mutable app label must never select a credential", hits.isEmpty())5657     }5758 59+    @Test fun native_caller_matches_an_entry_associated_with_its_package() {60+        val saved = entry("Saved here", "com.example.app")61+        val hits = PasswordCredentialPlanner.candidates(listOf(saved), emptySet(), "com.example.app")62+        assertEquals(listOf("Saved here"), hits.map { it.title })63+    }64+65+    @Test fun associated_package_match_is_exact_not_registrable_domain() {66+        val mine = entry("Mine", "com.a.example.app")67+        val theirs = entry("Theirs", "com.b.example.app")68+        val hits = PasswordCredentialPlanner.candidates(listOf(mine, theirs), emptySet(), "com.a.example.app")69+        assertEquals(listOf("Mine"), hits.map { it.title })70+    }71+5872     @Test fun entries_without_password_are_never_offered() {5973         val notes = entry("Notes", "https://example.com", password = null)6074         val blank = entry("Blank", "https://example.com", password = "  ")@@ -99,10 +113,10 @@99113         assertEquals(listOf("fresh", "old"), hits.map { it.username })100114     }101115 102-    @Test fun domain_and_title_matches_deduplicate_to_one_row() {116+    @Test fun duplicate_entries_yield_one_row() {103117         val peach = entry("Peach", "https://peachpasswords.com")104118         val hits = PasswordCredentialPlanner.candidates(105-            listOf(peach), setOf("peachpasswords.com"), "Peach",119+            listOf(peach, peach), setOf("peachpasswords.com"),106120         )107121         assertEquals(1, hits.size)108122     }

view file