@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 +6 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +16 -2
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +31 -3
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +33 -0
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +9 -0
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PrivilegedBrowserAllowlist.kt +238 -0
- package/android/src/main/res/raw/gpm_privileged_browser_allowlist.json +980 -0
- package/package.json +1 -1
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt
CHANGED
|
@@ -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
|
+
}
|