@tetherto/wdk-react-native-secure-storage 1.0.0-beta.1
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/LICENSE +180 -0
- package/README.md +472 -0
- package/package.json +72 -0
- package/src/__tests__/__mocks__/expo-crypto.ts +35 -0
- package/src/__tests__/__mocks__/expo-local-authentication.ts +40 -0
- package/src/__tests__/__mocks__/react-native-keychain.ts +60 -0
- package/src/__tests__/errors.test.ts +78 -0
- package/src/__tests__/logger.test.ts +131 -0
- package/src/__tests__/secureStorage.test.ts +1073 -0
- package/src/__tests__/utils.test.ts +182 -0
- package/src/__tests__/validation.test.ts +222 -0
- package/src/constants.ts +12 -0
- package/src/errors.ts +97 -0
- package/src/index.ts +41 -0
- package/src/keychainHelpers.ts +34 -0
- package/src/logger.ts +125 -0
- package/src/secureStorage.ts +707 -0
- package/src/utils.ts +176 -0
- package/src/validation.ts +160 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import { createSecureStorage, SecureStorage } from '../secureStorage'
|
|
2
|
+
import {
|
|
3
|
+
ValidationError,
|
|
4
|
+
KeychainWriteError,
|
|
5
|
+
KeychainReadError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
SecureStorageError,
|
|
8
|
+
TimeoutError,
|
|
9
|
+
} from '../errors'
|
|
10
|
+
import { Logger } from '../logger'
|
|
11
|
+
import * as Keychain from 'react-native-keychain'
|
|
12
|
+
import * as LocalAuthentication from 'expo-local-authentication'
|
|
13
|
+
|
|
14
|
+
// Mock logger that suppresses console output during tests
|
|
15
|
+
const mockLogger: Logger = {
|
|
16
|
+
debug: jest.fn(),
|
|
17
|
+
info: jest.fn(),
|
|
18
|
+
warn: jest.fn(),
|
|
19
|
+
error: jest.fn(),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Mock dependencies with factory functions
|
|
23
|
+
jest.mock('react-native-keychain', () => ({
|
|
24
|
+
ACCESSIBLE: {
|
|
25
|
+
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
26
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
27
|
+
},
|
|
28
|
+
ACCESS_CONTROL: {
|
|
29
|
+
BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
|
|
30
|
+
},
|
|
31
|
+
STORAGE_TYPE: {
|
|
32
|
+
AES_CBC: 'KeystoreAESCBC',
|
|
33
|
+
AES_GCM_NO_AUTH: 'KeystoreAESGCM_NoAuth',
|
|
34
|
+
AES_GCM: 'KeystoreAESGCM',
|
|
35
|
+
RSA: 'KeystoreRSAECB',
|
|
36
|
+
},
|
|
37
|
+
setGenericPassword: jest.fn(),
|
|
38
|
+
getGenericPassword: jest.fn(),
|
|
39
|
+
resetGenericPassword: jest.fn(),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
jest.mock('expo-local-authentication', () => ({
|
|
43
|
+
isEnrolledAsync: jest.fn(),
|
|
44
|
+
hasHardwareAsync: jest.fn(),
|
|
45
|
+
authenticateAsync: jest.fn(),
|
|
46
|
+
getEnrolledLevelAsync: jest.fn(),
|
|
47
|
+
SecurityLevel: {
|
|
48
|
+
NONE: 0,
|
|
49
|
+
SECRET: 1,
|
|
50
|
+
BIOMETRIC: 2,
|
|
51
|
+
},
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
jest.mock('expo-crypto', () => ({
|
|
55
|
+
CryptoDigestAlgorithm: {
|
|
56
|
+
SHA256: 'SHA256',
|
|
57
|
+
SHA384: 'SHA384',
|
|
58
|
+
SHA512: 'SHA512',
|
|
59
|
+
},
|
|
60
|
+
digestStringAsync: jest.fn(async (_algorithm: string, data: string) => {
|
|
61
|
+
// Simple deterministic hash for testing (not cryptographically secure, but consistent)
|
|
62
|
+
let hash = 0
|
|
63
|
+
for (let i = 0; i < data.length; i++) {
|
|
64
|
+
const char = data.charCodeAt(i)
|
|
65
|
+
hash = ((hash << 5) - hash) + char
|
|
66
|
+
hash = hash & hash // Convert to 32-bit integer
|
|
67
|
+
}
|
|
68
|
+
const hex = Math.abs(hash).toString(16).padStart(8, '0')
|
|
69
|
+
return (hex.repeat(8)).substring(0, 64)
|
|
70
|
+
}),
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
// Type the mocks
|
|
74
|
+
const mockKeychain = Keychain as jest.Mocked<typeof Keychain>
|
|
75
|
+
const mockLocalAuth = LocalAuthentication as jest.Mocked<typeof LocalAuthentication>
|
|
76
|
+
|
|
77
|
+
describe('SecureStorage', () => {
|
|
78
|
+
let storage: SecureStorage
|
|
79
|
+
|
|
80
|
+
// Helper to reset singleton
|
|
81
|
+
const resetStorage = () => {
|
|
82
|
+
storage = createSecureStorage({ logger: mockLogger })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
// Reset all mocks
|
|
87
|
+
jest.clearAllMocks()
|
|
88
|
+
|
|
89
|
+
// Default mock implementations
|
|
90
|
+
mockLocalAuth.isEnrolledAsync.mockResolvedValue(true)
|
|
91
|
+
mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
|
|
92
|
+
mockLocalAuth.authenticateAsync.mockResolvedValue({ success: true })
|
|
93
|
+
// Default to BIOMETRIC security level (device has authentication)
|
|
94
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
95
|
+
|
|
96
|
+
mockKeychain.setGenericPassword.mockResolvedValue({
|
|
97
|
+
service: 'test',
|
|
98
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM
|
|
99
|
+
})
|
|
100
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
101
|
+
service: 'test',
|
|
102
|
+
username: 'test',
|
|
103
|
+
password: 'test-value',
|
|
104
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
105
|
+
})
|
|
106
|
+
mockKeychain.resetGenericPassword.mockResolvedValue(true)
|
|
107
|
+
|
|
108
|
+
// Reset storage instance
|
|
109
|
+
resetStorage()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('setEncryptionKey', () => {
|
|
113
|
+
it('should store encryption key successfully', async () => {
|
|
114
|
+
await storage.setEncryptionKey('test-key')
|
|
115
|
+
|
|
116
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledWith(
|
|
117
|
+
'wallet_encryption_key',
|
|
118
|
+
'test-key',
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
service: expect.any(String),
|
|
121
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should store encryption key with identifier', async () => {
|
|
127
|
+
await storage.setEncryptionKey('test-key', 'user@example.com')
|
|
128
|
+
|
|
129
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalled()
|
|
130
|
+
const call = mockKeychain.setGenericPassword.mock.calls[0]
|
|
131
|
+
expect(call[0]).toBe('wallet_encryption_key')
|
|
132
|
+
expect(call[1]).toBe('test-key')
|
|
133
|
+
expect(call[2]?.service).toContain('wallet_encryption_key')
|
|
134
|
+
expect(call[2]?.accessible).toBe(Keychain.ACCESSIBLE.WHEN_UNLOCKED)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should use WHEN_UNLOCKED for encryption key to allow cloud sync', async () => {
|
|
138
|
+
await storage.setEncryptionKey('test-key')
|
|
139
|
+
|
|
140
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledWith(
|
|
141
|
+
'wallet_encryption_key',
|
|
142
|
+
'test-key',
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should throw ValidationError for empty key', async () => {
|
|
150
|
+
await expect(storage.setEncryptionKey('')).rejects.toThrow(ValidationError)
|
|
151
|
+
expect(mockKeychain.setGenericPassword).not.toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should throw ValidationError for null key', async () => {
|
|
155
|
+
await expect(storage.setEncryptionKey(null as any)).rejects.toThrow(ValidationError)
|
|
156
|
+
expect(mockKeychain.setGenericPassword).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should throw ValidationError for invalid identifier', async () => {
|
|
160
|
+
await expect(storage.setEncryptionKey('key', 'invalid@#$identifier')).rejects.toThrow(
|
|
161
|
+
ValidationError
|
|
162
|
+
)
|
|
163
|
+
expect(mockKeychain.setGenericPassword).not.toHaveBeenCalled()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should throw KeychainWriteError on keychain failure', async () => {
|
|
167
|
+
mockKeychain.setGenericPassword.mockResolvedValue(false)
|
|
168
|
+
|
|
169
|
+
await expect(storage.setEncryptionKey('key')).rejects.toThrow(KeychainWriteError)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should throw KeychainWriteError on keychain exception', async () => {
|
|
173
|
+
const error = new Error('Keychain error')
|
|
174
|
+
mockKeychain.setGenericPassword.mockRejectedValue(error)
|
|
175
|
+
|
|
176
|
+
await expect(storage.setEncryptionKey('key')).rejects.toThrow(KeychainWriteError)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should store encryption key without biometrics when requireBiometrics is false', async () => {
|
|
180
|
+
await storage.setEncryptionKey('test-key', undefined, { requireBiometrics: false });
|
|
181
|
+
|
|
182
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
|
|
183
|
+
const options = mockKeychain.setGenericPassword.mock.calls[0][2];
|
|
184
|
+
expect(options).not.toHaveProperty('accessControl');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should store encryption key with biometrics by default', async () => {
|
|
188
|
+
(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
|
|
189
|
+
|
|
190
|
+
await storage.setEncryptionKey('test-key');
|
|
191
|
+
|
|
192
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
|
|
193
|
+
const options = mockKeychain.setGenericPassword.mock.calls[0][2];
|
|
194
|
+
expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should store encryption key with biometrics when requireBiometrics is true', async () => {
|
|
198
|
+
(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
|
|
199
|
+
|
|
200
|
+
await storage.setEncryptionKey('test-key', undefined, { requireBiometrics: true });
|
|
201
|
+
|
|
202
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
|
|
203
|
+
const options = mockKeychain.setGenericPassword.mock.calls[0][2];
|
|
204
|
+
expect(options).toHaveProperty('accessControl', Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE);
|
|
205
|
+
});
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('getEncryptionKey', () => {
|
|
209
|
+
it('should retrieve encryption key successfully', async () => {
|
|
210
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
211
|
+
service: 'test',
|
|
212
|
+
username: 'wallet_encryption_key',
|
|
213
|
+
password: 'test-key',
|
|
214
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const key = await storage.getEncryptionKey()
|
|
218
|
+
|
|
219
|
+
expect(key).toBe('test-key')
|
|
220
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalled()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should return null when key not found', async () => {
|
|
224
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
225
|
+
|
|
226
|
+
const key = await storage.getEncryptionKey()
|
|
227
|
+
|
|
228
|
+
expect(key).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should throw ValidationError for invalid identifier', async () => {
|
|
232
|
+
await expect(storage.getEncryptionKey('invalid@#$id')).rejects.toThrow(ValidationError)
|
|
233
|
+
expect(mockKeychain.getGenericPassword).not.toHaveBeenCalled()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('should throw KeychainReadError on keychain exception', async () => {
|
|
237
|
+
const error = new Error('Keychain read error')
|
|
238
|
+
mockKeychain.getGenericPassword.mockRejectedValue(error)
|
|
239
|
+
|
|
240
|
+
await expect(storage.getEncryptionKey()).rejects.toThrow(KeychainReadError)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should pass authenticationPrompt to keychain when requireAuth is true', async () => {
|
|
244
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
245
|
+
service: 'test',
|
|
246
|
+
username: 'test',
|
|
247
|
+
password: 'test-key',
|
|
248
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
await storage.getEncryptionKey()
|
|
252
|
+
|
|
253
|
+
// Authentication is now handled natively by keychain via authenticationPrompt
|
|
254
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({
|
|
256
|
+
authenticationPrompt: {
|
|
257
|
+
title: 'Authenticate to access your wallet',
|
|
258
|
+
cancel: 'Cancel',
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
)
|
|
262
|
+
// Manual authentication should NOT be called
|
|
263
|
+
expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should throw KeychainReadError when keychain authentication fails', async () => {
|
|
267
|
+
// When keychain native auth fails, it throws an error
|
|
268
|
+
const keychainAuthError = new Error('User cancelled authentication')
|
|
269
|
+
mockKeychain.getGenericPassword.mockRejectedValue(keychainAuthError)
|
|
270
|
+
|
|
271
|
+
await expect(storage.getEncryptionKey()).rejects.toThrow(KeychainReadError)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should retrieve key without authentication when requireBiometrics is false', async () => {
|
|
275
|
+
await storage.getEncryptionKey(undefined, { requireBiometrics: false });
|
|
276
|
+
|
|
277
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
|
|
278
|
+
const options = mockKeychain.getGenericPassword.mock.calls[0][0];
|
|
279
|
+
expect(options).not.toHaveProperty('authenticationPrompt');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should retrieve key with authentication by default', async () => {
|
|
283
|
+
(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
|
|
284
|
+
|
|
285
|
+
await storage.getEncryptionKey();
|
|
286
|
+
|
|
287
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
|
|
288
|
+
const options = mockKeychain.getGenericPassword.mock.calls[0][0];
|
|
289
|
+
expect(options).toHaveProperty('authenticationPrompt');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should retrieve key with authentication when requireBiometrics is true', async () => {
|
|
293
|
+
(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2); // BIOMETRIC
|
|
294
|
+
|
|
295
|
+
await storage.getEncryptionKey(undefined, { requireBiometrics: true });
|
|
296
|
+
|
|
297
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1);
|
|
298
|
+
const options = mockKeychain.getGenericPassword.mock.calls[0][0];
|
|
299
|
+
expect(options).toHaveProperty('authenticationPrompt');
|
|
300
|
+
});
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('setEncryptedSeed', () => {
|
|
304
|
+
it('should store encrypted seed successfully', async () => {
|
|
305
|
+
await storage.setEncryptedSeed('encrypted-seed-data')
|
|
306
|
+
|
|
307
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledWith(
|
|
308
|
+
'wallet_encrypted_seed',
|
|
309
|
+
'encrypted-seed-data',
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
service: expect.any(String),
|
|
312
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
313
|
+
})
|
|
314
|
+
)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('should use WHEN_UNLOCKED_THIS_DEVICE_ONLY to prevent cloud sync', async () => {
|
|
318
|
+
await storage.setEncryptedSeed('encrypted-seed-data')
|
|
319
|
+
|
|
320
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledWith(
|
|
321
|
+
'wallet_encrypted_seed',
|
|
322
|
+
'encrypted-seed-data',
|
|
323
|
+
expect.objectContaining({
|
|
324
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
325
|
+
})
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('should throw ValidationError for empty seed', async () => {
|
|
330
|
+
await expect(storage.setEncryptedSeed('')).rejects.toThrow(ValidationError)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('getEncryptedSeed', () => {
|
|
335
|
+
it('should retrieve encrypted seed successfully', async () => {
|
|
336
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
337
|
+
service: 'test',
|
|
338
|
+
username: 'wallet_encrypted_seed',
|
|
339
|
+
password: 'encrypted-seed-data',
|
|
340
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const seed = await storage.getEncryptedSeed()
|
|
344
|
+
|
|
345
|
+
expect(seed).toBe('encrypted-seed-data')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('should return null when seed not found', async () => {
|
|
349
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
350
|
+
|
|
351
|
+
const seed = await storage.getEncryptedSeed()
|
|
352
|
+
|
|
353
|
+
expect(seed).toBeNull()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('should retrieve seed without authentication even when auth would fail', async () => {
|
|
357
|
+
// Encrypted seed does not require authentication
|
|
358
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
359
|
+
service: 'test',
|
|
360
|
+
username: 'wallet_encrypted_seed',
|
|
361
|
+
password: 'test-seed',
|
|
362
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const seed = await storage.getEncryptedSeed()
|
|
366
|
+
|
|
367
|
+
expect(seed).toBe('test-seed')
|
|
368
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalled()
|
|
369
|
+
// Should not call authentication since seed doesn't require it
|
|
370
|
+
expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled()
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('setEncryptedEntropy', () => {
|
|
375
|
+
it('should store encrypted entropy successfully', async () => {
|
|
376
|
+
await storage.setEncryptedEntropy('encrypted-entropy-data')
|
|
377
|
+
|
|
378
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledWith(
|
|
379
|
+
'wallet_encrypted_entropy',
|
|
380
|
+
'encrypted-entropy-data',
|
|
381
|
+
expect.objectContaining({
|
|
382
|
+
service: expect.any(String),
|
|
383
|
+
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
384
|
+
})
|
|
385
|
+
)
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe('getEncryptedEntropy', () => {
|
|
390
|
+
it('should retrieve encrypted entropy successfully', async () => {
|
|
391
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
392
|
+
service: 'test',
|
|
393
|
+
username: 'wallet_encrypted_entropy',
|
|
394
|
+
password: 'encrypted-entropy-data',
|
|
395
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const entropy = await storage.getEncryptedEntropy()
|
|
399
|
+
|
|
400
|
+
expect(entropy).toBe('encrypted-entropy-data')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('should return null when entropy not found', async () => {
|
|
404
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
405
|
+
|
|
406
|
+
const entropy = await storage.getEncryptedEntropy()
|
|
407
|
+
|
|
408
|
+
expect(entropy).toBeNull()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('should retrieve entropy without authentication even when auth would fail', async () => {
|
|
412
|
+
// Encrypted entropy does not require authentication
|
|
413
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
414
|
+
service: 'test',
|
|
415
|
+
username: 'wallet_encrypted_entropy',
|
|
416
|
+
password: 'test-entropy',
|
|
417
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const entropy = await storage.getEncryptedEntropy()
|
|
421
|
+
|
|
422
|
+
expect(entropy).toBe('test-entropy')
|
|
423
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalled()
|
|
424
|
+
// Should not call authentication since entropy doesn't require it
|
|
425
|
+
expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled()
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe('getAllEncrypted', () => {
|
|
430
|
+
it('should retrieve all encrypted data', async () => {
|
|
431
|
+
mockKeychain.getGenericPassword
|
|
432
|
+
.mockResolvedValueOnce({
|
|
433
|
+
service: 'test',
|
|
434
|
+
username: 'wallet_encrypted_seed',
|
|
435
|
+
password: 'seed-data',
|
|
436
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
437
|
+
})
|
|
438
|
+
.mockResolvedValueOnce({
|
|
439
|
+
service: 'test',
|
|
440
|
+
username: 'wallet_encrypted_entropy',
|
|
441
|
+
password: 'entropy-data',
|
|
442
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
443
|
+
})
|
|
444
|
+
.mockResolvedValueOnce({
|
|
445
|
+
service: 'test',
|
|
446
|
+
username: 'wallet_encryption_key',
|
|
447
|
+
password: 'key-data',
|
|
448
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const data = await storage.getAllEncrypted()
|
|
452
|
+
|
|
453
|
+
expect(data.encryptedSeed).toBe('seed-data')
|
|
454
|
+
expect(data.encryptedEntropy).toBe('entropy-data')
|
|
455
|
+
expect(data.encryptionKey).toBe('key-data')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('should return null for missing data', async () => {
|
|
459
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
460
|
+
|
|
461
|
+
const data = await storage.getAllEncrypted()
|
|
462
|
+
|
|
463
|
+
expect(data.encryptedSeed).toBeNull()
|
|
464
|
+
expect(data.encryptedEntropy).toBeNull()
|
|
465
|
+
expect(data.encryptionKey).toBeNull()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('should throw ValidationError for invalid identifier', async () => {
|
|
469
|
+
await expect(storage.getAllEncrypted('invalid@#$id')).rejects.toThrow(ValidationError)
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
describe('deleteWallet', () => {
|
|
474
|
+
it('should delete all wallet credentials successfully', async () => {
|
|
475
|
+
mockKeychain.resetGenericPassword.mockResolvedValue(true)
|
|
476
|
+
|
|
477
|
+
await storage.deleteWallet()
|
|
478
|
+
|
|
479
|
+
expect(mockKeychain.resetGenericPassword).toHaveBeenCalledTimes(3)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('should throw ValidationError for invalid identifier', async () => {
|
|
483
|
+
await expect(storage.deleteWallet('invalid@#$id')).rejects.toThrow(ValidationError)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('should throw error if partial deletion fails', async () => {
|
|
487
|
+
mockKeychain.resetGenericPassword
|
|
488
|
+
.mockResolvedValueOnce(true)
|
|
489
|
+
.mockResolvedValueOnce(false) // Second deletion fails
|
|
490
|
+
.mockResolvedValueOnce(true)
|
|
491
|
+
|
|
492
|
+
await expect(storage.deleteWallet()).rejects.toThrow(SecureStorageError)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('should throw error if deletion throws exception', async () => {
|
|
496
|
+
const error = new Error('Delete error')
|
|
497
|
+
mockKeychain.resetGenericPassword.mockRejectedValue(error)
|
|
498
|
+
|
|
499
|
+
await expect(storage.deleteWallet()).rejects.toThrow(SecureStorageError)
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
describe('hasWallet', () => {
|
|
504
|
+
it('should return true when wallet exists', async () => {
|
|
505
|
+
// hasWallet only checks seed existence, not encryption key
|
|
506
|
+
mockKeychain.getGenericPassword.mockResolvedValueOnce({
|
|
507
|
+
service: 'test',
|
|
508
|
+
username: 'wallet_encrypted_seed',
|
|
509
|
+
password: 'seed-data',
|
|
510
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
const exists = await storage.hasWallet()
|
|
514
|
+
|
|
515
|
+
expect(exists).toBe(true)
|
|
516
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('should return false when wallet does not exist', async () => {
|
|
520
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
521
|
+
|
|
522
|
+
const exists = await storage.hasWallet()
|
|
523
|
+
|
|
524
|
+
expect(exists).toBe(false)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('should throw ValidationError for invalid identifier', async () => {
|
|
528
|
+
await expect(storage.hasWallet('invalid@#$id')).rejects.toThrow(ValidationError)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('should return false when keychain requires authentication but fails', async () => {
|
|
532
|
+
// hasWallet doesn't use the authentication flow, but if keychain itself
|
|
533
|
+
// requires authentication and fails, it should return false
|
|
534
|
+
// Mock keychain to return false (not found) when authentication is required
|
|
535
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
536
|
+
|
|
537
|
+
const exists = await storage.hasWallet()
|
|
538
|
+
|
|
539
|
+
expect(exists).toBe(false)
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
describe('isBiometricAvailable', () => {
|
|
544
|
+
it('should return true when biometrics are available', async () => {
|
|
545
|
+
mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
|
|
546
|
+
mockLocalAuth.isEnrolledAsync.mockResolvedValue(true)
|
|
547
|
+
|
|
548
|
+
const available = await storage.isBiometricAvailable()
|
|
549
|
+
|
|
550
|
+
expect(available).toBe(true)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('should return false when hardware not available', async () => {
|
|
554
|
+
mockLocalAuth.hasHardwareAsync.mockResolvedValue(false)
|
|
555
|
+
mockLocalAuth.isEnrolledAsync.mockResolvedValue(true)
|
|
556
|
+
|
|
557
|
+
const available = await storage.isBiometricAvailable()
|
|
558
|
+
|
|
559
|
+
expect(available).toBe(false)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('should return false when not enrolled', async () => {
|
|
563
|
+
mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
|
|
564
|
+
mockLocalAuth.isEnrolledAsync.mockResolvedValue(false)
|
|
565
|
+
|
|
566
|
+
const available = await storage.isBiometricAvailable()
|
|
567
|
+
|
|
568
|
+
expect(available).toBe(false)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('should return false on error', async () => {
|
|
572
|
+
mockLocalAuth.hasHardwareAsync.mockRejectedValue(new Error('Hardware check failed'))
|
|
573
|
+
|
|
574
|
+
const available = await storage.isBiometricAvailable()
|
|
575
|
+
|
|
576
|
+
expect(available).toBe(false)
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
describe('authenticate', () => {
|
|
581
|
+
it('should authenticate successfully', async () => {
|
|
582
|
+
mockLocalAuth.authenticateAsync.mockResolvedValue({ success: true })
|
|
583
|
+
|
|
584
|
+
const result = await storage.authenticate()
|
|
585
|
+
|
|
586
|
+
expect(result).toBe(true)
|
|
587
|
+
expect(mockLocalAuth.authenticateAsync).toHaveBeenCalledWith({
|
|
588
|
+
promptMessage: 'Authenticate to access your wallet',
|
|
589
|
+
cancelLabel: 'Cancel',
|
|
590
|
+
disableDeviceFallback: false,
|
|
591
|
+
})
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('should handle authentication failure', async () => {
|
|
595
|
+
mockLocalAuth.authenticateAsync.mockResolvedValue({
|
|
596
|
+
success: false,
|
|
597
|
+
error: 'user_cancel'
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
const result = await storage.authenticate()
|
|
601
|
+
|
|
602
|
+
expect(result).toBe(false)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('should use custom authentication options', async () => {
|
|
606
|
+
const customStorage = createSecureStorage({
|
|
607
|
+
logger: mockLogger,
|
|
608
|
+
authentication: {
|
|
609
|
+
promptMessage: 'Custom message',
|
|
610
|
+
cancelLabel: 'Custom cancel',
|
|
611
|
+
disableDeviceFallback: true,
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
mockLocalAuth.authenticateAsync.mockResolvedValue({ success: true })
|
|
616
|
+
|
|
617
|
+
await customStorage.authenticate()
|
|
618
|
+
|
|
619
|
+
expect(mockLocalAuth.authenticateAsync).toHaveBeenCalledWith({
|
|
620
|
+
promptMessage: 'Custom message',
|
|
621
|
+
cancelLabel: 'Custom cancel',
|
|
622
|
+
disableDeviceFallback: true,
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('should throw AuthenticationError on exception', async () => {
|
|
627
|
+
const error = new Error('Auth error')
|
|
628
|
+
mockLocalAuth.authenticateAsync.mockRejectedValue(error)
|
|
629
|
+
|
|
630
|
+
await expect(storage.authenticate()).rejects.toThrow(AuthenticationError)
|
|
631
|
+
})
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
describe('concurrent operations', () => {
|
|
635
|
+
beforeEach(() => {
|
|
636
|
+
// Ensure getEnrolledLevelAsync is mocked before resetStorage
|
|
637
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
638
|
+
resetStorage()
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('should handle concurrent getEncryptionKey calls', async () => {
|
|
642
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
643
|
+
service: 'test',
|
|
644
|
+
username: 'wallet_encryption_key',
|
|
645
|
+
password: 'test-key',
|
|
646
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
const promises = Array.from({ length: 5 }, () => storage.getEncryptionKey())
|
|
650
|
+
const results = await Promise.all(promises)
|
|
651
|
+
|
|
652
|
+
expect(results.every(r => r === 'test-key')).toBe(true)
|
|
653
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(5)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('should handle concurrent setEncryptionKey calls', async () => {
|
|
657
|
+
const promises = Array.from({ length: 3 }, (_, i) =>
|
|
658
|
+
storage.setEncryptionKey(`key-${i}`, `user${i}@example.com`)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
await Promise.all(promises)
|
|
662
|
+
|
|
663
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(3)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('should handle concurrent getAllEncrypted calls', async () => {
|
|
667
|
+
mockKeychain.getGenericPassword
|
|
668
|
+
.mockResolvedValueOnce({
|
|
669
|
+
service: 'test',
|
|
670
|
+
username: 'wallet_encrypted_seed',
|
|
671
|
+
password: 'seed-data',
|
|
672
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
673
|
+
})
|
|
674
|
+
.mockResolvedValueOnce({
|
|
675
|
+
service: 'test',
|
|
676
|
+
username: 'wallet_encrypted_entropy',
|
|
677
|
+
password: 'entropy-data',
|
|
678
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
679
|
+
})
|
|
680
|
+
.mockResolvedValueOnce({
|
|
681
|
+
service: 'test',
|
|
682
|
+
username: 'wallet_encryption_key',
|
|
683
|
+
password: 'key-data',
|
|
684
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
const promises = Array.from({ length: 2 }, () => storage.getAllEncrypted())
|
|
688
|
+
const results = await Promise.all(promises)
|
|
689
|
+
|
|
690
|
+
expect(results).toHaveLength(2)
|
|
691
|
+
expect(results[0].encryptedSeed).toBe('seed-data')
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe('timeout handling', () => {
|
|
696
|
+
it('should throw TimeoutError on timeout', async () => {
|
|
697
|
+
// Clear mocks
|
|
698
|
+
jest.clearAllMocks()
|
|
699
|
+
|
|
700
|
+
// Re-setup required mocks after clearAllMocks
|
|
701
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
702
|
+
|
|
703
|
+
// Create storage with short timeout (minimum is 1000ms)
|
|
704
|
+
const fastStorage = createSecureStorage({ logger: mockLogger, timeoutMs: 1500 })
|
|
705
|
+
|
|
706
|
+
// Mock keychain to delay resolution beyond timeout
|
|
707
|
+
mockKeychain.setGenericPassword.mockImplementation(
|
|
708
|
+
() => new Promise((resolve) => setTimeout(() => resolve({ service: 'test', storage: Keychain.STORAGE_TYPE.AES_GCM }), 2000))
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
await expect(fastStorage.setEncryptionKey('key')).rejects.toThrow(TimeoutError)
|
|
712
|
+
}, 10000) // Increase timeout for this test
|
|
713
|
+
|
|
714
|
+
it('should throw ValidationError for negative timeout', () => {
|
|
715
|
+
expect(() => {
|
|
716
|
+
createSecureStorage({ timeoutMs: -1000 })
|
|
717
|
+
}).toThrow(ValidationError)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('should throw ValidationError for zero timeout', () => {
|
|
721
|
+
expect(() => {
|
|
722
|
+
createSecureStorage({ timeoutMs: 0 })
|
|
723
|
+
}).toThrow(ValidationError)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('should throw ValidationError for timeout below minimum', () => {
|
|
727
|
+
expect(() => {
|
|
728
|
+
createSecureStorage({ timeoutMs: 500 }) // Below MIN_TIMEOUT_MS (1000ms)
|
|
729
|
+
}).toThrow(ValidationError)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('should throw ValidationError for timeout above maximum', () => {
|
|
733
|
+
expect(() => {
|
|
734
|
+
createSecureStorage({ timeoutMs: 10 * 60 * 1000 }) // Above MAX_TIMEOUT_MS (5 minutes)
|
|
735
|
+
}).toThrow(ValidationError)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('should throw ValidationError for NaN timeout', () => {
|
|
739
|
+
expect(() => {
|
|
740
|
+
createSecureStorage({ timeoutMs: NaN })
|
|
741
|
+
}).toThrow(ValidationError)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('should throw ValidationError for Infinity timeout', () => {
|
|
745
|
+
expect(() => {
|
|
746
|
+
createSecureStorage({ timeoutMs: Infinity })
|
|
747
|
+
}).toThrow(ValidationError)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('should accept valid timeout values', () => {
|
|
751
|
+
expect(() => {
|
|
752
|
+
createSecureStorage({ timeoutMs: 5000 })
|
|
753
|
+
}).not.toThrow()
|
|
754
|
+
})
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
describe('device without authentication', () => {
|
|
758
|
+
it('should work when device has no authentication', async () => {
|
|
759
|
+
// Reset all mocks completely to remove any previous state
|
|
760
|
+
jest.clearAllMocks()
|
|
761
|
+
mockKeychain.getGenericPassword.mockReset()
|
|
762
|
+
|
|
763
|
+
// Set up mocks for this specific test - device has NO authentication
|
|
764
|
+
mockLocalAuth.isEnrolledAsync.mockResolvedValue(false)
|
|
765
|
+
mockLocalAuth.hasHardwareAsync.mockResolvedValue(false)
|
|
766
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(0) // SecurityLevel.NONE
|
|
767
|
+
|
|
768
|
+
const noAuthStorage = createSecureStorage({ logger: mockLogger })
|
|
769
|
+
|
|
770
|
+
// Mock to return value for encryption key service specifically
|
|
771
|
+
// The service will be 'wallet_encryption_key' (no identifier, so no hash)
|
|
772
|
+
mockKeychain.getGenericPassword.mockImplementation((options?: { service?: string }) => {
|
|
773
|
+
const service = options?.service || ''
|
|
774
|
+
// Return the value if it's for encryption key service (base key without identifier)
|
|
775
|
+
if (service === 'wallet_encryption_key') {
|
|
776
|
+
return Promise.resolve({
|
|
777
|
+
service: 'wallet_encryption_key',
|
|
778
|
+
username: 'wallet_encryption_key',
|
|
779
|
+
password: 'test-value',
|
|
780
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
// Return false for all other services (seed, entropy, etc.)
|
|
784
|
+
return Promise.resolve(false)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
const value = await noAuthStorage.getEncryptionKey()
|
|
788
|
+
|
|
789
|
+
expect(value).toBe('test-value')
|
|
790
|
+
// Should not require authentication
|
|
791
|
+
expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled()
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
describe('cleanup', () => {
|
|
796
|
+
it('should have cleanup method', () => {
|
|
797
|
+
expect(typeof storage.cleanup).toBe('function')
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('should call cleanup without errors', () => {
|
|
801
|
+
expect(() => storage.cleanup()).not.toThrow()
|
|
802
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
803
|
+
'Storage instance cleanup called (no-op)',
|
|
804
|
+
expect.any(Object)
|
|
805
|
+
)
|
|
806
|
+
})
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
describe('hasWallet simplified error handling', () => {
|
|
810
|
+
it('should return false when seed does not exist', async () => {
|
|
811
|
+
mockKeychain.getGenericPassword.mockResolvedValue(false)
|
|
812
|
+
|
|
813
|
+
const exists = await storage.hasWallet()
|
|
814
|
+
|
|
815
|
+
expect(exists).toBe(false)
|
|
816
|
+
// Should only check seed, not encryption key
|
|
817
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('should return true when seed exists (encryption key is not checked)', async () => {
|
|
821
|
+
// hasWallet only checks seed existence, not encryption key
|
|
822
|
+
mockKeychain.getGenericPassword.mockResolvedValueOnce({
|
|
823
|
+
service: 'test',
|
|
824
|
+
username: 'wallet_encrypted_seed',
|
|
825
|
+
password: 'seed-data',
|
|
826
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
const exists = await storage.hasWallet()
|
|
830
|
+
|
|
831
|
+
expect(exists).toBe(true)
|
|
832
|
+
// Should only check seed, not encryption key
|
|
833
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('should throw error with context when keychain fails', async () => {
|
|
837
|
+
const error = new Error('Keychain error')
|
|
838
|
+
mockKeychain.getGenericPassword.mockRejectedValue(error)
|
|
839
|
+
|
|
840
|
+
await expect(storage.hasWallet()).rejects.toThrow(KeychainReadError)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('should return false when key exists but password is null', async () => {
|
|
844
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
845
|
+
service: 'test',
|
|
846
|
+
username: 'wallet_encrypted_seed',
|
|
847
|
+
password: null as any,
|
|
848
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const exists = await storage.hasWallet()
|
|
852
|
+
expect(exists).toBe(false)
|
|
853
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('should return false when key exists but password is empty string', async () => {
|
|
857
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
858
|
+
service: 'test',
|
|
859
|
+
username: 'wallet_encrypted_seed',
|
|
860
|
+
password: '',
|
|
861
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
const exists = await storage.hasWallet()
|
|
865
|
+
expect(exists).toBe(false)
|
|
866
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('should return false when key exists but password is missing', async () => {
|
|
870
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
871
|
+
service: 'test',
|
|
872
|
+
username: 'wallet_encrypted_seed',
|
|
873
|
+
// password property missing
|
|
874
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
875
|
+
} as any)
|
|
876
|
+
|
|
877
|
+
const exists = await storage.hasWallet()
|
|
878
|
+
expect(exists).toBe(false)
|
|
879
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledTimes(1)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
describe('edge cases - keychain return values', () => {
|
|
885
|
+
beforeEach(() => {
|
|
886
|
+
// Ensure getEnrolledLevelAsync is mocked
|
|
887
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('should handle keychain returning null instead of false', async () => {
|
|
891
|
+
mockKeychain.getGenericPassword.mockResolvedValue(null as any)
|
|
892
|
+
|
|
893
|
+
const key = await storage.getEncryptionKey()
|
|
894
|
+
expect(key).toBeNull()
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('should handle keychain returning object without password property', async () => {
|
|
898
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
899
|
+
service: 'test',
|
|
900
|
+
username: 'test',
|
|
901
|
+
// missing password property
|
|
902
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
903
|
+
} as any)
|
|
904
|
+
|
|
905
|
+
const key = await storage.getEncryptionKey()
|
|
906
|
+
expect(key).toBeNull()
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
it('should handle keychain returning password as non-string', async () => {
|
|
910
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
911
|
+
service: 'test',
|
|
912
|
+
username: 'test',
|
|
913
|
+
password: 12345, // invalid type
|
|
914
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
915
|
+
} as any)
|
|
916
|
+
|
|
917
|
+
const key = await storage.getEncryptionKey()
|
|
918
|
+
expect(key).toBeNull()
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('should handle keychain returning null in checkKeyExists', async () => {
|
|
922
|
+
mockKeychain.getGenericPassword.mockResolvedValue(null as any)
|
|
923
|
+
|
|
924
|
+
const exists = await storage.hasWallet()
|
|
925
|
+
expect(exists).toBe(false)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('should handle keychain returning invalid object in checkKeyExists', async () => {
|
|
929
|
+
mockKeychain.getGenericPassword.mockResolvedValue('invalid' as any)
|
|
930
|
+
|
|
931
|
+
const exists = await storage.hasWallet()
|
|
932
|
+
expect(exists).toBe(false)
|
|
933
|
+
})
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
describe('edge cases - timeout scenarios', () => {
|
|
937
|
+
it('should handle timeout with multiple concurrent operations on non-auth items', async () => {
|
|
938
|
+
// Re-setup mock after any previous test modifications
|
|
939
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
940
|
+
|
|
941
|
+
// Create storage with short timeout for testing (minimum is 1000ms)
|
|
942
|
+
const testStorage = createSecureStorage({
|
|
943
|
+
logger: mockLogger,
|
|
944
|
+
timeoutMs: 1000, // Minimum valid timeout for testing
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
// Create a promise that never resolves
|
|
948
|
+
const neverResolves = new Promise(() => {})
|
|
949
|
+
mockKeychain.getGenericPassword.mockReturnValue(neverResolves as any)
|
|
950
|
+
|
|
951
|
+
// Use getEncryptedSeed (requireAuth=false) so timeout applies
|
|
952
|
+
// getEncryptionKey with auth doesn't use timeout (waits for biometrics)
|
|
953
|
+
const promises = [
|
|
954
|
+
testStorage.getEncryptedSeed('user1@example.com'),
|
|
955
|
+
testStorage.getEncryptedSeed('user2@example.com'),
|
|
956
|
+
testStorage.getEncryptedSeed('user3@example.com'),
|
|
957
|
+
]
|
|
958
|
+
|
|
959
|
+
// All should timeout
|
|
960
|
+
await Promise.all(
|
|
961
|
+
promises.map(p =>
|
|
962
|
+
expect(p).rejects.toThrow(TimeoutError)
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
}, 10000) // Increase Jest timeout for this test
|
|
966
|
+
|
|
967
|
+
it('should handle timeout during deleteWallet operation', async () => {
|
|
968
|
+
// Create storage with short timeout for testing (minimum is 1000ms)
|
|
969
|
+
const testStorage = createSecureStorage({
|
|
970
|
+
logger: mockLogger,
|
|
971
|
+
timeoutMs: 1000, // Minimum valid timeout for testing
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
const neverResolves = new Promise(() => {})
|
|
975
|
+
mockKeychain.resetGenericPassword.mockReturnValue(neverResolves as any)
|
|
976
|
+
|
|
977
|
+
// deleteWallet wraps timeout errors in SecureStorageError
|
|
978
|
+
await expect(testStorage.deleteWallet('user@example.com')).rejects.toThrow(SecureStorageError)
|
|
979
|
+
}, 10000) // Increase Jest timeout for this test
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
describe('edge cases - concurrent operations with same identifier', () => {
|
|
983
|
+
beforeEach(() => {
|
|
984
|
+
// Ensure getEnrolledLevelAsync is mocked
|
|
985
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
it('should handle concurrent get operations with same identifier', async () => {
|
|
989
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
990
|
+
service: 'test',
|
|
991
|
+
username: 'wallet_encryption_key',
|
|
992
|
+
password: 'test-key',
|
|
993
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
const promises = Array.from({ length: 5 }, () =>
|
|
997
|
+
storage.getEncryptionKey('user@example.com')
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
const results = await Promise.all(promises)
|
|
1001
|
+
expect(results.every(r => r === 'test-key')).toBe(true)
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
it('should handle concurrent set operations with same identifier', async () => {
|
|
1005
|
+
mockKeychain.setGenericPassword.mockResolvedValue({
|
|
1006
|
+
service: 'test',
|
|
1007
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
const promises = Array.from({ length: 3 }, () =>
|
|
1011
|
+
storage.setEncryptionKey('test-key', 'user@example.com')
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
await Promise.all(promises)
|
|
1015
|
+
expect(mockKeychain.setGenericPassword).toHaveBeenCalledTimes(3)
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
describe('error handling edge cases', () => {
|
|
1020
|
+
beforeEach(() => {
|
|
1021
|
+
// Ensure getEnrolledLevelAsync is mocked
|
|
1022
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockResolvedValue(2) // SecurityLevel.BIOMETRIC
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('should handle ValidationError in error handler', async () => {
|
|
1026
|
+
// ValidationError is thrown during validation before operations,
|
|
1027
|
+
// so it doesn't go through handleSecureStorageError.
|
|
1028
|
+
// This test verifies ValidationError is properly thrown.
|
|
1029
|
+
await expect(storage.getEncryptionKey('invalid@#$identifier')).rejects.toThrow(ValidationError)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
it('should handle error in getDeviceSecurityLevel gracefully', async () => {
|
|
1033
|
+
// Make getEnrolledLevelAsync throw an error
|
|
1034
|
+
;(mockLocalAuth as any).getEnrolledLevelAsync.mockRejectedValueOnce(new Error('Device security check failed'))
|
|
1035
|
+
|
|
1036
|
+
// setEncryptionKey should still work - it catches the error and assumes NONE
|
|
1037
|
+
await storage.setEncryptionKey('key')
|
|
1038
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
1039
|
+
'Failed to check device security level, assuming NONE',
|
|
1040
|
+
expect.objectContaining({ error: expect.any(String) })
|
|
1041
|
+
)
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
it('should pass authenticationPrompt with custom options', async () => {
|
|
1045
|
+
const customStorage = createSecureStorage({
|
|
1046
|
+
logger: mockLogger,
|
|
1047
|
+
authentication: {
|
|
1048
|
+
promptMessage: 'Custom message',
|
|
1049
|
+
cancelLabel: 'Custom cancel',
|
|
1050
|
+
},
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
mockKeychain.getGenericPassword.mockResolvedValue({
|
|
1054
|
+
service: 'test',
|
|
1055
|
+
username: 'test',
|
|
1056
|
+
password: 'test-key',
|
|
1057
|
+
storage: Keychain.STORAGE_TYPE.AES_GCM,
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
await customStorage.getEncryptionKey()
|
|
1061
|
+
|
|
1062
|
+
expect(mockKeychain.getGenericPassword).toHaveBeenCalledWith(
|
|
1063
|
+
expect.objectContaining({
|
|
1064
|
+
authenticationPrompt: {
|
|
1065
|
+
title: 'Custom message',
|
|
1066
|
+
cancel: 'Custom cancel',
|
|
1067
|
+
},
|
|
1068
|
+
})
|
|
1069
|
+
)
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
})
|
|
1073
|
+
})
|