PasskeyCallerOrigin.kt
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}