@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.20 → 1.0.0-canary.22

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/ARCHITECTURE.md CHANGED
@@ -58,7 +58,7 @@ 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.
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
62
  - `setHdRootKeyId(id: string)`: Sets the ID for the HD root key.
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.
@@ -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,21 @@
1
+ # [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)
2
+
3
+ ### Bug Fixes
4
+
5
+ - 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))
6
+
7
+ ### Features
8
+
9
+ - accept the master key as raw bytes instead of a hex string ([86f85a8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/86f85a8667489f938266188f7551598c3e14cc0c))
10
+ - store the iOS master key in the Keychain instead of plaintext UserDefaults ([00dd362](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/00dd362d7214e91296982e012aedd817b09ab83d))
11
+
12
+ # [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)
13
+
14
+ ### Bug Fixes
15
+
16
+ - check for quotes for the DEVELOPMENT_TEAM ([d4aa43d](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d4aa43dc74c318f9ac8a4d2d4b761d30bb44d576))
17
+ - fix missing prf file and target iOS 26 shapes ([150a161](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/150a1615b7e4588bea91bf3900f82f054c939276))
18
+
1
19
  # [1.0.0-canary.20](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.19...v1.0.0-canary.20) (2026-06-10)
2
20
 
3
21
  ### Bug Fixes
package/README.md CHANGED
@@ -103,7 +103,7 @@ For iOS integration, make sure that:
103
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:
104
104
 
105
105
  ```typescript
106
- await ReactNativePasskeyAutofill.setMasterKey(masterKeyHex);
106
+ await ReactNativePasskeyAutofill.setMasterKey(masterKeyBytes);
107
107
  await ReactNativePasskeyAutofill.setHdRootKeyId(hdRootKeyId);
108
108
  await ReactNativePasskeyAutofill.refreshCredentialIdentities();
109
109
  ```
@@ -115,8 +115,9 @@ Call `refreshCredentialIdentities()` after creating, importing, deleting, or res
115
115
  ```typescript
116
116
  import ReactNativePasskeyAutofill from "@algorandfoundation/react-native-passkey-autofill";
117
117
 
118
- // 1. Set the master key for encryption (hex string)
119
- await ReactNativePasskeyAutofill.setMasterKey(masterKeyHex);
118
+ // 1. Set the master key for encryption (raw bytes — never a hex string, so
119
+ // the secret isn't materialized as a non-zeroable JS string)
120
+ await ReactNativePasskeyAutofill.setMasterKey(masterKeyBytes);
120
121
 
121
122
  // 2. Set the HD root key ID if applicable
122
123
  await ReactNativePasskeyAutofill.setHdRootKeyId(hdRootKeyId);
@@ -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)
@@ -39,7 +39,7 @@ interface CredentialRepository {
39
39
  fun getPublicKeyFromKeyPair(keyPair: KeyPair?): ByteArray
40
40
  fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray
41
41
  fun isMasterKeyAvailable(context: Context): Boolean
42
- fun saveMasterKey(context: Context, secret: String)
42
+ fun saveMasterKey(context: Context, secret: ByteArray)
43
43
  fun saveHdRootKeyId(context: Context, id: String)
44
44
  fun getHdRootKeyId(context: Context): String?
45
45
  fun configureIntentActions(context: Context, getPasskeyAction: String, createPasskeyAction: String)
@@ -450,22 +450,11 @@ class Repository() : CredentialRepository {
450
450
  }
451
451
  }
452
452
 
453
- override fun saveMasterKey(context: Context, secret: String) {
454
-
455
- // Convert hex string to bytes if it's a valid hex string of appropriate length
456
- val keyBytes = try {
457
- if ((secret.length == 64 || secret.length == 32) && secret.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
458
- hexToBytes(secret.trim())
459
- } else {
460
- secret.toByteArray(Charsets.UTF_8)
461
- }
462
- } catch (e: Exception) {
463
- secret.toByteArray(Charsets.UTF_8)
464
- }
465
-
466
- // Store in our separate Keychain for persistence
453
+ override fun saveMasterKey(context: Context, secret: ByteArray) {
454
+ // The master key arrives as raw bytes (the bridge no longer takes a hex
455
+ // String), so encrypt it straight into the Keychain.
467
456
  try {
468
- encryptToKeychain(context, keyBytes)
457
+ encryptToKeychain(context, secret)
469
458
  } catch (e: Exception) {
470
459
  Log.e(CredentialRepository.TAG, "Failed to save master key to Keychain", e)
471
460
  }
package/app.plugin.js CHANGED
@@ -22,6 +22,7 @@ const IOS_EXTENSION_FILES = [
22
22
  "PasskeyCredentialStore.swift",
23
23
  "WebAuthn.swift",
24
24
  "BiometricRequirement.swift",
25
+ "Prf.swift",
25
26
  ];
26
27
  const IOS_EXTENSION_SOURCE_FILES = IOS_EXTENSION_FILES.filter((file) => !file.endsWith(".h"));
27
28
  const DETERMINISTIC_P256_PACKAGE_URL =
@@ -44,6 +45,12 @@ const getIosBundleIdentifier = (config) =>
44
45
  const getAppGroup = (config, props) =>
45
46
  props.appGroup || `group.${getIosBundleIdentifier(config)}.passkey-autofill`;
46
47
 
48
+ // Keychain access-group *base* (without the `$(AppIdentifierPrefix)` team
49
+ // prefix, which Xcode resolves at build time). Shared by the app and the
50
+ // AutoFill extension so both can read the master key from the Keychain.
51
+ const getKeychainGroup = (config, props) =>
52
+ props.keychainGroup || `${getIosBundleIdentifier(config)}.passkey-autofill`;
53
+
47
54
  const AAGUID_REGEX =
48
55
  /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
49
56
 
@@ -148,6 +155,8 @@ const extensionInfoPlist = ({
148
155
  <string>$(PASSKEY_AUTOFILL_APP_GROUP)</string>
149
156
  <key>AppGroupIdentifier</key>
150
157
  <string>$(PASSKEY_AUTOFILL_APP_GROUP)</string>
158
+ <key>ReactNativePasskeyAutofillKeychainGroup</key>
159
+ <string>$(PASSKEY_AUTOFILL_KEYCHAIN_GROUP)</string>
151
160
  ${aaguid ? ` <key>ReactNativePasskeyAutofillAAGUID</key>\n <string>${aaguid}</string>\n` : ""} <key>${IOS_BIOMETRIC_INFO_PLIST_KEY}</key>
152
161
  <string>${biometricRequirement}</string>
153
162
  <key>NSFaceIDUsageDescription</key>
@@ -185,7 +194,10 @@ ${supportedDomains.map((domain) => ` <string>${domain}</string>`).join("\n"
185
194
  </plist>
186
195
  `;
