PasskeyCallerOrigin.kt

5.0 KB · raw

1package com.peachpasswords.passkeys23import android.content.Context4import android.os.Build5import androidx.credentials.provider.CallingAppInfo6import com.peachpasswords.R7import com.peachpasswords.shared.extractDomain8import java.net.URI9import java.security.MessageDigest10import java.util.Base6411import java.util.Locale1213/**14 * F1/F2 (2026-08-03 security scan): passkey ceremonies are bound to the15 * verified caller. A credential is only released to a caller whose origin the16 * framework actually verified, and clientDataJSON never carries a synthesized17 * origin — the old `https://<rpId>` fallback is gone.18 */19object PasskeyCallerOrigin {20    const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:"2122    @Volatile private var cachedAllowlist: String? = null2324    /**25     * Google's privileged-app allowlist, snapshotted in26     * res/raw/passkey_privileged_apps from27     * https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json28     * (fetched 2026-08-03). Refresh by re-downloading that file when browsers29     * rotate signing certs; offline delivery matters more than live refresh.30     */31    fun privilegedAllowlist(context: Context): String {32        cachedAllowlist?.let { return it }33        val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps)34            .bufferedReader()35            .use { it.readText() }36        cachedAllowlist = loaded37        return loaded38    }3940    /**41     * The verified origin of a credential-provider caller:42     * - the web origin, only when the framework confirms the caller is a43     *   privileged (allowlisted) browser — getOrigin throws for callers that44     *   claim an origin but are not on the allowlist, so an untrusted caller45     *   never gets its claimed origin honored here46     * - otherwise the WebAuthn L3 native-app origin derived from the caller's47     *   signing certificate48     * - otherwise null — the caller cannot be identified; callers fail closed.49     */50    fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {51        val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()52        if (!webOrigin.isNullOrBlank()) return webOrigin53        // SigningInfo.getApkContentsSigners requires API 28 (minSdk 26). Passkey54        // callers only exist on 34+, but fail closed — never crash — if this is55        // ever reached on an older platform.56        if (Build.VERSION.SDK_INT < 28) return null57        val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()58        return signer?.let { apkKeyHashOrigin(it.toByteArray()) }59    }6061    /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:<b64url(sha256(cert))>. */62    fun apkKeyHashOrigin(certificateBytes: ByteArray): String =63        APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()64            .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))6566    /**67     * F1 gate: may a stored credential be released to this verified caller68     * origin?69     * - web callers (framework-verified via the privileged allowlist): the70     *   origin's registrable domain must match the credential's rpId —71     *   exactly as rpId semantics intend, across subdomains and browsers —72     *   unless the credential is bound to a native app, in which case only73     *   that app may use it.74     * - native-app callers (apk-key-hash origin): only credentials bound to75     *   the same origin at registration. Legacy credentials are never76     *   released to native callers because an app's signing hash cannot prove77     *   any relationship to the rpId.78     * - anything else: no.79     */80    fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =81        when {82            isWebOrigin(callerOrigin) ->83                rpIdMatchesOrigin(rpId, callerOrigin) &&84                    (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))85            storedCallerOrigin != null -> storedCallerOrigin == callerOrigin86            else -> false87        }8889    fun isWebOrigin(origin: String): Boolean =90        origin.startsWith("https://", ignoreCase = true) ||91            origin.startsWith("http://", ignoreCase = true)9293    /**94     * WebAuthn effective-domain check: rpId must equal the origin's host or95     * be a parent-domain suffix of it, and both must share a registrable96     * domain (the PSL guard rejects rpIds that are public suffixes, e.g.97     * "co.uk").98     */99    fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {100        if (!isWebOrigin(origin)) return false101        val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false102        val rp = rpId.trim().lowercase(Locale.ROOT)103        if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false104        val originDomain = extractDomain(origin) ?: return false105        val rpDomain = extractDomain(rp) ?: return false106        return originDomain == rpDomain107    }108}