@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.27 → 1.0.0-canary.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ # [1.0.0-canary.28](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.27...v1.0.0-canary.28) (2026-09-29)
2
+
3
+ ### Bug Fixes
4
+
5
+ - restrict client hash to privileged list ([d17cea0](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d17cea058745b5de0103d4ccd767885a2d185981))
6
+
1
7
  # [1.0.0-canary.27](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.26...v1.0.0-canary.27) (2026-09-22)
2
8
 
3
9
  ### Bug Fixes
@@ -1,6 +1,8 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
2
  xmlns:tools="http://schemas.android.com/tools">
3
3
  <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
4
+ <!-- Refreshes Google's privileged-browser allowlist (PrivilegedBrowserAllowlist). -->
5
+ <uses-permission android:name="android.permission.INTERNET"/>
4
6
  <application>
5
7
  <service
6
8
  android:name=".service.PasskeyAutofillCredentialProviderService"
@@ -29,6 +29,7 @@ import co.algorand.passkeyautofill.credentials.CredentialRepository
29
29
  import co.algorand.passkeyautofill.credentials.Credential
30
30
  import co.algorand.passkeyautofill.credentials.MasterKeyUnavailableException
31
31
  import co.algorand.passkeyautofill.utils.PasskeyUtils
32
+ import co.algorand.passkeyautofill.utils.PrivilegedBrowserAllowlist
32
33
  import java.security.KeyPair
33
34
  import android.util.Base64 as AndroidBase64
34
35
  import androidx.biometric.BiometricPrompt