187
196
 
188
- const extensionEntitlementsPlist = ({ appGroup }) => `<?xml version="1.0" encoding="UTF-8"?>
197
+ const extensionEntitlementsPlist = ({
198
+ appGroup,
199
+ keychainGroup,
200
+ }) => `<?xml version="1.0" encoding="UTF-8"?>
189
201
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
190
202
  <plist version="1.0">
191
203
  <dict>
@@ -195,6 +207,10 @@ const extensionEntitlementsPlist = ({ appGroup }) => `<?xml version="1.0" encodi
195
207
  <array>
196
208
  <string>${appGroup}</string>
197
209
  </array>
210
+ <key>keychain-access-groups</key>
211
+ <array>
212
+ <string>$(AppIdentifierPrefix)${keychainGroup}</string>
213
+ </array>
198
214
  </dict>
199
215
  </plist>
200
216
  `;
@@ -205,12 +221,14 @@ const withIosPasskeyAutofill = (config, props = {}) => {
205
221
  const associatedDomain = getAssociatedDomain(site);
206
222
  const supportedDomains = props.supportedDomains || [associatedDomain];
207
223
  const appGroup = getAppGroup(config, props);
224
+ const keychainGroup = getKeychainGroup(config, props);
208
225
  const aaguid = getAaguid(props);
209
226
  const biometricRequirement = getBiometricRequirement(props);
210
227
 
211
228
  config = withInfoPlist(config, (config) => {
212
229
  config.modResults.ReactNativePasskeyAutofillAppGroup = appGroup;
213
230
  config.modResults.AppGroupIdentifier = appGroup;
231
+ config.modResults.ReactNativePasskeyAutofillKeychainGroup = keychainGroup;
214
232
  return config;
215
233
  });
216
234
 
@@ -224,6 +242,10 @@ const withIosPasskeyAutofill = (config, props = {}) => {
224
242
  const appGroups = new Set(config.modResults["com.apple.security.application-groups"] || []);
225
243
  appGroups.add(appGroup);
226
244
  config.modResults["com.apple.security.application-groups"] = [...appGroups];
245
+
246
+ const keychainGroups = new Set(config.modResults["keychain-access-groups"] || []);
247
+ keychainGroups.add(`$(AppIdentifierPrefix)${keychainGroup}`);
248
+ config.modResults["keychain-access-groups"] = [...keychainGroups];
227
249
  return config;
228
250
  });
