@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.14 → 1.0.0-canary.16
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/.oxlintrc.json +2 -9
- package/ARCHITECTURE.md +6 -0
- package/CHANGELOG.md +22 -7
- package/README.md +49 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +22 -5
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +19 -5
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +97 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +82 -3
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +21 -0
- package/android/src/main/res/values/styles.xml +1 -1
- package/app.plugin.js +438 -1
- package/build/ReactNativePasskeyAutofill.types.d.ts +16 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts +27 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +103 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +191 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +437 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +584 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +173 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +156 -6
- package/lefthook.yml +2 -0
- package/package.json +69 -11
- package/pnpm-workspace.yaml +22 -0
- package/src/ReactNativePasskeyAutofill.types.ts +17 -0
- package/src/ReactNativePasskeyAutofillModule.ts +30 -1
package/.oxlintrc.json
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
-
"plugins": [
|
|
4
|
-
"eslint",
|
|
5
|
-
"react",
|
|
6
|
-
"react-perf",
|
|
7
|
-
"typescript",
|
|
8
|
-
"unicorn",
|
|
9
|
-
"oxc"
|
|
10
|
-
],
|
|
3
|
+
"plugins": ["eslint", "react", "react-perf", "typescript", "unicorn", "oxc"],
|
|
11
4
|
"categories": {
|
|
12
5
|
"correctness": "error"
|
|
13
6
|
},
|
|
@@ -18,4 +11,4 @@
|
|
|
18
11
|
"env": {
|
|
19
12
|
"builtin": true
|
|
20
13
|
}
|
|
21
|
-
}
|
|
14
|
+
}
|
package/ARCHITECTURE.md
CHANGED
|
@@ -63,7 +63,13 @@ The following methods are exposed to the JavaScript layer:
|
|
|
63
63
|
- `getHdRootKeyId()`: Retrieves the current HD root key ID.
|
|
64
64
|
- `configureIntentActions(getPasskeyAction: string, createPasskeyAction: string)`: Configures the intent actions used for Passkey flows.
|
|
65
65
|
- `clearCredentials()`: Clears all stored credentials.
|
|
66
|
+
- `isProviderActive()`: Returns `true` if this app is the user-selected system credential/autofill provider. Uses Android's `Settings.Secure("credential_service"[_primary])` (API 34+) and iOS's `ASCredentialIdentityStore.getState`. Useful both for gating passkey UI at runtime and for E2E tests, which need to confirm that an OS passkey prompt is served by _this_ provider rather than any other installed one.
|
|
67
|
+
- `openProviderSettings()`: Deep-links the user to the OS credential/autofill provider settings so they can enable this app as the active provider. Resolves to `true` if a settings screen could be launched.
|
|
66
68
|
|
|
67
69
|
## Security Considerations
|
|
68
70
|
|
|
69
71
|
As this module handles sensitive information (Passkeys), it is crucial to ensure that keys and secrets are handled securely in the native layers. Master keys should be stored in secure storage (like Android Keystore or iOS Keychain).
|
|
72
|
+
|
|
73
|
+
## End-to-End Tests
|
|
74
|
+
|
|
75
|
+
The `e2e/` workspace drives the `example/` app with Appium 2 + WebdriverIO, executed through Jest. The Android job uses the UiAutomator2 driver; the iOS job uses XCUITest. The happy-path spec mirrors the example and exercises passkey registration and assertion against `https://debug.liquidauth.com`. See [`e2e/README.md`](./e2e/README.md) for local usage and the [`E2E` workflow](./.github/workflows/e2e.yml) for CI.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
|
-
# [1.0.0-canary.
|
|
1
|
+
# [1.0.0-canary.16](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.15...v1.0.0-canary.16) (2026-05-15)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Bug Fixes
|
|
5
5
|
|
|
6
|
-
*
|
|
6
|
+
* align ios autofill root key flow ([1287f57](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/1287f57952b41829032ef368bad826624375e9a3))
|
|
7
7
|
|
|
8
|
-
# [1.0.0-canary.13](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.12...v1.0.0-canary.13) (2026-04-10)
|
|
9
8
|
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add iOS passkey autofill provider ([9aad8f8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/9aad8f8ceb2ad54cf53847b60dd6e70d5b2a1f2b))
|
|
12
|
+
|
|
13
|
+
# [1.0.0-canary.15](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.14...v1.0.0-canary.15) (2026-04-10)
|
|
10
14
|
|
|
11
15
|
### Bug Fixes
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
- use bouncycastle bcprov-jdk15to18 for android ([cd860b2](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/cd860b2012f2189e854137afa55c7a0c0dc1b94c))
|
|
14
18
|
|
|
15
|
-
# [1.0.0-canary.
|
|
19
|
+
# [1.0.0-canary.14](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.13...v1.0.0-canary.14) (2026-04-10)
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
- switch to maven for local AAR dependency ([f4e52b4](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/f4e52b46d618e6e8a7972e649c5d75d768e32339))
|
|
24
|
+
|
|
25
|
+
# [1.0.0-canary.13](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.12...v1.0.0-canary.13) (2026-04-10)
|
|
16
26
|
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
- bump keystore to 1.0.0-canary.9 ([f093bab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/f093bab0b74d5cbf93e8f03ecd8d56c77597d3f5))
|
|
30
|
+
|
|
31
|
+
# [1.0.0-canary.12](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.11...v1.0.0-canary.12) (2026-04-10)
|
|
17
32
|
|
|
18
33
|
### Features
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
- **android:** biometrics single tap and fallback ([a6cbe4c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a6cbe4cf51579b15b2a219e1e06e843bb6001ed4))
|
|
36
|
+
- **android:** support DER format ([492034f](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/492034f8b031e795b2575be69da8c3a1adb9096c))
|
|
22
37
|
|
|
23
38
|
# [1.0.0-canary.11](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.10...v1.0.0-canary.11) (2026-03-31)
|
|
24
39
|
|
package/README.md
CHANGED
|
@@ -45,6 +45,54 @@ If you are using Expo, you can configure the plugin in your `app.json` or `app.c
|
|
|
45
45
|
- `site`: The URL of your FIDO server (default: `https://debug.liquidauth.com`).
|
|
46
46
|
- `label`: The name of the credential provider as it appears in Android settings (default: `My Credential Provider`).
|
|
47
47
|
|
|
48
|
+
### Configure for iOS
|
|
49
|
+
|
|
50
|
+
iOS passkey AutoFill requires a Credential Provider extension, an App Group shared between the app and extension, and an associated domain for Web Credentials. The Expo config plugin can create and wire the extension during prebuild:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"expo": {
|
|
55
|
+
"ios": {
|
|
56
|
+
"bundleIdentifier": "com.example.wallet",
|
|
57
|
+
"associatedDomains": ["webcredentials:your-fido-server.com"],
|
|
58
|
+
"entitlements": {
|
|
59
|
+
"com.apple.developer.authentication-services.autofill-credential-provider": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"plugins": [
|
|
63
|
+
[
|
|
64
|
+
"@algorandfoundation/react-native-passkey-autofill",
|
|
65
|
+
{
|
|
66
|
+
"site": "https://your-fido-server.com",
|
|
67
|
+
"label": "My Custom Credential Provider",
|
|
68
|
+
"appGroup": "group.com.example.wallet.passkey-autofill",
|
|
69
|
+
"appleTeamId": "YOUR_TEAM_ID"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For iOS integration, make sure that:
|
|
78
|
+
|
|
79
|
+
- The app and Credential Provider extension both have the AutoFill Credential Provider capability.
|
|
80
|
+
- The app and extension both have the same App Group entitlement.
|
|
81
|
+
- The app has a `webcredentials:<domain>` associated domain, and that domain serves a valid `apple-app-site-association` file for the app identifier.
|
|
82
|
+
- The deployment target is iOS 17 or newer for passkey credential provider support.
|
|
83
|
+
- The generated extension target can link `AuthenticationServices.framework`, `CryptoKit.framework`, `MMKVCore`, and the deterministic P-256 Swift package.
|
|
84
|
+
- `NSFaceIDUsageDescription` is present when biometric authentication is used.
|
|
85
|
+
|
|
86
|
+
At runtime, the app must provide the native side with the master key, identify the HD root key stored in MMKV, and keep the iOS identity store in sync:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
await ReactNativePasskeyAutofill.setMasterKey(masterKeyHex);
|
|
90
|
+
await ReactNativePasskeyAutofill.setHdRootKeyId(hdRootKeyId);
|
|
91
|
+
await ReactNativePasskeyAutofill.refreshCredentialIdentities();
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Call `refreshCredentialIdentities()` after creating, importing, deleting, or restoring passkeys so iOS AutoFill sees the current credentials.
|
|
95
|
+
|
|
48
96
|
## Usage
|
|
49
97
|
|
|
50
98
|
```typescript
|
|
@@ -128,7 +176,7 @@ The project includes a GitHub Actions workflow that automatically runs linting,
|
|
|
128
176
|
|
|
129
177
|
### Integration Testing (E2E)
|
|
130
178
|
|
|
131
|
-
|
|
179
|
+
Full end-to-end tests driving the example app with Appium and WebdriverIO are available in the [`e2e/`](./e2e) directory. These tests exercise the entire passkey creation and usage flow on Android emulators and iOS simulators. See the [E2E README](./e2e/README.md) for more details.
|
|
132
180
|
|
|
133
181
|
## 📱 Example App
|
|
134
182
|
|
package/android/build.gradle
CHANGED
|
@@ -67,7 +67,7 @@ dependencies {
|
|
|
67
67
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4"
|
|
68
68
|
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
|
|
69
69
|
implementation "io.github.zhongwuzw:mmkv:2.3.0"
|
|
70
|
-
implementation "org.bouncycastle:bcprov-
|
|
70
|
+
implementation "org.bouncycastle:bcprov-jdk15to18:1.78.1"
|
|
71
71
|
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
|
72
72
|
implementation "co.algorand:dP256Android:1.0.0"
|
|
73
73
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
@@ -53,6 +53,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
53
53
|
private var request: ProviderCreateCredentialRequest? = null
|
|
54
54
|
private var biometricPromptResult: Any? = null
|
|
55
55
|
private var systemUnlockedCipher: javax.crypto.Cipher? = null
|
|
56
|
+
private var isHandling: Boolean = false
|
|
56
57
|
|
|
57
58
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
58
59
|
super.onCreate(savedInstanceState)
|
|
@@ -127,6 +128,13 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
127
128
|
return
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// Auto-trigger creation flow
|
|
132
|
+
handleCreation()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun setupUI() {
|
|
136
|
+
if (isFinishing || isDestroyed) return
|
|
137
|
+
|
|
130
138
|
// Improved UI
|
|
131
139
|
val layout = LinearLayout(this).apply {
|
|
132
140
|
val padding = (32 * resources.displayMetrics.density).toInt()
|
|
@@ -226,6 +234,11 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
226
234
|
|
|
227
235
|
val confirmButton = Button(this).apply {
|
|
228
236
|
text = "Create Passkey"
|
|
237
|
+
// Stable accessibility id for E2E tests (`~create-passkey-confirm`).
|
|
238
|
+
// The Create Passkey activity intermittently appears on top of the
|
|
239
|
+
// system biometric prompt, so tests drive this explicit id rather
|
|
240
|
+
// than depending on localised button text.
|
|
241
|
+
contentDescription = "create-passkey-confirm"
|
|
229
242
|
setOnClickListener {
|
|
230
243
|
handleCreation()
|
|
231
244
|
}
|
|
@@ -240,6 +253,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
240
253
|
|
|
241
254
|
val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
|
|
242
255
|
text = "Cancel"
|
|
256
|
+
contentDescription = "create-passkey-cancel"
|
|
243
257
|
setOnClickListener {
|
|
244
258
|
setResult(RESULT_CANCELED)
|
|
245
259
|
finish()
|
|
@@ -261,6 +275,8 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
261
275
|
|
|
262
276
|
@SuppressLint("RestrictedApi")
|
|
263
277
|
private fun handleCreation() {
|
|
278
|
+
if (isHandling) return
|
|
279
|
+
isHandling = true
|
|
264
280
|
Log.d(TAG, "handleCreation started, userVerification=$userVerification")
|
|
265
281
|
lifecycleScope.launch {
|
|
266
282
|
val cipherToUse = systemUnlockedCipher ?: run {
|
|
@@ -285,7 +301,8 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
285
301
|
Log.d(TAG, "Manual biometrics result: $result")
|
|
286
302
|
if (result == null) {
|
|
287
303
|
Log.w(TAG, "Biometrics failed or was canceled")
|
|
288
|
-
|
|
304
|
+
setupUI() // Show UI as fallback
|
|
305
|
+
isHandling = false
|
|
289
306
|
return@launch
|
|
290
307
|
}
|
|
291
308
|
result.cryptoObject?.cipher
|
|
@@ -420,14 +437,14 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
420
437
|
ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAdded", Bundle().apply {
|
|
421
438
|
putBoolean("success", true)
|
|
422
439
|
})
|
|
440
|
+
finish()
|
|
423
441
|
} catch (e: Exception) {
|
|
424
442
|
Log.e(TAG, "Error during passkey creation", e)
|
|
425
|
-
|
|
443
|
+
setupUI() // Show UI on error
|
|
444
|
+
isHandling = false
|
|
426
445
|
}
|
|
427
|
-
finish()
|
|
428
446
|
}
|
|
429
|
-
}
|
|
430
|
-
|
|
447
|
+
}
|
|
431
448
|
|
|
432
449
|
private suspend fun biometrics(needsCipher: Boolean): BiometricPrompt.AuthenticationResult? {
|
|
433
450
|
return suspendCoroutine { continuation ->
|
|
@@ -54,6 +54,7 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
54
54
|
private var request: ProviderGetCredentialRequest? = null
|
|
55
55
|
private var biometricPromptResult: Any? = null
|
|
56
56
|
private var systemUnlockedCipher: javax.crypto.Cipher? = null
|
|
57
|
+
private var isHandling: Boolean = false
|
|
57
58
|
|
|
58
59
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
59
60
|
super.onCreate(savedInstanceState)
|
|
@@ -124,6 +125,13 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
124
125
|
return
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// Auto-trigger assertion flow
|
|
129
|
+
handleAssertion()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun setupUI() {
|
|
133
|
+
if (isFinishing || isDestroyed) return
|
|
134
|
+
|
|
127
135
|
// Improved UI
|
|
128
136
|
val layout = LinearLayout(this).apply {
|
|
129
137
|
val padding = (32 * resources.displayMetrics.density).toInt()
|
|
@@ -214,6 +222,8 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
214
222
|
|
|
215
223
|
val confirmButton = Button(this).apply {
|
|
216
224
|
text = "Sign In"
|
|
225
|
+
// Stable accessibility id for E2E tests (`~get-passkey-confirm`).
|
|
226
|
+
contentDescription = "get-passkey-confirm"
|
|
217
227
|
setOnClickListener {
|
|
218
228
|
handleAssertion()
|
|
219
229
|
}
|
|
@@ -228,6 +238,7 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
228
238
|
|
|
229
239
|
val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
|
|
230
240
|
text = "Cancel"
|
|
241
|
+
contentDescription = "get-passkey-cancel"
|
|
231
242
|
setOnClickListener {
|
|
232
243
|
setResult(RESULT_CANCELED)
|
|
233
244
|
finish()
|
|
@@ -249,6 +260,8 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
249
260
|
|
|
250
261
|
@SuppressLint("RestrictedApi")
|
|
251
262
|
private fun handleAssertion() {
|
|
263
|
+
if (isHandling) return
|
|
264
|
+
isHandling = true
|
|
252
265
|
Log.d(TAG, "handleAssertion started, userVerification=$userVerification")
|
|
253
266
|
lifecycleScope.launch {
|
|
254
267
|
val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
|
|
@@ -281,7 +294,8 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
281
294
|
Log.d(TAG, "Manual biometrics result: $result")
|
|
282
295
|
if (result == null) {
|
|
283
296
|
Log.w(TAG, "Biometrics failed or was canceled")
|
|
284
|
-
|
|
297
|
+
setupUI() // Show UI as fallback
|
|
298
|
+
isHandling = false
|
|
285
299
|
return@launch
|
|
286
300
|
}
|
|
287
301
|
result.cryptoObject?.cipher
|
|
@@ -458,14 +472,14 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
458
472
|
ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAuthenticated", Bundle().apply {
|
|
459
473
|
putBoolean("success", true)
|
|
460
474
|
})
|
|
475
|
+
finish()
|
|
461
476
|
} catch (e: Exception) {
|
|
462
477
|
Log.e(TAG, "Error during passkey assertion", e)
|
|
463
|
-
|
|
478
|
+
setupUI() // Show UI on error
|
|
479
|
+
isHandling = false
|
|
464
480
|
}
|
|
465
|
-
finish()
|
|
466
481
|
}
|
|
467
|
-
}
|
|
468
|
-
|
|
482
|
+
}
|
|
469
483
|
|
|
470
484
|
private suspend fun biometrics(iv: String?): BiometricPrompt.AuthenticationResult? {
|
|
471
485
|
return suspendCoroutine { continuation ->
|
package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt
CHANGED
|
@@ -3,8 +3,14 @@ package co.algorand.passkeyautofill
|
|
|
3
3
|
import expo.modules.kotlin.modules.Module
|
|
4
4
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
5
|
import co.algorand.passkeyautofill.credentials.CredentialRepository
|
|
6
|
+
import co.algorand.passkeyautofill.service.PasskeyAutofillCredentialProviderService
|
|
7
|
+
import co.algorand.passkeyautofill.service.PasskeyAutofillCredentialProviderService.Companion.KEY_LAST_INVOKED_AT_MS
|
|
6
8
|
import com.tencent.mmkv.MMKV
|
|
9
|
+
import android.content.ComponentName
|
|
7
10
|
import android.content.Context
|
|
11
|
+
import android.content.Intent
|
|
12
|
+
import android.net.Uri
|
|
13
|
+
import android.provider.Settings
|
|
8
14
|
import android.util.Log
|
|
9
15
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
|
10
16
|
import java.security.Security
|
|
@@ -74,11 +80,102 @@ class ReactNativePasskeyAutofillModule : Module() {
|
|
|
74
80
|
credentialRepository.clearCredentials(context)
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
AsyncFunction("deleteCredential") { credentialId: String ->
|
|
84
|
+
val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
|
|
85
|
+
?: return@AsyncFunction Unit
|
|
86
|
+
credentialRepository.deleteCredential(context, credentialId)
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
AsyncFunction("configureIntentActions") { getPasskeyAction: String, createPasskeyAction: String ->
|
|
78
90
|
val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
|
|
79
91
|
if (context != null) {
|
|
80
92
|
credentialRepository.configureIntentActions(context, getPasskeyAction, createPasskeyAction)
|
|
81
93
|
}
|
|
82
94
|
}
|
|
95
|
+
|
|
96
|
+
AsyncFunction("isProviderActive") {
|
|
97
|
+
val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
|
|
98
|
+
?: return@AsyncFunction false
|
|
99
|
+
isProviderEnabled(context)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
AsyncFunction("openProviderSettings") {
|
|
103
|
+
val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
|
|
104
|
+
?: return@AsyncFunction false
|
|
105
|
+
openCredentialProviderSettings(context)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns `true` when this app's [PasskeyAutofillCredentialProviderService]
|
|
111
|
+
* is registered as an active credential provider for the current user.
|
|
112
|
+
*
|
|
113
|
+
* Android stores the enabled credential providers as a colon-separated list
|
|
114
|
+
* of flattened [ComponentName]s in the `credential_service` and
|
|
115
|
+
* `credential_service_primary` Secure settings (API 34+). However those
|
|
116
|
+
* keys are `@hide` on Android 12+, so reading them from a regular app
|
|
117
|
+
* throws `SecurityException`. We therefore combine two signals:
|
|
118
|
+
*
|
|
119
|
+
* 1. Best-effort: try [Settings.Secure.getString]; if it returns the
|
|
120
|
+
* expected component we know for sure we're the provider.
|
|
121
|
+
* 2. Fallback: inspect the MMKV timestamp written by the service itself
|
|
122
|
+
* whenever the system routes a `BeginCreate/BeginGetCredentialRequest`
|
|
123
|
+
* to it ([PasskeyAutofillCredentialProviderService.KEY_LAST_INVOKED_AT_MS]).
|
|
124
|
+
* The Credential Manager only routes requests to *enabled* providers,
|
|
125
|
+
* so a non-zero stamp is proof that we were selected at least once.
|
|
126
|
+
*/
|
|
127
|
+
private fun isProviderEnabled(context: Context): Boolean {
|
|
128
|
+
val expected = ComponentName(
|
|
129
|
+
context.packageName,
|
|
130
|
+
PasskeyAutofillCredentialProviderService::class.java.name,
|
|
131
|
+
).flattenToString()
|
|
132
|
+
val resolver = context.contentResolver
|
|
133
|
+
val keys = arrayOf("credential_service", "credential_service_primary")
|
|
134
|
+
for (key in keys) {
|
|
135
|
+
val value = try {
|
|
136
|
+
Settings.Secure.getString(resolver, key)
|
|
137
|
+
} catch (e: SecurityException) {
|
|
138
|
+
// `credential_service[_primary]` are @hide on Android 12+; fall
|
|
139
|
+
// through to the MMKV stamp check below.
|
|
140
|
+
Log.d("ReactNativePasskeyAutofill", "Secure key $key not readable: ${e.message}")
|
|
141
|
+
null
|
|
142
|
+
} ?: continue
|
|
143
|
+
if (value.isEmpty()) continue
|
|
144
|
+
val enabled = value.split(':').any { it.equals(expected, ignoreCase = true) } ||
|
|
145
|
+
value.contains(expected, ignoreCase = true)
|
|
146
|
+
if (enabled) return true
|
|
147
|
+
}
|
|
148
|
+
// Fallback: look for a timestamp written by our CredentialProviderService.
|
|
149
|
+
return try {
|
|
150
|
+
MMKV.initialize(context)
|
|
151
|
+
(MMKV.defaultMMKV()?.decodeLong(KEY_LAST_INVOKED_AT_MS, 0L) ?: 0L) > 0L
|
|
152
|
+
} catch (e: Exception) {
|
|
153
|
+
Log.w("ReactNativePasskeyAutofill", "Failed to read provider stamp: ${e.message}")
|
|
154
|
+
false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Best-effort deep link into the user's credential-provider preferences so
|
|
160
|
+
* they can toggle our service on. Falls back to the app-details page when
|
|
161
|
+
* the credential provider screen is not available on the device.
|
|
162
|
+
*/
|
|
163
|
+
private fun openCredentialProviderSettings(context: Context): Boolean {
|
|
164
|
+
val intents = listOf(
|
|
165
|
+
Intent("android.settings.CREDENTIAL_PROVIDER"),
|
|
166
|
+
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
167
|
+
data = Uri.fromParts("package", context.packageName, null)
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
for (intent in intents) {
|
|
171
|
+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
172
|
+
try {
|
|
173
|
+
context.startActivity(intent)
|
|
174
|
+
return true
|
|
175
|
+
} catch (e: Exception) {
|
|
176
|
+
Log.w("ReactNativePasskeyAutofill", "Failed to open ${intent.action}: ${e.message}")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false
|
|
83
180
|
}
|
|
84
181
|
}
|
package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt
CHANGED
|
@@ -45,6 +45,7 @@ interface CredentialRepository {
|
|
|
45
45
|
fun getCreatePasskeyAction(context: Context): String?
|
|
46
46
|
fun getGetPasskeyAction(context: Context): String?
|
|
47
47
|
fun clearCredentials(context: Context)
|
|
48
|
+
fun deleteCredential(context: Context, credentialId: String)
|
|
48
49
|
|
|
49
50
|
fun getBiometricCipherForEncryption(): Cipher
|
|
50
51
|
fun getBiometricCipherForDecryption(iv: ByteArray): Cipher
|
|
@@ -60,6 +61,15 @@ interface CredentialRepository {
|
|
|
60
61
|
const val KEYCHAIN_STORAGE_NAME = "PasskeyAutofillKeychain"
|
|
61
62
|
const val PASSKEY_AUTOFILL_MMKV_ID = "passkey_autofill"
|
|
62
63
|
const val HD_ROOT_KEY_ID_KEY = "hd_root_key_id"
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* JCE provider that exposes AndroidKeyStore-backed symmetric Cipher
|
|
67
|
+
* transformations (including AES/GCM). Pinning to this provider avoids
|
|
68
|
+
* BouncyCastle — which this module installs at position 1 — trying to
|
|
69
|
+
* extract raw bytes from a hardware-bound SecretKey and crashing with
|
|
70
|
+
* NullPointerException in `KeyParameter.<init>`.
|
|
71
|
+
*/
|
|
72
|
+
const val ANDROID_KEYSTORE_CIPHER_PROVIDER = "AndroidKeyStoreBCWorkaround"
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
|
|
@@ -254,6 +264,19 @@ class Repository() : CredentialRepository {
|
|
|
254
264
|
val paramSpec = (keyPairGen.generateKeyPair().public as java.security.interfaces.ECPublicKey).params
|
|
255
265
|
val factory = KeyFactory.getInstance("EC")
|
|
256
266
|
|
|
267
|
+
val publicKeyFromX509 = try {
|
|
268
|
+
factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
|
|
269
|
+
} catch (_: Exception) {
|
|
270
|
+
null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (publicKeyFromX509 != null && privateKeyBytes.size == 32) {
|
|
274
|
+
val s = java.math.BigInteger(1, privateKeyBytes)
|
|
275
|
+
val privSpec = ECPrivateKeySpec(s, paramSpec)
|
|
276
|
+
val privateKey = factory.generatePrivate(privSpec)
|
|
277
|
+
return KeyPair(publicKeyFromX509, privateKey)
|
|
278
|
+
}
|
|
279
|
+
|
|
257
280
|
// privateKey is 32-byte scalar
|
|
258
281
|
val rawXY: Pair<ByteArray, ByteArray>? = when {
|
|
259
282
|
// 65-byte uncompressed point: 04 || x(32) || y(32)
|
|
@@ -534,6 +557,36 @@ class Repository() : CredentialRepository {
|
|
|
534
557
|
}
|
|
535
558
|
}
|
|
536
559
|
|
|
560
|
+
override fun deleteCredential(context: Context, credentialId: String) {
|
|
561
|
+
try {
|
|
562
|
+
val mmkvPasskeys = getPasskeysMMKV(context)
|
|
563
|
+
for (candidateId in credentialIdCandidates(credentialId)) {
|
|
564
|
+
mmkvPasskeys.removeValueForKey(candidateId)
|
|
565
|
+
}
|
|
566
|
+
} catch (e: Exception) {
|
|
567
|
+
Log.e(CredentialRepository.TAG, "Error deleting credential", e)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private fun credentialIdCandidates(id: String): Set<String> {
|
|
572
|
+
val candidates = mutableSetOf(id)
|
|
573
|
+
val decoded = try {
|
|
574
|
+
AndroidBase64.decode(id, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
|
|
575
|
+
} catch (_: Exception) {
|
|
576
|
+
try {
|
|
577
|
+
AndroidBase64.decode(id, AndroidBase64.DEFAULT)
|
|
578
|
+
} catch (_: Exception) {
|
|
579
|
+
null
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (decoded != null) {
|
|
584
|
+
candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.DEFAULT).trim())
|
|
585
|
+
candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING))
|
|
586
|
+
}
|
|
587
|
+
return candidates
|
|
588
|
+
}
|
|
589
|
+
|
|
537
590
|
private fun hexToBytes(hex: String): ByteArray {
|
|
538
591
|
val result = ByteArray(hex.length / 2)
|
|
539
592
|
for (i in 0 until hex.length step 2) {
|
|
@@ -576,14 +629,40 @@ class Repository() : CredentialRepository {
|
|
|
576
629
|
return keyStore.getKey(CredentialRepository.BIOMETRIC_KEY_ALIAS, null) as SecretKey
|
|
577
630
|
}
|
|
578
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Build an AES/GCM Cipher tied to the AndroidKeyStore-backed biometric key.
|
|
634
|
+
*
|
|
635
|
+
* We request the provider explicitly because this module installs
|
|
636
|
+
* BouncyCastle at position 1 (see [co.algorand.passkeyautofill.ReactNativePasskeyAutofillModule]).
|
|
637
|
+
* Without a pinned provider, [Cipher.getInstance] resolves to BouncyCastle, which
|
|
638
|
+
* then tries to extract raw key material from an AndroidKeyStore SecretKey
|
|
639
|
+
* (whose `getEncoded()` is null for hardware-bound keys) and crashes with:
|
|
640
|
+
*
|
|
641
|
+
* java.lang.NullPointerException: Attempt to get length of null array
|
|
642
|
+
* at org.bouncycastle.crypto.params.KeyParameter.<init>(...)
|
|
643
|
+
*
|
|
644
|
+
* AndroidKeyStore AES keys are served by the "AndroidKeyStoreBCWorkaround"
|
|
645
|
+
* JCE provider on all supported Android versions.
|
|
646
|
+
*/
|
|
647
|
+
private fun newAndroidKeyStoreAesGcmCipher(): Cipher {
|
|
648
|
+
return try {
|
|
649
|
+
Cipher.getInstance("AES/GCM/NoPadding", CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER)
|
|
650
|
+
} catch (e: NoSuchProviderException) {
|
|
651
|
+
// Extremely unlikely on stock Android, but fall back to provider discovery
|
|
652
|
+
// by key rather than by name so we never hand the key to BouncyCastle.
|
|
653
|
+
Log.w(CredentialRepository.TAG, "${CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER} unavailable, falling back to default provider resolution", e)
|
|
654
|
+
Cipher.getInstance("AES/GCM/NoPadding")
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
579
658
|
override fun getBiometricCipherForEncryption(): Cipher {
|
|
580
|
-
val cipher =
|
|
659
|
+
val cipher = newAndroidKeyStoreAesGcmCipher()
|
|
581
660
|
cipher.init(Cipher.ENCRYPT_MODE, getBiometricSecretKey())
|
|
582
661
|
return cipher
|
|
583
662
|
}
|
|
584
663
|
|
|
585
664
|
override fun getBiometricCipherForDecryption(iv: ByteArray): Cipher {
|
|
586
|
-
val cipher =
|
|
665
|
+
val cipher = newAndroidKeyStoreAesGcmCipher()
|
|
587
666
|
cipher.init(Cipher.DECRYPT_MODE, getBiometricSecretKey(), GCMParameterSpec(128, iv))
|
|
588
667
|
return cipher
|
|
589
668
|
}
|
|
@@ -629,4 +708,4 @@ class Repository() : CredentialRepository {
|
|
|
629
708
|
val certHash = md.digest(cert)
|
|
630
709
|
return "android:apk-key-hash:${AndroidBase64.encodeToString(certHash, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)}"
|
|
631
710
|
}
|
|
632
|
-
}
|
|
711
|
+
}
|
|
@@ -50,6 +50,25 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
50
50
|
const val CREATE_PASSKEY_INTENT = 2
|
|
51
51
|
const val DEFAULT_GET_PASSKEY_ACTION = "co.algorand.passkeyautofill.GET_PASSKEY"
|
|
52
52
|
const val DEFAULT_CREATE_PASSKEY_ACTION = "co.algorand.passkeyautofill.CREATE_PASSKEY"
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* MMKV key holding the last time Android routed a credential request to
|
|
56
|
+
* this service. Set from both `onBeginCreate` and `onBeginGet` callbacks.
|
|
57
|
+
* Consumed by `ReactNativePasskeyAutofillModule.isProviderActive` as a
|
|
58
|
+
* reliable "this app is a registered provider" signal, since the raw
|
|
59
|
+
* `Settings.Secure("credential_service")` key is @hide-restricted on
|
|
60
|
+
* Android 12+ and not readable from regular apps.
|
|
61
|
+
*/
|
|
62
|
+
const val KEY_LAST_INVOKED_AT_MS = "provider_last_invoked_at_ms"
|
|
63
|
+
|
|
64
|
+
internal fun stampActivated(context: android.content.Context) {
|
|
65
|
+
try {
|
|
66
|
+
MMKV.initialize(context)
|
|
67
|
+
MMKV.defaultMMKV()?.encode(KEY_LAST_INVOKED_AT_MS, System.currentTimeMillis())
|
|
68
|
+
} catch (e: Exception) {
|
|
69
|
+
Log.w(TAG, "Failed to stamp provider activation: ${e.message}")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
53
72
|
}
|
|
54
73
|
/**
|
|
55
74
|
* Handle Create Credential Requests
|
|
@@ -60,6 +79,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
60
79
|
cancellationSignal: CancellationSignal,
|
|
61
80
|
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
|
|
62
81
|
) {
|
|
82
|
+
stampActivated(this)
|
|
63
83
|
val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request)
|
|
64
84
|
if (response != null) {
|
|
65
85
|
callback.onResult(response)
|
|
@@ -142,6 +162,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
142
162
|
cancellationSignal: CancellationSignal,
|
|
143
163
|
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
|
|
144
164
|
) {
|
|
165
|
+
stampActivated(this)
|
|
145
166
|
try {
|
|
146
167
|
val response = processGetCredentialRequest(request)
|
|
147
168
|
callback.onResult(response)
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
<item name="android:windowContentOverlay">@null</item>
|
|
6
6
|
<item name="android:windowNoTitle">true</item>
|
|
7
7
|
<item name="android:windowIsFloating">true</item>
|
|
8
|
-
<item name="android:backgroundDimEnabled">
|
|
8
|
+
<item name="android:backgroundDimEnabled">false</item>
|
|
9
9
|
</style>
|
|
10
10
|
</resources>
|