@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,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
|
+
}
|