@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/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tetherto/wdk-react-native-secure-storage",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Secure storage abstractions for React Native - provides secure storage for sensitive data (encrypted seeds, keys) using react-native-keychain",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"react-native": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/tetherto/wdk-react-native-secure-storage.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/tetherto/wdk-react-native-secure-storage/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/tetherto/wdk-react-native-secure-storage#readme",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "jest",
|
|
27
|
+
"test:watch": "jest --watch",
|
|
28
|
+
"test:coverage": "jest --coverage",
|
|
29
|
+
"lint": "eslint src --ext .ts",
|
|
30
|
+
"lint:fix": "eslint src --ext .ts --fix",
|
|
31
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
32
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
33
|
+
"prepublishOnly": "npm run build && npm run test",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"prepare": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"tsconfig.json"
|
|
43
|
+
],
|
|
44
|
+
"keywords": [
|
|
45
|
+
"wallet",
|
|
46
|
+
"react-native",
|
|
47
|
+
"secure-storage",
|
|
48
|
+
"react-native-keychain",
|
|
49
|
+
"keychain"
|
|
50
|
+
],
|
|
51
|
+
"author": "Tetherto",
|
|
52
|
+
"license": "Apache-2.0",
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"expo-crypto": "^15.0.8",
|
|
55
|
+
"expo-local-authentication": "~17.0.8",
|
|
56
|
+
"react-native-keychain": "^10.0.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react-native": ">=0.70.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@jest/test-sequencer": "^30.2.0",
|
|
63
|
+
"@types/jest": "^29.5.0",
|
|
64
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
65
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
66
|
+
"eslint": "^8.50.0",
|
|
67
|
+
"jest": "^29.5.0",
|
|
68
|
+
"prettier": "^3.0.0",
|
|
69
|
+
"ts-jest": "^29.1.0",
|
|
70
|
+
"typescript": "^5.3.3"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for expo-crypto
|
|
3
|
+
* Used in tests to simulate cryptographic operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export enum CryptoDigestAlgorithm {
|
|
7
|
+
SHA256 = 'SHA256',
|
|
8
|
+
SHA384 = 'SHA384',
|
|
9
|
+
SHA512 = 'SHA512',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mock digestStringAsync function
|
|
14
|
+
* Returns a deterministic hash based on input string
|
|
15
|
+
* For testing purposes, uses a simple hash to ensure consistency
|
|
16
|
+
*/
|
|
17
|
+
export async function digestStringAsync(
|
|
18
|
+
_algorithm: CryptoDigestAlgorithm,
|
|
19
|
+
data: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
// Simple deterministic hash for testing (not cryptographically secure, but consistent)
|
|
22
|
+
let hash = 0
|
|
23
|
+
for (let i = 0; i < data.length; i++) {
|
|
24
|
+
const char = data.charCodeAt(i)
|
|
25
|
+
hash = ((hash << 5) - hash) + char
|
|
26
|
+
hash = hash & hash // Convert to 32-bit integer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Convert to hex string (64 chars for SHA-256)
|
|
30
|
+
const hex = Math.abs(hash).toString(16).padStart(8, '0')
|
|
31
|
+
// Repeat to get 64 characters (SHA-256 length)
|
|
32
|
+
return (hex.repeat(8)).substring(0, 64)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for expo-local-authentication
|
|
3
|
+
* Used in tests to simulate authentication operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let mockIsEnrolled = true
|
|
7
|
+
let mockHasHardware = true
|
|
8
|
+
let mockAuthenticateResult = { success: true }
|
|
9
|
+
|
|
10
|
+
export function isEnrolledAsync(): Promise<boolean> {
|
|
11
|
+
return Promise.resolve(mockIsEnrolled)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasHardwareAsync(): Promise<boolean> {
|
|
15
|
+
return Promise.resolve(mockHasHardware)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function authenticateAsync(options?: {
|
|
19
|
+
promptMessage?: string
|
|
20
|
+
cancelLabel?: string
|
|
21
|
+
disableDeviceFallback?: boolean
|
|
22
|
+
}): Promise<{ success: boolean }> {
|
|
23
|
+
return Promise.resolve(mockAuthenticateResult)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Helpers for tests to control mock behavior
|
|
27
|
+
export function __setMockIsEnrolled(value: boolean): void {
|
|
28
|
+
mockIsEnrolled = value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function __setMockHasHardware(value: boolean): void {
|
|
32
|
+
mockHasHardware = value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function __setMockAuthenticateResult(value: { success: boolean }): void {
|
|
36
|
+
mockAuthenticateResult = value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for react-native-keychain
|
|
3
|
+
* Used in tests to simulate keychain operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ACCESSIBLE = {
|
|
7
|
+
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
8
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ACCESS_CONTROL = {
|
|
12
|
+
BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let mockStorage: Map<string, { username: string; password: string }> = new Map()
|
|
16
|
+
|
|
17
|
+
export function setGenericPassword(
|
|
18
|
+
username: string,
|
|
19
|
+
password: string,
|
|
20
|
+
options?: { service?: string }
|
|
21
|
+
): Promise<{ service: string; storage: string } | false> {
|
|
22
|
+
const service = options?.service || 'default'
|
|
23
|
+
mockStorage.set(service, { username, password })
|
|
24
|
+
return Promise.resolve({ service, storage: 'keychain' })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getGenericPassword(options?: { service?: string }): Promise<
|
|
28
|
+
| {
|
|
29
|
+
service: string
|
|
30
|
+
username: string
|
|
31
|
+
password: string
|
|
32
|
+
storage: string
|
|
33
|
+
}
|
|
34
|
+
| false
|
|
35
|
+
> {
|
|
36
|
+
const service = options?.service || 'default'
|
|
37
|
+
const stored = mockStorage.get(service)
|
|
38
|
+
if (stored) {
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
service,
|
|
41
|
+
username: stored.username,
|
|
42
|
+
password: stored.password,
|
|
43
|
+
storage: 'keychain',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return Promise.resolve(false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resetGenericPassword(options?: { service?: string }): Promise<boolean> {
|
|
50
|
+
const service = options?.service || 'default'
|
|
51
|
+
mockStorage.delete(service)
|
|
52
|
+
return Promise.resolve(true)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper for tests to reset mock storage
|
|
56
|
+
export function __resetMockStorage(): void {
|
|
57
|
+
mockStorage.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SecureStorageError,
|
|
3
|
+
KeychainError,
|
|
4
|
+
KeychainWriteError,
|
|
5
|
+
KeychainReadError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
TimeoutError,
|
|
9
|
+
} from '../errors'
|
|
10
|
+
|
|
11
|
+
describe('errors', () => {
|
|
12
|
+
describe('SecureStorageError', () => {
|
|
13
|
+
it('should create error with message and code', () => {
|
|
14
|
+
const error = new SecureStorageError('Test error', 'TEST_CODE')
|
|
15
|
+
expect(error.message).toBe('Test error')
|
|
16
|
+
expect(error.code).toBe('TEST_CODE')
|
|
17
|
+
expect(error.name).toBe('SecureStorageError')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should include cause error', () => {
|
|
21
|
+
const cause = new Error('Original error')
|
|
22
|
+
const error = new SecureStorageError('Test error', 'TEST_CODE', cause)
|
|
23
|
+
expect(error.cause).toBe(cause)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('KeychainError', () => {
|
|
28
|
+
it('should create keychain error', () => {
|
|
29
|
+
const error = new KeychainError('Keychain failed')
|
|
30
|
+
expect(error.message).toBe('Keychain failed')
|
|
31
|
+
expect(error.code).toBe('KEYCHAIN_ERROR')
|
|
32
|
+
expect(error.name).toBe('KeychainError')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('KeychainWriteError', () => {
|
|
37
|
+
it('should create write error', () => {
|
|
38
|
+
const error = new KeychainWriteError('Write failed')
|
|
39
|
+
expect(error.code).toBe('KEYCHAIN_WRITE_ERROR')
|
|
40
|
+
expect(error.name).toBe('KeychainWriteError')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('KeychainReadError', () => {
|
|
45
|
+
it('should create read error', () => {
|
|
46
|
+
const error = new KeychainReadError('Read failed')
|
|
47
|
+
expect(error.code).toBe('KEYCHAIN_READ_ERROR')
|
|
48
|
+
expect(error.name).toBe('KeychainReadError')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('AuthenticationError', () => {
|
|
53
|
+
it('should create authentication error', () => {
|
|
54
|
+
const error = new AuthenticationError('Auth failed')
|
|
55
|
+
expect(error.code).toBe('AUTHENTICATION_ERROR')
|
|
56
|
+
expect(error.name).toBe('AuthenticationError')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('ValidationError', () => {
|
|
61
|
+
it('should create validation error', () => {
|
|
62
|
+
const error = new ValidationError('Invalid input')
|
|
63
|
+
expect(error.code).toBe('VALIDATION_ERROR')
|
|
64
|
+
expect(error.name).toBe('ValidationError')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('TimeoutError', () => {
|
|
69
|
+
it('should create timeout error', () => {
|
|
70
|
+
const error = new TimeoutError('Operation timed out')
|
|
71
|
+
expect(error.code).toBe('TIMEOUT_ERROR')
|
|
72
|
+
expect(error.name).toBe('TimeoutError')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { defaultLogger, LogLevel } from '../logger'
|
|
2
|
+
|
|
3
|
+
describe('logger', () => {
|
|
4
|
+
let consoleErrorSpy: jest.SpyInstance
|
|
5
|
+
let consoleWarnSpy: jest.SpyInstance
|
|
6
|
+
let consoleLogSpy: jest.SpyInstance
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Spy on console methods
|
|
10
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
11
|
+
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
12
|
+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
// Restore console methods
|
|
17
|
+
consoleErrorSpy.mockRestore()
|
|
18
|
+
consoleWarnSpy.mockRestore()
|
|
19
|
+
consoleLogSpy.mockRestore()
|
|
20
|
+
// Reset logger level to default (ERROR)
|
|
21
|
+
defaultLogger.setLevel(LogLevel.ERROR)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('defaultLogger', () => {
|
|
25
|
+
it('should set log level', () => {
|
|
26
|
+
defaultLogger.setLevel(LogLevel.DEBUG)
|
|
27
|
+
expect(() => defaultLogger.setLevel(LogLevel.INFO)).not.toThrow()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should log error messages', () => {
|
|
31
|
+
defaultLogger.setLevel(LogLevel.ERROR)
|
|
32
|
+
const error = new Error('Test error')
|
|
33
|
+
defaultLogger.error('Error message', error, { context: 'test' })
|
|
34
|
+
|
|
35
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
|
|
36
|
+
const logCall = consoleErrorSpy.mock.calls[0][0]
|
|
37
|
+
expect(logCall).toContain('Error message')
|
|
38
|
+
expect(JSON.parse(logCall)).toMatchObject({
|
|
39
|
+
level: LogLevel.ERROR,
|
|
40
|
+
message: 'Error message',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should log warn messages when level is WARN or lower', () => {
|
|
45
|
+
defaultLogger.setLevel(LogLevel.WARN)
|
|
46
|
+
defaultLogger.warn('Warning message', { context: 'test' })
|
|
47
|
+
|
|
48
|
+
expect(consoleWarnSpy).toHaveBeenCalledTimes(1)
|
|
49
|
+
const logCall = consoleWarnSpy.mock.calls[0][0]
|
|
50
|
+
expect(JSON.parse(logCall)).toMatchObject({
|
|
51
|
+
level: LogLevel.WARN,
|
|
52
|
+
message: 'Warning message',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should log info messages when level is INFO or lower', () => {
|
|
57
|
+
defaultLogger.setLevel(LogLevel.INFO)
|
|
58
|
+
defaultLogger.info('Info message', { context: 'test' })
|
|
59
|
+
|
|
60
|
+
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
|
61
|
+
const logCall = consoleLogSpy.mock.calls[0][0]
|
|
62
|
+
expect(JSON.parse(logCall)).toMatchObject({
|
|
63
|
+
level: LogLevel.INFO,
|
|
64
|
+
message: 'Info message',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should log debug messages when level is DEBUG', () => {
|
|
69
|
+
defaultLogger.setLevel(LogLevel.DEBUG)
|
|
70
|
+
defaultLogger.debug('Debug message', { context: 'test' })
|
|
71
|
+
|
|
72
|
+
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
|
73
|
+
const logCall = consoleLogSpy.mock.calls[0][0]
|
|
74
|
+
expect(JSON.parse(logCall)).toMatchObject({
|
|
75
|
+
level: LogLevel.DEBUG,
|
|
76
|
+
message: 'Debug message',
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should not log messages below minimum level', () => {
|
|
81
|
+
defaultLogger.setLevel(LogLevel.ERROR)
|
|
82
|
+
|
|
83
|
+
defaultLogger.debug('Debug message')
|
|
84
|
+
defaultLogger.info('Info message')
|
|
85
|
+
defaultLogger.warn('Warning message')
|
|
86
|
+
|
|
87
|
+
expect(consoleLogSpy).not.toHaveBeenCalled()
|
|
88
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
|
89
|
+
|
|
90
|
+
// Error should still be logged
|
|
91
|
+
defaultLogger.error('Error message')
|
|
92
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should include context in log entries', () => {
|
|
96
|
+
defaultLogger.setLevel(LogLevel.DEBUG)
|
|
97
|
+
defaultLogger.info('Test message', { key: 'value', number: 123 })
|
|
98
|
+
|
|
99
|
+
const logCall = consoleLogSpy.mock.calls[0][0]
|
|
100
|
+
const entry = JSON.parse(logCall)
|
|
101
|
+
expect(entry.context).toEqual({ key: 'value', number: 123 })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should include error in log entries', () => {
|
|
105
|
+
defaultLogger.setLevel(LogLevel.ERROR)
|
|
106
|
+
const error = new Error('Test error')
|
|
107
|
+
error.stack = 'Error stack trace'
|
|
108
|
+
|
|
109
|
+
defaultLogger.error('Error message', error)
|
|
110
|
+
|
|
111
|
+
const logCall = consoleErrorSpy.mock.calls[0][0]
|
|
112
|
+
const entry = JSON.parse(logCall)
|
|
113
|
+
expect(entry.error).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should include timestamp in log entries', () => {
|
|
117
|
+
defaultLogger.setLevel(LogLevel.DEBUG)
|
|
118
|
+
const beforeTime = Date.now()
|
|
119
|
+
|
|
120
|
+
defaultLogger.info('Test message')
|
|
121
|
+
|
|
122
|
+
const logCall = consoleLogSpy.mock.calls[0][0]
|
|
123
|
+
const entry = JSON.parse(logCall)
|
|
124
|
+
const afterTime = Date.now()
|
|
125
|
+
|
|
126
|
+
expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime)
|
|
127
|
+
expect(entry.timestamp).toBeLessThanOrEqual(afterTime)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|