0413521f37b3

fix: bind passkey ceremonies to the verified caller, fail-closed origin (v2.10.16)

Security scan findings F1 (medium) + F2 (low), slice 1 of the verified-fix queue (checkpoint pre-security-fixes). - New PasskeyCallerOrigin: caller origin = privileged-browser web origin (Google allowlist bundled at res/raw/passkey_privileged_apps) else the WebAuthn L3 apk-key-hash origin from the caller's signing cert; unknown callers fail closed - PasskeyData.callerOrigin records the registration binding (legacy=null) - Get gate: web callers pass the rpId registrable-domain check unless the credential is bound to a native app; native callers get only their exact bound credentials; legacy credentials never go to native callers - Deleted originFromRpId synthesis in registration + assertion: clientDataJSON now carries only the verified origin; registration rejects web origins outside the rpId domain; selection activity stops trusting request.origin - Tests: PasskeyCallerOriginTest (6) + binding/fail-closed cases across the registration/assertion/planner suites. Full suite 912, only the 5 known pre-existing failures

time
2026-08-03 23:40 UTC · 2 months ago
machine
koh2
parent
6a5ffd129172
13 files VERSION+1 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+6 −8app/src/main/java/com/peachpasswords/passkeys/PasskeyCallerOrigin.kt+103 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+12 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+9 −3app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+12 −4app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+6 −1app/src/main/java/com/peachpasswords/vault/PasskeyData.kt+7 −0app/src/main/res/raw/passkey_privileged_apps.json+944 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+42 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyCallerOriginTest.kt+61 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+39 −2app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+31 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.151+2.10.16

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+6 −8
@@ -45,6 +45,11 @@4545     ): PasskeyAssertionResult? {4646         val passkey = entry.passkey ?: return null4747         val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null48+        // F2 fail-closed: never synthesize an origin. F1: the credential is49+        // only released to the caller origin it was bound to (legacy50+        // credentials keep the rpId-domain gate for verified web callers).51+        val callerOrigin = origin?.takeIf { it.isNotBlank() } ?: return null52+        if (!PasskeyCallerOrigin.canServeCredential(passkey.callerOrigin, passkey.rpId, callerOrigin)) return null4853         val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null4954         val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId5055         if (rpId != passkey.rpId) return null@@ -58,7 +63,7 @@5863         val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)5964         val clientDataJson = buildClientDataJson(6065             challenge = challenge,61-            origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),66+            origin = callerOrigin,6267         )6368         val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)6469         val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash) ?: return null@@ -161,13 +166,6 @@161166         params.init(ECGenParameterSpec("secp256r1"))162167         return params.getParameterSpec(ECParameterSpec::class.java)163168     }164-165-    private fun originFromRpId(rpId: String): String =166-        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") {167-            "http://$rpId"168-        } else {169-            "https://$rpId"170-        }171169 172170     private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)173171     private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCallerOrigin.kt+103 −0
@@ -0,0 +1,103 @@1+package com.peachpasswords.passkeys2+3+import android.content.Context4+import androidx.credentials.provider.CallingAppInfo5+import com.peachpasswords.R6+import com.peachpasswords.shared.extractDomain7+import java.net.URI8+import java.security.MessageDigest9+import java.util.Base6410+import java.util.Locale11+12+/**13+ * F1/F2 (2026-08-03 security scan): passkey ceremonies are bound to the14+ * verified caller. A credential is only released to a caller whose origin the15+ * framework actually verified, and clientDataJSON never carries a synthesized16+ * origin — the old `https://<rpId>` fallback is gone.17+ */18+object PasskeyCallerOrigin {19+    const val APK_KEY_HASH_PREFIX = "android:apk-key-hash:"20+21+    @Volatile private var cachedAllowlist: String? = null22+23+    /**24+     * Google's privileged-app allowlist, snapshotted in25+     * res/raw/passkey_privileged_apps from26+     * https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json27+     * (fetched 2026-08-03). Refresh by re-downloading that file when browsers28+     * rotate signing certs; offline delivery matters more than live refresh.29+     */30+    fun privilegedAllowlist(context: Context): String {31+        cachedAllowlist?.let { return it }32+        val loaded = context.resources.openRawResource(R.raw.passkey_privileged_apps)33+            .bufferedReader()34+            .use { it.readText() }35+        cachedAllowlist = loaded36+        return loaded37+    }38+39+    /**40+     * The verified origin of a credential-provider caller:41+     * - the web origin, only when the framework confirms the caller is a42+     *   privileged (allowlisted) browser — getOrigin throws for callers that43+     *   claim an origin but are not on the allowlist, so an untrusted caller44+     *   never gets its claimed origin honored here45+     * - otherwise the WebAuthn L3 native-app origin derived from the caller's46+     *   signing certificate47+     * - otherwise null — the caller cannot be identified; callers fail closed.48+     */49+    fun resolve(callingAppInfo: CallingAppInfo, privilegedAllowlist: String): String? {50+        val webOrigin = runCatching { callingAppInfo.getOrigin(privilegedAllowlist) }.getOrNull()51+        if (!webOrigin.isNullOrBlank()) return webOrigin52+        val signer = runCatching { callingAppInfo.signingInfo.apkContentsSigners.firstOrNull() }.getOrNull()53+        return signer?.let { apkKeyHashOrigin(it.toByteArray()) }54+    }55+56+    /** WebAuthn L3 §5.1.2 native-app origin: android:apk-key-hash:<b64url(sha256(cert))>. */57+    fun apkKeyHashOrigin(certificateBytes: ByteArray): String =58+        APK_KEY_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding()59+            .encodeToString(MessageDigest.getInstance("SHA-256").digest(certificateBytes))60+61+    /**62+     * F1 gate: may a stored credential be released to this verified caller63+     * origin?64+     * - web callers (framework-verified via the privileged allowlist): the65+     *   origin's registrable domain must match the credential's rpId —66+     *   exactly as rpId semantics intend, across subdomains and browsers —67+     *   unless the credential is bound to a native app, in which case only68+     *   that app may use it.69+     * - native-app callers (apk-key-hash origin): only credentials bound to70+     *   the same origin at registration. Legacy credentials are never71+     *   released to native callers because an app's signing hash cannot prove72+     *   any relationship to the rpId.73+     * - anything else: no.74+     */75+    fun canServeCredential(storedCallerOrigin: String?, rpId: String, callerOrigin: String): Boolean =76+        when {77+            isWebOrigin(callerOrigin) ->78+                rpIdMatchesOrigin(rpId, callerOrigin) &&79+                    (storedCallerOrigin == null || isWebOrigin(storedCallerOrigin))80+            storedCallerOrigin != null -> storedCallerOrigin == callerOrigin81+            else -> false82+        }83+84+    fun isWebOrigin(origin: String): Boolean =85+        origin.startsWith("https://", ignoreCase = true) ||86+            origin.startsWith("http://", ignoreCase = true)87+88+    /**89+     * WebAuthn effective-domain check: rpId must equal the origin's host or90+     * be a parent-domain suffix of it, and both must share a registrable91+     * domain (the PSL guard rejects rpIds that are public suffixes, e.g.92+     * "co.uk").93+     */94+    fun rpIdMatchesOrigin(rpId: String, origin: String): Boolean {95+        if (!isWebOrigin(origin)) return false96+        val host = runCatching { URI(origin).host?.lowercase(Locale.ROOT) }.getOrNull() ?: return false97+        val rp = rpId.trim().lowercase(Locale.ROOT)98+        if (rp.isEmpty() || host != rp && !host.endsWith(".$rp")) return false99+        val originDomain = extractDomain(origin) ?: return false100+        val rpDomain = extractDomain(rp) ?: return false101+        return originDomain == rpDomain102+    }103+}

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+12 −1
@@ -20,7 +20,17 @@2020 object PasskeyCredentialPlanner {2121     private val json = Json { ignoreUnknownKeys = true }2222 23-    fun candidates(entries: List<VaultEntry>, requestJson: String): List<PasskeyCredentialCandidate> {23+    /**24+     * F1: [callerOrigin] is the framework-verified origin of the calling app25+     * (see PasskeyCallerOrigin.resolve). An unknown caller gets no candidates26+     * at all — fail closed.27+     */28+    fun candidates(29+        entries: List<VaultEntry>,30+        requestJson: String,31+        callerOrigin: String?,32+    ): List<PasskeyCredentialCandidate> {33+        if (callerOrigin.isNullOrBlank()) return emptyList()2434         val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return emptyList()2535         val requestedRpId = request["rpId"]?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return emptyList()2636         val allowedIds = runCatching { request["allowCredentials"]?.jsonArray }.getOrNull()@@ -38,6 +48,7 @@3848                 val credentialId = passkey.credentialId3949                 if (rpId != requestedRpId) return@mapNotNull null4050                 if (!allowedIds.isNullOrEmpty() && credentialId !in allowedIds) return@mapNotNull null51+                if (!PasskeyCallerOrigin.canServeCredential(passkey.callerOrigin, rpId, callerOrigin)) return@mapNotNull null4152                 val username = passkey.userName.ifBlank { entry.username ?: entry.email ?: entry.title }4253                 PasskeyCredentialCandidate(4354                     entryId = entry.id,

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+9 −3
@@ -80,8 +80,11 @@8080             ?.firstOrNull()8181         val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)8282         val entry = entryId?.let(vault::getEntry)83+        // F1/F2: the origin must be framework-verified (privileged browser84+        // allowlist, else the caller's apk-key-hash). Unresolvable callers85+        // fail closed — PasskeyAssertion.build refuses a null origin.8386         val origin = providerRequest?.callingAppInfo?.let { info ->84-            runCatching { info.getOrigin(info.packageName) }.getOrNull()87+            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))8588         }8689         // H-07: WebAuthn's UV flag must reflect actual user verification, not a8790         // hard-coded bit. If the relying party demands UV, run the platform@@ -162,8 +165,11 @@162165         }.getOrNull()163166         val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest164167         val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)165-        val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->166-            runCatching { info.getOrigin(info.packageName) }.getOrNull()168+        // F1/F2: request.origin is caller-supplied and therefore untrusted;169+        // the only acceptable origins are the framework-verified ones. A null170+        // origin makes PasskeyRegistration.build fail closed.171+        val origin = providerRequest?.callingAppInfo?.let { info ->172+            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))167173         }168174         // H-07: WebAuthn's UV flag on registration must reflect an actual169175         // user-verification ceremony, not a hard-coded bit. Read the RP's

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+12 −4
@@ -42,9 +42,19 @@4242         userVerified: Boolean = false,4343     ): PasskeyRegistrationResult? {4444         val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null45+        // F2 fail-closed: clientDataJSON carries ONLY the framework-verified46+        // caller origin — never a synthesized https://<rpId>. F1: web origins47+        // must sit inside the rpId's registrable domain; native-app origins48+        // (android:apk-key-hash:*) bind the credential to the calling app.49+        val callerOrigin = origin?.takeIf { it.isNotBlank() } ?: return null4550         val rp = request["rp"]?.jsonObject ?: return null4651         val user = request["user"]?.jsonObject ?: return null4752         val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null53+        if (PasskeyCallerOrigin.isWebOrigin(callerOrigin) &&54+            !PasskeyCallerOrigin.rpIdMatchesOrigin(rpId, callerOrigin)55+        ) {56+            return null57+        }4858         val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId4959         val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null5060         val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null@@ -69,7 +79,7 @@6979             "attStmt" to Cbor.map(),7080             "authData" to Cbor.bytes(authData),7181         )72-        val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))82+        val clientDataJson = clientDataJson("webauthn.create", challenge, callerOrigin)7383         val passkey = PasskeyData(7484             credentialId = b64(credentialId),7585             publicKeyCose = b64(publicKeyCose),@@ -86,6 +96,7 @@8696             backupState = backupState,8797             transports = listOf("internal"),8898             createdAt = now,99+            callerOrigin = callerOrigin,89100         )90101         val entry = VaultEntry(91102             id = "",@@ -234,9 +245,6 @@234245                 put("y", b64(publicKey.w.affineY.toFixed32()))235246             },236247         )237-238-    private fun originFromRpId(rpId: String): String =239-        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"240248 241249     private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)242250 

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+6 −1
@@ -83,8 +83,13 @@8383         }8484 8585         val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)86+        // F1: resolve the framework-verified caller origin once (privileged87+        // browser allowlist, else apk-key-hash). Unknown caller -> no rows.88+        val callerOrigin = request.callingAppInfo?.let {89+            PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))90+        }8691         val entries = passkeyOptions.flatMap { option ->87-            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson).map { candidate ->92+            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->8893                 PublicKeyCredentialEntry(8994                     context = this,9095                     username = candidate.username,

view file

app/src/main/java/com/peachpasswords/vault/PasskeyData.kt+7 −0
@@ -23,4 +23,11 @@2323     val backupState: Boolean = false,2424     val transports: List<String>? = null,2525     val createdAt: Long = System.currentTimeMillis(),26+    /**27+     * F1: the verified caller origin this credential was registered from28+     * (a browser web origin or android:apk-key-hash:<cert hash>). Null for29+     * credentials created before caller binding existed or synced from the30+     * browser — those keep the legacy rpId-domain gate on get.31+     */32+    val callerOrigin: String? = null,2633 ) : Parcelable

view file

app/src/main/res/raw/passkey_privileged_apps.json+944 −0
@@ -0,0 +1,944 @@1+{2+  "apps": [3+    {4+      "type": "android",5+      "info": {6+        "package_name": "com.android.chrome",7+        "signatures": [8+          {9+            "build": "release",10+            "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"11+          },12+          {13+            "build": "userdebug",14+            "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"15+          }16+        ]17+      }18+    },19+    {20+      "type": "android",21+      "info": {22+        "package_name": "com.chrome.beta",23+        "signatures": [24+          {25+            "build": "release",26+            "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C"27+          },28+          {29+            "build": "release",30+            "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A"31+          }32+        ]33+      }34+    },35+    {36+      "type": "android",37+      "info": {38+        "package_name": "com.chrome.dev",39+        "signatures": [40+          {41+            "build": "release",42+            "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF"43+          },44+          {45+            "build": "release",46+            "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A"47+          }48+        ]49+      }50+    },51+    {52+      "type": "android",53+      "info": {54+        "package_name": "com.chrome.canary",55+        "signatures": [56+          {57+            "build": "release",58+            "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC"59+          }60+        ]61+      }62+    },63+    {64+      "type": "android",65+      "info": {66+        "package_name": "org.chromium.chrome",67+        "signatures": [68+          {69+            "build": "release",70+            "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3"71+          },72+          {73+            "build": "userdebug",74+            "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"75+          }76+        ]77+      }78+    },79+    {80+      "type": "android",81+      "info": {82+        "package_name": "com.google.android.apps.chrome",83+        "signatures": [84+          {85+            "build": "userdebug",86+            "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"87+          }88+        ]89+      }90+    },91+    {92+      "type": "android",93+      "info": {94+        "package_name": "org.mozilla.fennec_webauthndebug",95+        "signatures": [96+          {97+            "build": "userdebug",98+            "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48"99+          }100+        ]101+      }102+    },103+    {104+      "type": "android",105+      "info": {106+        "package_name": "org.mozilla.firefox",107+        "signatures": [108+          {109+            "build": "release",110+            "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04"111+          }112+        ]113+      }114+    },115+    {116+      "type": "android",117+      "info": {118+        "package_name": "org.mozilla.firefox_beta",119+        "signatures": [120+          {121+            "build": "release",122+            "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04"123+          }124+        ]125+      }126+    },127+    {128+      "type": "android",129+      "info": {130+        "package_name": "org.mozilla.focus",131+        "signatures": [132+          {133+            "build": "release",134+            "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC"135+          }136+        ]137+      }138+    },139+    {140+      "type": "android",141+      "info": {142+        "package_name": "org.mozilla.fennec_aurora",143+        "signatures": [144+          {145+            "build": "release",146+            "cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA"147+          }148+        ]149+      }150+    },151+    {152+      "type": "android",153+      "info": {154+        "package_name": "org.mozilla.rocket",155+        "signatures": [156+          {157+            "build": "release",158+            "cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91"159+          }160+        ]161+      }162+    },163+    {164+      "type": "android",165+      "info": {166+        "package_name": "org.mozilla.fenix",167+        "signatures": [168+          {169+            "build": "release",170+            "cert_fingerprint_sha256": "50:04:77:90:88:E7:F9:88:D5:BC:5C:C5:F8:79:8F:EB:F4:F8:CD:08:4A:1B:2A:46:EF:D4:C8:EE:4A:EA:F2:11"171+          }172+        ]173+      }174+    },175+    {176+      "type": "android",177+      "info": {178+        "package_name": "org.mozilla.fenix.debug",179+        "signatures": [180+          {181+            "build": "userdebug",182+            "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48"183+          }184+        ]185+      }186+    },187+    {188+      "type": "android",189+      "info": {190+        "package_name": "org.mozilla.focus.beta",191+        "signatures": [192+          {193+            "build": "release",194+            "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC"195+          }196+        ]197+      }198+    },199+    {200+      "type": "android",201+      "info": {202+        "package_name": "org.mozilla.focus.nightly",203+        "signatures": [204+          {205+            "build": "release",206+            "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC"207+          }208+        ]209+      }210+    },211+    {212+      "type": "android",213+      "info": {214+        "package_name": "org.mozilla.klar",215+        "signatures": [216+          {217+            "build": "release",218+            "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC"219+          }220+        ]221+      }222+    },223+    {224+      "type": "android",225+      "info": {226+        "package_name": "org.mozilla.reference.browser",227+        "signatures": [228+          {229+            "build": "release",230+            "cert_fingerprint_sha256": "B0:09:90:E3:0F:9D:81:5D:2E:BC:7B:9B:B2:21:CE:47:E5:C9:D5:17:AA:C7:0E:7F:D5:95:B1:E5:3E:9A:4B:14"231+          }232+        ]233+      }234+    },235+    {236+      "type": "android",237+      "info": {238+        "package_name": "com.microsoft.emmx.canary",239+        "signatures": [240+          {241+            "build": "release",242+            "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"243+          }244+        ]245+      }246+    },247+    {248+      "type": "android",249+      "info": {250+        "package_name": "com.microsoft.emmx.dev",251+        "signatures": [252+          {253+            "build": "release",254+            "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"255+          }256+        ]257+      }258+    },259+    {260+      "type": "android",261+      "info": {262+        "package_name": "com.microsoft.emmx.beta",263+        "signatures": [264+          {265+            "build": "release",266+            "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"267+          }268+        ]269+      }270+    },271+    {272+      "type": "android",273+      "info": {274+        "package_name": "com.microsoft.emmx",275+        "signatures": [276+          {277+            "build": "release",278+            "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"279+          }280+        ]281+      }282+    },283+    {284+      "type": "android",285+      "info": {286+        "package_name": "com.microsoft.emmx.rolling",287+        "signatures": [288+          {289+            "build": "userdebug",290+            "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0"291+          }292+        ]293+      }294+    },295+    {296+      "type": "android",297+      "info": {298+        "package_name": "com.microsoft.emmx.local",299+        "signatures": [300+          {301+            "build": "userdebug",302+            "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0"303+          }304+        ]305+      }306+    },307+    {308+      "type": "android",309+      "info": {310+        "package_name": "com.brave.browser",311+        "signatures": [312+          {313+            "build": "release",314+            "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"315+          }316+        ]317+      }318+    },319+    {320+      "type": "android",321+      "info": {322+        "package_name": "com.brave.browser_beta",323+        "signatures": [324+          {325+            "build": "release",326+            "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"327+          }328+        ]329+      }330+    },331+    {332+      "type": "android",333+      "info": {334+        "package_name": "com.brave.browser_nightly",335+        "signatures": [336+          {337+            "build": "release",338+            "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"339+          }340+        ]341+      }342+    },343+    {344+      "type": "android",345+      "info": {346+        "package_name": "app.vanadium.browser",347+        "signatures": [348+          {349+            "build": "release",350+            "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3"351+          }352+        ]353+      }354+    },355+    {356+      "type": "android",357+      "info": {358+        "package_name": "com.vivaldi.browser",359+        "signatures": [360+          {361+            "build": "release",362+            "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"363+          }364+        ]365+      }366+    },367+    {368+      "type": "android",369+      "info": {370+        "package_name": "com.vivaldi.browser.snapshot",371+        "signatures": [372+          {373+            "build": "release",374+            "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"375+          }376+        ]377+      }378+    },379+    {380+      "type": "android",381+      "info": {382+        "package_name": "com.vivaldi.browser.sopranos",383+        "signatures": [384+          {385+            "build": "release",386+            "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"387+          }388+        ]389+      }390+    },391+    {392+      "type": "android",393+      "info": {394+        "package_name": "com.citrix.Receiver",395+        "signatures": [396+          {397+            "build": "release",398+            "cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49"399+          },400+          {401+            "build": "release",402+            "cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E"403+          },404+          {405+            "build": "release",406+            "cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB"407+          }408+        ]409+      }410+    },411+    {412+      "type": "android",413+      "info": {414+        "package_name": "com.android.browser",415+        "signatures": [416+          {417+            "build": "release",418+            "cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25"419+          }420+        ]421+      }422+    },423+    {424+      "type": "android",425+      "info": {426+        "package_name": "com.sec.android.app.sbrowser",427+        "signatures": [428+          {429+            "build": "release",430+            "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8"431+          },432+          {433+            "build": "release",434+            "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42"435+          }436+        ]437+      }438+    },439+    {440+      "type": "android",441+      "info": {442+        "package_name": "com.sec.android.app.sbrowser.beta",443+        "signatures": [444+          {445+            "build": "release",446+            "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8"447+          },448+          {449+            "build": "release",450+            "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42"451+          }452+        ]453+      }454+    },455+    {456+      "type": "android",457+      "info": {458+        "package_name": "com.google.android.gms",459+        "signatures": [460+          {461+            "build": "release",462+            "cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53"463+          },464+          {465+            "build": "release",466+            "cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78"467+          },468+          {469+            "build": "release",470+            "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"471+          },472+          {473+            "build": "release",474+            "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"475+          }476+        ]477+      }478+    },479+    {480+      "type": "android",481+      "info": {482+        "package_name": "com.yandex.browser",483+        "signatures": [484+          {485+            "build": "release",486+            "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"487+          }488+        ]489+      }490+    },491+    {492+      "type": "android",493+      "info": {494+        "package_name": "com.yandex.browser.beta",495+        "signatures": [496+          {497+            "build": "release",498+            "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"499+          }500+        ]501+      }502+    },503+    {504+      "type": "android",505+      "info": {506+        "package_name": "com.yandex.browser.alpha",507+        "signatures": [508+          {509+            "build": "release",510+            "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"511+          }512+        ]513+      }514+    },515+    {516+      "type": "android",517+      "info": {518+        "package_name": "com.yandex.browser.corp",519+        "signatures": [520+          {521+            "build": "release",522+            "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"523+          }524+        ]525+      }526+    },527+    {528+      "type": "android",529+      "info": {530+        "package_name": "com.yandex.browser.canary",531+        "signatures": [532+          {533+            "build": "release",534+            "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49"535+          }536+        ]537+      }538+    },539+    {540+      "type": "android",541+      "info": {542+        "package_name": "com.yandex.browser.broteam",543+        "signatures": [544+          {545+            "build": "release",546+            "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49"547+          }548+        ]549+      }550+    },551+    {552+      "type": "android",553+      "info": {554+        "package_name": "com.talonsec.talon",555+        "signatures": [556+          {557+            "build": "release",558+            "cert_fingerprint_sha256": "A3:66:03:44:A6:F6:AF:CA:81:8C:BF:43:96:A2:3C:CF:D5:ED:7A:78:1B:B4:A3:D1:85:03:01:E2:F4:6D:23:83"559+          },560+          {561+            "build": "release",562+            "cert_fingerprint_sha256": "E2:A5:64:74:EA:23:7B:06:67:B6:F5:2C:DC:E9:04:5E:24:88:3B:AE:D0:82:59:9A:A2:DF:0B:60:3A:CF:6A:3B"563+          }564+        ]565+      }566+    },567+    {568+      "type": "android",569+      "info": {570+        "package_name": "com.talonsec.talon_beta",571+        "signatures": [572+          {573+            "build": "release",574+            "cert_fingerprint_sha256": "F5:86:62:7A:32:C8:9F:E6:7E:00:6D:B1:8C:34:31:9E:01:7F:B3:B2:BE:D6:9D:01:01:B7:F9:43:E7:7C:48:AE"575+          },576+          {577+            "build": "release",578+            "cert_fingerprint_sha256": "9A:A1:25:D5:E5:5E:3F:B0:DE:96:72:D9:A9:5D:04:65:3F:49:4A:1E:C3:EE:76:1E:94:C4:4E:5D:2F:65:8E:2F"579+          }580+        ]581+      }582+    },583+    {584+      "type": "android",585+      "info": {586+        "package_name": "com.talonsec.intune.talon",587+        "signatures": [588+          {589+            "build": "release",590+            "cert_fingerprint_sha256": "09:0C:4C:E6:20:65:9B:04:00:35:8F:C8:3F:DC:9F:02:D2:AB:77:C3:7F:4C:80:14:30:8E:FF:96:BA:54:49:55"591+          }592+        ]593+      }594+    },595+    {596+      "type": "android",597+      "info": {598+        "package_name": "com.duckduckgo.mobile.android.debug",599+        "signatures": [600+          {601+            "build": "release",602+            "cert_fingerprint_sha256": "C4:F0:9E:2B:D7:25:AD:F5:AD:92:0B:A2:80:27:66:AC:16:4A:C1:53:B3:EA:9E:08:48:B0:57:98:37:F7:6A:29"603+          }604+        ]605+      }606+    },607+    {608+      "type": "android",609+      "info": {610+        "package_name": "com.duckduckgo.mobile.android",611+        "signatures": [612+          {613+            "build": "release",614+            "cert_fingerprint_sha256": "BB:7B:B3:1C:57:3C:46:A1:DA:7F:C5:C5:28:A6:AC:F4:32:10:84:56:FE:EC:50:81:0C:7F:33:69:4E:B3:D2:D4"615+          }616+        ]617+      }618+    },619+    {620+      "type": "android",621+      "info": {622+        "package_name": "com.naver.whale",623+        "signatures": [624+          {625+            "build": "release",626+            "cert_fingerprint_sha256": "0B:8B:85:23:BB:4A:EF:FA:34:6E:4B:DD:4F:BF:7D:19:34:50:56:9A:A1:4A:AA:D4:AD:FD:94:A3:F7:B2:27:BB"627+          }628+        ]629+      }630+    },631+    {632+      "type": "android",633+      "info": {634+        "package_name": "com.fido.fido2client",635+        "signatures": [636+          {637+            "build": "release",638+            "cert_fingerprint_sha256": "FC:98:DA:E6:3A:D3:96:26:C8:C6:7F:BE:83:F2:F0:6F:74:93:2A:9C:D1:46:B9:2C:EC:FC:6A:04:7A:90:43:86"639+          }640+        ]641+      }642+    },643+    {644+      "type": "android",645+      "info": {646+        "package_name": "com.heytap.browser",647+        "signatures": [648+          {649+            "build": "release",650+            "cert_fingerprint_sha256": "B2:9A:A0:BB:DC:9F:D9:DE:F5:5D:C5:6E:A7:D7:45:76:D5:84:6C:BC:F5:E5:AB:D3:05:E2:D9:31:9E:4F:42:AE"651+          },652+          {653+            "build": "release",654+            "cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5"655+          },656+          {657+            "build": "release",658+            "cert_fingerprint_sha256": "A8:FE:A4:CA:FB:93:32:DA:26:B8:E6:81:08:17:C1:DA:90:A5:03:0E:35:A6:0A:79:E0:6C:90:97:AA:C6:A4:42"659+          }660+        ]661+      }662+    },663+    {664+      "type": "android",665+      "info": {666+        "package_name": "com.oplus.credential",667+        "signatures": [668+          {669+            "build": "release",670+            "cert_fingerprint_sha256": "E4:98:02:40:95:84:CE:53:15:2A:90:00:82:0A:51:E4:FA:8A:72:3B:7B:CC:26:3E:33:52:40:AC:F1:00:BF:9E"671+          }672+        ]673+      }674+    },675+    {676+      "type": "android",677+      "info": {678+        "package_name": "io.island.Island",679+        "signatures": [680+          {681+            "build": "release",682+            "cert_fingerprint_sha256": "D9:C3:39:AC:9C:3A:EE:E1:75:1D:85:8C:35:D9:BA:C5:CC:87:B3:CE:76:30:93:F0:F5:10:64:F5:A2:F6:9B:04"683+          },684+          {685+            "build": "userdebug",686+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"687+          }688+        ]689+      }690+    },691+    {692+      "type": "android",693+      "info": {694+        "package_name": "io.island.IslandCanary",695+        "signatures": [696+          {697+            "build": "release",698+            "cert_fingerprint_sha256": "90:17:13:23:45:6E:6F:39:CB:FD:CF:B2:56:BE:1D:CF:F3:BC:1C:59:8A:15:93:30:E4:97:73:D0:4C:B9:C9:05"699+          },700+          {701+            "build": "userdebug",702+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"703+          }704+        ]705+      }706+    },707+    {708+      "type": "android",709+      "info": {710+        "package_name": "io.island.IslandBeta",711+        "signatures": [712+          {713+            "build": "release",714+            "cert_fingerprint_sha256": "35:31:83:1A:9E:2B:21:1D:E6:AA:C3:69:4B:45:83:6E:56:09:B9:D7:D0:04:C3:1B:21:87:40:FB:77:17:38:D1"715+          },716+          {717+            "build": "userdebug",718+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"719+          }720+        ]721+      }722+    },723+    {724+      "type": "android",725+      "info": {726+        "package_name": "io.island.IslandDev",727+        "signatures": [728+          {729+            "build": "userdebug",730+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"731+          }732+        ]733+      }734+    },735+    {736+      "type": "android",737+      "info": {738+        "package_name": "io.island.island.intune",739+        "signatures": [740+          {741+            "build": "release",742+            "cert_fingerprint_sha256": "C2:38:24:15:41:20:A0:8F:C3:95:42:AC:D8:2A:E9:24:94:78:80:1E:47:FD:6C:66:2B:18:1C:28:CA:7E:59:4E"743+          },744+          {745+            "build": "userdebug",746+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"747+          }748+        ]749+      }750+    },751+    {752+      "type": "android",753+      "info": {754+        "package_name": "io.island.island.canary.intune",755+        "signatures": [756+          {757+            "build": "release",758+            "cert_fingerprint_sha256": "1E:16:74:BB:79:EA:09:FB:37:CF:9F:1B:07:1B:1D:51:8D:46:03:0E:D3:EE:F2:C1:4E:AD:93:9E:C6:EE:3A:4C"759+          },760+          {761+            "build": "userdebug",762+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"763+          }764+        ]765+      }766+    },767+    {768+      "type": "android",769+      "info": {770+        "package_name": "io.island.island.beta.intune",771+        "signatures": [772+          {773+            "build": "release",774+            "cert_fingerprint_sha256": "D2:5E:AD:F6:1C:E6:36:6C:A4:23:A4:7F:C4:DB:9B:8C:9C:8A:35:B4:B0:19:E8:D9:82:FB:D0:8A:D9:DB:49:5A"775+          },776+          {777+            "build": "userdebug",778+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"779+          }780+        ]781+      }782+    },783+    {784+      "type": "android",785+      "info": {786+        "package_name": "io.island.island.dev.intune",787+        "signatures": [788+          {789+            "build": "userdebug",790+            "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44"791+          }792+        ]793+      }794+    },795+    {796+      "type": "android",797+      "info": {798+        "package_name": "net.quetta.browser",799+        "signatures": [800+          {801+            "build": "release",802+            "cert_fingerprint_sha256": "BE:FE:E7:31:12:6A:A5:6E:7E:FD:AE:AF:5E:F3:FA:EA:44:1C:19:CC:E0:CA:EC:42:6B:65:BB:F8:2C:59:46:80"803+          },804+          {805+            "build": "userdebug",806+            "cert_fingerprint_sha256": "F1:38:00:4F:38:04:51:D4:8A:05:2B:B3:A3:EF:17:24:23:D4:B0:D0:C8:A3:AA:DD:FB:DB:66:30:31:48:EC:A4"807+          }808+        ]809+      }810+    },811+    {812+      "type": "android",813+      "info": {814+        "package_name": "cz.seznam.sbrowser",815+        "signatures": [816+          {817+            "build": "release",818+            "cert_fingerprint_sha256": "DB:95:40:66:10:78:83:6E:4E:B1:66:F6:9E:F4:07:30:9E:8D:AE:33:34:68:5E:C8:F6:FA:2F:13:81:B9:AC:F6"819+          }820+        ]821+      }822+    },823+    {824+      "type": "android",825+      "info": {826+        "package_name": "com.opera.mini.native",827+        "signatures": [828+          {829+            "build": "release",830+            "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2"831+          }832+        ]833+      }834+    },835+    {836+      "type": "android",837+      "info": {838+        "package_name": "com.opera.mini.native.beta",839+        "signatures": [840+          {841+            "build": "release",842+            "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2"843+          }844+        ]845+      }846+    },847+    {848+      "type": "android",849+      "info": {850+        "package_name": "com.zoho.primeum.stable",851+        "signatures": [852+          {853+            "build": "release",854+            "cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"855+          }856+        ]857+      }858+    },859+    {860+      "type": "android",861+      "info": {862+        "package_name": "ai.perplexity.comet",863+        "signatures": [864+          {865+            "build": "release",866+            "cert_fingerprint_sha256": "89:58:A4:05:40:1F:69:F5:B0:FB:54:44:24:74:6C:40:DE:C3:0C:09:1F:40:1F:95:1F:61:3C:48:35:C3:E5:EC"867+          },868+          {869+            "build": "userdebug",870+            "cert_fingerprint_sha256": "68:75:3A:54:59:93:C1:34:D3:BD:A3:72:2A:30:53:BF:4D:48:AD:23:63:2C:4E:27:8B:B3:BF:C1:FB:F6:52:8C"871+          }872+        ]873+      }874+    },875+    {876+      "type": "android",877+      "info": {878+        "package_name": "com.amazon.cloud9",879+        "signatures": [880+          {881+            "build": "release",882+            "cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"883+          },884+          {885+            "build": "release",886+            "cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"887+          },888+          {889+            "build": "userdebug",890+            "cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"891+          }892+        ]893+      }894+    },895+    {896+      "type": "android",897+      "info": {898+        "package_name": "net.waterfox.android.release",899+        "signatures": [900+          {901+            "build": "release",902+            "cert_fingerprint_sha256": "29:39:99:7A:2D:8F:07:30:3C:EB:37:AD:68:10:AF:EF:0B:DA:71:0B:E2:11:64:76:E3:52:5A:73:79:EC:2E:1A"903+          }904+        ]905+      }906+    },907+    {908+      "type": "android",909+      "info": {910+        "package_name": "com.opera.browser",911+        "signatures": [912+          {913+            "build": "release",914+            "cert_fingerprint_sha256": "5D:6A:FB:F8:7F:65:2A:F0:46:47:AD:A0:DF:63:4C:F2:23:70:90:0B:16:4B:09:D5:0B:D2:3A:A2:CB:52:85:B8"915+          }916+        ]917+      }918+    },919+    {920+      "type": "android",921+      "info": {922+        "package_name": "com.opera.browser.beta",923+        "signatures": [924+          {925+            "build": "release",926+            "cert_fingerprint_sha256": "5D:6A:FB:F8:7F:65:2A:F0:46:47:AD:A0:DF:63:4C:F2:23:70:90:0B:16:4B:09:D5:0B:D2:3A:A2:CB:52:85:B8"927+          }928+        ]929+      }930+    },931+    {932+      "type": "android",933+      "info": {934+        "package_name": "com.wsapp.browser",935+        "signatures": [936+          {937+            "build": "release",938+            "cert_fingerprint_sha256": "F5:21:95:30:FC:8F:87:99:3B:3C:F5:0E:83:8A:96:75:AB:3B:2D:C5:79:A2:A8:52:0D:99:87:2A:26:B1:5C:5F"939+          }940+        ]941+      }942+    }943+  ]944+}\ No newline at end of file

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+42 −0
@@ -92,6 +92,48 @@9292         assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))9393     }9494 95+    @Test fun rejects_missing_or_blank_origin() {96+        val keyPair = KeyPairGenerator.getInstance("EC").apply {97+            initialize(ECGenParameterSpec("secp256r1"))98+        }.generateKeyPair()99+        val entry = passkeyEntry(keyPair.private as ECPrivateKey)100+        assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", null))101+        assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", " "))102+    }103+104+    @Test fun bound_credential_refuses_every_other_caller() {105+        val keyPair = KeyPairGenerator.getInstance("EC").apply {106+            initialize(ECGenParameterSpec("secp256r1"))107+        }.generateKeyPair()108+        val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"109+        val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"110+        val bound = passkeyEntry(keyPair.private as ECPrivateKey).copy(111+            passkey = passkeyEntry(keyPair.private as ECPrivateKey).passkey!!.copy(callerOrigin = appA),112+        )113+        val request = """{"challenge":"c","rpId":"example.com"}"""114+        assertNotNull(PasskeyAssertion.build(bound, request, appA))115+        assertEquals(null, PasskeyAssertion.build(bound, request, appB))116+        // A web caller cannot use a credential bound to a native app either.117+        assertEquals(null, PasskeyAssertion.build(bound, request, "https://example.com"))118+    }119+120+    @Test fun legacy_credential_stays_hidden_from_native_app_callers() {121+        val keyPair = KeyPairGenerator.getInstance("EC").apply {122+            initialize(ECGenParameterSpec("secp256r1"))123+        }.generateKeyPair()124+        val legacy = passkeyEntry(keyPair.private as ECPrivateKey)125+        assertEquals(126+            null,127+            PasskeyAssertion.build(128+                legacy,129+                """{"challenge":"c","rpId":"example.com"}""",130+                "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",131+            ),132+        )133+        // ...but a verified web caller inside the rpId domain still works.134+        assertNotNull(PasskeyAssertion.build(legacy, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))135+    }136+95137     @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {96138         // Backup-eligible + UV performed → UP | UV | BE = 0x0d. AT (0x40) must97139         // NOT be set on an assertion (it's registration-only). BS depends on

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyCallerOriginTest.kt+61 −0
@@ -0,0 +1,61 @@1+package com.peachpasswords.passkeys2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * F1/F2: caller-origin resolution policy — which callers may register for an10+ * rpId and which may obtain a stored credential.11+ */12+class PasskeyCallerOriginTest {13+14+    @Test fun apk_key_hash_origin_matches_webauthn_l3_format() {15+        // sha256("cert") = known digest; asserts prefix + b64url-no-padding.16+        val origin = PasskeyCallerOrigin.apkKeyHashOrigin("cert".toByteArray())17+        assertTrue(origin.startsWith("android:apk-key-hash:"))18+        val digest = origin.removePrefix("android:apk-key-hash:")19+        assertFalse(digest.contains('='))20+        assertEquals(43, digest.length) // 32 bytes -> 43 b64url chars unpadded21+    }22+23+    @Test fun rp_id_matches_origin_subdomain_semantics() {24+        assertTrue(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://example.com"))25+        assertTrue(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://login.example.com"))26+        // rpId may not claim a sibling subdomain's host.27+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("login.example.com", "https://example.com"))28+        // Suffix matching is dot-anchored: "ample.com" is not a parent of example.com.29+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("ample.com", "https://example.com"))30+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://evilexample.com"))31+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "https://evil-example.com"))32+    }33+34+    @Test fun rp_id_public_suffixes_are_rejected() {35+        // PSL guard: an rpId that is itself a public suffix must not match.36+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("co.uk", "https://bank.co.uk"))37+    }38+39+    @Test fun non_web_origins_never_match_rp_ids() {40+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "android:apk-key-hash:AAAA"))41+        assertFalse(PasskeyCallerOrigin.rpIdMatchesOrigin("example.com", "not-an-origin"))42+    }43+44+    @Test fun web_callers_pass_rp_id_domain_gate_regardless_of_binding() {45+        // Legacy (null) and web-bound credentials both serve a verified web46+        // caller inside the rpId domain.47+        assertTrue(PasskeyCallerOrigin.canServeCredential(null, "example.com", "https://example.com"))48+        assertTrue(PasskeyCallerOrigin.canServeCredential("https://example.com", "example.com", "https://login.example.com"))49+        // A web caller from another domain gets nothing.50+        assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", "https://evil.com"))51+    }52+53+    @Test fun native_callers_only_get_their_own_bound_credentials() {54+        val appA = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"55+        val appB = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"56+        assertTrue(PasskeyCallerOrigin.canServeCredential(appA, "example.com", appA))57+        assertFalse(PasskeyCallerOrigin.canServeCredential(appA, "example.com", appB))58+        // Legacy credentials are never released to native-app callers.59+        assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", appA))60+    }61+}

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+39 −2
@@ -7,6 +7,8 @@77 import org.junit.Test88 99 class PasskeyCredentialPlannerTest {10+    private val webCaller = "https://example.com"11+1012     @Test fun matches_passkeys_by_exact_rp_id() {1113         val matches = PasskeyCredentialPlanner.candidates(1214             listOf(@@ -14,6 +16,7 @@1416                 passkey("b", rpId = "evil-example.com", username = "lookalike"),1517             ),1618             """{"challenge":"c","rpId":"example.com"}""",19+            callerOrigin = webCaller,1720         )1821 1922         assertEquals(listOf("a"), matches.map { it.entryId })@@ -27,22 +30,55 @@2730                 passkey("b", credentialId = "cred-b", lastUsedAt = 20),2831             ),2932             """{"challenge":"c","rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cred-a"}]}""",33+            callerOrigin = webCaller,3034         )3135 3236         assertEquals(listOf("a"), matches.map { it.entryId })3337     }3438 3539     @Test fun empty_or_invalid_requests_offer_no_credentials() {36-        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""").isEmpty())37-        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json").isEmpty())40+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""", webCaller).isEmpty())41+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json", webCaller).isEmpty())3842     }3943 44+    @Test fun unknown_caller_gets_no_credentials() {45+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", null).isEmpty())46+        assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c","rpId":"example.com"}""", " ").isEmpty())47+    }48+49+    @Test fun legacy_credentials_hide_from_native_app_callers() {50+        // callerOrigin == null means pre-binding (or browser-synced). A native51+        // app's apk-key-hash origin can never prove a relationship to the52+        // rpId, so legacy credentials stay hidden from it.53+        val nativeCaller = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"54+        assertTrue(55+            PasskeyCredentialPlanner.candidates(56+                listOf(passkey("a")),57+                """{"challenge":"c","rpId":"example.com"}""",58+                nativeCaller,59+            ).isEmpty(),60+        )61+    }62+63+    @Test fun bound_credentials_only_go_to_their_bound_caller() {64+        val boundOrigin = "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"65+        val otherApp = "android:apk-key-hash:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"66+        val entries = listOf(passkey("a", callerOrigin = boundOrigin))67+        val request = """{"challenge":"c","rpId":"example.com"}"""68+69+        assertEquals(listOf("a"), PasskeyCredentialPlanner.candidates(entries, request, boundOrigin).map { it.entryId })70+        assertTrue(PasskeyCredentialPlanner.candidates(entries, request, otherApp).isEmpty())71+        // Web callers also cannot take a credential bound to a native app.72+        assertTrue(PasskeyCredentialPlanner.candidates(entries, request, webCaller).isEmpty())73+    }74+4075     private fun passkey(4176         id: String,4277         rpId: String = "example.com",4378         credentialId: String = "cred-$id",4479         username: String = "user-$id",4580         lastUsedAt: Long? = null,81+        callerOrigin: String? = null,4682     ) = VaultEntry(4783         id = id,4884         type = "passkey",@@ -54,6 +90,7 @@5490             userHandle = "handle-$id",5591             userName = username,5692             userDisplayName = "User $id",93+            callerOrigin = callerOrigin,5794         ),5895         lastUsedAt = lastUsedAt,5996         createdAt = 1,

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+31 −0
@@ -68,6 +68,37 @@6868         assertEquals(null, result)6969     }7070 71+    @Test fun rejects_missing_or_blank_origin() {72+        val request = """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}"""73+        assertEquals(null, PasskeyRegistration.build(request, null))74+        assertEquals(null, PasskeyRegistration.build(request, "  "))75+    }76+77+    @Test fun rejects_web_origin_outside_rp_id_domain() {78+        val request = """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}"""79+        assertEquals(null, PasskeyRegistration.build(request, "https://evil.com"))80+        assertEquals(null, PasskeyRegistration.build(request, "https://evilexample.com"))81+    }82+83+    @Test fun stores_verified_caller_origin_on_the_credential() {84+        val web = PasskeyRegistration.build(85+            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}""",86+            "https://login.example.com",87+        )88+        assertEquals("https://login.example.com", web?.entryDraft?.passkey?.callerOrigin)89+90+        // Native-app origins carry no domain, so no rpId domain check applies;91+        // the binding itself is the protection on get.92+        val native = PasskeyRegistration.build(93+            """{"challenge":"c","rp":{"id":"example.com"},"user":{"id":"u","name":"june"},"pubKeyCredParams":[{"alg":-7}]}""",94+            "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",95+        )96+        assertEquals(97+            "android:apk-key-hash:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",98+            native?.entryDraft?.passkey?.callerOrigin,99+        )100+    }101+71102     @Test fun registration_sets_uv_bit_when_user_verified() {72103         val result = PasskeyRegistration.build(73104             requestJson = """

view file