PasskeyCallerOrigin.kt

7.4 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    /** Pure allowlist predicate so the package-and-signer boundary is unit-testable. */68    internal fun isAllowlistedBrowser(69        packageName: String,70        certificateFingerprints: Set<String>,71        allowlistJson: String,72    ): Boolean {73        val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false74        for (index in 0 until apps.length()) {75            val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue76            if (app.optString("package_name") != packageName) continue77            val signatures = app.optJSONArray("signatures") ?: return false78            return (0 until signatures.length()).any { signatureIndex ->79                val expected = signatures.optJSONObject(signatureIndex)80                    ?.optString("cert_fingerprint_sha256")81                    ?.uppercase(Locale.ROOT)82                expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected }83            }84        }85        return false86    }8788    internal fun certificateFingerprint(certificateBytes: ByteArray): String =89        MessageDigest.getInstance("SHA-256").digest(certificateBytes)90            .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) }9192    /**93     * The verified origin of a credential-provider caller:94     * - the web origin, only when the framework confirms the caller is a95     *   privileged (allowlisted) browser — getOrigin throws for callers that96     *   claim an origin but are not on the allowlist, so an untrusted caller97     *   never gets its claimed origin honored here98     * - otherwise the WebAuthn L3 native-app origin derived from the caller's99     *   signing certificate100     * - otherwise null — the caller cannot be identified; callers fail closed.101     */102    fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {103        val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()104        if (!webOrigin.isNullOrBlank()) return webOrigin105        // SigningInfo.getApkContentsSigners requires API 28 (minSdk 26). Passkey106        // callers only exist on 34+, but fail closed — never crash — if this is107        // ever reached on an older platform.108        if (Build.VERSION.SDK_INT < 28) return null109        val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()110        return signer?.let { apkKeyHashOrigin(it.toByteArray()) }111    }112113    /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:<b64url(sha256(cert))>. */114    fun apkKeyHashOrigin(certificateBytes: ByteArray): String =115        APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()116            .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))117118    /**119     * F1 gate: may a stored credential be released to this verified caller120     * origin?121     * - web callers (framework-verified via the privileged allowlist): the122     *   origin's registrable domain must match the credential's rpId —123     *   exactly as rpId semantics intend, across subdomains and browsers —124     *   unless the credential is bound to a native app, in which case only125     *   that app may use it.126     * - native-app callers (apk-key-hash origin): only credentials bound to127     *   the same origin at registration. Legacy credentials are never128     *   released to native callers because an app's signing hash cannot prove129     *   any relationship to the rpId.130     * - anything else: no.131     */132    fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =133        when {134            isWebOrigin(callerOrigin) ->135                rpIdMatchesOrigin(rpId, callerOrigin) &&136                    (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))137            storedCallerOrigin != null -> storedCallerOrigin == callerOrigin138            else -> false139        }140141    fun isWebOrigin(origin: String): Boolean =142        origin.startsWith("https://", ignoreCase = true) ||143            origin.startsWith("http://", ignoreCase = true)144145    /**146     * WebAuthn effective-domain check: rpId must equal the origin's host or147     * be a parent-domain suffix of it, and both must share a registrable148     * domain (the PSL guard rejects rpIds that are public suffixes, e.g.149     * "co.uk").150     */151    fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {152        if (!isWebOrigin(origin)) return false153        val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false154        val rp = rpId.trim().lowercase(Locale.ROOT)155        if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false156        val originDomain = extractDomain(origin) ?: return false157        val rpDomain = extractDomain(rp) ?: return false158        return originDomain == rpDomain159    }160}