package com.peachpasswords.autofill import android.content.Context import android.content.pm.PackageManager import android.content.pm.verify.domain.DomainVerificationManager import android.content.pm.verify.domain.DomainVerificationUserState import android.os.Build import com.peachpasswords.shared.extractDomain import java.security.MessageDigest /** * Browsers allowed to assert a web origin, pinned to their release * certificates. The fingerprints below were inherited from the audit patch but * have no checked-in upstream provenance in this repository. They are retained * as an explicit UNCONFIRMED registry and are therefore never trusted until a * release process records and flips the provenance to CONFIRMED. Unknown and * unproven packages fail closed. */ object TrustedBrowsers { private enum class PinProvenance { UNCONFIRMED, CONFIRMED } private data class BrowserCertificatePins( val hashes: Set, val provenance: PinProvenance, ) private const val GOOGLE_RELEASE_CERT = "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83" private const val MOZILLA_RELEASE_CERT = "a78b62a5165b4494b2fead9e76a280d22d937fee6251aece599446b2ea319b04" private const val BRAVE_RELEASE_CERT = "9c2db70513515fdbfbbc585b3edf3d7123d4dc67c94ffd306361c1d79bbf18ac" // Unknown browsers still reach the guarded manual picker; they never receive // credentials merely because their package name resembles a supported browser. // Do not flip UNCONFIRMED to CONFIRMED without a locally recorded official // release-certificate source and a test vector for that exact package. private val CERTIFICATE_REGISTRY = mapOf( "com.android.chrome" to BrowserCertificatePins( hashes = setOf(GOOGLE_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), "com.chrome.beta" to BrowserCertificatePins( hashes = setOf(GOOGLE_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), "com.chrome.dev" to BrowserCertificatePins( hashes = setOf(GOOGLE_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), "org.mozilla.firefox" to BrowserCertificatePins( hashes = setOf(MOZILLA_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), "org.mozilla.firefox_beta" to BrowserCertificatePins( hashes = setOf(MOZILLA_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), "com.brave.browser" to BrowserCertificatePins( hashes = setOf(BRAVE_RELEASE_CERT), provenance = PinProvenance.UNCONFIRMED, ), ) fun isTrusted(context: Context, packageName: String?): Boolean { val packageName = packageName ?: return false val pins = CERTIFICATE_REGISTRY[packageName] ?: return false // A package entry without locally verified provenance is not an // allowlist entry yet. This deliberately trades browser convenience for // fail-closed security until release metadata is supplied. if (pins.provenance != PinProvenance.CONFIRMED) return false return runCatching { val info = packageInfo(context.packageManager, packageName) ?: return@runCatching false val current = mutableSetOf() val history = mutableSetOf() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val signingInfo = info.signingInfo ?: return@runCatching false signingInfo.apkContentsSigners.orEmpty() .mapTo(current) { sha256(it.toByteArray()) } signingInfo.signingCertificateHistory.orEmpty() .mapTo(history) { sha256(it.toByteArray()) } } else { @Suppress("DEPRECATION") info.signatures.orEmpty().mapTo(current) { sha256(it.toByteArray()) } } certificateSetMatches( expected = pins.hashes, current = current, history = history, apiLevel = Build.VERSION.SDK_INT, ) }.getOrDefault(false) } private fun packageInfo(packageManager: PackageManager, packageName: String) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) } else { @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) } /** * Accept only when a CURRENT signer matches the pinned certificate. * * API 26/27: only current signers are available via GET_SIGNATURES. * API 28+ (P): [SigningInfo.getApkContentsSigners] is the authoritative * current signer set; [SigningInfo.getSigningCertificateHistory] is * rotation history. We accept only on a current match — a package whose * current signer is absent or unpinned is rejected even if a historical * cert matches, because rotation history alone cannot prove the current * binary is authentic. Empty expected, empty current, or null * [SigningInfo] all fail closed. */ internal fun certificateSetMatches( expected: Set, current: Set, history: Set, apiLevel: Int, ): Boolean { if (expected.isEmpty()) return false if (current.isEmpty()) return false val normalized = expected.map(String::lowercase) return current.any { it.lowercase() in normalized } } internal fun hasLocallyConfirmedPins(packageName: String?): Boolean = CERTIFICATE_REGISTRY[packageName]?.provenance == PinProvenance.CONFIRMED /** All registered package names — exposed for test assertions. */ internal fun registeredPackages(): Set = CERTIFICATE_REGISTRY.keys /** Whether every registry entry is still UNCONFIRMED — locks the fail-closed decision. */ internal fun allPinsUnconfirmed(): Boolean = CERTIFICATE_REGISTRY.values.all { it.provenance == PinProvenance.UNCONFIRMED } internal fun hasExpectedCertificate(packageName: String?, certificateSha256: String): Boolean = certificateSha256.lowercase() in CERTIFICATE_REGISTRY[packageName]?.hashes.orEmpty() private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } } internal fun authenticateAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure { val claimedWebDomain = parsed.webDomain if (claimedWebDomain != null) { val domain = extractDomain(claimedWebDomain) ?: return parsed.copy( originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = emptyList(), ) if (TrustedBrowsers.isTrusted(context, parsed.packageName)) { return parsed.copy( originTrust = AutofillOriginTrust.TRUSTED_WEB, verifiedDomains = listOf(domain), ) } val appDomains = verifiedAppLinkDomains(context, parsed.packageName) return if (domain in appDomains) { parsed.copy( originTrust = AutofillOriginTrust.TRUSTED_NATIVE, verifiedDomains = listOf(domain), ) } else { parsed.copy( originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = emptyList(), ) } } val appDomains = verifiedAppLinkDomains(context, parsed.packageName) return if (appDomains.isEmpty()) { parsed.copy(originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = emptyList()) } else { parsed.copy( originTrust = AutofillOriginTrust.TRUSTED_NATIVE, verifiedDomains = appDomains, ) } } /** Uses Android's cached Digital Asset Links verdict; older Android versions fail closed. */ internal fun verifiedAppLinkDomains(context: Context, packageName: String?): List { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) { return emptyList() } return runCatching { val state = context.getSystemService(DomainVerificationManager::class.java) ?.getDomainVerificationUserState(packageName) state?.hostToStateMap.orEmpty() .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED } .keys .mapNotNull { extractDomain(it.removePrefix("*.")) } .distinct() .sorted() }.getOrDefault(emptyList()) }