@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.
@@ -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
+ })