@tetherto/wdk-react-native-secure-storage 1.0.0-beta.2 → 1.0.0-beta.4

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.
@@ -1,4 +1,5 @@
1
1
  import * as Keychain from 'react-native-keychain';
2
+ import * as LocalAuthentication from 'expo-local-authentication';
2
3
  /**
3
4
  * Keychain options for setGenericPassword
4
5
  */
@@ -6,10 +7,18 @@ export type KeychainOptions = Parameters<typeof Keychain.setGenericPassword>[2];
6
7
  /**
7
8
  * Create keychain options with conditional access control
8
9
  *
9
- * @param deviceAuthAvailable - Whether device authentication (biometrics/PIN) is available
10
+ * On Android, BIOMETRY_ANY_OR_DEVICE_PASSCODE requires at least one biometric
11
+ * (e.g. fingerprint) to be enrolled for BiometricPrompt to render. If the device
12
+ * only has a PIN/pattern/password, BiometricPrompt never appears and the keychain
13
+ * read silently fails. We use the security level to pick the right access control:
14
+ * - BIOMETRIC_WEAK/STRONG -> BIOMETRY_ANY_OR_DEVICE_PASSCODE (prompt with PIN fallback)
15
+ * - SECRET -> DEVICE_PASSCODE (PIN/pattern/password prompt directly)
16
+ * - NONE -> no access control
17
+ *
18
+ * @param securityLevel - The device security level from expo-local-authentication
10
19
  * @param requireAuth - Whether authentication should be required for this operation
11
20
  * @param syncable - Whether the value should sync across devices (default: true)
12
21
  * @returns Keychain options object with appropriate access control settings
13
22
  */
14
- export declare function createKeychainOptions(deviceAuthAvailable: boolean, requireAuth?: boolean, syncable?: boolean): KeychainOptions;
23
+ export declare function createKeychainOptions(securityLevel: LocalAuthentication.SecurityLevel, requireAuth?: boolean, syncable?: boolean): KeychainOptions;
15
24
  //# sourceMappingURL=keychainHelpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"keychainHelpers.d.ts","sourceRoot":"","sources":["../src/keychainHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,uBAAuB,CAAA;AAEjD;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAA;AAE/E;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,mBAAmB,EAAE,OAAO,EAC5B,WAAW,GAAE,OAAc,EAC3B,QAAQ,GAAE,OAAc,GACvB,eAAe,CAYjB"}
