diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 1b0ea469..985b3f8b 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -69,6 +69,7 @@ "expo-application": "~56.0.3", "expo-clipboard": "~56.0.4", "expo-navigation-bar": "~56.0.3", + "expo-secure-store": "~56.0.4", "expo-system-ui": "56.0.5", "lottie-react-native": "7.3.5", "pressto": "0.7.0", diff --git a/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts index c094fd13..96e9f78e 100644 --- a/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts @@ -72,7 +72,7 @@ export async function loadCantonWallet(input: string): Promise<{ await storage.setItem('CANTON_SECRET_KEY_1', newWallet.getSecretKey()); if (__DEV__) { console.warn( - '[SECURITY] Canton secret key stored unencrypted. Use secure enclave in production.', + '[SECURITY] Canton secret key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts index d74f831c..119663d5 100644 --- a/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts @@ -96,7 +96,7 @@ export async function loadSolanaWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Solana key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Solana key material stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts index 55d3a382..1b3b64f2 100644 --- a/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts @@ -64,7 +64,7 @@ export async function loadSuiWallet(input: string): Promise<{ await storage.setItem('SUI_MNEMONIC_1', trimmedInput); if (__DEV__) { console.warn( - '[SECURITY] SUI mnemonic stored unencrypted. Use secure enclave in production.', + '[SECURITY] SUI mnemonic stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts index c18b559d..e951734c 100644 --- a/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts @@ -79,7 +79,7 @@ export async function loadTonWallet(input: string): Promise<{ await storage.setItem('TON_SECRET_KEY_1', newWallet.getSecretKey()); if (__DEV__) { console.warn( - '[SECURITY] TON secret key stored unencrypted. Use secure enclave in production.', + '[SECURITY] TON secret key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts index 06638a1f..37ea5f3c 100644 --- a/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts @@ -68,7 +68,7 @@ export async function loadTronWallet(input: string): Promise<{ storage.setItem('TRON_PrivateKey_1', trimmedInput); if (__DEV__) { console.warn( - '[SECURITY] TRON private key stored unencrypted. Use secure enclave in production.', + '[SECURITY] TRON private key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts new file mode 100644 index 00000000..62c92561 --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts @@ -0,0 +1,90 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; + +// Owns the lifecycle of the MMKV encryption key. The key is generated once and +// persisted in the OS Keychain (iOS) / Keystore (Android) via expo-secure-store, +// so the key that protects the wallet secrets never sits in plaintext MMKV. +// +// Native-only: SecureStore is unavailable on web, and the react-native-mmkv web +// shim (localStorage) ignores encryption anyway — so on web we return undefined +// and the secure store stays best-effort/unencrypted by design. + +const SECURE_STORE_KEY = 'mmkv_encryption_key'; + +// Upper bound on how long we wait for the Keychain/Keystore before giving up. +// On real signed builds SecureStore resolves in milliseconds; on an unsigned +// build (E2E) the Keychain can stall on the securityd/KeychainMigrator path, +// and this call sits on the app-init critical path — so we cap it to avoid the +// splash screen hanging, falling back to unencrypted storage on timeout. +const SECURE_STORE_TIMEOUT_MS = 4000; + +class SecureStoreTimeoutError extends Error {} + +function withTimeout(promise: Promise, ms: number): Promise { + // Swallow a late rejection so it doesn't surface as an unhandled rejection + // once the timeout has already won the race. + promise.catch(() => {}); + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new SecureStoreTimeoutError(`SecureStore timed out after ${ms}ms`)), + ms, + ), + ), + ]); +} + +// MMKV's encryption key cannot exceed 16 bytes. We generate 12 random bytes and +// base64-encode them into a 16-character (16-byte) key — 96 bits of entropy, +// the most that fits within MMKV's limit. +function generateKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + // Buffer is polyfilled globally by '@walletconnect/react-native-compat'. + return Buffer.from(bytes).toString('base64'); +} + +let keyPromise: Promise | undefined; + +// Resolves the MMKV encryption key, generating and persisting it on first use. +// Returns undefined on web (no native secure storage available). +export function getEncryptionKey(): Promise { + if (!keyPromise) { + keyPromise = (async () => { + if (Platform.OS === 'web') { + return undefined; + } + + try { + const existing = await withTimeout( + SecureStore.getItemAsync(SECURE_STORE_KEY), + SECURE_STORE_TIMEOUT_MS, + ); + if (existing) { + return existing; + } + + const key = generateKey(); + await withTimeout( + SecureStore.setItemAsync(SECURE_STORE_KEY, key), + SECURE_STORE_TIMEOUT_MS, + ); + return key; + } catch (err) { + // The Keychain/Keystore can be unavailable or stall — e.g. an UNSIGNED + // iOS build (our E2E simulator archive is built with + // CODE_SIGNING_ALLOWED=NO) has no application-identifier entitlement, so + // Keychain access fails or hangs on the securityd migration path. Fall + // back to an unencrypted store so the app still initializes instead of + // hanging on the splash screen; encryption stays active on real signed + // builds, where SecureStore resolves quickly. + console.warn( + '[secureEncryptionKey] Keychain/Keystore unavailable; wallet storage will be unencrypted on this build:', + err, + ); + return undefined; + } + })(); + } + return keyPromise; +} diff --git a/wallets/rn_cli_wallet/src/utils/storage.ts b/wallets/rn_cli_wallet/src/utils/storage.ts index 6e8f1ee3..6d9118c4 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -1,13 +1,44 @@ import { MMKV } from 'react-native-mmkv'; import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json'; +import { getEncryptionKey } from './secureEncryptionKey'; -const mmkv = new MMKV(); +// Wallet secrets (mnemonics/private keys) and WalletConnect Core data are kept +// in a DEDICATED, encrypted MMKV instance — never the default store. Other +// modules (SettingsStore, WalletStore, LogStore) open the default `new MMKV()` +// without a key; if this data shared that instance, encrypting it would make +// their reads/writes fail. Isolating it under its own id avoids that conflict. +const SECURE_STORE_ID = 'wallet-secure'; + +// Created lazily because the encryption key must be fetched from the OS +// Keychain/Keystore first (async). On native the store is encrypted at rest; on +// web (no key) it falls back to the localStorage shim, unencrypted by design. +let mmkvPromise: Promise | undefined; + +function getStore(): Promise { + if (!mmkvPromise) { + mmkvPromise = (async () => { + const key = await getEncryptionKey(); + + // No key on web (SecureStore unavailable) — fall back to the unencrypted + // localStorage shim, which is best-effort by design. + if (!key) { + return new MMKV({ id: SECURE_STORE_ID }); + } + + return new MMKV({ id: SECURE_STORE_ID, encryptionKey: key }); + })(); + } + return mmkvPromise; +} export const storage = { getKeys: async () => { + const mmkv = await getStore(); return mmkv.getAllKeys(); }, getEntries: async (): Promise<[string, T][]> => { + const mmkv = await getStore(); + function parseEntry(key: string): [string, any] { const value = mmkv.getString(key); return [key, safeJsonParse(value ?? '')]; @@ -17,9 +48,11 @@ export const storage = { return keys.map(parseEntry); }, setItem: async (key: string, value: T) => { + const mmkv = await getStore(); return mmkv.set(key, safeJsonStringify(value)); }, getItem: async (key: string): Promise => { + const mmkv = await getStore(); const item = mmkv.getString(key); if (typeof item === 'undefined' || item === null) { return undefined; @@ -28,6 +61,7 @@ export const storage = { return safeJsonParse(item) as T; }, removeItem: async (key: string) => { + const mmkv = await getStore(); return mmkv.delete(key); }, }; diff --git a/wallets/rn_cli_wallet/yarn.lock b/wallets/rn_cli_wallet/yarn.lock index 8fcece50..e8f8948c 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -5256,6 +5256,7 @@ __metadata: expo-application: ~56.0.3 expo-clipboard: ~56.0.4 expo-navigation-bar: ~56.0.3 + expo-secure-store: ~56.0.4 expo-system-ui: 56.0.5 jest: ^29.2.1 jest-expo: 56.0.5 @@ -8387,6 +8388,15 @@ __metadata: languageName: node linkType: hard +"expo-secure-store@npm:~56.0.4": + version: 56.0.4 + resolution: "expo-secure-store@npm:56.0.4" + peerDependencies: + expo: "*" + checksum: a47a5c0378fdc7676df9d11b3f2417bac4106fcc9b7b461ee4774c8ffb0277a52e6c35545a0dff82de2f48dc9c112bd14060cc20a019cca1ea7507286a6822ac + languageName: node + linkType: hard + "expo-server@npm:^56.0.5": version: 56.0.5 resolution: "expo-server@npm:56.0.5"