@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.21 → 1.0.0-canary.23

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 (35) hide show
  1. package/ARCHITECTURE.md +5 -5
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +11 -7
  4. package/android/build.gradle +5 -0
  5. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +9 -2
  6. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +13 -4
  7. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +28 -3
  8. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +22 -1
  9. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +335 -101
  10. package/android/src/main/java/co/algorand/passkeyautofill/credentials/KeystoreRecords.kt +339 -0
  11. package/android/src/main/java/co/algorand/passkeyautofill/credentials/PasskeyDerivation.kt +84 -0
  12. package/app.plugin.js +24 -2
  13. package/build/PasskeyDerivation.d.ts +128 -0
  14. package/build/PasskeyDerivation.d.ts.map +1 -0
  15. package/build/PasskeyDerivation.js +147 -0
  16. package/build/PasskeyDerivation.js.map +1 -0
  17. package/build/ReactNativePasskeyAutofill.types.d.ts +14 -2
  18. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  19. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  20. package/build/ReactNativePasskeyAutofillModule.d.ts +19 -1
  21. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  22. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  23. package/build/index.d.ts +1 -0
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +1 -0
  26. package/build/index.js.map +1 -1
  27. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +15 -6
  28. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +359 -39
  29. package/ios/AutofillCredentialProvider/PasskeyDerivation.swift +88 -0
  30. package/ios/ReactNativePasskeyAutofillModule.swift +35 -4
  31. package/package.json +3 -2
  32. package/src/PasskeyDerivation.ts +170 -0
  33. package/src/ReactNativePasskeyAutofill.types.ts +14 -2
  34. package/src/ReactNativePasskeyAutofillModule.ts +19 -1
  35. package/src/index.ts +1 -0
package/ARCHITECTURE.md CHANGED
@@ -18,7 +18,7 @@ The module provides an interface to interact with native Passkey AutoFill capabi
18
18
  ### TypeScript Layer (`src/`)
19
19
 
20
20
  - `index.ts`: The entry point for the module, re-exporting the native module.
21
- - `ReactNativePasskeyAutofillModule.ts`: Defines the `ReactNativePasskeyAutofillModule` class and its methods (`setMasterKey`, `setHdRootKeyId`, `getHdRootKeyId`, etc.).
21
+ - `ReactNativePasskeyAutofillModule.ts`: Defines the `ReactNativePasskeyAutofillModule` class and its methods (`setMasterKey`, `setMainKeyId`, `getMainKeyId`, etc.).
22
22
  - `ReactNativePasskeyAutofill.types.ts`: Defines types and interfaces used by the module.
23
23
 
24
24
  ### Android Implementation (`android/`)
@@ -58,9 +58,9 @@ The iOS part is implemented using Swift.
58
58
 
59
59
  The following methods are exposed to the JavaScript layer:
60
60
 
61
- - `setMasterKey(secret: string)`: Sets the master key for credential encryption/decryption.
62
- - `setHdRootKeyId(id: string)`: Sets the ID for the HD root key.
63
- - `getHdRootKeyId()`: Retrieves the current HD root key ID.
61
+ - `setMasterKey(secret: Uint8Array)`: Sets the master key for credential encryption/decryption. Takes raw bytes (not a hex string) so the secret never becomes a non-zeroable JS string.
62
+ - `setMainKeyId(id: string)`: Sets the ID for the P-256 main key (the parent secret for passkey derivation). The legacy `setHdRootKeyId` remains as a deprecated alias.
63
+ - `getMainKeyId()`: Retrieves the current P-256 main 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
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.
@@ -68,7 +68,7 @@ The following methods are exposed to the JavaScript layer:
68
68
 
69
69
  ## Security Considerations
70
70
 
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).
71
+ As this module handles sensitive information (Passkeys), keys and secrets are handled securely in the native layers. The master key is stored in platform secure storage — never in plaintext: on Android it is AES/GCM-encrypted under the AndroidKeyStore, and on iOS it is held in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) within a shared keychain access group so the app and the AutoFill extension can both read it. It is deliberately not biometric-gated, because the extension must read it to enumerate credentials before the user authenticates; biometric verification is applied at the assertion step instead. The master key crosses the JS↔native bridge as raw bytes (`Uint8Array`), never as a hex string.
72
72
 
