PasskeyCallerOrigin.kt

8.1 KB · raw

1package com.peachpasswords.passkeys23import android.content.Context4import android.content.pm.PackageManager5import android.os.Build6import androidx.credentials.provider.CallingAppInfo7import com.peachpasswords.R8import com.peachpasswords.shared.extractDomain9import org.json.JSONObject10import java.net.URI11import java.security.MessageDigest12import java.util.Base6413import java.util.Locale1415/**16 * F1/F2 (2026-08-03 security scan): passkey ceremonies are bound to the17 * verified caller. A credential is only released to a caller whose origin the18 * framework actually verified, and clientDataJSON never carries a synthesized19 * origin — the old `https://<rpId>` fallback is gone.20 */21object PasskeyCallerOrigin {22    const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:"2324    @Volatile private var cachedAllowlist: String? = null2526    /**27     * Google's privileged-app allowlist, snapshotted in28     * res/raw/passkey_privileged_apps from29     * https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json30     * (fetched 2026-08-03). Refresh by re-downloading that file when browsers31     * rotate signing certs; offline delivery matters more than live refresh.32     */33    fun privilegedAllowlist(context: Context): String {34        cachedAllowlist?.let { return it }35        val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps)36            .bufferedReader()37            .use { it.readText() }38        cachedAllowlist = loaded39        return loaded40    }4142    /**43     * Autofill has no CallingAppInfo object, so its framework-authenticated44     * AssistStructure activity package must be checked against the same signed45     * browser allowlist used by passkeys before a ViewNode webDomain is useful.46     */47    fun isVerifiedBrowser(context: Context, packageName: String?): Boolean {48        if (Build.VERSION.SDK_INT < 28 || packageName.isNullOrBlank()) return false49        val signingInfo = runCatching {50            context.packageManager.getPackageInfo(51                packageName,52                PackageManager.GET_SIGNING_CERTIFICATES,53            ).signingInfo54        }.getOrNull() ?: return false55        val signers = if (signingInfo.hasMultipleSigners()) {56            signingInfo.apkContentsSigners57        } else {58            signingInfo.signingCertificateHistory59        }60        return isAllowlistedBrowser(61            packageName = packageName,62            certificateFingerprints = signers.map { certificateFingerprint(it.toByteArray()) }.toSet(),63            allowlistJson = privilegedAllowlist(context),64        )65    }6667    /** Current package signing certificates, retaining rotation history. */68    fun signerFingerprints(context: Context, packageName: String): Set<String> {69        if (Build.VERSION.SDK_INT < 28) return emptySet()70        val signingInfo = runCatching {71            context.packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo72        }.getOrNull() ?: return emptySet()73        return signingInfo.signingCertificateHistory.map { certificateFingerprint(it.toByteArray()) }.toSet()74    }7576    /** Pure allowlist predicate so the package-and-signer boundary is unit-testable. */77    internal fun isAllowlistedBrowser(78        packageName: String,79        certificateFingerprints: Set<String>,80        allowlistJson: String,81    ): Boolean {82        val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false83        for (index in 0 until apps.length()) {84            val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue85            if (app.optString("package_name") != packageName) continue86            val signatures = app.optJSONArray("signatures") ?: return false87            return (0 until signatures.length()).any { signatureIndex ->88                val expected = signatures.optJSONObject(signatureIndex)89                    ?.optString("cert_fingerprint_sha256")90                    ?.uppercase(Locale.ROOT)91                expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected }92            }93        }94        return false95    }9697    internal fun certificateFingerprint(certificateBytes: ByteArray): String =98        MessageDigest.getInstance("SHA-256").digest(certificateBytes)99            .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) }100101    /**102     * The verified origin of a credential-provider caller:103     * - the web origin, only when the framework confirms the caller is a104     *   privileged (allowlisted) browser — getOrigin throws for callers that105     *   claim an origin but are not on the allowlist, so an untrusted caller106     *   never gets its claimed origin honored here107     * - otherwise the WebAuthn L3 native-app origin derived from the caller's108     *   signing certificate109     * - otherwise null — the caller cannot be identified; callers fail closed.110     */111    fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {112        val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()113        if (!webOrigin.isNullOrBlank()) return webOrigin114        // SigningInfo.getApkContentsSigners requires API 28 (minSdk 26). Passkey115        // callers only exist on 34+, but fail closed — never crash — if this is116        // ever reached on an older platform.117        if (Build.VERSION.SDK_INT < 28) return null118        val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()119        return signer?.let { apkKeyHashOrigin(it.toByteArray()) }120    }121122    /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:<b64url(sha256(cert))>. */123    fun apkKeyHashOrigin(certificateBytes: ByteArray): String =124        APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()125            .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))126127    /**128     * F1 gate: may a stored credential be released to this verified caller129     * origin?130     * - web callers (framework-verified via the privileged allowlist): the131     *   origin's registrable domain must match the credential's rpId —132     *   exactly as rpId semantics intend, across subdomains and browsers —133     *   unless the credential is bound to a native app, in which case only134     *   that app may use it.135     * - native-app callers (apk-key-hash origin): only credentials bound to136     *   the same origin at registration. Legacy credentials are never137     *   released to native callers because an app's signing hash cannot prove138     *   any relationship to the rpId.139     * - anything else: no.140     */141    fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =142        when {143            isPlaintextWebOrigin(callerOrigin) -> false144            isWebOrigin(callerOrigin) ->145                rpIdMatchesOrigin(rpId, callerOrigin) &&146                    (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))147            storedCallerOrigin != null -> storedCallerOrigin == callerOrigin148            else -> false149        }150151    internal fun isPlaintextWebOrigin(origin: String): Boolean =152        runCatching { URI(origin).scheme.equals("http", ignoreCase = true) }.getOrDefault(false)153154    fun isWebOrigin(origin: String): Boolean =155        runCatching { URI(origin).scheme.equals("https", ignoreCase = true) }.getOrDefault(false)156157    /**158     * WebAuthn effective-domain check: rpId must equal the origin's host or159     * be a parent-domain suffix of it, and both must share a registrable160     * domain (the PSL guard rejects rpIds that are public suffixes, e.g.161     * "co.uk").162     */163    fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {164        if (!isWebOrigin(origin)) return false165        val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false166        val rp = rpId.trim().lowercase(Locale.ROOT)167        if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false168        val originDomain = extractDomain(origin) ?: return false169        val rpDomain = extractDomain(rp) ?: return false170        return originDomain == rpDomain171    }172}