1package com.peachpasswords.passkeys23import android.content.Context4import android.content.pm.PackageManager5import android.os.Build6import androidx.credentials.provider.CallingAppInfo7import com.peachpasswords.R8import com.peachpasswords.shared.extractDomain9import org.json.JSONObject10import java.net.URI11import java.security.MessageDigest12import java.util.Base6413import java.util.Locale1415161718192021object PasskeyCallerOrigin {22 const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:"2324 @Volatile private var cachedAllowlist: String? = null2526 27282930313233 fun privilegedAllowlist(context: Context): String {34 cachedAllowlist?.let { return it }35 val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps)36 .bufferedReader()37 .use { it.readText() }38 cachedAllowlist = loaded39 return loaded40 }4142 4344454647 fun isVerifiedBrowser(context: Context, packageName: String?): Boolean {48 if (Build.VERSION.SDK_INT < 28 || packageName.isNullOrBlank()) return false49 val signingInfo = runCatching {50 context.packageManager.getPackageInfo(51 packageName,52 PackageManager.GET_SIGNING_CERTIFICATES,53 ).signingInfo54 }.getOrNull() ?: return false55 val signers = if (signingInfo.hasMultipleSigners()) {56 signingInfo.apkContentsSigners57 } else {58 signingInfo.signingCertificateHistory59 }60 return isAllowlistedBrowser(61 packageName = packageName,62 certificateFingerprints = signers.map { certificateFingerprint(it.toByteArray()) }.toSet(),63 allowlistJson = privilegedAllowlist(context),64 )65 }6667 68 fun signerFingerprints(context: Context, packageName: String): Set<String> {69 if (Build.VERSION.SDK_INT < 28) return emptySet()70 val signingInfo = runCatching {71 context.packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo72 }.getOrNull() ?: return emptySet()73 return signingInfo.signingCertificateHistory.map { certificateFingerprint(it.toByteArray()) }.toSet()74 }7576 77 internal fun isAllowlistedBrowser(78 packageName: String,79 certificateFingerprints: Set<String>,80 allowlistJson: String,81 ): Boolean {82 val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false83 for (index in 0 until apps.length()) {84 val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue85 if (app.optString("package_name") != packageName) continue86 val signatures = app.optJSONArray("signatures") ?: return false87 return (0 until signatures.length()).any { signatureIndex ->88 val expected = signatures.optJSONObject(signatureIndex)89 ?.optString("cert_fingerprint_sha256")90 ?.uppercase(Locale.ROOT)91 expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected }92 }93 }94 return false95 }9697 internal fun certificateFingerprint(certificateBytes: ByteArray): String =98 MessageDigest.getInstance("SHA-256").digest(certificateBytes)99 .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) }100101 102103104105106107108109110111 fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {112 val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()113 if (!webOrigin.isNullOrBlank()) return webOrigin114 115 116 117 if (Build.VERSION.SDK_INT < 28) return null118 val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()119 return signer?.let { apkKeyHashOrigin(it.toByteArray()) }120 }121122 123 fun apkKeyHashOrigin(certificateBytes: ByteArray): String =124 APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()125 .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))126127 128129130131132133134135136137138139140141 fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =142 when {143 isPlaintextWebOrigin(callerOrigin) -> false144 isWebOrigin(callerOrigin) ->145 rpIdMatchesOrigin(rpId, callerOrigin) &&146 (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))147 storedCallerOrigin != null -> storedCallerOrigin == callerOrigin148 else -> false149 }150151 internal fun isPlaintextWebOrigin(origin: String): Boolean =152 runCatching { URI(origin).scheme.equals("http", ignoreCase = true) }.getOrDefault(false)153154 fun isWebOrigin(origin: String): Boolean =155 runCatching { URI(origin).scheme.equals("https", ignoreCase = true) }.getOrDefault(false)156157 158159160161162163 fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {164 if (!isWebOrigin(origin)) return false165 val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false166 val rp = rpId.trim().lowercase(Locale.ROOT)167 if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false168 val originDomain = extractDomain(origin) ?: return false169 val rpDomain = extractDomain(rp) ?: return false170 return originDomain == rpDomain171 }172}