73
73
  ## End-to-End Tests
74
74
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # [1.0.0-canary.23](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.22...v1.0.0-canary.23) (2026-08-13)
2
+
3
+ ### Features
4
+
5
+ - keystore migrations ([00f01b6](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/00f01b6ce17842a58e989a71696fe809d969b002))
6
+
7
+ # [1.0.0-canary.22](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.21...v1.0.0-canary.22) (2026-07-15)
8
+
9
+ ### Bug Fixes
10
+
11
+ - write keychain group into the extension Info.plist so the extension can resolve it ([a98ea08](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a98ea0827164b5402a3f530579cd6908e52c0242))
12
+
13
+ ### Features
14
+
15
+ - accept the master key as raw bytes instead of a hex string ([86f85a8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/86f85a8667489f938266188f7551598c3e14cc0c))
16
+ - store the iOS master key in the Keychain instead of plaintext UserDefaults ([00dd362](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/00dd362d7214e91296982e012aedd817b09ab83d))
17
+
1
18
  # [1.0.0-canary.21](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.20...v1.0.0-canary.21) (2026-06-11)
2
19
 
3
20
  ### Bug Fixes
package/README.md CHANGED
@@ -100,14 +100,16 @@ For iOS integration, make sure that:
100
100
  - The generated extension target can link `AuthenticationServices.framework`, `CryptoKit.framework`, `MMKVCore`, and the deterministic P-256 Swift package.
101
101
  - `NSFaceIDUsageDescription` is present when biometric authentication is used.
102
102
 
103
- 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:
103
+ At runtime, the app must provide the native side with the master key, identify the P-256 main key (the parent secret for passkey derivation) stored in MMKV, and keep the iOS identity store in sync:
104
104
 
105
105
  ```typescript
106
- await ReactNativePasskeyAutofill.setMasterKey(masterKeyHex);
107
- await ReactNativePasskeyAutofill.setHdRootKeyId(hdRootKeyId);
106
+ await ReactNativePasskeyAutofill.setMasterKey(masterKeyBytes);
107
+ await ReactNativePasskeyAutofill.setMainKeyId(mainKeyId);
108
108
  await ReactNativePasskeyAutofill.refreshCredentialIdentities();
109
109
  ```
110
110
 
111
+ The `mainKeyId` points at the keystore record whose sealed material is the 64-byte parent secret. The native side determines the derivation scheme from the record's metadata (`pbkdf2-p256` is preferred; legacy `bip32-ed25519` is also supported).
112
+
111
113
  Call `refreshCredentialIdentities()` after creating, importing, deleting, or restoring passkeys so iOS AutoFill sees the current credentials.
112
114
 
113
115
  ## Usage
