@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
package/src/utils.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// External packages
|
|
2
|
+
import * as Crypto from 'expo-crypto'
|
|
3
|
+
|
|
4
|
+
// Internal modules
|
|
5
|
+
import { MIN_TIMEOUT_MS, MAX_TIMEOUT_MS } from './constants'
|
|
6
|
+
import { TimeoutError, ValidationError } from './errors'
|
|
7
|
+
import { validateIdentifier } from './validation'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Type for keychain credentials returned by react-native-keychain
|
|
11
|
+
*/
|
|
12
|
+
export type KeychainCredentials = {
|
|
13
|
+
username: string
|
|
14
|
+
password: string
|
|
15
|
+
service: string
|
|
16
|
+
storage?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Type guard to check if a value is valid keychain credentials
|
|
21
|
+
*
|
|
22
|
+
* @param value - The value to check
|
|
23
|
+
* @returns true if value is valid keychain credentials with non-empty password
|
|
24
|
+
*/
|
|
25
|
+
export function isKeychainCredentials(value: unknown): value is KeychainCredentials {
|
|
26
|
+
if (
|
|
27
|
+
value === false ||
|
|
28
|
+
value === null ||
|
|
29
|
+
typeof value !== 'object' ||
|
|
30
|
+
Array.isArray(value)
|
|
31
|
+
) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const obj = value as Record<string, unknown>
|
|
36
|
+
return (
|
|
37
|
+
typeof obj.password === 'string' &&
|
|
38
|
+
obj.password.length > 0 &&
|
|
39
|
+
typeof obj.username === 'string' &&
|
|
40
|
+
typeof obj.service === 'string' &&
|
|
41
|
+
(obj.storage === undefined || typeof obj.storage === 'string')
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hash identifier using SHA-256 from expo-crypto
|
|
47
|
+
*
|
|
48
|
+
* @param str - Input string to hash
|
|
49
|
+
* @returns Promise that resolves to a 64-character hexadecimal SHA-256 hash
|
|
50
|
+
*/
|
|
51
|
+
async function hashIdentifier(str: string): Promise<string> {
|
|
52
|
+
return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, str)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Valid storage key names
|
|
57
|
+
* These are the only allowed base keys for secure storage
|
|
58
|
+
*/
|
|
59
|
+
export const VALID_STORAGE_KEYS = [
|
|
60
|
+
'wallet_encryption_key',
|
|
61
|
+
'wallet_encrypted_seed',
|
|
62
|
+
'wallet_encrypted_entropy',
|
|
63
|
+
] as const
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Type-safe storage key names
|
|
67
|
+
* Union type of valid storage keys
|
|
68
|
+
*/
|
|
69
|
+
export type StorageKey = (typeof VALID_STORAGE_KEYS)[number]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Runtime validation for storage keys
|
|
73
|
+
* Ensures only valid base keys are used at runtime
|
|
74
|
+
*
|
|
75
|
+
* @param key - The key to validate
|
|
76
|
+
* @throws {ValidationError} If the key is not a valid storage key
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
function assertValidStorageKey(key: string): asserts key is StorageKey {
|
|
80
|
+
const validKey = VALID_STORAGE_KEYS.find((k) => k === key)
|
|
81
|
+
if (!validKey) {
|
|
82
|
+
throw new ValidationError(
|
|
83
|
+
`Invalid storage key: ${key}. Valid keys are: ${VALID_STORAGE_KEYS.join(', ')}`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a StorageKey from a string with runtime validation
|
|
90
|
+
*
|
|
91
|
+
* @param key - The key string to convert to StorageKey
|
|
92
|
+
* @returns The validated StorageKey
|
|
93
|
+
* @throws {ValidationError} If the key is not a valid storage key
|
|
94
|
+
*/
|
|
95
|
+
export function createStorageKey(key: string): StorageKey {
|
|
96
|
+
assertValidStorageKey(key)
|
|
97
|
+
// After validation, we know key is one of VALID_STORAGE_KEYS
|
|
98
|
+
return key
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate a secure storage key from base key and optional identifier
|
|
103
|
+
*
|
|
104
|
+
* @param baseKey - The base storage key (must be a valid StorageKey)
|
|
105
|
+
* @param identifier - Optional identifier (e.g., email) to support multiple wallets
|
|
106
|
+
* @returns Promise that resolves to the storage key
|
|
107
|
+
* @throws {ValidationError} If baseKey is not a valid storage key
|
|
108
|
+
*/
|
|
109
|
+
export async function getStorageKey(baseKey: StorageKey, identifier?: string): Promise<string> {
|
|
110
|
+
// Runtime validation for type system bypasses (e.g., 'invalid_key' as any)
|
|
111
|
+
assertValidStorageKey(baseKey)
|
|
112
|
+
|
|
113
|
+
// Handle undefined/null early
|
|
114
|
+
if (identifier == null) {
|
|
115
|
+
return baseKey
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate identifier first (this will throw for empty strings and invalid formats)
|
|
119
|
+
validateIdentifier(identifier)
|
|
120
|
+
|
|
121
|
+
// Normalize: lowercase and trim (validation ensures it's not empty after trim)
|
|
122
|
+
const normalized = identifier.toLowerCase().trim()
|
|
123
|
+
|
|
124
|
+
// Use SHA-256 hash from expo-crypto to prevent collisions and ensure safe key format
|
|
125
|
+
// This is a battle-tested, production-ready solution
|
|
126
|
+
const hash = await hashIdentifier(normalized)
|
|
127
|
+
|
|
128
|
+
return `${baseKey}_${hash}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a timeout wrapper for promises
|
|
134
|
+
*
|
|
135
|
+
* **IMPORTANT:** Uses Promise.race() which does NOT cancel the underlying promise.
|
|
136
|
+
* The original promise continues executing after timeout (result is ignored).
|
|
137
|
+
* This is acceptable for keychain operations as they're fast and OS-bounded.
|
|
138
|
+
*
|
|
139
|
+
* @param promise - The promise to wrap
|
|
140
|
+
* @param timeoutMs - Timeout in milliseconds (should be validated via validateTimeout before calling)
|
|
141
|
+
* @param operation - Name of the operation for error messages
|
|
142
|
+
* @returns Promise that rejects on timeout
|
|
143
|
+
* @throws {ValidationError} If timeoutMs is invalid
|
|
144
|
+
* @throws {TimeoutError} If operation times out
|
|
145
|
+
*/
|
|
146
|
+
export async function withTimeout<T>(
|
|
147
|
+
promise: Promise<T>,
|
|
148
|
+
timeoutMs: number,
|
|
149
|
+
operation: string
|
|
150
|
+
): Promise<T> {
|
|
151
|
+
// Note: validateTimeout should be called before this function to ensure timeoutMs is valid.
|
|
152
|
+
// This function only performs basic safety checks for direct calls.
|
|
153
|
+
if (typeof timeoutMs !== 'number' || !isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
154
|
+
throw new ValidationError(`Invalid timeout value: ${timeoutMs}. Must be a positive finite number.`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) {
|
|
158
|
+
throw new ValidationError(
|
|
159
|
+
`Timeout ${timeoutMs}ms is out of range. Must be between ${MIN_TIMEOUT_MS}ms and ${MAX_TIMEOUT_MS}ms.`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let timeout
|
|
164
|
+
const timeoutPromise = new Promise<T>((_, reject) => {
|
|
165
|
+
timeout = setTimeout(() => {
|
|
166
|
+
reject(new TimeoutError(`Operation ${operation} timed out after ${timeoutMs}ms`))
|
|
167
|
+
}, timeoutMs)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
return await Promise.race([promise, timeoutPromise])
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timeout)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ValidationError } from './errors'
|
|
2
|
+
import { MIN_TIMEOUT_MS, MAX_TIMEOUT_MS } from './constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authentication options for biometric prompts
|
|
6
|
+
*/
|
|
7
|
+
interface AuthenticationOptions {
|
|
8
|
+
promptMessage?: string
|
|
9
|
+
cancelLabel?: string
|
|
10
|
+
disableDeviceFallback?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximum length for identifier strings
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_IDENTIFIER_LENGTH = 256
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum length for stored values (10KB)
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_VALUE_LENGTH = 10240
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pattern for valid identifiers
|
|
25
|
+
* Allows: alphanumeric, dots, dashes, underscores, plus signs, and optional email-like format
|
|
26
|
+
*
|
|
27
|
+
* Examples of valid identifiers:
|
|
28
|
+
* - "user123" (simple identifier)
|
|
29
|
+
* - "my_wallet" (with underscore)
|
|
30
|
+
* - "test-identifier" (with dash)
|
|
31
|
+
* - "user@example.com" (email format)
|
|
32
|
+
* - "user+tag@example.com" (email with plus sign in local part)
|
|
33
|
+
*
|
|
34
|
+
* The email part (after @) is optional - simple identifiers are fully supported.
|
|
35
|
+
*/
|
|
36
|
+
const IDENTIFIER_PATTERN = /^[a-zA-Z0-9._+-]+(@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})?$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validates an identifier parameter
|
|
40
|
+
*
|
|
41
|
+
* @param identifier - The identifier to validate (optional)
|
|
42
|
+
* @throws {ValidationError} If identifier is invalid
|
|
43
|
+
*/
|
|
44
|
+
export function validateIdentifier(identifier?: string): void {
|
|
45
|
+
if (identifier === undefined || identifier === null) {
|
|
46
|
+
return // Optional parameter is allowed
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof identifier !== 'string') {
|
|
50
|
+
throw new ValidationError('Identifier must be a string')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimmed = identifier.trim()
|
|
54
|
+
if (trimmed === '') {
|
|
55
|
+
throw new ValidationError('Identifier cannot be empty')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimmed.length > MAX_IDENTIFIER_LENGTH) {
|
|
59
|
+
throw new ValidationError(
|
|
60
|
+
`Identifier exceeds maximum length of ${MAX_IDENTIFIER_LENGTH} characters`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!IDENTIFIER_PATTERN.test(trimmed)) {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
'Identifier contains invalid characters. Allowed: alphanumeric, dots, dashes, underscores, plus signs, and email format'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates a value to be stored
|
|
73
|
+
*
|
|
74
|
+
* @param value - The value to validate
|
|
75
|
+
* @param fieldName - Name of the field for error messages
|
|
76
|
+
* @throws {ValidationError} If value is invalid
|
|
77
|
+
*/
|
|
78
|
+
export function validateValue(value: string, fieldName: string = 'value'): void {
|
|
79
|
+
if (value === null || value === undefined) {
|
|
80
|
+
throw new ValidationError(`${fieldName} cannot be null or undefined`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof value !== 'string') {
|
|
84
|
+
throw new ValidationError(`${fieldName} must be a string`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (value.length === 0) {
|
|
88
|
+
throw new ValidationError(`${fieldName} cannot be empty`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (value.length > MAX_VALUE_LENGTH) {
|
|
92
|
+
throw new ValidationError(
|
|
93
|
+
`${fieldName} exceeds maximum length of ${MAX_VALUE_LENGTH} characters`
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validates a timeout value
|
|
100
|
+
*
|
|
101
|
+
* @param timeoutMs - The timeout value to validate (optional)
|
|
102
|
+
* @returns The validated timeout value, or undefined if not provided
|
|
103
|
+
* @throws {ValidationError} If timeout is invalid
|
|
104
|
+
*/
|
|
105
|
+
export function validateTimeout(timeoutMs: number | undefined): number | undefined {
|
|
106
|
+
if (timeoutMs === undefined) {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof timeoutMs !== 'number' || isNaN(timeoutMs) || !isFinite(timeoutMs)) {
|
|
111
|
+
throw new ValidationError(`Invalid timeout value: ${timeoutMs}. Must be a finite number.`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (timeoutMs < MIN_TIMEOUT_MS) {
|
|
115
|
+
throw new ValidationError(`Timeout ${timeoutMs}ms is too short. Minimum is ${MIN_TIMEOUT_MS}ms.`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (timeoutMs > MAX_TIMEOUT_MS) {
|
|
119
|
+
throw new ValidationError(`Timeout ${timeoutMs}ms is too long. Maximum is ${MAX_TIMEOUT_MS}ms.`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return timeoutMs
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates authentication options
|
|
127
|
+
*
|
|
128
|
+
* @param options - The authentication options to validate (optional)
|
|
129
|
+
* @throws {ValidationError} If any option is invalid
|
|
130
|
+
*/
|
|
131
|
+
export function validateAuthenticationOptions(options?: AuthenticationOptions): void {
|
|
132
|
+
if (!options) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (options.promptMessage !== undefined) {
|
|
137
|
+
if (typeof options.promptMessage !== 'string') {
|
|
138
|
+
throw new ValidationError('Authentication promptMessage must be a string')
|
|
139
|
+
}
|
|
140
|
+
if (options.promptMessage.trim().length === 0) {
|
|
141
|
+
throw new ValidationError('Authentication promptMessage cannot be empty')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (options.cancelLabel !== undefined) {
|
|
146
|
+
if (typeof options.cancelLabel !== 'string') {
|
|
147
|
+
throw new ValidationError('Authentication cancelLabel must be a string')
|
|
148
|
+
}
|
|
149
|
+
if (options.cancelLabel.trim().length === 0) {
|
|
150
|
+
throw new ValidationError('Authentication cancelLabel cannot be empty')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options.disableDeviceFallback !== undefined) {
|
|
155
|
+
if (typeof options.disableDeviceFallback !== 'boolean') {
|
|
156
|
+
throw new ValidationError('Authentication disableDeviceFallback must be a boolean')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"moduleResolution": "node",
|
|
16
|
+
"noUnusedLocals": true,
|
|
17
|
+
"noUnusedParameters": true,
|
|
18
|
+
"noImplicitReturns": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*"],
|
|
22
|
+
"exclude": ["node_modules", "dist", "src/__tests__", "**/*.test.ts", "**/*.spec.ts"]
|
|
23
|
+
}
|
|
24
|
+
|