1
+ {"version":3,"file":"keychainHelpers.d.ts","sourceRoot":"","sources":["../src/keychainHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,uBAAuB,CAAA;AACjD,OAAO,KAAK,mBAAmB,MAAM,2BAA2B,CAAA;AAEhE;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAA;AAE/E;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,mBAAmB,CAAC,aAAa,EAChD,WAAW,GAAE,OAAc,EAC3B,QAAQ,GAAE,OAAc,GACvB,eAAe,CAkBjB"}
@@ -35,22 +35,35 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.createKeychainOptions = createKeychainOptions;
37
37
  const Keychain = __importStar(require("react-native-keychain"));
38
+ const LocalAuthentication = __importStar(require("expo-local-authentication"));
38
39
  /**
39
40
  * Create keychain options with conditional access control
40
41
  *
41
- * @param deviceAuthAvailable - Whether device authentication (biometrics/PIN) is available
42
+ * On Android, BIOMETRY_ANY_OR_DEVICE_PASSCODE requires at least one biometric
43
+ * (e.g. fingerprint) to be enrolled for BiometricPrompt to render. If the device
44
+ * only has a PIN/pattern/password, BiometricPrompt never appears and the keychain
45
+ * read silently fails. We use the security level to pick the right access control:
46
+ * - BIOMETRIC_WEAK/STRONG -> BIOMETRY_ANY_OR_DEVICE_PASSCODE (prompt with PIN fallback)
47
+ * - SECRET -> DEVICE_PASSCODE (PIN/pattern/password prompt directly)
48
+ * - NONE -> no access control
49
+ *
50
+ * @param securityLevel - The device security level from expo-local-authentication
42
51
  * @param requireAuth - Whether authentication should be required for this operation
43
52
  * @param syncable - Whether the value should sync across devices (default: true)
44
53
  * @returns Keychain options object with appropriate access control settings
45
54
  */
46
- function createKeychainOptions(deviceAuthAvailable, requireAuth = true, syncable = true) {
55
+ function createKeychainOptions(securityLevel, requireAuth = true, syncable = true) {
47
56
  const options = {
48
57
  accessible: syncable
49
58
  ? Keychain.ACCESSIBLE.WHEN_UNLOCKED
50
59
  : Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
51
60
  };
52
- if (requireAuth && deviceAuthAvailable) {
53
- options.accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE;
61
+ const isBiometricSecurityLevel = securityLevel === LocalAuthentication.SecurityLevel.BIOMETRIC_WEAK ||
62
+ securityLevel === LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG;
63
+ if (requireAuth && securityLevel !== LocalAuthentication.SecurityLevel.NONE) {
64
+ options.accessControl = isBiometricSecurityLevel
65
+ ? Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
66
+ : Keychain.ACCESS_CONTROL.DEVICE_PASSCODE;
54
67
  }
55
68
  return options;
56
69
  }
@@ -1 +1 @@
1
- {"version":3,"file":"secureStorage.d.ts","sourceRoot":"","sources":["../src/secureStorage.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,MAAM,EAAiB,MAAM,UAAU,CAAA;AAoBhD;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,CAAC,EAAE,qBAAqB,CAAA;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IAC3C,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IACxC,YAAY,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IAChC,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrG,gBAAgB,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACjG,gBAAgB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3E,gBAAgB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC7D,mBAAmB,CAAC,gBAAgB,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjF,mBAAmB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAChE,eAAe,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAC5C,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;QAC5B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;QAC/B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;KAC7B,CAAC,CAAA;IACF,SAAS,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAChD,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChD;;;;;;;;;OASG;IACH,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,CAsjBjF"}
1
+ {"version":3,"file":"secureStorage.d.ts","sourceRoot":"","sources":["../src/secureStorage.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,MAAM,EAAiB,MAAM,UAAU,CAAA;AAoBhD;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,CAAC,EAAE,qBAAqB,CAAA;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IAC3C,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IACxC,YAAY,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IAChC,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrG,gBAAgB,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACjG,gBAAgB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3E,gBAAgB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC7D,mBAAmB,CAAC,gBAAgB,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjF,mBAAmB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAChE,eAAe,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAC5C,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;QAC5B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;QAC/B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;KAC7B,CAAC,CAAA;IACF,SAAS,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAChD,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChD;;;;;;;;;OASG;IACH,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,CAmjBjF"}
@@ -216,11 +216,9 @@ function createSecureStorage(options) {
216
216
  getDeviceSecurityLevel(),
217
217
  (0, utils_1.getStorageKey)(baseKey, identifier),
218
218
  ]);
219
- // Device has authentication if security level is not NONE
220
- const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE;
221
219
  // If auth was requested but device has no security, log a warning but proceed without auth
222
220
  // Data will still be encrypted at rest by the OS, just not protected by user authentication
223
- if (requireAuth && !deviceAuthAvailable) {
221
+ if (requireAuth && securityLevel === LocalAuthentication.SecurityLevel.NONE) {
224
222
  logger.warn('Device has no security configured. Storing data without authentication protection.', {
225
223
  baseKey,
226
224
  identifier,
@@ -230,7 +228,7 @@ function createSecureStorage(options) {
230
228
  logger.debug('Storing secure value', { baseKey, identifier, requireAuth, syncable });
231
229
  const result = await (0, utils_1.withTimeout)(Keychain.setGenericPassword(baseKey, value, {
232
230
  service: storageKey,
233
- ...(0, keychainHelpers_1.createKeychainOptions)(deviceAuthAvailable, requireAuth, syncable),
231
+ ...(0, keychainHelpers_1.createKeychainOptions)(securityLevel, requireAuth, syncable),
234
232
  }), timeoutMs, `setSecureValue(${baseKey})`);
235
233
  if (result === false) {
236
234
  throw new errors_1.KeychainWriteError(`Failed to store ${baseKey}`);
@@ -290,12 +288,10 @@ function createSecureStorage(options) {
290
288
  getDeviceSecurityLevel(),
291
289
  (0, utils_1.getStorageKey)(baseKey, identifier),
292
290
  ]);
293
- // Device has authentication if security level is not NONE
294
- const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE;
295
291
  // If auth was requested but device has no security, read without auth
296
292
  // Data was stored without auth protection on this device, so we can read it without auth
297
- const actuallyRequireAuth = requireAuth && deviceAuthAvailable;
298
- if (requireAuth && !deviceAuthAvailable) {
293
+ const actuallyRequireAuth = requireAuth && securityLevel !== LocalAuthentication.SecurityLevel.NONE;
294
+ if (requireAuth && securityLevel === LocalAuthentication.SecurityLevel.NONE) {
299
295
  logger.warn('Device has no security configured. Reading data without authentication.', {
300
296
  baseKey,
301
297
  identifier,
@@ -303,9 +299,11 @@ function createSecureStorage(options) {
303
299
  });
304
300
  }
305
301
  logger.debug('Retrieving secure value', { baseKey, identifier, requireAuth, actuallyRequireAuth });
302
+ const readOptions = (0, keychainHelpers_1.createKeychainOptions)(securityLevel, actuallyRequireAuth);
306
303
  const keychainOptions = actuallyRequireAuth
307
304
  ? {
308
305
  service: storageKey,
306
+ accessControl: readOptions?.accessControl,
309
307
  authenticationPrompt: {
310
308
  title: authOptions.promptMessage || 'Authenticate to access your wallet',
311
309
  cancel: authOptions.cancelLabel || 'Cancel',
@@ -487,8 +485,8 @@ function createSecureStorage(options) {
487
485
  * Returns false only when wallet is definitively not found. Errors are thrown.
488
486
  *
489
487
  * IMPORTANT: Only checks encrypted seed, NOT encryption key.
490
- * Encryption key is protected with biometrics, so checking it would trigger
491
- * an authentication prompt. Encrypted seed is stored without auth requirement,
488
+ * Encryption key can be protected with authentication, so checking it would
489
+ * trigger an authentication prompt. Encrypted seed is stored without auth requirement,
492
490
  * so checking it won't trigger biometrics.
493
491
  *
494
492
  * @param identifier - Optional identifier (e.g., email) to support multiple wallets
@@ -499,8 +497,8 @@ function createSecureStorage(options) {
499
497
  */
500
498
  async hasWallet(identifier) {
501
499
  // ONLY check encrypted seed - it does NOT require biometrics
502
- // Encryption key is protected with ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
503
- // so checking it would trigger a biometric prompt with the default
500
+ // Encryption key can be protected with keychain access control,
501
+ // so checking it could trigger an authentication prompt with the default
504
502
  // "Authenticate to retrieve secret" message from react-native-keychain.
505
503
  // By only checking the seed (which is stored without auth requirement),
506
504
  // we can determine wallet existence without triggering biometrics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tetherto/wdk-react-native-secure-storage",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Secure storage abstractions for React Native - provides secure storage for sensitive data (encrypted seeds, keys) using react-native-keychain",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -51,8 +51,8 @@
51
51
  "author": "Tetherto",
52
52
  "license": "Apache-2.0",
53
53
  "dependencies": {
54
- "expo-crypto": "^15.0.8",
55
- "expo-local-authentication": "~17.0.8",
54
+ "expo-crypto": "^55.0.14",
55
+ "expo-local-authentication": "^55.0.13",
56
56
  "react-native-keychain": "^10.0.0"
57
57
  },
58
58
  "peerDependencies": {
@@ -68,5 +68,10 @@
68
68
  "prettier": "^3.0.0",
69
69
  "ts-jest": "^29.1.0",
70
70
  "typescript": "^5.3.3"
71
+ },
72
+ "overrides": {
73
+ "@typescript-eslint/typescript-estree": {
74
+ "minimatch": "9.0.7"
75
+ }
71
76
  }
72
77
  }
@@ -15,7 +15,7 @@ export function hasHardwareAsync(): Promise<boolean> {
15
15
  return Promise.resolve(mockHasHardware)
16
16
  }
17
17
 
18
- export function authenticateAsync(options?: {
18
+ export function authenticateAsync(_options?: {
19
19
  promptMessage?: string
20
20
  cancelLabel?: string
21
21
  disableDeviceFallback?: boolean
@@ -10,9 +10,10 @@ export const ACCESSIBLE = {
10
10
 
11
11
  export const ACCESS_CONTROL = {
12
12
  BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
13
+ DEVICE_PASSCODE: 'DEVICE_PASSCODE',
13
14
  }
14
15
 
15
- let mockStorage: Map<string, { username: string; password: string }> = new Map()
16
+ const mockStorage: Map<string, { username: string; password: string }> = new Map()
16
17
 
17
18
  export function setGenericPassword(
18
19
  username: string,
@@ -0,0 +1,108 @@
1
+ import { createKeychainOptions } from '../keychainHelpers'
2
+ import * as Keychain from 'react-native-keychain'
3
+ import * as LocalAuthentication from 'expo-local-authentication'
4
+
5
+ jest.mock('react-native-keychain', () => ({
6
+ ACCESSIBLE: {
7
+ WHEN_UNLOCKED: 'WHEN_UNLOCKED',
8
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
9
+ },
10
+ ACCESS_CONTROL: {
11
+ BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
12
+ DEVICE_PASSCODE: 'DEVICE_PASSCODE',
13
+ },
14
+ }))
15
+
16
+ jest.mock('expo-local-authentication', () => ({
17
+ SecurityLevel: {
18
+ NONE: 0,
19
+ SECRET: 1,
20
+ BIOMETRIC: 2,
21
+ BIOMETRIC_WEAK: 2,
22
+ BIOMETRIC_STRONG: 3,
23
+ },
24
+ }))
25
+
26
+ const { SecurityLevel } = LocalAuthentication
27
+
28
+ describe('createKeychainOptions', () => {
29
+ describe('access control selection by security level', () => {
30
+ it('should use BIOMETRY_ANY_OR_DEVICE_PASSCODE when security level is BIOMETRIC_WEAK', () => {
31
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_WEAK, true, true)
32
+
33
+ expect(options).toHaveProperty(
34
+ 'accessControl',
35
+ Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
36
+ )
37
+ })
38
+
39
+ it('should use BIOMETRY_ANY_OR_DEVICE_PASSCODE when security level is BIOMETRIC_STRONG', () => {
40
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_STRONG, true, true)
41
+
42
+ expect(options).toHaveProperty(
43
+ 'accessControl',
44
+ Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
45
+ )
46
+ })
47
+
48
+ it('should use DEVICE_PASSCODE when security level is SECRET (PIN only)', () => {
49
+ const options = createKeychainOptions(SecurityLevel.SECRET, true, true)
50
+
51
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.DEVICE_PASSCODE)
52
+ })
53
+
54
+ it('should not set accessControl when security level is NONE', () => {
55
+ const options = createKeychainOptions(SecurityLevel.NONE, true, true)
56
+
57
+ expect(options).not.toHaveProperty('accessControl')
58
+ })
59
+ })
60
+
61
+ describe('requireAuth=false bypasses access control', () => {
62
+ it('should not set accessControl when requireAuth is false even with BIOMETRIC_WEAK', () => {
63
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_WEAK, false, true)
64
+
65
+ expect(options).not.toHaveProperty('accessControl')
66
+ })
67
+
68
+ it('should not set accessControl when requireAuth is false even with SECRET', () => {
69
+ const options = createKeychainOptions(SecurityLevel.SECRET, false, true)
70
+
71
+ expect(options).not.toHaveProperty('accessControl')
72
+ })
73
+ })
74
+
75
+ describe('accessible flag based on syncable', () => {
76
+ it('should use WHEN_UNLOCKED when syncable is true', () => {
77
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_WEAK, true, true)
78
+
79
+ expect(options).toHaveProperty('accessible', Keychain.ACCESSIBLE.WHEN_UNLOCKED)
80
+ })
81
+
82
+ it('should use WHEN_UNLOCKED_THIS_DEVICE_ONLY when syncable is false', () => {
83
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_WEAK, true, false)
84
+
85
+ expect(options).toHaveProperty(
86
+ 'accessible',
87
+ Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY
88
+ )
89
+ })
90
+ })
91
+
92
+ describe('defaults', () => {
93
+ it('should default requireAuth to true', () => {
94
+ const options = createKeychainOptions(SecurityLevel.BIOMETRIC_WEAK)
95
+
96
+ expect(options).toHaveProperty(
97
+ 'accessControl',
98
+ Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
99
+ )
100
+ })
101
+
102
+ it('should default syncable to true', () => {
103
+ const options = createKeychainOptions(SecurityLevel.NONE)
104
+
105
+ expect(options).toHaveProperty('accessible', Keychain.ACCESSIBLE.WHEN_UNLOCKED)
106
+ })
107
+ })
108
+ })
@@ -27,6 +27,7 @@ jest.mock('react-native-keychain', () => ({
27
27
  },
28
28
  ACCESS_CONTROL: {
29
29
  BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
30
+ DEVICE_PASSCODE: 'DEVICE_PASSCODE',
30
31
  },
31
32
  STORAGE_TYPE: {
32
33
  AES_CBC: 'KeystoreAESCBC',
@@ -48,6 +49,8 @@ jest.mock('expo-local-authentication', () => ({
48
49
  NONE: 0,
49
50
  SECRET: 1,
50
51
  BIOMETRIC: 2,
52
+ BIOMETRIC_WEAK: 2,
53
+ BIOMETRIC_STRONG: 3,
51
54
  },
52
55
  }))
53
56
 
@@ -90,8 +93,8 @@ describe('SecureStorage', () => {
90
93
  mockLocalAuth.isEnrolledAsync.mockResolvedValue(true)
91
94
  mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
92
95
  mockLocalAuth.authenticateAsync.mockResolvedValue({ success: true })
93
- // Default to BIOMETRIC security level (device has authentication)
94
- ;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
96
+ // Default to biometric security level (device has authentication)
97
+ ;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC_WEAK
95
98
 
96
99
  mockKeychain.setGenericPassword.mockResolvedValue({
97
100
  service: 'test',
@@ -185,7 +188,17 @@ describe('SecureStorage', () => {
185
188
  });
186
189
 
187
190
  it('should store encryption key with biometrics by default', async () => {
188
- (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
191
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC_WEAK
192
+
193
+ await storage.setEncryptionKey('test-key');
194
+
195
+ expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
196
+ const options = mockKeychain.setGenericPassword.mock.calls[0][2];
197
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
198
+ });
199
+
200
+ it('should store encryption key with biometrics when security level is BIOMETRIC_STRONG', async () => {
201
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(3); // BIOMETRIC_STRONG
189
202
 
190
203
  await storage.setEncryptionKey('test-key');
191
204
 
@@ -195,7 +208,7 @@ describe('SecureStorage', () => {
195
208
  });
196
209
 
197
210
  it('should store encryption key with biometrics when requireBiometrics is true', async () => {
198
- (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
211
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC_WEAK
199
212
 
200
213
  await storage.setEncryptionKey('test-key', undefined, { requireBiometrics: true });
201
214
 
@@ -203,6 +216,26 @@ describe('SecureStorage', () => {
203
216
  const options = mockKeychain.setGenericPassword.mock.calls[0][2];
204
217
  expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
205
218
  });
219
+
220
+ it('should use DEVICE_PASSCODE when device has PIN but no biometrics', async () => {
221
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(1); // SECRET
222
+
223
+ await storage.setEncryptionKey('test-key');
224
+
225
+ expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
226
+ const options = mockKeychain.setGenericPassword.mock.calls[0][2];
227
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.DEVICE_PASSCODE);
228
+ });
229
+
230
+ it('should not set accessControl when device has no security', async () => {
231
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(0); // NONE
232
+
233
+ await storage.setEncryptionKey('test-key');
234
+
235
+ expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
236
+ const options = mockKeychain.setGenericPassword.mock.calls[0][2];
237
+ expect(options).not.toHaveProperty('accessControl');
238
+ });
206
239
  })
207
240
 
208
241
  describe('getEncryptionKey', () => {
@@ -280,23 +313,63 @@ describe('SecureStorage', () => {
280
313
  });
281
314
 
282
315
  it('should retrieve key with authentication by default', async () => {
283
- (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
316
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC_WEAK
284
317
 
285
318
  await storage.getEncryptionKey();
286
319
 
287
320
  expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
288
321
  const options = mockKeychain.getGenericPassword.mock.calls[0][0];
289
322
  expect(options).toHaveProperty('authenticationPrompt');
323
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
290
324
  });
291
325
 
292
- it('should retrieve key with authentication when requireBiometrics is true', async () => {
293
- (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
326
+ it('should retrieve key with BIOMETRY_ANY_OR_DEVICE_PASSCODE when BIOMETRIC_STRONG', async () => {
327
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(3); // BIOMETRIC_STRONG
294
328
 
295
- await storage.getEncryptionKey(undefined, { requireBiometrics: true });
329
+ await storage.getEncryptionKey();
296
330
 
297
331
  expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
298
332
  const options = mockKeychain.getGenericPassword.mock.calls[0][0];
299
333
  expect(options).toHaveProperty('authenticationPrompt');
334
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
335
+ });
336
+
337
+ it('should retrieve key with DEVICE_PASSCODE when device has PIN but no biometrics', async () => {
338
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(1); // SECRET
339
+
340
+ mockKeychain.getGenericPassword.mockResolvedValue({
341
+ service: 'test',
342
+ username: 'wallet_encryption_key',
343
+ password: 'test-key',
344
+ storage: Keychain.STORAGE_TYPE.AES_GCM,
345
+ })
346
+
347
+ const key = await storage.getEncryptionKey();
348
+
349
+ expect(key).toBe('test-key');
350
+ expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
351
+ const options = mockKeychain.getGenericPassword.mock.calls[0][0];
352
+ expect(options).toHaveProperty('authenticationPrompt');
353
+ expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.DEVICE_PASSCODE);
354
+ });
355
+
356
+ it('should retrieve key without authentication when device has no security', async () => {
357
+ (mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(0); // NONE
358
+
359
+ mockKeychain.getGenericPassword.mockResolvedValue({
360
+ service: 'test',
361
+ username: 'wallet_encryption_key',
362
+ password: 'test-key',
363
+ storage: Keychain.STORAGE_TYPE.AES_GCM,
364
+ })
365
+
366
+ const key = await storage.getEncryptionKey();
367
+
368
+ expect(key).toBe('test-key');
369
+ expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
370
+ const options = mockKeychain.getGenericPassword.mock.calls[0][0];
371
+ expect(options).not.toHaveProperty('authenticationPrompt');
372
+ expect(options).not.toHaveProperty('accessControl');
300
373
  });
301
374
  })
302
375
 
@@ -1,4 +1,5 @@
1
1
  import * as Keychain from 'react-native-keychain'
2
+ import * as LocalAuthentication from 'expo-local-authentication'
2
3
 
3
4
  /**
4
5
  * Keychain options for setGenericPassword
@@ -8,13 +9,21 @@ export type KeychainOptions = Parameters<typeof Keychain.setGenericPassword>[2]
8
9
  /**
9
10
  * Create keychain options with conditional access control
10
11
  *
11
- * @param deviceAuthAvailable - Whether device authentication (biometrics/PIN) is available
12
+ * On Android, BIOMETRY_ANY_OR_DEVICE_PASSCODE requires at least one biometric
13
+ * (e.g. fingerprint) to be enrolled for BiometricPrompt to render. If the device
14
+ * only has a PIN/pattern/password, BiometricPrompt never appears and the keychain
15
+ * read silently fails. We use the security level to pick the right access control:
16
+ * - BIOMETRIC_WEAK/STRONG -> BIOMETRY_ANY_OR_DEVICE_PASSCODE (prompt with PIN fallback)
17
+ * - SECRET -> DEVICE_PASSCODE (PIN/pattern/password prompt directly)
18
+ * - NONE -> no access control
19
+ *
20
+ * @param securityLevel - The device security level from expo-local-authentication
12
21
  * @param requireAuth - Whether authentication should be required for this operation
13
22
  * @param syncable - Whether the value should sync across devices (default: true)
14
23
  * @returns Keychain options object with appropriate access control settings
15
24
  */
16
25
  export function createKeychainOptions(
17
- deviceAuthAvailable: boolean,
26
+ securityLevel: LocalAuthentication.SecurityLevel,
18
27
  requireAuth: boolean = true,
19
28
  syncable: boolean = true
20
29
  ): KeychainOptions {
@@ -24,8 +33,14 @@ export function createKeychainOptions(
24
33
  : Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
25
34
  }
26
35
 
27
- if (requireAuth && deviceAuthAvailable) {
28
- options.accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
36
+ const isBiometricSecurityLevel =
37
+ securityLevel === LocalAuthentication.SecurityLevel.BIOMETRIC_WEAK ||
38
+ securityLevel === LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG
39
+
40
+ if (requireAuth && securityLevel !== LocalAuthentication.SecurityLevel.NONE) {
41
+ options.accessControl = isBiometricSecurityLevel
42
+ ? Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
43
+ : Keychain.ACCESS_CONTROL.DEVICE_PASSCODE
29
44
  }
30
45
 
31
46
  return options
@@ -292,12 +292,9 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
292
292
  getStorageKey(baseKey, identifier),
293
293
  ])
294
294
 
295
- // Device has authentication if security level is not NONE
296
- const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE
297
-
298
295
  // If auth was requested but device has no security, log a warning but proceed without auth
299
296
  // Data will still be encrypted at rest by the OS, just not protected by user authentication
300
- if (requireAuth && !deviceAuthAvailable) {
297
+ if (requireAuth && securityLevel === LocalAuthentication.SecurityLevel.NONE) {
301
298
  logger.warn('Device has no security configured. Storing data without authentication protection.', {
302
299
  baseKey,
303
300
  identifier,
@@ -310,7 +307,7 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
310
307
  const result = await withTimeout(
311
308
  Keychain.setGenericPassword(baseKey, value, {
312
309
  service: storageKey,
313
- ...createKeychainOptions(deviceAuthAvailable, requireAuth, syncable),
310
+ ...createKeychainOptions(securityLevel, requireAuth, syncable),
314
311
  }),
315
312
  timeoutMs,
316
313
  `setSecureValue(${baseKey})`
@@ -395,14 +392,11 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
395
392
  getStorageKey(baseKey, identifier),
396
393
  ])
397
394
 
398
- // Device has authentication if security level is not NONE
399
- const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE
400
-
401
395
  // If auth was requested but device has no security, read without auth
402
396
  // Data was stored without auth protection on this device, so we can read it without auth
403
- const actuallyRequireAuth = requireAuth && deviceAuthAvailable
397
+ const actuallyRequireAuth = requireAuth && securityLevel !== LocalAuthentication.SecurityLevel.NONE
404
398
 
405
- if (requireAuth && !deviceAuthAvailable) {
399
+ if (requireAuth && securityLevel === LocalAuthentication.SecurityLevel.NONE) {
406
400
  logger.warn('Device has no security configured. Reading data without authentication.', {
407
401
  baseKey,
408
402
  identifier,
@@ -412,9 +406,12 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
412
406
 
413
407
  logger.debug('Retrieving secure value', { baseKey, identifier, requireAuth, actuallyRequireAuth })
414
408
 
409
+ const readOptions = createKeychainOptions(securityLevel, actuallyRequireAuth)
410
+
415
411
  const keychainOptions = actuallyRequireAuth
416
412
  ? {
417
413
  service: storageKey,
414
+ accessControl: readOptions?.accessControl,
418
415
  authenticationPrompt: {
419
416
  title: authOptions.promptMessage || 'Authenticate to access your wallet',
420
417
  cancel: authOptions.cancelLabel || 'Cancel',
@@ -618,8 +615,8 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
618
615
  * Returns false only when wallet is definitively not found. Errors are thrown.
619
616
  *
620
617
  * IMPORTANT: Only checks encrypted seed, NOT encryption key.
621
- * Encryption key is protected with biometrics, so checking it would trigger
622
- * an authentication prompt. Encrypted seed is stored without auth requirement,
618
+ * Encryption key can be protected with authentication, so checking it would
619
+ * trigger an authentication prompt. Encrypted seed is stored without auth requirement,
623
620
  * so checking it won't trigger biometrics.
624
621
  *
625
622
  * @param identifier - Optional identifier (e.g., email) to support multiple wallets
@@ -630,8 +627,8 @@ export function createSecureStorage(options?: SecureStorageOptions): SecureStora
630
627
  */
631
628
  async hasWallet(identifier?: string): Promise<boolean> {
632
629
  // ONLY check encrypted seed - it does NOT require biometrics
633
- // Encryption key is protected with ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
634
- // so checking it would trigger a biometric prompt with the default
630
+ // Encryption key can be protected with keychain access control,
631
+ // so checking it could trigger an authentication prompt with the default
635
632
  // "Authenticate to retrieve secret" message from react-native-keychain.
636
633
  // By only checking the seed (which is stored without auth requirement),
637
634
  // we can determine wallet existence without triggering biometrics.