TrustedBrowsers.kt

8.6 KB · raw

1package com.peachpasswords.autofill23import android.content.Context4import android.content.pm.PackageManager5import android.content.pm.verify.domain.DomainVerificationManager6import android.content.pm.verify.domain.DomainVerificationUserState7import android.os.Build8import com.peachpasswords.shared.extractDomain9import java.security.MessageDigest1011/**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 */19object TrustedBrowsers {20    private enum class PinProvenance { UNCONFIRMED, CONFIRMED }2122    private data class BrowserCertificatePins(23        val hashes: Set<String>,24        val provenance: PinProvenance,25    )2627    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"3334    // 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    )6465    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    }9596    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        }103104    /**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    }127128    internal fun hasLocallyConfirmedPins(packageName: String?): Boolean =129        CERTIFICATE_REGISTRY[packageName]?.provenance == PinProvenance.CONFIRMED130131    /** All registered package names — exposed for test assertions. */132    internal fun registeredPackages(): Set<String> = CERTIFICATE_REGISTRY.keys133134    /** 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 }137138    internal fun hasExpectedCertificate(packageName: String?, certificateSha256: String): Boolean =139        certificateSha256.lowercase() in CERTIFICATE_REGISTRY[packageName]?.hashes.orEmpty()140141    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}146147internal 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    }174175    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}185186/** Uses Android's cached Digital Asset Links verdict; older Android versions fail closed. */187internal 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}