@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,707 @@
1
+ // External packages
2
+ import * as Keychain from 'react-native-keychain'
3
+ import * as LocalAuthentication from 'expo-local-authentication'
4
+
5
+ // Internal modules
6
+ import { DEFAULT_TIMEOUT_MS } from './constants'
7
+ import {
8
+ AuthenticationError,
9
+ KeychainReadError,
10
+ KeychainWriteError,
11
+ SecureStorageError,
12
+ TimeoutError,
13
+ ValidationError,
14
+ } from './errors'
15
+ import { createKeychainOptions } from './keychainHelpers'
16
+ import { Logger, defaultLogger } from './logger'
17
+ import {
18
+ createStorageKey,
19
+ getStorageKey,
20
+ isKeychainCredentials,
21
+ type StorageKey,
22
+ withTimeout,
23
+ } from './utils'
24
+ import {
25
+ validateAuthenticationOptions,
26
+ validateIdentifier,
27
+ validateTimeout,
28
+ validateValue,
29
+ } from './validation'
30
+
31
+ // Secure storage keys (base keys without identifier)
32
+ const ENCRYPTION_KEY = createStorageKey('wallet_encryption_key')
33
+ const ENCRYPTED_SEED = createStorageKey('wallet_encrypted_seed')
34
+ const ENCRYPTED_ENTROPY = createStorageKey('wallet_encrypted_entropy')
35
+
36
+ /**
37
+ * Authentication options for biometric prompts
38
+ */
39
+ export interface AuthenticationOptions {
40
+ promptMessage?: string
41
+ cancelLabel?: string
42
+ disableDeviceFallback?: boolean
43
+ }
44
+
45
+ /**
46
+ * Options for a single secure storage item
47
+ */
48
+ export interface SecureStorageItemOptions {
49
+ requireBiometrics?: boolean
50
+ }
51
+
52
+ /**
53
+ * Options for creating secure storage instance
54
+ */
55
+ export interface SecureStorageOptions {
56
+ logger?: Logger
57
+ authentication?: AuthenticationOptions
58
+ timeoutMs?: number
59
+ }
60
+
61
+ /**
62
+ * Secure storage interface
63
+ *
64
+ * All methods accept an optional identifier parameter to support multiple wallets.
65
+ * When identifier is provided, it's used to create unique storage keys for each wallet.
66
+ * When identifier is undefined or empty, default keys are used (backward compatibility).
67
+ *
68
+ * Error Handling:
69
+ * - Getters return null when data is not found
70
+ * - All methods throw SecureStorageError or subclasses on failure
71
+ * - Validation errors are thrown before any operations
72
+ */
73
+ export interface SecureStorage {
74
+ isDeviceSecurityEnabled(): Promise<boolean>
75
+ isBiometricAvailable(): Promise<boolean>
76
+ authenticate(): Promise<boolean>
77
+ setEncryptionKey(key: string, identifier?: string, options?: SecureStorageItemOptions): Promise<void>
78
+ getEncryptionKey(identifier?: string, options?: SecureStorageItemOptions): Promise<string | null>
79
+ setEncryptedSeed(encryptedSeed: string, identifier?: string): Promise<void>
80
+ getEncryptedSeed(identifier?: string): Promise<string | null>
81
+ setEncryptedEntropy(encryptedEntropy: string, identifier?: string): Promise<void>
82
+ getEncryptedEntropy(identifier?: string): Promise<string | null>
83
+ getAllEncrypted(identifier?: string): Promise<{
84
+ encryptedSeed: string | null
85
+ encryptedEntropy: string | null
86
+ encryptionKey: string | null
87
+ }>
88
+ hasWallet(identifier?: string): Promise<boolean>
89
+ deleteWallet(identifier?: string): Promise<void>
90
+ /**
91
+ * Cleanup method to release resources associated with this storage instance
92
+ *
93
+ * This method should be called when the storage instance is no longer needed
94
+ * to ensure proper cleanup of resources. Note that this does NOT delete stored
95
+ * wallet data - use deleteWallet() for that purpose.
96
+ *
97
+ * Currently, this is a no-op as the module uses shared resources, but it's
98
+ * provided for future extensibility and to maintain a consistent API.
99
+ */
100
+ cleanup(): void
101
+ }
102
+
103
+ /**
104
+ * Secure storage wrapper factory for wallet credentials
105
+ *
106
+ * Uses react-native-keychain which provides encrypted storage with selective cloud sync.
107
+ * Creates a new instance each time it's called, allowing different configurations
108
+ * for different use cases. For most apps, you should create one instance and reuse it.
109
+ *
110
+ * SECURITY:
111
+ * - Storage is app-scoped by the OS (isolated by bundle ID/package name)
112
+ * - iOS: Uses Keychain Services with iCloud Keychain sync (when user signed into iCloud)
113
+ * - Android: Uses KeyStore with Google Cloud backup (when device backup enabled)
114
+ * - Data is ALWAYS encrypted at rest by Keychain (iOS) / KeyStore (Android)
115
+ * - Cloud sync behavior:
116
+ * - Encryption key: ACCESSIBLE.WHEN_UNLOCKED enables iCloud Keychain sync (iOS) and Google Cloud backup (Android)
117
+ * - Encrypted seed and entropy: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY prevents cloud sync (device-only storage)
118
+ * - Data is encrypted by Apple/Google's E2EE infrastructure
119
+ * - Encryption key requires device unlock + biometric/PIN authentication to access (when available)
120
+ * - Encrypted seed and entropy do not require authentication but are still encrypted at rest
121
+ * - On devices without authentication, data is still encrypted at rest but accessible when device is unlocked
122
+ * - Device-level keychain/keystore provides rate limiting and lockout mechanisms
123
+ * - Input validation prevents injection attacks
124
+ *
125
+ * Two different apps will NOT share data because storage is isolated by bundle ID/package name.
126
+ *
127
+ * @param options - Optional configuration for logger, authentication messages, and timeouts
128
+ * @returns SecureStorage instance
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * const storage = createSecureStorage({
133
+ * logger: customLogger,
134
+ * authentication: {
135
+ * promptMessage: 'Authenticate to access wallet',
136
+ * },
137
+ * timeoutMs: 30000,
138
+ * })
139
+ * ```
140
+ */
141
+ export function createSecureStorage(options?: SecureStorageOptions): SecureStorage {
142
+ const logger = options?.logger || defaultLogger
143
+ const authOptions = options?.authentication || {}
144
+
145
+ // Validate timeout value if provided
146
+ const requestedTimeout = validateTimeout(options?.timeoutMs)
147
+
148
+ // Validate authentication options if provided
149
+ validateAuthenticationOptions(authOptions)
150
+
151
+ const timeoutMs = requestedTimeout || DEFAULT_TIMEOUT_MS
152
+
153
+ /**
154
+ * Error log message generators for consistent error handling
155
+ */
156
+ const getErrorLogMessage = (error: SecureStorageError, operation: string): string => {
157
+ if (error instanceof AuthenticationError) {
158
+ return `Authentication failed for ${operation}`
159
+ }
160
+ if (error instanceof TimeoutError) {
161
+ return `Timeout during ${operation}`
162
+ }
163
+ if (error instanceof ValidationError) {
164
+ return `Validation error during ${operation}`
165
+ }
166
+ return `Failed to ${operation}`
167
+ }
168
+
169
+ /**
170
+ * Standardized error handling helper
171
+ *
172
+ * @param error - The error to handle
173
+ * @param operation - Name of the operation for logging
174
+ * @param errorType - The error type to wrap unexpected errors in
175
+ * @param context - Additional context for logging
176
+ * @throws The error (either rethrown or wrapped)
177
+ * @internal
178
+ */
179
+ function handleSecureStorageError<T extends SecureStorageError>(
180
+ error: unknown,
181
+ operation: string,
182
+ errorType: new (message: string, cause?: Error) => T,
183
+ context?: Record<string, unknown>
184
+ ): never {
185
+ if (error instanceof SecureStorageError) {
186
+ const errorContext =
187
+ error instanceof TimeoutError ? { ...context, timeoutMs } : context
188
+ logger.error(getErrorLogMessage(error, operation), error, errorContext)
189
+ throw error
190
+ }
191
+
192
+ const err = error instanceof Error ? error : new Error(String(error))
193
+ const wrappedError = new errorType(`Unexpected error during ${operation}`, err)
194
+ logger.error(`Unexpected error during ${operation}`, wrappedError, context)
195
+ throw wrappedError
196
+ }
197
+
198
+ /**
199
+ * Get the device security level
200
+ *
201
+ * @returns The security level: NONE, SECRET (PIN/pattern), or BIOMETRIC
202
+ * @internal
203
+ */
204
+ async function getDeviceSecurityLevel(): Promise<LocalAuthentication.SecurityLevel> {
205
+ try {
206
+ const securityLevel = await LocalAuthentication.getEnrolledLevelAsync()
207
+ logger.debug('Device security level', { securityLevel })
208
+ return securityLevel
209
+ } catch (error) {
210
+ const err = error instanceof Error ? error : new Error(String(error))
211
+ logger.warn('Failed to check device security level, assuming NONE', { error: err.message })
212
+ return LocalAuthentication.SecurityLevel.NONE
213
+ }
214
+ }
215
+
216
+
217
+ /**
218
+ * Check if biometric authentication is available
219
+ */
220
+ async function checkBiometricAvailable(): Promise<boolean> {
221
+ try {
222
+ const compatible = await LocalAuthentication.hasHardwareAsync()
223
+ const enrolled = await LocalAuthentication.isEnrolledAsync()
224
+ return compatible && enrolled
225
+ } catch (error) {
226
+ const err = error instanceof Error ? error : new Error(String(error))
227
+ logger.error('Failed to check biometric availability', err, {})
228
+ return false
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Authenticate with biometrics
234
+ *
235
+ * @returns true if authentication succeeded, false otherwise
236
+ */
237
+ async function performAuthentication(): Promise<boolean> {
238
+ try {
239
+ const options = {
240
+ promptMessage: authOptions.promptMessage || 'Authenticate to access your wallet',
241
+ cancelLabel: authOptions.cancelLabel || 'Cancel',
242
+ disableDeviceFallback: authOptions.disableDeviceFallback ?? false,
243
+ }
244
+
245
+ logger.debug('Starting biometric authentication')
246
+
247
+ const result = await LocalAuthentication.authenticateAsync(options)
248
+
249
+ if (result.success) {
250
+ logger.info('Biometric authentication successful')
251
+ return true
252
+ } else {
253
+ logger.warn('Biometric authentication failed or cancelled')
254
+ return false
255
+ }
256
+ } catch (error) {
257
+ const err = error instanceof Error ? error : new Error(String(error))
258
+ const authError = new AuthenticationError('Biometric authentication failed', err)
259
+ logger.error('Biometric authentication error', authError, {})
260
+ throw authError
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Generic setter for secure values
266
+ *
267
+ * @param baseKey - The base storage key (e.g., ENCRYPTION_KEY)
268
+ * @param value - The value to store (must be non-empty string, max 10KB)
269
+ * @param identifier - Optional identifier for multiple wallets
270
+ * @param requireAuth - Whether authentication should be required (default: true)
271
+ * @param syncable - Whether the value should sync across devices (default: true)
272
+ * @throws {ValidationError} If input validation fails
273
+ * @throws {KeychainWriteError} If keychain operation fails
274
+ * @throws {TimeoutError} If operation times out
275
+ * @internal
276
+ */
277
+ async function setSecureValue(
278
+ baseKey: StorageKey,
279
+ value: string,
280
+ identifier?: string,
281
+ requireAuth: boolean = true,
282
+ syncable: boolean = true
283
+ ): Promise<void> {
284
+ validateValue(value, 'value')
285
+ validateIdentifier(identifier)
286
+
287
+ try {
288
+ // Use getEnrolledLevelAsync for more accurate device security detection
289
+ // This handles the Android case where device might not have any security configured
290
+ const [securityLevel, storageKey] = await Promise.all([
291
+ getDeviceSecurityLevel(),
292
+ getStorageKey(baseKey, identifier),
293
+ ])
294
+
295
+ // Device has authentication if security level is not NONE
296
+ const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE
297
+
298
+ // If auth was requested but device has no security, log a warning but proceed without auth
299
+ // Data will still be encrypted at rest by the OS, just not protected by user authentication
300
+ if (requireAuth && !deviceAuthAvailable) {
301
+ logger.warn('Device has no security configured. Storing data without authentication protection.', {
302
+ baseKey,
303
+ identifier,
304
+ securityLevel
305
+ })
306
+ }
307
+
308
+ logger.debug('Storing secure value', { baseKey, identifier, requireAuth, syncable })
309
+
310
+ const result = await withTimeout(
311
+ Keychain.setGenericPassword(baseKey, value, {
312
+ service: storageKey,
313
+ ...createKeychainOptions(deviceAuthAvailable, requireAuth, syncable),
314
+ }),
315
+ timeoutMs,
316
+ `setSecureValue(${baseKey})`
317
+ )
318
+
319
+ if (result === false) {
320
+ throw new KeychainWriteError(`Failed to store ${baseKey}`)
321
+ }
322
+
323
+ logger.info('Secure value stored successfully', { baseKey, identifier })
324
+ } catch (error) {
325
+ handleSecureStorageError(
326
+ error,
327
+ `store ${baseKey}`,
328
+ KeychainWriteError,
329
+ { identifier, baseKey, requireAuth, syncable }
330
+ )
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Check if a key exists in keychain without reading its value
336
+ * Used by hasWallet to check existence without authentication
337
+ *
338
+ * @param storageKey - The storage key to check
339
+ * @returns true if key exists with valid password, false if not found
340
+ * @throws {KeychainReadError} If keychain operation fails (not just "key not found")
341
+ * @throws {TimeoutError} If operation times out
342
+ * @internal
343
+ */
344
+ async function checkKeyExists(storageKey: string): Promise<boolean> {
345
+ try {
346
+ const credentials = await withTimeout(
347
+ Keychain.getGenericPassword({
348
+ service: storageKey,
349
+ // NO authenticationPrompt - we're just checking existence
350
+ }),
351
+ timeoutMs,
352
+ `checkKeyExists(${storageKey})`
353
+ )
354
+ // Keychain.getGenericPassword returns:
355
+ // - false when key doesn't exist (not an error, just missing)
356
+ // - {username, password} object when key exists
357
+ // - null in some edge cases (treat as not found)
358
+ return isKeychainCredentials(credentials)
359
+ } catch (error) {
360
+ // Any exception here indicates a real keychain failure (not just "key not found")
361
+ // These should be propagated as errors, not treated as "key doesn't exist"
362
+ handleSecureStorageError(
363
+ error,
364
+ `check key existence (${storageKey})`,
365
+ KeychainReadError,
366
+ { storageKey }
367
+ )
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Generic getter for secure values
373
+ *
374
+ * @param baseKey - The base storage key (e.g., ENCRYPTION_KEY)
375
+ * @param identifier - Optional identifier for multiple wallets
376
+ * @param requireAuth - Whether authentication is required (default: true)
377
+ * @returns The stored value, or null if not found
378
+ * @throws {ValidationError} If identifier validation fails
379
+ * @throws {AuthenticationError} If authentication fails (when required and device has security)
380
+ * @throws {KeychainReadError} If keychain operation fails
381
+ * @throws {TimeoutError} If operation times out (only for non-auth operations)
382
+ * @internal
383
+ */
384
+ async function getSecureValue(
385
+ baseKey: StorageKey,
386
+ identifier: string | undefined,
387
+ requireAuth: boolean = true
388
+ ): Promise<string | null> {
389
+ validateIdentifier(identifier)
390
+
391
+ try {
392
+ // Check device security level to determine if auth is actually possible
393
+ const [securityLevel, storageKey] = await Promise.all([
394
+ getDeviceSecurityLevel(),
395
+ getStorageKey(baseKey, identifier),
396
+ ])
397
+
398
+ // Device has authentication if security level is not NONE
399
+ const deviceAuthAvailable = securityLevel !== LocalAuthentication.SecurityLevel.NONE
400
+
401
+ // If auth was requested but device has no security, read without auth
402
+ // Data was stored without auth protection on this device, so we can read it without auth
403
+ const actuallyRequireAuth = requireAuth && deviceAuthAvailable
404
+
405
+ if (requireAuth && !deviceAuthAvailable) {
406
+ logger.warn('Device has no security configured. Reading data without authentication.', {
407
+ baseKey,
408
+ identifier,
409
+ securityLevel
410
+ })
411
+ }
412
+
413
+ logger.debug('Retrieving secure value', { baseKey, identifier, requireAuth, actuallyRequireAuth })
414
+
415
+ const keychainOptions = actuallyRequireAuth
416
+ ? {
417
+ service: storageKey,
418
+ authenticationPrompt: {
419
+ title: authOptions.promptMessage || 'Authenticate to access your wallet',
420
+ cancel: authOptions.cancelLabel || 'Cancel',
421
+ },
422
+ }
423
+ : { service: storageKey }
424
+
425
+ // For auth-required operations (biometrics), don't use timeout.
426
+ // Biometric authentication should wait indefinitely for user interaction.
427
+ // Only apply timeout for non-auth operations which should complete quickly.
428
+ const keychainPromise = Keychain.getGenericPassword(keychainOptions)
429
+ const credentials = actuallyRequireAuth
430
+ ? await keychainPromise // No timeout for biometrics - wait for user
431
+ : await withTimeout(keychainPromise, timeoutMs, `getSecureValue(${baseKey})`)
432
+
433
+ if (!isKeychainCredentials(credentials)) {
434
+ logger.debug('Secure value not found', { baseKey, identifier })
435
+ return null
436
+ }
437
+
438
+ logger.info('Secure value retrieved successfully', { baseKey, identifier })
439
+ return credentials.password
440
+ } catch (error) {
441
+ handleSecureStorageError(
442
+ error,
443
+ `get ${baseKey}`,
444
+ KeychainReadError,
445
+ { identifier, baseKey, requireAuth }
446
+ )
447
+ }
448
+ }
449
+
450
+ // Create and return the instance
451
+ return {
452
+ /**
453
+ * Check if device security (PIN/pattern/password/biometrics) is enabled
454
+ *
455
+ * Apps can use this to check device security status and decide whether to
456
+ * require users to enable a PIN/pattern/password before storing sensitive data.
457
+ * The library will function without device security (data is still encrypted at rest).
458
+ *
459
+ * @returns Promise resolving to true if device security is enabled, false otherwise
460
+ */
461
+ async isDeviceSecurityEnabled(): Promise<boolean> {
462
+ try {
463
+ const securityLevel = await LocalAuthentication.getEnrolledLevelAsync()
464
+ const isEnabled = securityLevel !== LocalAuthentication.SecurityLevel.NONE
465
+ logger.debug('Device security check', { securityLevel, isEnabled })
466
+ return isEnabled
467
+ } catch (error) {
468
+ const err = error instanceof Error ? error : new Error(String(error))
469
+ logger.warn('Failed to check device security level', { error: err.message })
470
+ // If we can't check, assume it's not enabled to be safe
471
+ return false
472
+ }
473
+ },
474
+
475
+ /**
476
+ * Check if biometric authentication is available
477
+ */
478
+ async isBiometricAvailable(): Promise<boolean> {
479
+ return checkBiometricAvailable()
480
+ },
481
+
482
+ /**
483
+ * Authenticate with biometrics
484
+ *
485
+ * @returns true if authentication succeeded, false otherwise
486
+ */
487
+ async authenticate(): Promise<boolean> {
488
+ return performAuthentication()
489
+ },
490
+
491
+ /**
492
+ * Store encryption key securely
493
+ *
494
+ * @param key - The encryption key to store (must be non-empty string, max 10KB)
495
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
496
+ * @throws {ValidationError} If key or identifier is invalid
497
+ * @throws {KeychainWriteError} If keychain operation fails
498
+ * @throws {TimeoutError} If operation times out
499
+ */
500
+ async setEncryptionKey(key: string, identifier?: string, options?: SecureStorageItemOptions): Promise<void> {
501
+ const requireAuth = options?.requireBiometrics ?? true
502
+ return setSecureValue(ENCRYPTION_KEY, key, identifier, requireAuth, true)
503
+ },
504
+
505
+ /**
506
+ * Get encryption key from secure storage
507
+ *
508
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
509
+ * @returns The encryption key, or null if not found
510
+ *
511
+ * @throws {ValidationError} If identifier is invalid format
512
+ * @throws {AuthenticationError} If authentication fails
513
+ * @throws {KeychainReadError} If keychain operation fails
514
+ * @throws {TimeoutError} If operation times out
515
+ */
516
+ async getEncryptionKey(identifier?: string, options?: SecureStorageItemOptions): Promise<string | null> {
517
+ const requireAuth = options?.requireBiometrics ?? true
518
+ return getSecureValue(ENCRYPTION_KEY, identifier, requireAuth)
519
+ },
520
+
521
+ /**
522
+ * Store encrypted seed securely
523
+ *
524
+ * @param encryptedSeed - The encrypted seed to store (must be non-empty string, max 10KB)
525
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
526
+ *
527
+ * @throws {ValidationError} If encryptedSeed is invalid
528
+ * @throws {ValidationError} If identifier is invalid format
529
+ * @throws {KeychainWriteError} If keychain operation fails
530
+ * @throws {TimeoutError} If operation times out
531
+ *
532
+ * Note: Encrypted seed does not require authentication for access and is stored device-only (not synced across devices)
533
+ */
534
+ async setEncryptedSeed(encryptedSeed: string, identifier?: string): Promise<void> {
535
+ return setSecureValue(ENCRYPTED_SEED, encryptedSeed, identifier, false, false)
536
+ },
537
+
538
+ /**
539
+ * Get encrypted seed from secure storage
540
+ *
541
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
542
+ * @returns The encrypted seed, or null if not found
543
+ *
544
+ * @throws {ValidationError} If identifier is invalid format
545
+ * @throws {KeychainReadError} If keychain operation fails
546
+ * @throws {TimeoutError} If operation times out
547
+ *
548
+ * Note: Encrypted seed does not require authentication for access and is stored device-only (not synced across devices)
549
+ */
550
+ async getEncryptedSeed(identifier?: string): Promise<string | null> {
551
+ return getSecureValue(ENCRYPTED_SEED, identifier, false)
552
+ },
553
+
554
+ /**
555
+ * Store encrypted entropy securely
556
+ *
557
+ * @param encryptedEntropy - The encrypted entropy to store (must be non-empty string, max 10KB)
558
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
559
+ *
560
+ * @throws {ValidationError} If encryptedEntropy is invalid
561
+ * @throws {ValidationError} If identifier is invalid format
562
+ * @throws {KeychainWriteError} If keychain operation fails
563
+ * @throws {TimeoutError} If operation times out
564
+ *
565
+ * Note: Encrypted entropy does not require authentication for access and is stored device-only (not synced across devices)
566
+ */
567
+ async setEncryptedEntropy(encryptedEntropy: string, identifier?: string): Promise<void> {
568
+ return setSecureValue(ENCRYPTED_ENTROPY, encryptedEntropy, identifier, false, false)
569
+ },
570
+
571
+ /**
572
+ * Get encrypted entropy from secure storage
573
+ *
574
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
575
+ * @returns The encrypted entropy, or null if not found
576
+ *
577
+ * @throws {ValidationError} If identifier is invalid format
578
+ * @throws {KeychainReadError} If keychain operation fails
579
+ * @throws {TimeoutError} If operation times out
580
+ *
581
+ * Note: Encrypted entropy does not require authentication for access and is stored device-only (not synced across devices)
582
+ */
583
+ async getEncryptedEntropy(identifier?: string): Promise<string | null> {
584
+ return getSecureValue(ENCRYPTED_ENTROPY, identifier, false)
585
+ },
586
+
587
+ /**
588
+ * Get all encrypted wallet data at once (seed, entropy, and encryption key)
589
+ * Uses Promise.all, so fails fast if any operation fails.
590
+ *
591
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
592
+ * @returns Object containing seed, entropy, and encryptionKey (may be null if not found)
593
+ * @throws {ValidationError} If identifier is invalid format
594
+ * @throws {AuthenticationError} If authentication fails
595
+ * @throws {KeychainReadError} If keychain operation fails
596
+ * @throws {TimeoutError} If operation times out
597
+ */
598
+ async getAllEncrypted(identifier?: string): Promise<{
599
+ encryptedSeed: string | null
600
+ encryptedEntropy: string | null
601
+ encryptionKey: string | null
602
+ }> {
603
+ const [encryptedSeed, encryptedEntropy, encryptionKey] = await Promise.all([
604
+ this.getEncryptedSeed(identifier),
605
+ this.getEncryptedEntropy(identifier),
606
+ this.getEncryptionKey(identifier),
607
+ ])
608
+
609
+ return {
610
+ encryptedSeed,
611
+ encryptedEntropy,
612
+ encryptionKey,
613
+ }
614
+ },
615
+
616
+ /**
617
+ * Check if wallet credentials exist (without requiring authentication)
618
+ * Returns false only when wallet is definitively not found. Errors are thrown.
619
+ *
620
+ * IMPORTANT: Only checks encrypted seed, NOT encryption key.
621
+ * Encryption key is protected with biometrics, so checking it would trigger
622
+ * an authentication prompt. Encrypted seed is stored without auth requirement,
623
+ * so checking it won't trigger biometrics.
624
+ *
625
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
626
+ * @returns true if wallet exists, false if definitively not found
627
+ * @throws {ValidationError} If identifier is invalid format
628
+ * @throws {TimeoutError} If operation times out
629
+ * @throws {KeychainReadError} If keychain operation fails
630
+ */
631
+ async hasWallet(identifier?: string): Promise<boolean> {
632
+ // ONLY check encrypted seed - it does NOT require biometrics
633
+ // Encryption key is protected with ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
634
+ // so checking it would trigger a biometric prompt with the default
635
+ // "Authenticate to retrieve secret" message from react-native-keychain.
636
+ // By only checking the seed (which is stored without auth requirement),
637
+ // we can determine wallet existence without triggering biometrics.
638
+ const seedStorageKey = await getStorageKey(ENCRYPTED_SEED, identifier)
639
+ return await checkKeyExists(seedStorageKey)
640
+ },
641
+
642
+ /**
643
+ * Delete all wallet credentials
644
+ *
645
+ * @param identifier - Optional identifier (e.g., email) to support multiple wallets
646
+ *
647
+ * @throws {ValidationError} If identifier is invalid format
648
+ * @throws {SecureStorageError} If deletion fails (with details of which items failed)
649
+ * @throws {TimeoutError} If operation times out
650
+ */
651
+ async deleteWallet(identifier?: string): Promise<void> {
652
+ // Batch storage key generation
653
+ const [encryptionKey, encryptedSeed, encryptedEntropy] = await Promise.all([
654
+ getStorageKey(ENCRYPTION_KEY, identifier),
655
+ getStorageKey(ENCRYPTED_SEED, identifier),
656
+ getStorageKey(ENCRYPTED_ENTROPY, identifier),
657
+ ])
658
+
659
+ const serviceKeys = [encryptionKey, encryptedSeed, encryptedEntropy]
660
+ const serviceNames = ['encryptionKey', 'encryptedSeed', 'encryptedEntropy']
661
+
662
+ logger.debug('Deleting wallet', { identifier, services: serviceNames })
663
+
664
+ const results = await Promise.allSettled(
665
+ serviceKeys.map((key) =>
666
+ withTimeout(
667
+ Keychain.resetGenericPassword({ service: key }),
668
+ timeoutMs,
669
+ `deleteWallet(${key})`
670
+ )
671
+ )
672
+ )
673
+
674
+ const failedServices = serviceNames.filter((_, index) => {
675
+ const result = results[index]
676
+ if (!result) return true
677
+ return result.status === 'rejected' || (result.status === 'fulfilled' && result.value === false)
678
+ })
679
+
680
+ if (failedServices.length > 0) {
681
+ const error = new SecureStorageError(
682
+ `Failed to delete wallet: ${failedServices.join(', ')}`,
683
+ 'WALLET_DELETE_ERROR'
684
+ )
685
+ logger.error('Wallet deletion failed', error, { identifier, failedServices })
686
+ throw error
687
+ }
688
+
689
+ logger.info('Wallet deleted successfully', { identifier })
690
+ },
691
+
692
+ /**
693
+ * Cleanup method to release resources associated with this storage instance
694
+ *
695
+ * Currently, this is a no-op as the module uses shared resources. This method is provided
696
+ * for future extensibility and to maintain a consistent API.
697
+ *
698
+ * Note: This does NOT delete stored wallet data - use deleteWallet() for that purpose.
699
+ */
700
+ cleanup(): void {
701
+ // Currently a no-op, but provided for future extensibility
702
+ // If instance-specific resources are added in the future, they should be
703
+ // cleaned up here
704
+ logger.debug('Storage instance cleanup called (no-op)', {})
705
+ },
706
+ }
707
+ }