@@ -115,11 +117,13 @@ Call `refreshCredentialIdentities()` after creating, importing, deleting, or res
115
117
  ```typescript
116
118
  import ReactNativePasskeyAutofill from "@algorandfoundation/react-native-passkey-autofill";
117
119
 
118
- // 1. Set the master key for encryption (hex string)
119
- await ReactNativePasskeyAutofill.setMasterKey(masterKeyHex);
120
+ // 1. Set the master key for encryption (raw bytes — never a hex string, so
121
+ // the secret isn't materialized as a non-zeroable JS string)
122
+ await ReactNativePasskeyAutofill.setMasterKey(masterKeyBytes);
120
123
 
121
- // 2. Set the HD root key ID if applicable
122
- await ReactNativePasskeyAutofill.setHdRootKeyId(hdRootKeyId);
124
+ // 2. Set the P-256 main key ID (parent secret for passkey derivation).
125
+ // The legacy `setHdRootKeyId(id)` is a deprecated alias for this method.
126
+ await ReactNativePasskeyAutofill.setMainKeyId(mainKeyId);
123
127
 
124
128
  // 3. Configure intent actions for the Passkey flows
125
129
  await ReactNativePasskeyAutofill.configureIntentActions(
@@ -80,6 +80,11 @@ dependencies {
80
80
 
81
81
  // Testing dependencies
82
82
  testImplementation "junit:junit:4.13.2"
83
+ // `unitTests.returnDefaultValues` stubs the whole android.jar, and that
84
+ // includes `org.json` — every JSONObject method then returns null, which the
85
+ // record-format tests cannot work with. Put the real implementation on the
86
+ // unit-test classpath instead.
87
+ testImplementation "org.json:json:20240303"
83
88
  testImplementation "org.robolectric:robolectric:4.11.1"
84
89
  testImplementation "io.mockk:mockk:1.13.8"
85
90
  testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0"
@@ -342,7 +342,12 @@ class CreatePasskeyActivity : AppCompatActivity() {
342
342
  }
343
343
 
344
344
  Log.d(TAG, "Creating deterministic key pair")
345
- val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this@CreatePasskeyActivity, origin, userHandle)
345
+ // No requested scheme: a new credential takes the preferred parent
346
+ // (the wallet's deterministic-P256 main key), and records which one
347
+ // it got so every later assertion re-derives the same key.
348
+ val derived = credentialRepository.createDomainKeyPair(this@CreatePasskeyActivity, origin, userHandle)
349
+ val keyPair: KeyPair = derived.keyPair
350
+ Log.d(TAG, "Derived from parent ${derived.parentKeyId} (${derived.derivationScheme})")
346
351
  Log.d(TAG, "Generating credential ID")
347
352
  val credentialId = credentialRepository.generateCredentialId(keyPair)
348
353
  val credentialIdBase64 = AndroidBase64.encodeToString(credentialId, AndroidBase64.NO_WRAP)
@@ -354,7 +359,9 @@ class CreatePasskeyActivity : AppCompatActivity() {
354
359
  userId = userId,
355
360
  publicKey = AndroidBase64.encodeToString(keyPair.public.encoded, AndroidBase64.NO_WRAP),
356
361
  privateKey = AndroidBase64.encodeToString(keyPair.private.encoded, AndroidBase64.NO_WRAP),
357
- count = 0
362
+ count = 0,
363
+ parentKeyId = derived.parentKeyId,
364
+ derivationScheme = derived.derivationScheme
358
365
  )
359
366
 
360
367
  Log.d(TAG, "Saving credential to repository")
@@ -26,6 +26,8 @@ import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions
26
26
  import co.algorand.passkeyautofill.auth.BiometricRequirement
27
27
  import co.algorand.passkeyautofill.credentials.CredentialRepository
28
28
  import co.algorand.passkeyautofill.credentials.Credential
29
+ import co.algorand.passkeyautofill.credentials.KeystoreRecords
30
+ import co.algorand.passkeyautofill.credentials.ParentSecretResult
29
31
  import co.algorand.passkeyautofill.utils.PasskeyUtils
30
32
  import java.security.KeyPair
31
33
  import java.security.MessageDigest
@@ -584,12 +586,19 @@ class GetPasskeyActivity : AppCompatActivity() {
584
586
  )
585
587
  val prfInput = Prf.parseInput(passkeyRequestJsonObj, credentialIdB64Url)
586
588
  if (prfInput != null) {
587
- val hdRootSecret = credentialRepository.getHdRootSecret(this@GetPasskeyActivity)
588
- if (hdRootSecret == null) {
589
- Log.w(TAG, "PRF input present but HD root secret unavailable; skipping PRF output")
589
+ // PRF is recomputed from the parent secret on EVERY assertion,
590
+ // so it must use the very parent this credential was created
591
+ // against — an unstamped credential predates the dp256 main
592
+ // key and is pinned to the BIP32-Ed25519 root.
593
+ val parent = credentialRepository.resolveParentSecret(
594
+ this@GetPasskeyActivity,
595
+ dbCred.derivationScheme ?: KeystoreRecords.SCHEME_BIP32_ED25519,
596
+ )
597
+ if (parent !is ParentSecretResult.Available) {
598
+ Log.w(TAG, "PRF input present but parent secret unavailable (${parent.reason}); skipping PRF output")
590
599
  } else {
591
600
  val credRandom = Prf.credRandom(
592
- hdRootSecret = hdRootSecret,
601
+ hdRootSecret = parent.secret.bytes,
593
602
  relyingPartyIdentifier = displayOrigin,
594
603
  userHandle = dbCred.userHandle,
595
604
  )
@@ -45,7 +45,7 @@ class ReactNativePasskeyAutofillModule : Module() {
45
45
 
46
46
  Events("onPasskeyAdded", "onPasskeyAuthenticated")
47
47
 
48
- AsyncFunction("setMasterKey") { secret: String ->
48
+ AsyncFunction("setMasterKey") { secret: ByteArray ->
49
49
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
50
50
  if (context != null) {
51
51
  credentialRepository.saveMasterKey(context, secret)
@@ -54,10 +54,34 @@ class ReactNativePasskeyAutofillModule : Module() {
54
54
  }
55
55
  }
56
56
 
57
+ // Points the passkey hierarchy at the wallet's deterministic-P256 main key.
58
+ // The scheme is not a parameter: it is read from the record's own metadata,
59
+ // so a wallet cannot mislabel which hierarchy it handed us.
60
+ AsyncFunction("setMainKeyId") { id: String ->
61
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
62
+ if (context != null) {
63
+ credentialRepository.saveMainKeyId(context, id)
64
+ } else {
65
+ Log.e(CredentialRepository.TAG, "Could not get context to save main key ID")
66
+ }
67
+ }
68
+
69
+ AsyncFunction("getMainKeyId") {
70
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
71
+ if (context != null) {
72
+ credentialRepository.getMainKeyId(context)
73
+ } else {
74
+ Log.e(CredentialRepository.TAG, "Could not get context to get main key ID")
75
+ null
76
+ }
77
+ }
78
+
79
+ // Deprecated aliases of the two above, kept because installed wallets still
80
+ // call them. They address the same slot — see `saveMainKeyId`.
57
81
  AsyncFunction("setHdRootKeyId") { id: String ->
58
82
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
59
83
  if (context != null) {
60
- credentialRepository.saveHdRootKeyId(context, id)
84
+ credentialRepository.saveMainKeyId(context, id)
61
85
  } else {
62
86
  Log.e(CredentialRepository.TAG, "Could not get context to save HD root key ID")
63
87
  }
@@ -66,7 +90,7 @@ class ReactNativePasskeyAutofillModule : Module() {
66
90
  AsyncFunction("getHdRootKeyId") {
67
91
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
68
92
  if (context != null) {
69
- credentialRepository.getHdRootKeyId(context)
93
+ credentialRepository.getMainKeyId(context)
70
94
  } else {
71
95
  Log.e(CredentialRepository.TAG, "Could not get context to get HD root key ID")
72
96
  null
@@ -141,6 +165,7 @@ class ReactNativePasskeyAutofillModule : Module() {
141
165
  "userName" to credential.userHandle,
142
166
  "userHandle" to credential.userHandle,
143
167
  "publicKey" to credential.publicKey,
168
+ "derivationScheme" to credential.derivationScheme,
144
169
  )
145
170
  }
146
171
  }
@@ -8,5 +8,26 @@ data class Credential(
8
8
  val publicKey: String,
9
9
  val privateKey: String,
10
10
  val count: Int,
11
- val biometricIv: String? = null
11
+ val biometricIv: String? = null,
12
+ /** The root record this credential's key was derived from, when known. */
13
+ val parentKeyId: String? = null,
14
+ /**
15
+ * The derivation scheme this credential is pinned to for life
16
+ * ([KeystoreRecords.SCHEME_PBKDF2_P256] or
17
+ * [KeystoreRecords.SCHEME_BIP32_ED25519]).
18
+ *
19
+ * `null` on every credential created before the wallet exposed its
20
+ * deterministic-P256 main key, and those all derive from the BIP32-Ed25519
21
+ * root — re-deriving one against a different parent would produce a
22
+ * different key and silently break the passkey the relying party already
23
+ * trusts.
24
+ */
25
+ val derivationScheme: String? = null,
26
+ /**
27
+ * Which identity string this credential's material is derived from (see
28
+ * [PasskeyDerivation]). Records written before the field existed read back as
29
+ * [PasskeyDerivation.VERSION_LEGACY_LABEL], leaving their derivation
30
+ * unchanged.
31
+ */
32
+ val derivationVersion: Int = PasskeyDerivation.VERSION_LEGACY_LABEL,
12
33
  )