From a3425d35d4cb30e0463300ecaf3483ed29bb5990 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:38:01 -0300 Subject: [PATCH] feat(rn_cli_wallet): encrypt MMKV secret storage with keychain-backed key Wallet mnemonics/private keys were persisted as plaintext in MMKV. Encrypt the MMKV store at rest on native using MMKV's built-in encryptionKey, with a random key generated once and kept in the iOS Keychain / Android Keystore via expo-secure-store. All secret access is funneled through a single chokepoint (getEncryptionKey), so every chain is protected at once and the design is ready for optional biometric gating later. - add expo-secure-store dependency - new src/utils/secureEncryptionKey.ts owns the key lifecycle (native-only; web returns undefined and stays best-effort/unencrypted by design) - storage.ts lazily opens an encrypted MMKV; on first run it recrypts the existing plaintext store in place so wallets aren't reset on upgrade - reword now-inaccurate "stored unencrypted" dev warnings in the chain utils Co-Authored-By: Claude Opus 4.8 --- wallets/rn_cli_wallet/package.json | 1 + .../src/utils/CantonWalletUtil.ts | 2 +- .../src/utils/SolanaWalletUtil.ts | 2 +- .../rn_cli_wallet/src/utils/SuiWalletUtil.ts | 2 +- .../rn_cli_wallet/src/utils/TonWalletUtil.ts | 2 +- .../rn_cli_wallet/src/utils/TronWalletUtil.ts | 2 +- .../src/utils/secureEncryptionKey.ts | 51 +++++++++++++++++++ wallets/rn_cli_wallet/src/utils/storage.ts | 48 ++++++++++++++++- wallets/rn_cli_wallet/yarn.lock | 10 ++++ 9 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 8b1cbefd7..708c83276 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -63,6 +63,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 c094fd139..96e9f78ec 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 d74f831cd..119663d58 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 55d3a3823..1b3b64f25 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 c18b559d2..e951734c7 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 06638a1f1..37ea5f3c5 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 000000000..ddfbef50e --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts @@ -0,0 +1,51 @@ +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 storage stays best-effort/unencrypted by design. + +const SECURE_STORE_KEY = 'mmkv_encryption_key'; + +// MMKV's encryption key cannot exceed 16 bytes, so we generate 8 random bytes +// and hex-encode them into a 16-character key. +function generateKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +export interface EncryptionKeyResult { + // The MMKV encryption key, or undefined on web (no encryption available). + key: string | undefined; + // True when the key was just generated this launch — meaning an existing + // unencrypted MMKV store may need to be recrypted in place before use. + isNew: boolean; +} + +let keyPromise: Promise | undefined; + +export function getEncryptionKey(): Promise { + if (!keyPromise) { + keyPromise = (async () => { + if (Platform.OS === 'web') { + return { key: undefined, isNew: false }; + } + + const existing = await SecureStore.getItemAsync(SECURE_STORE_KEY); + if (existing) { + return { key: existing, isNew: false }; + } + + const key = generateKey(); + await SecureStore.setItemAsync(SECURE_STORE_KEY, key); + return { key, isNew: true }; + })(); + } + return keyPromise; +} diff --git a/wallets/rn_cli_wallet/src/utils/storage.ts b/wallets/rn_cli_wallet/src/utils/storage.ts index 6e8f1ee3d..6329c5839 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -1,13 +1,56 @@ import { MMKV } from 'react-native-mmkv'; import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json'; +import { getEncryptionKey } from './secureEncryptionKey'; -const mmkv = new MMKV(); +// The MMKV instance is 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, +// which is unencrypted by design. +let mmkvPromise: Promise | undefined; + +function getStore(): Promise { + if (!mmkvPromise) { + mmkvPromise = (async () => { + const { key, isNew } = await getEncryptionKey(); + + if (!key) { + if (__DEV__) { + console.log('[storage] MMKV opened UNENCRYPTED (web / no key available)'); + } + return new MMKV(); + } + + if (isNew) { + // Existing installs have an unencrypted default store on disk. Open it + // in plaintext, then encrypt it in place so previously stored wallet + // secrets survive the upgrade instead of appearing reset. + const instance = new MMKV(); + instance.recrypt(key); + if (__DEV__) { + console.log( + '[storage] MMKV recrypted in place with new key (first run / plaintext→encrypted migration)', + ); + } + return instance; + } + + if (__DEV__) { + console.log('[storage] MMKV opened ENCRYPTED with existing key from SecureStore'); + } + return new MMKV({ 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 +60,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 +73,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 0d31062d7..542500d2e 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -5234,6 +5234,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 @@ -8166,6 +8167,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"