package com.peachpasswords.passkeys import android.content.Context import androidx.credentials.provider.CallingAppInfo import com.peachpasswords.R import com.peachpasswords.shared.extractDomain import java.net.URI import java.security.MessageDigest import java.util.Base64 import java.util.Locale /** * F1/F2 (2026-08-03 security scan): passkey ceremonies are bound to the * verified caller. A credential is only released to a caller whose origin the * framework actually verified, and clientDataJSON never carries a synthesized * origin — the old `https://` fallback is gone. */ object PasskeyCallerOrigin { const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:" @Volatile private var cachedAllowlist: String? = null /** * Google's privileged-app allowlist, snapshotted in * res/raw/passkey_privileged_apps from * https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json * (fetched 2026-08-03). Refresh by re-downloading that file when browsers * rotate signing certs; offline delivery matters more than live refresh. */ fun privilegedAllowlist(context: Context): String { cachedAllowlist?.let { return it } val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps) .bufferedReader() .use { it.readText() } cachedAllowlist = loaded return loaded } /** * The verified origin of a credential-provider caller: * - the web origin, only when the framework confirms the caller is a * privileged (allowlisted) browser — getOrigin throws for callers that * claim an origin but are not on the allowlist, so an untrusted caller * never gets its claimed origin honored here * - otherwise the WebAuthn L3 native-app origin derived from the caller's * signing certificate * - otherwise null — the caller cannot be identified; callers fail closed. */ fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? { val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull() if (!webOrigin.isNullOrBlank()) return webOrigin val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull() return signer?.let { apkKeyHashOrigin(it.toByteArray()) } } /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:. */ fun apkKeyHashOrigin(certificateBytes: ByteArray): String = APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding() .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes)) /** * F1 gate: may a stored credential be released to this verified caller * origin? * - web callers (framework-verified via the privileged allowlist): the * origin's registrable domain must match the credential's rpId — * exactly as rpId semantics intend, across subdomains and browsers — * unless the credential is bound to a native app, in which case only * that app may use it. * - native-app callers (apk-key-hash origin): only credentials bound to * the same origin at registration. Legacy credentials are never * released to native callers because an app's signing hash cannot prove * any relationship to the rpId. * - anything else: no. */ fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean = when { isWebOrigin(callerOrigin) -> rpIdMatchesOrigin(rpId, callerOrigin) && (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin)) storedCallerOrigin != null -> storedCallerOrigin == callerOrigin else -> false } fun isWebOrigin(origin: String): Boolean = origin.startsWith("https://", ignoreCase = true) || origin.startsWith("http://", ignoreCase = true) /** * WebAuthn effective-domain check: rpId must equal the origin's host or * be a parent-domain suffix of it, and both must share a registrable * domain (the PSL guard rejects rpIds that are public suffixes, e.g. * "co.uk"). */ fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean { if (!isWebOrigin(origin)) return false val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false val rp = rpId.trim().lowercase(Locale.ROOT) if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false val originDomain = extractDomain(origin) ?: return false val rpDomain = extractDomain(rp) ?: return false return originDomain == rpDomain } }