@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.21 → 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,14 @@
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
+
1
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)
2
13
 
3
14
  ### 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
@@ -45,6 +45,12 @@ const getIosBundleIdentifier = (config) =>
45
45
  const getAppGroup = (config, props) =>
46
46
  props.appGroup || `group.${getIosBundleIdentifier(config)}.passkey-autofill`;
47
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
+
48
54
  const AAGUID_REGEX =
49
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}$/;
50
56
 
@@ -149,6 +155,8 @@ const extensionInfoPlist = ({
149
155
  <string>$(PASSKEY_AUTOFILL_APP_GROUP)</string>
150
156
  <key>AppGroupIdentifier</key>
151
157
  <string>$(PASSKEY_AUTOFILL_APP_GROUP)</string>
158
+ <key>ReactNativePasskeyAutofillKeychainGroup</key>
159
+ <string>$(PASSKEY_AUTOFILL_KEYCHAIN_GROUP)</string>
152
160
  ${aaguid ? ` <key>ReactNativePasskeyAutofillAAGUID</key>\n <string>${aaguid}</string>\n` : ""} <key>${IOS_BIOMETRIC_INFO_PLIST_KEY}</key>
153
161
  <string>${biometricRequirement}</string>
154
162
  <key>NSFaceIDUsageDescription</key>
@@ -186,7 +194,10 @@ ${supportedDomains.map((domain) => ` <string>${domain}</string>`).join("\n"
186
194
  </plist>
187
195
  `;
188
196
 
189
- 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"?>
190
201
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
191
202
  <plist version="1.0">
192
203
  <dict>
@@ -196,6 +207,10 @@ const extensionEntitlementsPlist = ({ appGroup }) => `<?xml version="1.0" encodi
196
207
  <array>
197
208
  <string>${appGroup}</string>
198
209
  </array>
210
+ <key>keychain-access-groups</key>
211
+ <array>
212
+ <string>$(AppIdentifierPrefix)${keychainGroup}</string>
213
+ </array>
199
214
  </dict>
200
215
  </plist>
201
216
  `;
@@ -206,12 +221,14 @@ const withIosPasskeyAutofill = (config, props = {}) => {
206
221
  const associatedDomain = getAssociatedDomain(site);
207
222
  const supportedDomains = props.supportedDomains || [associatedDomain];
208
223
  const appGroup = getAppGroup(config, props);
224
+ const keychainGroup = getKeychainGroup(config, props);
209
225
  const aaguid = getAaguid(props);
210
226
  const biometricRequirement = getBiometricRequirement(props);
211
227
 
212
228
  config = withInfoPlist(config, (config) => {
213
229
  config.modResults.ReactNativePasskeyAutofillAppGroup = appGroup;
214
230
  config.modResults.AppGroupIdentifier = appGroup;
231
+ config.modResults.ReactNativePasskeyAutofillKeychainGroup = keychainGroup;
215
232
  return config;
216
233
  });
217
234
 
@@ -225,6 +242,10 @@ const withIosPasskeyAutofill = (config, props = {}) => {
225
242
  const appGroups = new Set(config.modResults["com.apple.security.application-groups"] || []);
226
243
  appGroups.add(appGroup);
227
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];
228
249
  return config;
229
250
  });
230
251
 
@@ -246,7 +267,7 @@ const withIosPasskeyAutofill = (config, props = {}) => {
246
267
  );
247
268
  writePlist(
248
269
  path.join(extensionRoot, `${IOS_EXTENSION_NAME}.entitlements`),
249
- extensionEntitlementsPlist({ appGroup }),
270
+ extensionEntitlementsPlist({ appGroup, keychainGroup }),
250
271
  );
251
272
  return config;
252
273
  },
@@ -297,6 +318,7 @@ const withIosPasskeyAutofill = (config, props = {}) => {
297
318
  IPHONEOS_DEPLOYMENT_TARGET: "17.0",
298
319
  MARKETING_VERSION: `"${config.version || "1.0.0"}"`,
299
320
  PASSKEY_AUTOFILL_APP_GROUP: `"${appGroup}"`,
321
+ PASSKEY_AUTOFILL_KEYCHAIN_GROUP: `"${keychainGroup}"`,
300
322
  PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
301
323
  SWIFT_ACTIVE_COMPILATION_CONDITIONS: "PASSKEY_AUTOFILL_EXTENSION",
302
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"]}
@@ -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.21",
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>;