@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.
- package/dist/keychainHelpers.d.ts +11 -2
- package/dist/keychainHelpers.d.ts.map +1 -1
- package/dist/keychainHelpers.js +17 -4
- package/dist/secureStorage.d.ts.map +1 -1
- package/dist/secureStorage.js +10 -12
- package/package.json +8 -3
- package/src/__tests__/__mocks__/expo-local-authentication.ts +1 -1
- package/src/__tests__/__mocks__/react-native-keychain.ts +2 -1
- package/src/__tests__/keychainHelpers.test.ts +108 -0
- package/src/__tests__/secureStorage.test.ts +81 -8
- package/src/keychainHelpers.ts +19 -4
- package/src/secureStorage.ts +11 -14
|
@@ -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
|
-
*
|
|
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(
|
|
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;
|
|
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"}
|
package/dist/keychainHelpers.js
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
|
|
53
|
-
|
|
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,
|
|
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"}
|
package/dist/secureStorage.js
CHANGED
|
@@ -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 &&
|
|
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)(
|
|
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 &&
|
|
298
|
-
if (requireAuth &&
|
|
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
|
|
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
|
|
503
|
-
// so checking it
|
|
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.
|
|
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": "^
|
|
55
|
-
"expo-local-authentication": "
|
|
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(
|
|
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
|
-
|
|
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
|
|
94
|
-
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.
|
|
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); //
|
|
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); //
|
|
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); //
|
|
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
|
|
293
|
-
(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(
|
|
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(
|
|
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
|
|
package/src/keychainHelpers.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
package/src/secureStorage.ts
CHANGED
|
@@ -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 &&
|
|
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(
|
|
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 &&
|
|
397
|
+
const actuallyRequireAuth = requireAuth && securityLevel !== LocalAuthentication.SecurityLevel.NONE
|
|
404
398
|
|
|
405
|
-
if (requireAuth &&
|
|
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
|
|
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
|
|
634
|
-
// so checking it
|
|
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.
|