899b79c9c90a
remove mutable app-label credential matching and bind native callers to package name (v2.14.80)
- time
- 2026-09-01 00:05 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- resolve independent audit findings
- parent
- d382f5081a3d
↓ peach-android-899b79c9.tar.gzatlas at this save
files changed9 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 −4app/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,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). */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 =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 nullapp/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 {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 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 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 }