@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/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
+