PasskeyCallerOrigin.kt

4.7 KB · raw

1package com.peachpasswords.passkeys23import android.content.Context4import androidx.credentials.provider.CallingAppInfo5import com.peachpasswords.R6import com.peachpasswords.shared.extractDomain7import java.net.URI8import java.security.MessageDigest9import java.util.Base6410import java.util.Locale1112/**13 * F1/F2 (2026-08-03 security scan): passkey ceremonies are bound to the14 * verified caller. A credential is only released to a caller whose origin the15 * framework actually verified, and clientDataJSON never carries a synthesized16 * origin — the old `https://<rpId>` fallback is gone.17 */18object PasskeyCallerOrigin {19    const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:"2021    @Volatile private var cachedAllowlist: String? = null2223    /**24     * Google's privileged-app allowlist, snapshotted in25     * res/raw/passkey_privileged_apps from26     * https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json27     * (fetched 2026-08-03). Refresh by re-downloading that file when browsers28     * rotate signing certs; offline delivery matters more than live refresh.29     */30    fun privilegedAllowlist(context: Context): String {31        cachedAllowlist?.let { return it }32        val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps)33            .bufferedReader()34            .use { it.readText() }35        cachedAllowlist = loaded36        return loaded37    }3839    /**40     * The verified origin of a credential-provider caller:41     * - the web origin, only when the framework confirms the caller is a42     *   privileged (allowlisted) browser — getOrigin throws for callers that43     *   claim an origin but are not on the allowlist, so an untrusted caller44     *   never gets its claimed origin honored here45     * - otherwise the WebAuthn L3 native-app origin derived from the caller's46     *   signing certificate47     * - otherwise null — the caller cannot be identified; callers fail closed.48     */49    fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {50        val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()51        if (!webOrigin.isNullOrBlank()) return webOrigin52        val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()53        return signer?.let { apkKeyHashOrigin(it.toByteArray()) }54    }5556    /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:<b64url(sha256(cert))>. */57    fun apkKeyHashOrigin(certificateBytes: ByteArray): String =58        APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()59            .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))6061    /**62     * F1 gate: may a stored credential be released to this verified caller63     * origin?64     * - web callers (framework-verified via the privileged allowlist): the65     *   origin's registrable domain must match the credential's rpId —66     *   exactly as rpId semantics intend, across subdomains and browsers —67     *   unless the credential is bound to a native app, in which case only68     *   that app may use it.69     * - native-app callers (apk-key-hash origin): only credentials bound to70     *   the same origin at registration. Legacy credentials are never71     *   released to native callers because an app's signing hash cannot prove72     *   any relationship to the rpId.73     * - anything else: no.74     */75    fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =76        when {77            isWebOrigin(callerOrigin) ->78                rpIdMatchesOrigin(rpId, callerOrigin) &&79                    (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))80            storedCallerOrigin != null -> storedCallerOrigin == callerOrigin81            else -> false82        }8384    fun isWebOrigin(origin: String): Boolean =85        origin.startsWith("https://", ignoreCase = true) ||86            origin.startsWith("http://", ignoreCase = true)8788    /**89     * WebAuthn effective-domain check: rpId must equal the origin's host or90     * be a parent-domain suffix of it, and both must share a registrable91     * domain (the PSL guard rejects rpIds that are public suffixes, e.g.92     * "co.uk").93     */94    fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {95        if (!isWebOrigin(origin)) return false96        val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false97        val rp = rpId.trim().lowercase(Locale.ROOT)98        if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false99        val originDomain = extractDomain(origin) ?: return false100        val rpDomain = extractDomain(rp) ?: return false101        return originDomain == rpDomain102    }103}