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