@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 +2 -2
- package/CHANGELOG.md +11 -0
- package/README.md +4 -3
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +1 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +5 -16
- package/app.plugin.js +24 -2
- package/build/ReactNativePasskeyAutofillModule.d.ts +7 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +114 -18
- package/ios/ReactNativePasskeyAutofillModule.swift +1 -1
- package/package.json +1 -1
- package/src/ReactNativePasskeyAutofillModule.ts +7 -1
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:
|
|
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),
|
|
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(
|
|
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
|
-
|
|
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);
|
package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt
CHANGED
|
@@ -45,7 +45,7 @@ class ReactNativePasskeyAutofillModule : Module() {
|
|
|
45
45
|
|
|
46
46
|
Events("onPasskeyAdded", "onPasskeyAuthenticated")
|
|
47
47
|
|
|
48
|
-
AsyncFunction("setMasterKey") { secret:
|
|
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)
|
package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt
CHANGED
|
@@ -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:
|
|
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:
|
|
454
|
-
|
|
455
|
-
//
|
|
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,
|
|
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 = ({
|
|
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
|
-
|
|
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,
|
|
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;
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
|
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:
|
|
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
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
} from "./ReactNativePasskeyAutofill.types";
|
|
8
8
|
|
|
9
9
|
declare class ReactNativePasskeyAutofillModule extends NativeModule<ReactNativePasskeyAutofillModuleEvents> {
|
|
10
|
-
|
|
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>;
|