package com.peachpasswords.passkeys import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.credentials.provider.CallingAppInfo import com.peachpasswords.R import com.peachpasswords.shared.extractDomain import org.json.JSONObject 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 } /** * Autofill has no CallingAppInfo object, so its framework-authenticated * AssistStructure activity package must be checked against the same signed * browser allowlist used by passkeys before a ViewNode webDomain is useful. */ fun isVerifiedBrowser(context: Context, packageName: String?): Boolean { if (Build.VERSION.SDK_INT < 28 || packageName.isNullOrBlank()) return false val signingInfo = runCatching { context.packageManager.getPackageInfo( packageName, PackageManager.GET_SIGNING_CERTIFICATES, ).signingInfo }.getOrNull() ?: return false val signers = if (signingInfo.hasMultipleSigners()) { signingInfo.apkContentsSigners } else { signingInfo.signingCertificateHistory } return isAllowlistedBrowser( packageName = packageName, certificateFingerprints = signers.map { certificateFingerprint(it.toByteArray()) }.toSet(), allowlistJson = privilegedAllowlist(context), ) } /** Pure allowlist predicate so the package-and-signer boundary is unit-testable. */ internal fun isAllowlistedBrowser( packageName: String, certificateFingerprints: Set, allowlistJson: String, ): Boolean { val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false for (index in 0 until apps.length()) { val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue if (app.optString("package_name") != packageName) continue val signatures = app.optJSONArray("signatures") ?: return false return (0 until signatures.length()).any { signatureIndex -> val expected = signatures.optJSONObject(signatureIndex) ?.optString("cert_fingerprint_sha256") ?.uppercase(Locale.ROOT) expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected } } } return false } internal fun certificateFingerprint(certificateBytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(certificateBytes) .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) } /** * 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 // SigningInfo.getApkContentsSigners requires API 28 (minSdk 26). Passkey // callers only exist on 34+, but fail closed — never crash — if this is // ever reached on an older platform. if (Build.VERSION.SDK_INT < 28) return null 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 } }