0413521f37b3
fix: bind passkey ceremonies to the verified caller, fail-closed origin (v2.10.16)
- time
- 2026-08-03 23:40 UTC · 2 months ago
- machine
- koh2
- parent
- 6a5ffd129172
↓ peach-android-0413521f.tar.gzatlas at this save
files changed13 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 −0app/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)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+}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,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'sapp/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 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,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 ) : Parcelableapp/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 fileapp/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 onapp/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+}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,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 = """