229
251
 
@@ -245,7 +267,7 @@ const withIosPasskeyAutofill = (config, props = {}) => {
245
267
  );
246
268
  writePlist(
247
269
  path.join(extensionRoot, `${IOS_EXTENSION_NAME}.entitlements`),
248
- extensionEntitlementsPlist({ appGroup }),
270
+ extensionEntitlementsPlist({ appGroup, keychainGroup }),
249
271
  );
250
272
  return config;
251
273
  },
@@ -255,11 +277,13 @@ const withIosPasskeyAutofill = (config, props = {}) => {
255
277
  const project = config.modResults;
256
278
  const bundleIdentifier = `${getIosBundleIdentifier(config)}.PasskeyAutofillCredentialProvider`;
257
279
  const buildNumber = config.ios?.buildNumber || config.versionCode || "1";
258
- const developmentTeam =
259
- props.developmentTeam ||
260
- props.appleTeamId ||
261
- getDevelopmentTeam(project) ||
262
- "$(DEVELOPMENT_TEAM)";
280
+ const rawDevelopmentTeam =
281
+ props.developmentTeam || props.appleTeamId || getDevelopmentTeam(project);
282
+ const developmentTeam = rawDevelopmentTeam
283
+ ? rawDevelopmentTeam.startsWith('"')
284
+ ? rawDevelopmentTeam
285
+ : `"${rawDevelopmentTeam}"`
286
+ : '"$(DEVELOPMENT_TEAM)"';
263
287
  let target =
264
288
  getExtensionTarget(project) ||
265
289
  project.addTarget(IOS_EXTENSION_NAME, "app_extension", IOS_EXTENSION_NAME, bundleIdentifier);
@@ -294,6 +318,7 @@ const withIosPasskeyAutofill = (config, props = {}) => {
294
318
  IPHONEOS_DEPLOYMENT_TARGET: "17.0",
295
319
  MARKETING_VERSION: `"${config.version || "1.0.0"}"`,
296
320
  PASSKEY_AUTOFILL_APP_GROUP: `"${appGroup}"`,
321
+ PASSKEY_AUTOFILL_KEYCHAIN_GROUP: `"${keychainGroup}"`,
297
322
  PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
298
323
  SWIFT_ACTIVE_COMPILATION_CONDITIONS: "PASSKEY_AUTOFILL_EXTENSION",
299
324
  HEADER_SEARCH_PATHS: [
@@ -1,7 +1,13 @@
1
1
  import { NativeModule } from "expo";
2
2
  import { PasskeyAutofillCredentialIdentity, ReactNativePasskeyAutofillModuleEvents } from "./ReactNativePasskeyAutofill.types";
3
3
  declare class ReactNativePasskeyAutofillModule extends NativeModule<ReactNativePasskeyAutofillModuleEvents> {
4
- setMasterKey(secret: string): Promise<void>;
4
+ /**
5
+ * Persists the keystore master key as **raw bytes**, so a non-zeroable hex
6
+ * string is never materialized in the JS heap (immutable JS strings can't be
7
+ * wiped and linger until GC). The caller should zero the `Uint8Array` after
8
+ * the promise resolves.
9
+ */
10
+ setMasterKey(secret: Uint8Array): Promise<void>;
5
11
  setHdRootKeyId(id: string): Promise<void>;
6
12
  getHdRootKeyId(): Promise<string | null>;
7
13
  configureIntentActions(getPasskeyAction: string, createPasskeyAction: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativePasskeyAutofillModule.d.ts","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofillModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAGzD,OAAO,EACL,iCAAiC,EACjC,sCAAsC,EACvC,MAAM,oCAAoC,CAAC;AAE5C,OAAO,OAAO,gCAAiC,SAAQ,YAAY,CAAC,sCAAsC,CAAC;IACzG,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3C,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACzC,cAAc,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IACxC,sBAAsB,CAAC,gBAAgB,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC5F,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IACjC,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACrD;;;;;OAKG;IACH,oBAAoB,IAAI,OAAO,CAAC,iCAAiC,EAAE,CAAC;IACpE;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IACnC;;;;;;;OAOG;IACH,2BAA2B,CAAC,WAAW,EAAE,iCAAiC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC5F,2BAA2B,IAAI,OAAO,CAAC,IAAI,CAAC;IAC5C;;;;;;OAMG;IACH,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC;IACpC;;;;OAIG;IACH,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC;CACzC;AAGD,QAAA,MAAM,YAAY,kCAEjB,CAAC;AAoBF,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ReactNativePasskeyAutofillModule.d.ts","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofillModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAGzD,OAAO,EACL,iCAAiC,EACjC,sCAAsC,EACvC,MAAM,oCAAoC,CAAC;AAE5C,OAAO,OAAO,gCAAiC,SAAQ,YAAY,CAAC,sCAAsC,CAAC;IACzG;;;;;OAKG;IACH,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAC/C,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACzC,cAAc,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IACxC,sBAAsB,CAAC,gBAAgB,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC5F,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IACjC,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACrD;;;;;OAKG;IACH,oBAAoB,IAAI,OAAO,CAAC,iCAAiC,EAAE,CAAC;IACpE;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IACnC;;;;;;;OAOG;IACH,2BAA2B,CAAC,WAAW,EAAE,iCAAiC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC5F,2BAA2B,IAAI,OAAO,CAAC,IAAI,CAAC;IAC5C;;;;;;OAMG;IACH,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC;IACpC;;;;OAIG;IACH,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC;CACzC;AAGD,QAAA,MAAM,YAAY,kCAEjB,CAAC;AAoBF,eAAe,YAAY,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativePasskeyAutofillModule.js","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofillModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAoDxC,yDAAyD;AACzD,MAAM,YAAY,GAAG,mBAAmB,CACtC,4BAA4B,CAC7B,CAAC;AAEF,6EAA6E;AAC7E,uEAAuE;AACvE,wEAAwE;AACxE,uCAAuC;AACvC,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;IAC9B,MAAM,KAAK,GAA6D;QACtE,2BAA2B,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;QACpD,2BAA2B,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;QACpD,oBAAoB,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC/C,cAAc,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;KAC1C,CAAC;IACF,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,IAAI,OAAQ,YAAmD,CAAC,IAAI,CAAC,KAAK,UAAU,EAAE,CAAC;YACpF,YAAmD,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;AACH,CAAC;AAED,eAAe,YAAY,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\nimport { Platform } from \"react-native\";\n\nimport {\n PasskeyAutofillCredentialIdentity,\n ReactNativePasskeyAutofillModuleEvents,\n} from \"./ReactNativePasskeyAutofill.types\";\n\ndeclare class ReactNativePasskeyAutofillModule extends NativeModule<ReactNativePasskeyAutofillModuleEvents> {\n setMasterKey(secret: string): Promise<void>;\n setHdRootKeyId(id: string): Promise<void>;\n getHdRootKeyId(): Promise<string | null>;\n configureIntentActions(getPasskeyAction: string, createPasskeyAction: string): Promise<void>;\n clearCredentials(): Promise<void>;\n deleteCredential(credentialId: string): Promise<void>;\n /**\n * iOS: returns the identities currently published to the AutoFill\n * `ASCredentialIdentityStore`. Android: no-op returning `[]` because the\n * native Credential Provider service reads credentials directly from MMKV\n * and there is no separate identity store to query.\n */\n getStoredCredentials(): Promise<PasskeyAutofillCredentialIdentity[]>;\n /**\n * iOS: returns diagnostic strings from the AutoFill extension.\n * Android: no-op returning `[]`.\n */\n getDiagnostics(): Promise<string[]>;\n /**\n * Replaces the iOS AutoFill passkey identity store with credentials\n * available to the native Credential Provider extension. Each item must\n * include a credential id, relying party (`relyingPartyIdentifier`,\n * `rpId`, or `origin`), user handle, and P-256 private key material.\n * Android ignores this method (no-op) because the provider reads MMKV\n * directly.\n */\n replaceCredentialIdentities(credentials: PasskeyAutofillCredentialIdentity[]): Promise<void>;\n refreshCredentialIdentities(): Promise<void>;\n /**\n * Resolves to `true` when this app is registered as the active\n * credential/autofill provider on the current device (Android 14+\n * credential provider or iOS AutoFill). Useful for gating passkey UI\n * and for E2E tests that need to confirm the prompt that appears\n * belongs to this provider and not a third party.\n */\n isProviderActive(): Promise<boolean>;\n /**\n * Opens the OS credential/autofill provider settings screen so the\n * user can enable this app as the active provider. Resolves to `true`\n * if a settings screen could be launched, `false` otherwise.\n */\n openProviderSettings(): Promise<boolean>;\n}\n\n// This call loads the native module object from the JSI.\nconst nativeModule = requireNativeModule<ReactNativePasskeyAutofillModule>(\n \"ReactNativePasskeyAutofill\",\n);\n\n// A handful of methods are only implemented in the iOS native module because\n// they interact with `ASCredentialIdentityStore`, which has no Android\n// equivalent (the Android Credential Provider service reads credentials\n// directly from MMKV on each request).\nif (Platform.OS === \"android\") {\n const noops: Record<string, (...args: unknown[]) => Promise<unknown>> = {\n replaceCredentialIdentities: () => Promise.resolve(),\n refreshCredentialIdentities: () => Promise.resolve(),\n getStoredCredentials: () => Promise.resolve([]),\n getDiagnostics: () => Promise.resolve([]),\n };\n for (const name of Object.keys(noops)) {\n if (typeof (nativeModule as unknown as Record<string, unknown>)[name] !== \"function\") {\n (nativeModule as unknown as Record<string, unknown>)[name] = noops[name];\n }\n }\n}\n\nexport default nativeModule;\n"]}
1
+ {"version":3,"file":"ReactNativePasskeyAutofillModule.js","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofillModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AA0DxC,yDAAyD;AACzD,MAAM,YAAY,GAAG,mBAAmB,CACtC,4BAA4B,CAC7B,CAAC;AAEF,6EAA6E;AAC7E,uEAAuE;AACvE,wEAAwE;AACxE,uCAAuC;AACvC,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;IAC9B,MAAM,KAAK,GAA6D;QACtE,2BAA2B,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;QACpD,2BAA2B,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;QACpD,oBAAoB,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC/C,cAAc,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;KAC1C,CAAC;IACF,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,IAAI,OAAQ,YAAmD,CAAC,IAAI,CAAC,KAAK,UAAU,EAAE,CAAC;YACpF,YAAmD,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;AACH,CAAC;AAED,eAAe,YAAY,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\nimport { Platform } from \"react-native\";\n\nimport {\n PasskeyAutofillCredentialIdentity,\n ReactNativePasskeyAutofillModuleEvents,\n} from \"./ReactNativePasskeyAutofill.types\";\n\ndeclare class ReactNativePasskeyAutofillModule extends NativeModule<ReactNativePasskeyAutofillModuleEvents> {\n /**\n * Persists the keystore master key as **raw bytes**, so a non-zeroable hex\n * string is never materialized in the JS heap (immutable JS strings can't be\n * wiped and linger until GC). The caller should zero the `Uint8Array` after\n * the promise resolves.\n */\n setMasterKey(secret: Uint8Array): Promise<void>;\n setHdRootKeyId(id: string): Promise<void>;\n getHdRootKeyId(): Promise<string | null>;\n configureIntentActions(getPasskeyAction: string, createPasskeyAction: string): Promise<void>;\n clearCredentials(): Promise<void>;\n deleteCredential(credentialId: string): Promise<void>;\n /**\n * iOS: returns the identities currently published to the AutoFill\n * `ASCredentialIdentityStore`. Android: no-op returning `[]` because the\n * native Credential Provider service reads credentials directly from MMKV\n * and there is no separate identity store to query.\n */\n getStoredCredentials(): Promise<PasskeyAutofillCredentialIdentity[]>;\n /**\n * iOS: returns diagnostic strings from the AutoFill extension.\n * Android: no-op returning `[]`.\n */\n getDiagnostics(): Promise<string[]>;\n /**\n * Replaces the iOS AutoFill passkey identity store with credentials\n * available to the native Credential Provider extension. Each item must\n * include a credential id, relying party (`relyingPartyIdentifier`,\n * `rpId`, or `origin`), user handle, and P-256 private key material.\n * Android ignores this method (no-op) because the provider reads MMKV\n * directly.\n */\n replaceCredentialIdentities(credentials: PasskeyAutofillCredentialIdentity[]): Promise<void>;\n refreshCredentialIdentities(): Promise<void>;\n /**\n * Resolves to `true` when this app is registered as the active\n * credential/autofill provider on the current device (Android 14+\n * credential provider or iOS AutoFill). Useful for gating passkey UI\n * and for E2E tests that need to confirm the prompt that appears\n * belongs to this provider and not a third party.\n */\n isProviderActive(): Promise<boolean>;\n /**\n * Opens the OS credential/autofill provider settings screen so the\n * user can enable this app as the active provider. Resolves to `true`\n * if a settings screen could be launched, `false` otherwise.\n */\n openProviderSettings(): Promise<boolean>;\n}\n\n// This call loads the native module object from the JSI.\nconst nativeModule = requireNativeModule<ReactNativePasskeyAutofillModule>(\n \"ReactNativePasskeyAutofill\",\n);\n\n// A handful of methods are only implemented in the iOS native module because\n// they interact with `ASCredentialIdentityStore`, which has no Android\n// equivalent (the Android Credential Provider service reads credentials\n// directly from MMKV on each request).\nif (Platform.OS === \"android\") {\n const noops: Record<string, (...args: unknown[]) => Promise<unknown>> = {\n replaceCredentialIdentities: () => Promise.resolve(),\n refreshCredentialIdentities: () => Promise.resolve(),\n getStoredCredentials: () => Promise.resolve([]),\n getDiagnostics: () => Promise.resolve([]),\n };\n for (const name of Object.keys(noops)) {\n if (typeof (nativeModule as unknown as Record<string, unknown>)[name] !== \"function\") {\n (nativeModule as unknown as Record<string, unknown>)[name] = noops[name];\n }\n }\n}\n\nexport default nativeModule;\n"]}
@@ -466,7 +466,8 @@ extension CredentialProviderViewController {
466
466
  /// (iOS 17.4+ for the type itself, iOS 18+ for the `prf` property).
467
467
  static func prfInput(fromAssertion request: ASPasskeyCredentialRequest) -> PrfInput? {
468
468
  if #available(iOSApplicationExtension 18.0, *) {
469
- guard let prf = request.extensionInput?.prf else { return nil }
469
+ guard case let .assertion(input) = request.extensionInput,
470
+ let prf = input.prf else { return nil }
470
471
  return prfInput(fromAssertionInput: prf)
471
472
  }
472
473
  return nil
@@ -487,7 +488,8 @@ extension CredentialProviderViewController {
487
488
  /// to immediately derive a secret.
488
489
  static func prfInput(fromRegistration request: ASPasskeyCredentialRequest) -> PrfInput? {
489
490
  if #available(iOSApplicationExtension 18.0, *) {
490
- guard let prf = request.extensionInput?.prf else { return nil }
491
+ guard case let .registration(input) = request.extensionInput,
492
+ let prf = input.prf else { return nil }
491
493
  return prfInput(fromRegistrationInput: prf)
492
494
  }
493
495
  return nil
@@ -535,8 +537,8 @@ extension CredentialProviderViewController {
535
537
  relyingPartyIdentifier: relyingPartyIdentifier,
536
538
  userHandle: userHandle
537
539
  )
538
- let first = Prf.evaluate(credRandom: credRandom, salt: input.first)
539
- let second = input.second.map { Prf.evaluate(credRandom: credRandom, salt: $0) }
540
+ let first = SymmetricKey(data: Prf.evaluate(credRandom: credRandom, salt: input.first))
541
+ let second = input.second.map { SymmetricKey(data: Prf.evaluate(credRandom: credRandom, salt: $0)) }
540
542
  let prfOutput = ASAuthorizationPublicKeyCredentialPRFAssertionOutput(
541
543
  first: first,
542
544
  second: second
@@ -561,7 +563,7 @@ extension CredentialProviderViewController {
561
563
  userHandle: String
562
564
  ) {
563
565
  guard #available(iOSApplicationExtension 18.0, *) else { return }
564
- let prfOutput = ASAuthorizationPublicKeyCredentialPRFRegistrationOutput.supported()
566
+ let prfOutput = ASAuthorizationPublicKeyCredentialPRFRegistrationOutput.supported
565
567
  registrationCredential.extensionOutput = ASPasskeyRegistrationCredentialExtensionOutput(prf: prfOutput)
566
568
  store?.appendDiagnostic("attached PRF registration output (enabled)")
567
569
  }
@@ -35,6 +35,10 @@ final class PasskeyCredentialStore {
35
35
  static let defaultCreatePasskeyActionKey = "ReactNativePasskeyAutofillCreatePasskeyAction"
36
36
  static let defaultDiagnosticsKey = "ReactNativePasskeyAutofillDiagnostics"
37
37
  static let defaultDeletedCredentialIdsKey = "ReactNativePasskeyAutofillDeletedCredentialIds"
38
+ /// Info.plist key holding the keychain access-group *base* (without the team
39
+ /// prefix) shared by the app and the AutoFill extension. Injected by the
40
+ /// config plugin so the module stays team-agnostic.
41
+ static let keychainGroupInfoKey = "ReactNativePasskeyAutofillKeychainGroup"
38
42
 
39
43
  private let defaults: UserDefaults
40
44
  private let credentialKey: String
@@ -248,23 +252,126 @@ final class PasskeyCredentialStore {
248
252
  defaults.removeObject(forKey: Self.defaultHdRootKeyIdKey)
249
253
  defaults.removeObject(forKey: Self.defaultGetPasskeyActionKey)
250
254
  defaults.removeObject(forKey: Self.defaultCreatePasskeyActionKey)
251
- }
252
-
253
- func saveMasterKey(_ secret: String) {
254
- defaults.set(Self.normalizeSecret(secret).base64URLEncodedString(), forKey: Self.defaultMasterKeyKey)
255
+ if let query = masterKeyQuery() {
256
+ _ = SecItemDelete(query as CFDictionary)
257
+ }
258
+ }
259
+
260
+ // MARK: - Master key (Keychain-backed)
261
+ //
262
+ // The master key is the KEK for the credential store and is read by *both*
263
+ // the app and the AutoFill extension. It is stored in the Keychain (encrypted
264
+ // at rest, hardware-backed) in a shared access group so both processes can
265
+ // read it — never in plaintext UserDefaults. Accessibility is
266
+ // `AfterFirstUnlockThisDeviceOnly`, NOT biometric: the extension must read the
267
+ // key to *enumerate* credentials before the user authenticates, so a
268
+ // biometric-gated item would break the AutoFill list. Biometric checks stay at
269
+ // the assertion step (`LAContext` in `CredentialProviderViewController`).
270
+
271
+ func saveMasterKey(_ secret: Data) {
272
+ guard var query = masterKeyQuery() else { return }
273
+ // Upsert: drop any existing value, then add the new one.
274
+ _ = SecItemDelete(query as CFDictionary)
275
+ query[kSecValueData as String] = secret
276
+ query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
277
+ _ = SecItemAdd(query as CFDictionary, nil)
278
+ // Scrub any legacy plaintext copy now that the Keychain holds the key.
279
+ defaults.removeObject(forKey: Self.defaultMasterKeyKey)
255
280
  }
256
281
 
257
282
  func masterKey() -> Data? {
258
- guard let secret = defaults.string(forKey: Self.defaultMasterKeyKey) else {
259
- return nil
283
+ if var query = masterKeyQuery() {
284
+ query[kSecReturnData as String] = true
285
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
286
+ var item: CFTypeRef?
287
+ if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
288
+ let data = item as? Data
289
+ {
290
+ return data
291
+ }
292
+ }
293
+ // Migration: older builds stored the key as plaintext in the App Group
294
+ // UserDefaults. Move it into the Keychain (and scrub the plaintext) once.
295
+ if let legacy = defaults.string(forKey: Self.defaultMasterKeyKey),
296
+ let data = Data(base64URLEncoded: legacy) ?? Data(base64Encoded: legacy)
297
+ {
298
+ saveMasterKey(data)
299
+ return data
260
300
  }
261
- return Data(base64URLEncoded: secret) ?? Data(base64Encoded: secret)
301
+ return nil
262
302
  }
263
303
 
264
304
  func isMasterKeyAvailable() -> Bool {
265
305
  masterKey() != nil
266
306
  }
267
307
 
308
+ /// Base Keychain query identifying the shared master-key item. Returns `nil`
309
+ /// when the access group can't be resolved (no Info.plist group or no team
310
+ /// prefix), in which case the caller falls back / no-ops rather than writing
311
+ /// to the wrong place.
312
+ private func masterKeyQuery() -> [String: Any]? {
313
+ guard let accessGroup = masterKeyAccessGroup() else { return nil }
314
+ return [
315
+ kSecClass as String: kSecClassGenericPassword,
316
+ kSecAttrService as String: Self.defaultMasterKeyKey,
317
+ kSecAttrAccount as String: Self.defaultMasterKeyKey,
318
+ kSecAttrAccessGroup as String: accessGroup,
319
+ ]
320
+ }
321
+
322
+ /// The full keychain access group (`<TeamID>.<base>`). The base is injected by
323
+ /// the config plugin via Info.plist; the team prefix is resolved at runtime.
324
+ private func masterKeyAccessGroup() -> String? {
325
+ guard
326
+ let base = Bundle.main.object(forInfoDictionaryKey: Self.keychainGroupInfoKey) as? String,
327
+ let prefix = Self.keychainTeamPrefix()
328
+ else {
329
+ return nil
330
+ }
331
+ return prefix + base
332
+ }
333
+
334
+ private static var cachedTeamPrefix: String?
335
+
336
+ /// Resolves the app's keychain access-group team prefix (`<TeamID>.`) at
337
+ /// runtime by probing a throwaway Keychain item and reading back the group the
338
+ /// system assigns. Avoids hardcoding a team ID in a module shared across teams.
339
+ private static func keychainTeamPrefix() -> String? {
340
+ if let cached = cachedTeamPrefix {
341
+ return cached
342
+ }
343
+ let probeAccount = "ReactNativePasskeyAutofillTeamPrefixProbe"
344
+ let cleanup: [String: Any] = [
345
+ kSecClass as String: kSecClassGenericPassword,
346
+ kSecAttrAccount as String: probeAccount,
347
+ ]
348
+ _ = SecItemDelete(cleanup as CFDictionary)
349
+
350
+ let query: [String: Any] = [
351
+ kSecClass as String: kSecClassGenericPassword,
352
+ kSecAttrAccount as String: probeAccount,
353
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
354
+ kSecValueData as String: Data(),
355
+ kSecReturnAttributes as String: true,
356
+ ]
357
+ var result: CFTypeRef?
358
+ let status = SecItemAdd(query as CFDictionary, &result)
359
+ defer { _ = SecItemDelete(cleanup as CFDictionary) }
360
+
361
+ guard
362
+ status == errSecSuccess,
363
+ let attributes = result as? [String: Any],
364
+ let accessGroup = attributes[kSecAttrAccessGroup as String] as? String,
365
+ let dotIndex = accessGroup.firstIndex(of: ".")
366
+ else {
367
+ return nil
368
+ }
369
+ // accessGroup is "<TeamID>.<something>"; keep through the first dot.
370
+ let prefix = String(accessGroup[...dotIndex])
371
+ cachedTeamPrefix = prefix
372
+ return prefix
373
+ }
374
+
268
375
  func saveHdRootKeyId(_ id: String) {
269
376
  defaults.set(id, forKey: Self.defaultHdRootKeyIdKey)
270
377
  }
@@ -360,17 +467,6 @@ final class PasskeyCredentialStore {
360
467
  }
361
468
  }
362
469
 
363
- private static func normalizeSecret(_ secret: String) -> Data {
364
- let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
365
- if trimmed.count % 2 == 0,
366
- trimmed.allSatisfy({ $0.isHexDigit }),
367
- let data = Data(hex: trimmed)
368
- {
369
- return data
370
- }
371
- return Data(trimmed.utf8)
372
- }
373
-
374
470
  private func credentialIdCandidates(_ id: String) -> Set<String> {
375
471
  var candidates: Set<String> = [id]
376
472
  if let data = Data(base64URLEncoded: id) ?? Data(base64Encoded: id) {
@@ -16,7 +16,7 @@ public class ReactNativePasskeyAutofillModule: Module {
16
16
 
17
17
  Events("onPasskeyAdded", "onPasskeyAuthenticated")
18
18
 
19
- AsyncFunction("setMasterKey") { (secret: String) in
19
+ AsyncFunction("setMasterKey") { (secret: Data) in
20
20
  guard let store = PasskeyCredentialStore() else {
21
21
  throw NSError(
22
22
  domain: "ReactNativePasskeyAutofill",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algorandfoundation/react-native-passkey-autofill",
3
- "version": "1.0.0-canary.20",
3
+ "version": "1.0.0-canary.22",
4
4
  "description": "Passkey AutoFill React Native",
5
5
  "keywords": [
6
6
  "@algorandfoundation/react-native-passkey-autofill",
@@ -7,7 +7,13 @@ import {
7
7
  } from "./ReactNativePasskeyAutofill.types";
8
8
 
9
9
  declare class ReactNativePasskeyAutofillModule extends NativeModule<ReactNativePasskeyAutofillModuleEvents> {
10
- setMasterKey(secret: string): Promise<void>;
10
+ /**
11
+ * Persists the keystore master key as **raw bytes**, so a non-zeroable hex
12
+ * string is never materialized in the JS heap (immutable JS strings can't be
13
+ * wiped and linger until GC). The caller should zero the `Uint8Array` after
14
+ * the promise resolves.
15
+ */
16
+ setMasterKey(secret: Uint8Array): Promise<void>;
11
17
  setHdRootKeyId(id: string): Promise<void>;
12
18
  getHdRootKeyId(): Promise<string | null>;
13
19
  configureIntentActions(getPasskeyAction: string, createPasskeyAction: string): Promise<void>;