diff --git a/App.tsx b/App.tsx index 1ff87c7..0888fb3 100644 --- a/App.tsx +++ b/App.tsx @@ -9,10 +9,15 @@ import { registerForPushNotifications, sendTokenToServer, } from './src/services/notifications'; +import { initWalletVault } from './src/services/walletVault'; function AppSync() { const { syncPendingProofs } = useProofSubmit(); + useEffect(() => { + void initWalletVault(); + }, []); + useEffect(() => { const sub = AppState.addEventListener('change', (state: AppStateStatus) => { if (state === 'active') { diff --git a/__mocks__/react-native-keychain.js b/__mocks__/react-native-keychain.js new file mode 100644 index 0000000..36e688d --- /dev/null +++ b/__mocks__/react-native-keychain.js @@ -0,0 +1,27 @@ +const store = {}; + +const serviceKey = opts => (opts && opts.service) || 'default'; + +export const ACCESSIBLE = { + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly', +}; + +export const SECURITY_LEVEL = { + SECURE_HARDWARE: 'secureHardware', + ANY: 'any', +}; + +export const setGenericPassword = jest.fn(async (username, password, opts) => { + store[serviceKey(opts)] = { username, password }; + return true; +}); + +export const getGenericPassword = jest.fn(async opts => { + const key = serviceKey(opts); + return key in store ? store[key] : false; +}); + +export const resetGenericPassword = jest.fn(async opts => { + delete store[serviceKey(opts)]; + return true; +}); diff --git a/package-lock.json b/package-lock.json index a1407a8..6e8b6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "18.2.0", "react-native": "0.73.6", "react-native-config": "^1.6.1", + "react-native-keychain": "^8.2.0", "react-native-maps": "1.14.0", "react-native-mmkv": "^2.12.0", "react-native-safe-area-context": "^4.8.0", @@ -88,6 +89,7 @@ "node_modules/@babel/core": { "version": "7.29.7", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -117,6 +119,7 @@ "version": "7.29.7", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", @@ -1676,6 +1679,7 @@ "node_modules/@babel/preset-env": { "version": "7.29.7", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", @@ -3323,6 +3327,7 @@ "node_modules/@react-navigation/native": { "version": "6.1.18", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^6.4.17", "escape-string-regexp": "^4.0.0", @@ -3799,6 +3804,7 @@ "version": "18.3.30", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3847,6 +3853,7 @@ "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", "@typescript-eslint/scope-manager": "6.21.0", @@ -4038,6 +4045,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -4534,6 +4542,7 @@ "node_modules/acorn": { "version": "8.16.0", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6755,6 +6764,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9155,6 +9165,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11012,6 +11023,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -11212,6 +11224,7 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11382,6 +11395,7 @@ "node_modules/react": { "version": "18.2.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11432,6 +11446,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.6.tgz", "integrity": "sha512-oqmZe8D2/VolIzSPZw+oUd6j/bEmeRHwsLn1xLA5wllEYsZ5zNuMsDus235ONOnCRwexqof/J3aztyQswSmiaA==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.6", @@ -11498,6 +11513,12 @@ } } }, + "node_modules/react-native-keychain": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/react-native-keychain/-/react-native-keychain-8.2.0.tgz", + "integrity": "sha512-SkRtd9McIl1Ss2XSWNLorG+KMEbgeVqX+gV+t3u1EAAqT8q2/OpRmRbxpneT2vnb/dMhiU7g6K/pf3nxLUXRvA==", + "license": "MIT" + }, "node_modules/react-native-maps": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.14.0.tgz", @@ -11531,6 +11552,7 @@ "node_modules/react-native-safe-area-context": { "version": "4.14.1", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11541,6 +11563,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.37.0.tgz", "integrity": "sha512-vEi4qZqWYoGuVGuHTv1K2XA90rgSydksmR5+tb5uhL93whl6Bch6EEXzC+8eEfj4SimiCgXBPY7r/xTXJxvnUg==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -12966,6 +12989,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13195,6 +13219,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13402,6 +13427,7 @@ "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index ff47b00..1b95cd6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react": "18.2.0", "react-native": "0.73.6", "react-native-config": "^1.6.1", + "react-native-keychain": "^8.2.0", "react-native-maps": "1.14.0", "react-native-mmkv": "^2.12.0", "react-native-safe-area-context": "^4.8.0", diff --git a/src/__tests__/__mocks__/setup.ts b/src/__tests__/__mocks__/setup.ts index 018ee55..46d1338 100644 --- a/src/__tests__/__mocks__/setup.ts +++ b/src/__tests__/__mocks__/setup.ts @@ -1,19 +1,88 @@ -const __MMKV_STORE__: Record = {}; +const mockMMKVInstances: Record> = { + default: {}, +}; +const mockMMKVDefaultId = 'default'; + +function mockGetInstance(id: string | undefined) { + const instanceId = id ?? mockMMKVDefaultId; + let store = mockMMKVInstances[instanceId]; + if (!store) { + store = {}; + mockMMKVInstances[instanceId] = store; + } + const instanceStore: Record = store; + return { + getString: (key: string) => + key in instanceStore ? instanceStore[key] : null, + set: (key: string, value: string) => { + instanceStore[key] = value; + }, + delete: (key: string) => { + delete instanceStore[key]; + }, + getAllKeys: () => Object.keys(instanceStore), + clear: () => { + for (const key of Object.keys(instanceStore)) { + delete instanceStore[key]; + } + }, + }; +} jest.mock('react-native-mmkv', () => { + const MMKV = jest + .fn() + .mockImplementation((options?: { id?: string }) => + mockGetInstance(options?.id), + ); + (MMKV as unknown as { removeMMKV: jest.Mock }).removeMMKV = jest.fn( + (id: string) => { + delete mockMMKVInstances[id]; + }, + ); return { - MMKV: jest.fn().mockImplementation(() => ({ - getString: (key: string) => __MMKV_STORE__[key] ?? null, - set: (key: string, value: string) => { - __MMKV_STORE__[key] = value; - }, - delete: (key: string) => { - delete __MMKV_STORE__[key]; - }, - })), + MMKV, + removeMMKV: (MMKV as unknown as { removeMMKV: jest.Mock }).removeMMKV, }; }); +jest.mock( + 'react-native-keychain', + () => { + const store: Record = {}; + const serviceKey = (opts?: { service?: string }) => + opts?.service || 'default'; + return { + ACCESSIBLE: { + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly', + }, + SECURITY_LEVEL: { + SECURE_HARDWARE: 'secureHardware', + ANY: 'any', + }, + setGenericPassword: jest.fn( + async ( + username: string, + password: string, + opts?: { service?: string }, + ) => { + store[serviceKey(opts)] = { username, password }; + return true; + }, + ), + getGenericPassword: jest.fn(async (opts?: { service?: string }) => { + const key = serviceKey(opts); + return key in store ? store[key] : false; + }), + resetGenericPassword: jest.fn(async (opts?: { service?: string }) => { + delete store[serviceKey(opts)]; + return true; + }), + }; + }, + { virtual: true }, +); + type ZustandSet = (...args: unknown[]) => void; type ZustandGet = (...args: unknown[]) => unknown; type ZustandStateCreator = ( @@ -52,14 +121,31 @@ jest.mock('zustand/middleware', () => { return config(set, get, api); }; }, - createJSONStorage: () => ({ - getItem: (name: string) => __MMKV_STORE__[name] ?? null, - setItem: (name: string, value: string) => { - __MMKV_STORE__[name] = value; - }, - removeItem: (name: string) => { - delete __MMKV_STORE__[name]; - }, - }), + createJSONStorage: (getStorage?: () => unknown) => { + const storage = (getStorage ? getStorage() : undefined) as + | { + getItem: (name: string) => string | null; + setItem: (name: string, value: string) => void; + removeItem: (name: string) => void; + } + | undefined; + if (!storage) { + const fallback = mockMMKVInstances[mockMMKVDefaultId]!; + return { + getItem: (name: string) => fallback[name] ?? null, + setItem: (name: string, value: string) => { + fallback[name] = value; + }, + removeItem: (name: string) => { + delete fallback[name]; + }, + }; + } + return { + getItem: (name: string) => storage.getItem(name) ?? null, + setItem: (name: string, value: string) => storage.setItem(name, value), + removeItem: (name: string) => storage.removeItem(name), + }; + }, }; }); diff --git a/src/__tests__/useStellarWallet.test.tsx b/src/__tests__/useStellarWallet.test.tsx index 9b5085b..8222894 100644 --- a/src/__tests__/useStellarWallet.test.tsx +++ b/src/__tests__/useStellarWallet.test.tsx @@ -23,7 +23,11 @@ import React from 'react'; import renderer, { act } from 'react-test-renderer'; import { useStellarWallet } from '../hooks/useStellarWallet'; import { useWalletStore } from '../store/walletStore'; -import { getInAppSecret, clearInAppSecret } from '../services/walletVault'; +import { + getInAppSecret, + clearInAppSecret, + initWalletVault, +} from '../services/walletVault'; import * as stellarMock from '../services/stellar'; import * as lobstrMock from '../services/lobstr'; @@ -168,9 +172,10 @@ async function renderProbe() { describe('useStellarWallet connect flow', () => { let tree: renderer.ReactTestRenderer | null = null; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); resetWalletStore(); + await initWalletVault(); // The MMKV mock store persists across tests in a file — clear vault // entries so secret-hygiene assertions start from a clean slate. clearInAppSecret(IN_APP_PK); diff --git a/src/__tests__/walletVault.test.ts b/src/__tests__/walletVault.test.ts index bae7670..b18f49c 100644 --- a/src/__tests__/walletVault.test.ts +++ b/src/__tests__/walletVault.test.ts @@ -1,14 +1,28 @@ import './__mocks__/setup'; +import { MMKV } from 'react-native-mmkv'; +import * as Keychain from 'react-native-keychain'; import { + initWalletVault, + resetWalletVaultForTests, saveInAppSecret, getInAppSecret, hasInAppSecret, clearInAppSecret, } from '../services/walletVault'; +const LEGACY_VAULT_ID = 'wallet-vault'; +const SECURE_VAULT_ID = 'wallet-vault-secure'; + +type MockMMKV = MMKV & { clear: () => void }; +const clearStore = (id: string) => + (new MMKV({ id }) as unknown as MockMMKV).clear(); + describe('walletVault', () => { - beforeEach(() => { - clearInAppSecret('GCKEY'); + beforeEach(async () => { + resetWalletVaultForTests(); + clearStore(LEGACY_VAULT_ID); + clearStore(SECURE_VAULT_ID); + await initWalletVault(); }); it('starts with no secret for an unknown key', () => { @@ -35,4 +49,62 @@ describe('walletVault', () => { expect(hasInAppSecret('GCKEY')).toBe(false); expect(getInAppSecret('GCKEY')).toBeNull(); }); + + it('stores the secret in the encrypted MMKV instance', () => { + saveInAppSecret('GCKEY', 'Ssecret123'); + const secure = new MMKV({ id: SECURE_VAULT_ID }); + expect(secure.getString('secret:GCKEY')).toBe('Ssecret123'); + }); + + describe('migration from the plaintext vault', () => { + it('migrates an existing plaintext secret into the encrypted vault and removes the legacy file', async () => { + resetWalletVaultForTests(); + clearStore(SECURE_VAULT_ID); + + const legacy = new MMKV({ id: LEGACY_VAULT_ID }); + legacy.set('secret:GCKEY', 'SlegacyPlain'); + legacy.set('secret:GCOTHER', 'SotherPlain'); + + await initWalletVault(); + + expect(getInAppSecret('GCKEY')).toBe('SlegacyPlain'); + expect(getInAppSecret('GCOTHER')).toBe('SotherPlain'); + + const legacyAfter = new MMKV({ id: LEGACY_VAULT_ID }); + expect(legacyAfter.getString('secret:GCKEY')).toBeNull(); + expect(legacyAfter.getString('secret:GCOTHER')).toBeNull(); + }); + + it('does not re-migrate on subsequent launches', async () => { + resetWalletVaultForTests(); + clearStore(SECURE_VAULT_ID); + + const legacy = new MMKV({ id: LEGACY_VAULT_ID }); + legacy.set('secret:GCKEY', 'SlegacyPlain'); + await initWalletVault(); + expect(getInAppSecret('GCKEY')).toBe('SlegacyPlain'); + + legacy.set('secret:GCKEY', 'SshouldBeIgnored'); + resetWalletVaultForTests(); + await initWalletVault(); + + expect(getInAppSecret('GCKEY')).toBe('SlegacyPlain'); + }); + + it('derives a device-bound encryption key from the keychain', async () => { + resetWalletVaultForTests(); + clearStore(SECURE_VAULT_ID); + + await initWalletVault(); + + const creds = await Keychain.getGenericPassword({ + service: 'com.ecotask.walletvault', + }); + expect(creds).not.toBe(false); + if (creds && 'password' in creds) { + expect(typeof creds.password).toBe('string'); + expect(creds.password.length).toBeGreaterThan(0); + } + }); + }); }); diff --git a/src/services/walletVault.ts b/src/services/walletVault.ts index 98cc785..a50776b 100644 --- a/src/services/walletVault.ts +++ b/src/services/walletVault.ts @@ -1,20 +1,171 @@ import { MMKV } from 'react-native-mmkv'; +import * as Keychain from 'react-native-keychain'; -const storage = new MMKV({ id: 'wallet-vault' }); +const LEGACY_VAULT_ID = 'wallet-vault'; +const SECURE_VAULT_ID = 'wallet-vault-secure'; +const KEYCHAIN_SERVICE = 'com.ecotask.walletvault'; +const KEYCHAIN_USERNAME = 'wallet-vault-encryption-key'; +const MIGRATION_FLAG = '__wallet_vault_migrated__'; function secretKey(publicKey: string): string { return `secret:${publicKey}`; } +const BASE64_CHARS = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/* eslint-disable no-bitwise -- base64 encoding inherently requires bit shifts */ +function base64Encode(bytes: Uint8Array): string { + const charAt = (index: number): string => BASE64_CHARS[index] ?? ''; + let result = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!; + result += + charAt((n >> 18) & 63) + + charAt((n >> 12) & 63) + + charAt((n >> 6) & 63) + + charAt(n & 63); + } + const remaining = bytes.length - i; + if (remaining === 1) { + const n = bytes[i]! << 16; + result += charAt((n >> 18) & 63) + charAt((n >> 12) & 63) + '=='; + } else if (remaining === 2) { + const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8); + result += + charAt((n >> 18) & 63) + + charAt((n >> 12) & 63) + + charAt((n >> 6) & 63) + + '='; + } + return result; +} + +function getSecureRandomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + const globalCrypto = ( + globalThis as { + crypto?: { getRandomValues?: (b: Uint8Array) => void }; + } + ).crypto; + if (globalCrypto && typeof globalCrypto.getRandomValues === 'function') { + globalCrypto.getRandomValues(bytes); + } else { + throw new Error( + 'No secure random source available to derive the wallet vault key', + ); + } + return bytes; +} + +function generateEncryptionKey(): string { + return base64Encode(getSecureRandomBytes(32)); +} + +async function loadOrCreateEncryptionKey(): Promise { + const existing = await Keychain.getGenericPassword({ + service: KEYCHAIN_SERVICE, + }); + if ( + typeof existing === 'object' && + existing !== null && + 'password' in existing && + existing.password + ) { + return existing.password; + } + + const key = generateEncryptionKey(); + try { + const stored = await Keychain.setGenericPassword(KEYCHAIN_USERNAME, key, { + service: KEYCHAIN_SERVICE, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE, + }); + if (stored === false) { + throw new Error('Failed to persist wallet vault key to the keychain'); + } + } catch { + // Android Keystore is unavailable below API 23 — fall back to a + // software-protected credential store rather than failing closed. + const stored = await Keychain.setGenericPassword(KEYCHAIN_USERNAME, key, { + service: KEYCHAIN_SERVICE, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + securityLevel: Keychain.SECURITY_LEVEL.ANY, + }); + if (stored === false) { + throw new Error( + 'Failed to persist wallet vault key to the keychain (fallback)', + ); + } + } + return key; +} + +let secureStorage: MMKV | null = null; +let initPromise: Promise | null = null; + +function ensureStorage(): MMKV { + if (!secureStorage) { + throw new Error( + 'walletVault is not initialized — call initWalletVault() during app startup', + ); + } + return secureStorage; +} + +async function migrateFromLegacyVault(): Promise { + const storage = ensureStorage(); + if (storage.getString(MIGRATION_FLAG)) { + return; + } + + const legacy = new MMKV({ id: LEGACY_VAULT_ID }); + for (const key of legacy.getAllKeys()) { + if (key.startsWith('secret:')) { + const value = legacy.getString(key); + if (value != null) { + storage.set(key, value); + } + legacy.delete(key); + } + } + + // The plaintext file is no longer needed — remove it entirely. + (MMKV as unknown as { removeMMKV: (id: string) => void }).removeMMKV( + LEGACY_VAULT_ID, + ); + storage.set(MIGRATION_FLAG, '1'); +} + +async function doInit(): Promise { + const key = await loadOrCreateEncryptionKey(); + secureStorage = new MMKV({ id: SECURE_VAULT_ID, encryptionKey: key }); + await migrateFromLegacyVault(); +} + +export function initWalletVault(): Promise { + if (!initPromise) { + initPromise = doInit(); + } + return initPromise; +} + +export function resetWalletVaultForTests(): void { + initPromise = null; + secureStorage = null; +} + export function saveInAppSecret( publicKey: string, secretKeyValue: string, ): void { - storage.set(secretKey(publicKey), secretKeyValue); + ensureStorage().set(secretKey(publicKey), secretKeyValue); } export function getInAppSecret(publicKey: string): string | null { - return storage.getString(secretKey(publicKey)) ?? null; + return ensureStorage().getString(secretKey(publicKey)) ?? null; } export function hasInAppSecret(publicKey: string): boolean { @@ -22,5 +173,5 @@ export function hasInAppSecret(publicKey: string): boolean { } export function clearInAppSecret(publicKey: string): void { - storage.delete(secretKey(publicKey)); + ensureStorage().delete(secretKey(publicKey)); }