@@ -49,6 +50,8 @@ class CreatePasskeyActivity : AppCompatActivity() {
49
50
 
50
51
  private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
51
52
  private var displayOrigin: String = "unknown-origin"
53
+ /** The verified web origin when the caller is an allow-listed privileged browser; null for every ordinary app. */
54
+ private var privilegedOrigin: String? = null
52
55
  private var userHandle: String = "unknown-user"
53
56
  private var userName: String = "User"
54
57
  private var userId: String = "unknown-id"
@@ -108,6 +111,12 @@ class CreatePasskeyActivity : AppCompatActivity() {
108
111
 
109
112
  if (request != null) {
110
113
  try {
114
+ // A caller-asserted web origin is trusted only for an allow-listed
115
+ // privileged browser; everyone else is bound to their app identity.
116
+ val allowlist = PrivilegedBrowserAllowlist.json(this)
117
+ privilegedOrigin = allowlist?.let {
118
+ credentialRepository.getPrivilegedOrigin(request!!.callingAppInfo, it)
119
+ }
111
120
  val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
112
121
  origin = systemOrigin
113
122
  displayOrigin = systemOrigin
@@ -403,11 +412,16 @@ class CreatePasskeyActivity : AppCompatActivity() {
403
412
  "Relying party requires user verification but no verification ceremony completed"
404
413
  }
405
414
  PasskeyLog.d(TAG, "Building AuthenticatorAttestationResponse (uv=${verification.verified})")
415
+ // The origin the registration is bound to: the verified web origin
416
+ // for an allow-listed privileged browser, otherwise the caller's
417
+ // android:apk-key-hash: identity. This is independent of `origin`,
418
+ // which stays the derivation origin (rpId) so keys re-derive stably.
419
+ val responseOrigin = privilegedOrigin ?: credentialRepository.getOrigin(req.callingAppInfo)
406
420
  val response = AuthenticatorAttestationResponse(
407
421
  requestOptions = requestOptions,
408
422
  credentialId = credentialId,
409
423
  credentialPublicKey = credentialRepository.getPublicKeyFromKeyPair(keyPair),
410
- origin = credentialRepository.getOrigin(req.callingAppInfo),
424
+ origin = responseOrigin,
411
425
  up = true,
412
426
  uv = verification.verified,
413
427
  be = true,
@@ -426,7 +440,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
426
440
  val respJson = fullJson.getJSONObject("response")
427
441
 
428
442
  // Ensure compact clientDataJSON for registration too
429
- val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
443
+ val sanitizedOrigin = responseOrigin.replace(Regex("/$"), "")
430
444
  val challenge = if (requestJson.has("publicKey")) {
431
445
  requestJson.getJSONObject("publicKey").getString("challenge")
432
446
  } else {
@@ -31,6 +31,7 @@ import co.algorand.passkeyautofill.credentials.KeystoreRecords
31
31
  import co.algorand.passkeyautofill.credentials.ParentSecretResult
32
32
  import co.algorand.passkeyautofill.credentials.RelyingParty
33
33
  import co.algorand.passkeyautofill.utils.PasskeyUtils
34
+ import co.algorand.passkeyautofill.utils.PrivilegedBrowserAllowlist
34
35
  import java.security.KeyPair
35
36
  import java.security.MessageDigest
36
37
  import android.util.Base64 as AndroidBase64
@@ -53,6 +54,8 @@ class GetPasskeyActivity : AppCompatActivity() {
53
54
 
54
55
  private var origin: String = "unknown-origin"
55
56
  private var displayOrigin: String = "unknown-origin"
57
+ /** The verified web origin when the caller is an allow-listed privileged browser; null for every ordinary app. */
58
+ private var privilegedOrigin: String? = null
56
59
  private var userHandle: String = "unknown-user"
57
60
  private var credentialIdEnc: String? = null
58
61
  private var userVerification: String = "preferred"
@@ -112,7 +115,13 @@ class GetPasskeyActivity : AppCompatActivity() {
112
115
  }
113
116
 
114
117
  if (request != null) {
115
- origin = credentialRepository.getOrigin(request!!.callingAppInfo)
118
+ // A caller-asserted web origin is trusted only for an allow-listed
119
+ // privileged browser; everyone else is bound to their app identity.
120
+ val allowlist = PrivilegedBrowserAllowlist.json(this)
121
+ privilegedOrigin = allowlist?.let {
122
+ credentialRepository.getPrivilegedOrigin(request!!.callingAppInfo, it)
123
+ }
124
+ origin = privilegedOrigin ?: credentialRepository.getOrigin(request!!.callingAppInfo)
116
125
  displayOrigin = origin
117
126
 
118
127
  // Try to extract rpId for better display
@@ -505,7 +514,20 @@ class GetPasskeyActivity : AppCompatActivity() {
505
514
  json.toString()
506
515
  }
507
516
 
508
- val clientDataHash = systemClientDataHash ?: MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
517
+ // Only an allow-listed privileged browser may have its own
518
+ // clientDataHash (and web origin) trusted. For every other caller we
519
+ // IGNORE any supplied hash and sign OUR OWN hash of the app-bound
520
+ // clientDataJSON, so the assertion is bound to the caller's
521
+ // android:apk-key-hash: identity and a foreign RP rejects it.
522
+ val trustSystemHash = privilegedOrigin != null && systemClientDataHash != null
523
+ if (systemClientDataHash != null && !trustSystemHash) {
524
+ PasskeyLog.w(TAG, "Ignoring caller-supplied clientDataHash from non-privileged caller; binding assertion to app origin")
525
+ }
526
+ val clientDataHash = if (trustSystemHash) {
527
+ systemClientDataHash!!
528
+ } else {
529
+ MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
530
+ }
509
531
 
510
532
  // Metadata only: everything the response needs before signing (user
511
533
  // handle, derivation pins) is read without touching the private key.
@@ -591,7 +613,13 @@ class GetPasskeyActivity : AppCompatActivity() {
591
613
 
592
614
  val fullJson = JSONObject(fidoCredential.json())
593
615
  val respJson = fullJson.getJSONObject("response")
594
- respJson.put("clientDataJSON", clientDataJSONb64)
616
+ // When a privileged browser's own hash was signed, our clientDataJSON
617
+ // is not what was signed (the browser supplies its own), so we do not
618
+ // attach it. For all app-bound callers we attach the clientDataJSON
619
+ // whose hash we signed.
620
+ if (!trustSystemHash) {
621
+ respJson.put("clientDataJSON", clientDataJSONb64)
622
+ }
595
623
  respJson.put("signature", signatureb64)
596
624
 
597
625
  // Add clientExtensionResults as seen in the example
@@ -107,6 +107,24 @@ interface CredentialRepository {
107
107
  fun getOrigin(info: CallingAppInfo): String
108
108
  fun appInfoToOrigin(info: CallingAppInfo): String
109
109
 
110
+ /**
111
+ * The real web origin of a caller that is an allow-listed privileged
112
+ * browser (one holding `CREDENTIAL_MANAGER_SET_ORIGIN` and present in
113
+ * [privilegedAllowlist]), or `null` for every ordinary app.
114
+ *
115
+ * This is the only place a caller-asserted origin (and, by extension, a
116
+ * caller-supplied `clientDataHash`) may be trusted. It delegates to
117
+ * [CallingAppInfo.getOrigin], whose contract is:
118
+ * - returns the web origin when the caller set one AND is on the allowlist;
119
+ * - returns `null` when the caller set no origin (the common native case);
120
+ * - throws [IllegalStateException] when a non-allow-listed caller tries to
121
+ * assert an origin (a spoofing attempt), which we swallow into `null` so
122
+ * the assertion falls back to the caller's `android:apk-key-hash:` origin.
123
+ *
124
+ * @param privilegedAllowlist the FIDO/GPM privileged-apps JSON allowlist.
125
+ */
126
+ fun getPrivilegedOrigin(info: CallingAppInfo, privilegedAllowlist: String): String?
127
+
110
128
  /**
111
129
  * The credential stored under `credentialId` WITH its private material
112
130
  * (`privateKey`), opening the biometric wrapper with `biometricCipher` when
@@ -1105,6 +1123,21 @@ class Repository() : CredentialRepository {
1105
1123
  return appInfoToOrigin(info)
1106
1124
  }
1107
1125
 
1126
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1127
+ override fun getPrivilegedOrigin(info: CallingAppInfo, privilegedAllowlist: String): String? {
1128
+ return try {
1129
+ info.getOrigin(privilegedAllowlist)
1130
+ } catch (e: IllegalStateException) {
1131
+ // A non-allow-listed caller asserted an origin: treat as spoofing
1132
+ // and fall back to the app-bound origin.
1133
+ PasskeyLog.w(CredentialRepository.TAG, "Caller asserted an origin but is not an allow-listed privileged browser; ignoring it")
1134
+ null
1135
+ } catch (e: Exception) {
1136
+ PasskeyLog.w(CredentialRepository.TAG, "Failed to resolve privileged browser origin; treating caller as non-privileged", e)
1137
+ null
1138
+ }
1139
+ }
1140
+
1108
1141
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1109
1142
  override fun appInfoToOrigin(info: CallingAppInfo): String {
1110
1143
  val cert = info.signingInfo.apkContentsSigners[0].toByteArray()
@@ -31,6 +31,7 @@ import co.algorand.passkeyautofill.credentials.CredentialRepository
31
31
  import co.algorand.passkeyautofill.credentials.Credential
32
32
  import co.algorand.passkeyautofill.credentials.RelyingParty
33
33
  import co.algorand.passkeyautofill.utils.PasskeyUtils
34
+ import co.algorand.passkeyautofill.utils.PrivilegedBrowserAllowlist
34
35
  import android.util.Base64 as AndroidBase64
35
36
  import com.tencent.mmkv.MMKV
36
37
  import javax.crypto.Cipher
@@ -82,6 +83,10 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
82
83
  ) {
83
84
  PasskeyLog.init(this)
84
85
  stampActivated(this)
86
+ // Opportunistically keep the privileged-browser allowlist current so a
87
+ // rotated-out (e.g. compromised) browser stops being trusted; never
88
+ // blocks this callback.
89
+ PrivilegedBrowserAllowlist.refreshIfStale(this)
85
90
  val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request)
86
91
  if (response != null) {
87
92
  callback.onResult(response)
@@ -170,6 +175,10 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
170
175
  ) {
171
176
  PasskeyLog.init(this)
172
177
  stampActivated(this)
178
+ // Opportunistically keep the privileged-browser allowlist current so a
179
+ // rotated-out (e.g. compromised) browser stops being trusted; never
180
+ // blocks this callback.
181
+ PrivilegedBrowserAllowlist.refreshIfStale(this)
173
182
  try {
174
183
  val response = processGetCredentialRequest(request)
175
184
  callback.onResult(response)
@@ -0,0 +1,238 @@
1
+ package co.algorand.passkeyautofill.utils
2
+
3
+ import android.content.Context
4
+ import co.algorand.passkeyautofill.R
5
+ import kotlinx.coroutines.CoroutineScope
6
+ import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.launch
9
+ import org.json.JSONObject
10
+ import java.io.File
11
+ import java.net.HttpURLConnection
12
+ import java.net.URL
13
+
14
+ /**
15
+ * Supplies the FIDO/GPM privileged-browser allowlist (Google's
16
+ * `gpm-passkeys-privileged-apps` list) to
17
+ * [co.algorand.passkeyautofill.credentials.CredentialRepository.getPrivilegedOrigin].
18
+ *
19
+ * The allowlist is the set of callers this provider is willing to treat as real
20
+ * browsers: only for a caller on this list will a request-supplied web origin
21
+ * and `clientDataHash` be trusted. Every other caller is bound to its own
22
+ * `android:apk-key-hash:` origin.
23
+ *
24
+ * ### Sourcing strategy
25
+ * The list is not static: Google adds/removes browsers over time (and an app
26
+ * that turns malicious can be rotated *out* of it). Bundling only a snapshot
27
+ * means such changes never reach an installed provider until it ships again, so
28
+ * this loader keeps a current copy at runtime while degrading safely:
29
+ *
30
+ * 1. a fresh cached download (younger than [TTL_MS]);
31
+ * 2. the last good download, even if stale;
32
+ * 3. the bundled `res/raw` snapshot as the guaranteed offline floor.
33
+ *
34
+ * [json] is on the latency-sensitive credential path, so it NEVER touches the
35
+ * network; it only reads cache/disk/bundle. [refreshIfStale] performs the
36
+ * actual download on a background scope and is meant to be called from the
37
+ * credential-provider callbacks; it validates the payload before replacing the
38
+ * cache and leaves the previous copy untouched on any failure.
39
+ *
40
+ * Staleness cuts both ways. A browser *added* after our copy was fetched just
41
+ * falls back to app-binding (safe). But a browser *removed* from Google's list
42
+ * (e.g. rotated out because it turned malicious) keeps being trusted until the
43
+ * cache refreshes, so [TTL_MS] is the revocation window and is kept short. A
44
+ * missing/unreadable list makes every caller non-privileged (safe default).
45
+ */
46
+ object PrivilegedBrowserAllowlist {
47
+ private const val TAG = "PrivilegedAllowlist"
48
+
49
+ /**
50
+ * Google's published GPM privileged-apps allowlist. This is the same file
51
+ * bundled as `res/raw/gpm_privileged_browser_allowlist.json`, kept current
52
+ * here. `CallingAppInfo.getOrigin` consumes the raw JSON string, so a fresh
53
+ * download can be handed to it as-is.
54
+ */
55
+ private const val REMOTE_URL = "https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json"
56
+
57
+ /** Filename of the cached download inside the app's private `filesDir`. */
58
+ private const val CACHE_FILE_NAME = "gpm_privileged_browser_allowlist.json"
59
+
60
+ /**
61
+ * How long a downloaded copy is considered fresh (24 hours). This bounds how
62
+ * long a browser Google has removed from the list can still be trusted; the
63
+ * payload is ~30 KB, so refreshing daily is cheap.
64
+ */
65
+ private const val TTL_MS = 24L * 60 * 60 * 1000
66
+
67
+ private const val CONNECT_TIMEOUT_MS = 10_000
68
+ private const val READ_TIMEOUT_MS = 10_000
69
+
70
+ /** Guards against a runaway download producing an oversized in-memory string. */
71
+ private const val MAX_DOWNLOAD_BYTES = 1L * 1024 * 1024 // 1 MiB
72
+
73
+ /**
74
+ * Process-lifetime scope for refreshes. Deliberately not tied to the
75
+ * short-lived provider service, whose scope is cancelled in `onDestroy` and
76
+ * would otherwise abort a download mid-flight.
77
+ */
78
+ private val refreshScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
79
+
80
+ @Volatile
81
+ private var memoryCache: String? = null
82
+
83
+ @Volatile
84
+ private var refreshInProgress = false
85
+
86
+ /**
87
+ * The allowlist JSON to hand to `CallingAppInfo.getOrigin`, chosen in
88
+ * priority order: an already-resolved in-memory copy → a valid cached
89
+ * download → the bundled raw snapshot. Returns `null` only if every source
90
+ * fails, which makes every caller non-privileged (the safe, app-bound
91
+ * default).
92
+ *
93
+ * This never performs network I/O and is safe to call on the credential
94
+ * path.
95
+ */
96
+ fun json(context: Context): String? {
97
+ memoryCache?.let { return it }
98
+ return synchronized(this) {
99
+ memoryCache ?: run {
100
+ val resolved = readCacheFile(context) ?: readBundled(context)
101
+ memoryCache = resolved
102
+ resolved
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Kicks off a background refresh when the cached download is missing or older
109
+ * than [TTL_MS]. Returns immediately and never blocks the credential flow;
110
+ * the download runs on a process-lifetime background scope. The result
111
+ * replaces the cache (and the in-memory copy) only if it
112
+ * validates, so a truncated/garbage response can never clobber a good copy
113
+ * (which would otherwise silently make every browser non-privileged).
114
+ *
115
+ * Intended to be called opportunistically from the credential-provider
116
+ * callbacks so the list stays current without a dedicated update job.
117
+ */
118
+ fun refreshIfStale(context: Context) {
119
+ if (!isCacheStale(context)) return
120
+ synchronized(this) {
121
+ if (refreshInProgress) return
122
+ refreshInProgress = true
123
+ }
124
+ val appContext = context.applicationContext
125
+ refreshScope.launch {
126
+ try {
127
+ val downloaded = download()
128
+ if (downloaded == null || !isValidAllowlist(downloaded)) {
129
+ PasskeyLog.w(TAG, "Downloaded privileged allowlist missing or invalid; keeping previous copy")
130
+ return@launch
131
+ }
132
+ writeCacheFile(appContext, downloaded)
133
+ memoryCache = downloaded
134
+ PasskeyLog.d(TAG, "Refreshed privileged browser allowlist from remote source")
135
+ } catch (e: Exception) {
136
+ PasskeyLog.w(TAG, "Failed to refresh privileged browser allowlist; keeping previous copy", e)
137
+ } finally {
138
+ synchronized(this@PrivilegedBrowserAllowlist) { refreshInProgress = false }
139
+ }
140
+ }
141
+ }
142
+
143
+ private fun cacheFile(context: Context) = File(context.filesDir, CACHE_FILE_NAME)
144
+
145
+ private fun isCacheStale(context: Context): Boolean {
146
+ val f = cacheFile(context)
147
+ if (!f.exists() || f.length() == 0L) return true
148
+ return (System.currentTimeMillis() - f.lastModified()) > TTL_MS
149
+ }
150
+
151
+ private fun readCacheFile(context: Context): String? {
152
+ return try {
153
+ val f = cacheFile(context)
154
+ if (!f.exists() || f.length() == 0L) return null
155
+ val text = f.readText()
156
+ if (isValidAllowlist(text)) {
157
+ text
158
+ } else {
159
+ PasskeyLog.w(TAG, "Cached privileged allowlist is invalid; falling back to bundled copy")
160
+ null
161
+ }
162
+ } catch (e: Exception) {
163
+ PasskeyLog.w(TAG, "Failed to read cached privileged allowlist; falling back to bundled copy", e)
164
+ null
165
+ }
166
+ }
167
+
168
+ private fun writeCacheFile(context: Context, text: String) {
169
+ val dst = cacheFile(context)
170
+ val tmp = File(context.filesDir, "$CACHE_FILE_NAME.tmp")
171
+ try {
172
+ tmp.writeText(text)
173
+ if (!tmp.renameTo(dst)) {
174
+ // Atomic rename failed (e.g. dst exists on some FS): overwrite.
175
+ dst.writeText(text)
176
+ }
177
+ } finally {
178
+ if (tmp.exists()) tmp.delete()
179
+ }
180
+ }
181
+
182
+ private fun readBundled(context: Context): String? {
183
+ return try {
184
+ context.resources
185
+ .openRawResource(R.raw.gpm_privileged_browser_allowlist)
186
+ .bufferedReader()
187
+ .use { it.readText() }
188
+ } catch (e: Exception) {
189
+ PasskeyLog.w(TAG, "Failed to load bundled privileged browser allowlist; all callers will be treated as non-privileged", e)
190
+ null
191
+ }
192
+ }
193
+
194
+ private fun download(): String? {
195
+ val conn = (URL(REMOTE_URL).openConnection() as HttpURLConnection).apply {
196
+ connectTimeout = CONNECT_TIMEOUT_MS
197
+ readTimeout = READ_TIMEOUT_MS
198
+ requestMethod = "GET"
199
+ setRequestProperty("Accept", "application/json")
200
+ }
201
+ return try {
202
+ val code = conn.responseCode
203
+ if (code != HttpURLConnection.HTTP_OK) {
204
+ PasskeyLog.w(TAG, "Privileged allowlist download returned HTTP $code")
205
+ return null
206
+ }
207
+ val bytes = conn.inputStream.buffered().use { input ->
208
+ val out = java.io.ByteArrayOutputStream()
209
+ val buf = ByteArray(8 * 1024)
210
+ var total = 0L
211
+ while (true) {
212
+ val n = input.read(buf)
213
+ if (n < 0) break
214
+ total += n
215
+ if (total > MAX_DOWNLOAD_BYTES) {
216
+ PasskeyLog.w(TAG, "Privileged allowlist download exceeded size limit; discarding")
217
+ return null
218
+ }
219
+ out.write(buf, 0, n)
220
+ }
221
+ out.toByteArray()
222
+ }
223
+ String(bytes, Charsets.UTF_8)
224
+ } finally {
225
+ conn.disconnect()
226
+ }
227
+ }
228
+
229
+ /** A well-formed allowlist parses as JSON with a non-empty `apps` array. */
230
+ private fun isValidAllowlist(text: String): Boolean {
231
+ return try {
232
+ val apps = JSONObject(text).optJSONArray("apps")
233
+ apps != null && apps.length() > 0
234
+ } catch (e: Exception) {
235
+ false
236
+ }
237
+ }
238
+ }