@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.
Files changed (42) hide show
  1. package/.oxlintrc.json +2 -9
  2. package/ARCHITECTURE.md +6 -0
  3. package/CHANGELOG.md +22 -7
  4. package/README.md +49 -1
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +22 -5
  7. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +19 -5
  8. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +97 -0
  9. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +82 -3
  10. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +21 -0
  11. package/android/src/main/res/values/styles.xml +1 -1
  12. package/app.plugin.js +438 -1
  13. package/build/ReactNativePasskeyAutofill.types.d.ts +16 -0
  14. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  15. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  16. package/build/ReactNativePasskeyAutofillModule.d.ts +27 -1
  17. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  18. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  19. package/e2e/README.md +155 -0
  20. package/e2e/driver/adb.ts +73 -0
  21. package/e2e/driver/capabilities.ts +103 -0
  22. package/e2e/driver/credentialProvider.ts +104 -0
  23. package/e2e/driver/fingerprint.ts +885 -0
  24. package/e2e/driver/selectors.ts +103 -0
  25. package/e2e/driver/session.ts +25 -0
  26. package/e2e/jest.config.ts +20 -0
  27. package/e2e/package.json +11 -0
  28. package/e2e/tests/passkey.test.ts +191 -0
  29. package/e2e/tsconfig.json +16 -0
  30. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +437 -0
  31. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  32. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +584 -0
  33. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  34. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  35. package/ios/AutofillCredentialProvider/WebAuthn.swift +173 -0
  36. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  37. package/ios/ReactNativePasskeyAutofillModule.swift +156 -6
  38. package/lefthook.yml +2 -0
  39. package/package.json +69 -11
  40. package/pnpm-workspace.yaml +22 -0
  41. package/src/ReactNativePasskeyAutofill.types.ts +17 -0
  42. 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.14](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.13...v1.0.0-canary.14) (2026-04-10)
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
- * switch to maven for local AAR dependency ([f4e52b4](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/f4e52b46d618e6e8a7972e649c5d75d768e32339))
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
- * bump keystore to 1.0.0-canary.9 ([f093bab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/f093bab0b74d5cbf93e8f03ecd8d56c77597d3f5))
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.12](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.11...v1.0.0-canary.12) (2026-04-10)
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
- * **android:** biometrics single tap and fallback ([a6cbe4c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a6cbe4cf51579b15b2a219e1e06e843bb6001ed4))
21
- * **android:** support DER format ([492034f](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/492034f8b031e795b2575be69da8c3a1adb9096c))
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
- For full end-to-end testing that covers the bridge between JavaScript and Native, we recommend using [Maestro](https://maestro.mobile.dev/). It provides a clean, YAML-based way to automate UI flows and verify the integration of the Passkey Autofill service with the system UI.
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
 
@@ -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-jdk18on:1.78.1"
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
- finish()
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
- setResult(Activity.RESULT_CANCELED)
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
- finish()
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
- setResult(Activity.RESULT_CANCELED)
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 ->
@@ -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
  }
@@ -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 = Cipher.getInstance("AES/GCM/NoPadding")
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 = Cipher.getInstance("AES/GCM/NoPadding")
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">true</item>
8
+ <item name="android:backgroundDimEnabled">false</item>
9
9
  </style>
10
10
  </resources>