AutofillOrigin.kt

3.8 KB · raw

1package com.peachpasswords.autofill23import android.content.Context4import android.content.pm.verify.domain.DomainVerificationManager5import android.content.pm.verify.domain.DomainVerificationUserState6import android.os.Build7import com.peachpasswords.shared.extractDomain8import com.peachpasswords.passkeys.PasskeyCallerOrigin9import com.peachpasswords.vault.VaultEntry1011/**12 * Origin resolution for autofill matching.13 *14 * The caller package comes from the framework-owned activity component. A raw15 * ViewNode.webDomain is only accepted when that package is a signed browser in16 * the same allowlist used by passkeys; otherwise the request fails closed.17 */1819/**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.29 */30internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {31    if (parsed.webDomain != null) {32        return applyWebOriginVerification(33            parsed,34            PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName),35        )36    }37    return parsed.copy(38        nativeSignerDigests = parsed.packageName?.let { PasskeyCallerOrigin.signerFingerprints(context, it) }.orEmpty(),39        originDomains = appLinkDomains(context, parsed.packageName),40    )41}4243/** Preserve an unverified web claim as a deny decision instead of reinterpreting it as native. */44internal fun applyWebOriginVerification(45    parsed: ParsedStructure,46    verifiedBrowser: Boolean,47): ParsedStructure {48    val webDomain = parsed.webDomain ?: return parsed49    return if (verifiedBrowser) {50        parsed.copy(51            originDomains = listOfNotNull(extractDomain(webDomain)),52            credentialReleaseAllowed = true,53        )54    } else {55        parsed.copy(56            webDomain = null,57            originDomains = emptyList(),58            credentialReleaseAllowed = false,59        )60    }61}6263/** A native app's verified app-link domains (Android's cached Digital Asset Links verdict). */64internal fun appLinkDomains(context: Context, packageName: String?): List<String> {65    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) {66        return emptyList()67    }68    return runCatching {69        val state = context.getSystemService(DomainVerificationManager::class.java)70            ?.getDomainVerificationUserState(packageName)71        state?.hostToStateMap.orEmpty()72            .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED }73            .keys74            .mapNotNull { extractDomain(it.removePrefix("*.")) }75            .distinct()76            .sorted()77    }.getOrDefault(emptyList())78}7980/**81 * Domains to offer to "remember" when the user manually picks [entry] for this82 * origin (Android counterpart of the browser's remember-this-site toast).83 * Mirrors the browser trigger: only offer when the entry already carries at84 * least one known domain and none of them cover this origin. Empty = no offer.85 */86internal fun rememberOfferDomains(entry: VaultEntry, parsed: ParsedStructure): List<String> {87    val existing = sequenceOf(entry.url)88        .plus(entry.additionalUrls.orEmpty().asSequence())89        .filterNotNull()90        .mapNotNull(::extractDomain)91        .toSet()92    if (existing.isEmpty() || parsed.originDomains.isEmpty()) return emptyList()93    return parsed.originDomains.filter { it !in existing }94}