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 internal fun isAllowlistedBrowser(69 packageName: String,70 certificateFingerprints: Set<String>,71 allowlistJson: String,72 ): Boolean {73 val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false74 for (index in 0 until apps.length()) {75 val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue76 if (app.optString("package_name") != packageName) continue77 val signatures = app.optJSONArray("signatures") ?: return false78 return (0 until signatures.length()).any { signatureIndex ->79 val expected = signatures.optJSONObject(signatureIndex)80 ?.optString("cert_fingerprint_sha256")81 ?.uppercase(Locale.ROOT)82 expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected }83 }84 }85 return false86 }8788 internal fun certificateFingerprint(certificateBytes: ByteArray): String =89 MessageDigest.getInstance("SHA-256").digest(certificateBytes)90 .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) }9192 93949596979899100101102 fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {103 val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()104 if (!webOrigin.isNullOrBlank()) return webOrigin105 106 107 108 if (Build.VERSION.SDK_INT < 28) return null109 val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()110 return signer?.let { apkKeyHashOrigin(it.toByteArray()) }111 }112113 114 fun apkKeyHashOrigin(certificateBytes: ByteArray): String =115 APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()116 .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))117118 119120121122123124125126127128129130131132 fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =133 when {134 isWebOrigin(callerOrigin) ->135 rpIdMatchesOrigin(rpId, callerOrigin) &&136 (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))137 storedCallerOrigin != null -> storedCallerOrigin == callerOrigin138 else -> false139 }140141 fun isWebOrigin(origin: String): Boolean =142 origin.startsWith("https://", ignoreCase = true) ||143 origin.startsWith("http://", ignoreCase = true)144145 146147148149150151 fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {152 if (!isWebOrigin(origin)) return false153 val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false154 val rp = rpId.trim().lowercase(Locale.ROOT)155 if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false156 val originDomain = extractDomain(origin) ?: return false157 val rpDomain = extractDomain(rp) ?: return false158 return originDomain == rpDomain159 }160}