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 1/8] 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" From e2bf5c7ff33c6d92f9b7fc26670ae6d42a504411 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:53:56 -0300 Subject: [PATCH 2/8] fix(rn_cli_wallet): isolate encrypted secrets in a dedicated MMKV instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encrypting the DEFAULT MMKV store broke the app: SettingsStore, WalletStore and LogStore also open `new MMKV()` (default, no key), so once the default store was encrypted their reads returned garbage and WalletKit init threw "Value is undefined, expected a String" — the app hung on the splash screen (flagged by Copilot on the PR). - storage.ts now uses a dedicated MMKV id ('wallet-secure') with the encryptionKey, so wallet secrets + WalletConnect Core data are encrypted in isolation and never touch the default store other modules rely on - drop the recrypt-in-place migration (no longer encrypting the default store) - bump key entropy to 96 bits (12 random bytes → 16-char base64) within MMKV's 16-byte key limit, per PR review feedback Verified on Android (debug + internal): fresh install, cold restart, and already-encrypted persisted state all initialize WalletKit successfully. Co-Authored-By: Claude Opus 4.8 --- .../src/utils/secureEncryptionKey.ts | 34 +++++++---------- wallets/rn_cli_wallet/src/utils/storage.ts | 38 ++++++++----------- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts index ddfbef50e..ce9910ca5 100644 --- a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts +++ b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts @@ -7,44 +7,38 @@ import * as SecureStore from 'expo-secure-store'; // // 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. +// and the secure store 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. +// 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(8)); - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); + const bytes = crypto.getRandomValues(new Uint8Array(12)); + // Buffer is polyfilled globally by '@walletconnect/react-native-compat'. + return Buffer.from(bytes).toString('base64'); } -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; +let keyPromise: Promise | undefined; -export function getEncryptionKey(): Promise { +// 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 { key: undefined, isNew: false }; + return undefined; } const existing = await SecureStore.getItemAsync(SECURE_STORE_KEY); if (existing) { - return { key: existing, isNew: false }; + return existing; } const key = generateKey(); await SecureStore.setItemAsync(SECURE_STORE_KEY, key); - return { key, isNew: true }; + return key; })(); } return keyPromise; diff --git a/wallets/rn_cli_wallet/src/utils/storage.ts b/wallets/rn_cli_wallet/src/utils/storage.ts index 6329c5839..f54d14743 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -2,42 +2,34 @@ import { MMKV } from 'react-native-mmkv'; import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json'; import { getEncryptionKey } from './secureEncryptionKey'; -// 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. +// 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, isNew } = await getEncryptionKey(); + const key = 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)', - ); + console.log('[storage] secure MMKV opened UNENCRYPTED (web / no key)'); } - return instance; + return new MMKV({ id: SECURE_STORE_ID }); } if (__DEV__) { - console.log('[storage] MMKV opened ENCRYPTED with existing key from SecureStore'); + console.log('[storage] secure MMKV opened ENCRYPTED (key from SecureStore)'); } - return new MMKV({ encryptionKey: key }); + return new MMKV({ id: SECURE_STORE_ID, encryptionKey: key }); })(); } return mmkvPromise; From a072226a0ad278ff22c4703797fc8fad4f6d3a61 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:59:39 -0300 Subject: [PATCH 3/8] chore(rn_cli_wallet): remove dev-only storage diagnostic logs Co-Authored-By: Claude Opus 4.8 --- wallets/rn_cli_wallet/src/utils/storage.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/wallets/rn_cli_wallet/src/utils/storage.ts b/wallets/rn_cli_wallet/src/utils/storage.ts index f54d14743..6d9118c42 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -19,16 +19,12 @@ function getStore(): Promise { 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) { - if (__DEV__) { - console.log('[storage] secure MMKV opened UNENCRYPTED (web / no key)'); - } return new MMKV({ id: SECURE_STORE_ID }); } - if (__DEV__) { - console.log('[storage] secure MMKV opened ENCRYPTED (key from SecureStore)'); - } return new MMKV({ id: SECURE_STORE_ID, encryptionKey: key }); })(); } From 3a9ea55c3baa9eff7024ba8d36421054eaced059 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:51:17 -0300 Subject: [PATCH 4/8] fix(rn_cli_wallet): fall back to unencrypted storage when Keychain is unavailable Unsigned iOS builds (our E2E simulator archive is built with CODE_SIGNING_ALLOWED=NO) have no application-identifier entitlement, so expo-secure-store's Keychain access throws (`getValueWithKeyAsync ... failed`). getEncryptionKey didn't catch it, so the rejected promise propagated through storage into WalletKit init and the app hung on the splash screen (iOS E2E only; Android Keystore works unsigned). Wrap the SecureStore calls in try/catch and fall back to an unencrypted store so the app always initializes. Encryption stays active on real signed builds (TestFlight/App Store); only unsigned test builds degrade to unencrypted. Verified by reproducing CI's exact unsigned Release archive locally: the app now loads the wallet home instead of hanging, and logs the fallback warning. Co-Authored-By: Claude Opus 4.8 --- .../src/utils/secureEncryptionKey.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts index ce9910ca5..d55657922 100644 --- a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts +++ b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts @@ -31,14 +31,27 @@ export function getEncryptionKey(): Promise { return undefined; } - const existing = await SecureStore.getItemAsync(SECURE_STORE_KEY); - if (existing) { - return existing; - } + try { + const existing = await SecureStore.getItemAsync(SECURE_STORE_KEY); + if (existing) { + return existing; + } - const key = generateKey(); - await SecureStore.setItemAsync(SECURE_STORE_KEY, key); - return key; + const key = generateKey(); + await SecureStore.setItemAsync(SECURE_STORE_KEY, key); + return key; + } catch (err) { + // The Keychain/Keystore can be unavailable — 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. Fall + // back to an unencrypted store so the app still initializes instead of + // hanging; encryption stays active on real signed builds. + console.warn( + '[secureEncryptionKey] Keychain/Keystore unavailable; wallet storage will be unencrypted on this build:', + err, + ); + return undefined; + } })(); } return keyPromise; From c09e51c3e8d3b50f6ac96bb9f081af56d57241ba Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:47:39 -0300 Subject: [PATCH 5/8] fix(rn_cli_wallet): time-box the Keychain call so init can't hang on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Keychain fallback stopped the deterministic splash hang, but iOS E2E was still flaky: on the slower CI simulator the unsigned build's SecureStore call intermittently stalls on the securityd/KeychainMigrator path. Because that call sits on the app-init critical path (storage awaits the key before opening), init sometimes didn't finish within Maestro's wait and the app stayed on the splash screen — failing ~most pay flows with "input-paste-url not found". Race the SecureStore get/set against a 4s timeout; on timeout, fall back to unencrypted storage so init never blocks on a slow/hanging Keychain. On real signed builds SecureStore resolves in milliseconds, so the timeout never fires and encryption stays active. Co-Authored-By: Claude Opus 4.8 --- .../src/utils/secureEncryptionKey.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts index d55657922..62c925617 100644 --- a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts +++ b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts @@ -11,6 +11,30 @@ import * as SecureStore from 'expo-secure-store'; 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. @@ -32,20 +56,28 @@ export function getEncryptionKey(): Promise { } try { - const existing = await SecureStore.getItemAsync(SECURE_STORE_KEY); + const existing = await withTimeout( + SecureStore.getItemAsync(SECURE_STORE_KEY), + SECURE_STORE_TIMEOUT_MS, + ); if (existing) { return existing; } const key = generateKey(); - await SecureStore.setItemAsync(SECURE_STORE_KEY, key); + await withTimeout( + SecureStore.setItemAsync(SECURE_STORE_KEY, key), + SECURE_STORE_TIMEOUT_MS, + ); return key; } catch (err) { - // The Keychain/Keystore can be unavailable — 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. Fall + // 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; encryption stays active on real signed builds. + // 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, From 192bbdfb58989c51d37785f1a89be0d4af66a99b Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:13:09 -0300 Subject: [PATCH 6/8] ci(walletkit): sign the iOS E2E simulator build so the Keychain works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR encrypts wallet secrets via expo-secure-store, which needs the iOS Keychain. PR #563 made the Maestro iOS build unsigned (no application-identifier entitlement), so SecureStore fails/stalls there — leaving the app on the splash screen and failing the pay suite (the app-level fallback + timeout reduced but didn't eliminate the flakiness, since the stalled Keychain call sits on the init critical path). Re-wire the match Development signing secrets into the e2e-ios job (the exact 6 lines #563 dropped). The Fastfile build_for_simulator lane already gates signing on MATCH_GIT_URL, so this flips it from unsigned back to signed and the Keychain becomes available — init behaves like main again and the encrypted storage path is actually exercised in CI. Trade-off: reintroduces the match clone + keychain setup on every iOS E2E run (the build speed #563 reclaimed, which was only needed for the since-removed Universal Link test). The app-level SecureStore fallback/timeout stays as defensive code; it's inert on signed builds where the Keychain resolves immediately. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci_e2e_walletkit.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci_e2e_walletkit.yaml b/.github/workflows/ci_e2e_walletkit.yaml index 4da6d2ad7..997bda547 100644 --- a/.github/workflows/ci_e2e_walletkit.yaml +++ b/.github/workflows/ci_e2e_walletkit.yaml @@ -105,6 +105,22 @@ jobs: merchant-api-key-multi-kyc: ${{ secrets.WPAY_CUSTOMER_KEY_MULTI_KYC }} merchant-id-multi-kyc: ${{ secrets.WPAY_MERCHANT_ID_MULTI_KYC }} maestro-tags: ${{ env.MAESTRO_TAGS }} + # Sign the simulator archive with a match Development profile so the + # iOS Keychain is available to expo-secure-store and the ENCRYPTED + # storage path is exercised in E2E. Without these the build is unsigned + # (no application-identifier entitlement), SecureStore fails, and the + # app falls back to unencrypted storage. The Fastfile build_for_simulator + # lane gates signing on MATCH_GIT_URL, so passing these flips it on. + # Re-adds the exact 6 lines PR #563 removed (signing was dropped there + # once the Universal Link test — its only consumer — left the suite). + # Trade-off: reintroduces the match clone + keychain setup on every iOS + # E2E run (the build speed #563 reclaimed). + apple-key-id: ${{ secrets.APPLE_KEY_ID }} + apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }} + apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }} + match-git-url: ${{ secrets.MATCH_GIT_URL }} + match-password: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + match-ssh-key: ${{ secrets.MATCH_SSH_KEY }} - name: Send Slack notification if: failure() From 3680660ca3a5553503cec5bc48952fb8dfa770b8 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:50:41 -0300 Subject: [PATCH 7/8] Revert "ci(walletkit): sign the iOS E2E simulator build so the Keychain works" This reverts commit 192bbdfb58989c51d37785f1a89be0d4af66a99b. --- .github/workflows/ci_e2e_walletkit.yaml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/ci_e2e_walletkit.yaml b/.github/workflows/ci_e2e_walletkit.yaml index 997bda547..4da6d2ad7 100644 --- a/.github/workflows/ci_e2e_walletkit.yaml +++ b/.github/workflows/ci_e2e_walletkit.yaml @@ -105,22 +105,6 @@ jobs: merchant-api-key-multi-kyc: ${{ secrets.WPAY_CUSTOMER_KEY_MULTI_KYC }} merchant-id-multi-kyc: ${{ secrets.WPAY_MERCHANT_ID_MULTI_KYC }} maestro-tags: ${{ env.MAESTRO_TAGS }} - # Sign the simulator archive with a match Development profile so the - # iOS Keychain is available to expo-secure-store and the ENCRYPTED - # storage path is exercised in E2E. Without these the build is unsigned - # (no application-identifier entitlement), SecureStore fails, and the - # app falls back to unencrypted storage. The Fastfile build_for_simulator - # lane gates signing on MATCH_GIT_URL, so passing these flips it on. - # Re-adds the exact 6 lines PR #563 removed (signing was dropped there - # once the Universal Link test — its only consumer — left the suite). - # Trade-off: reintroduces the match clone + keychain setup on every iOS - # E2E run (the build speed #563 reclaimed). - apple-key-id: ${{ secrets.APPLE_KEY_ID }} - apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }} - apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }} - match-git-url: ${{ secrets.MATCH_GIT_URL }} - match-password: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} - match-ssh-key: ${{ secrets.MATCH_SSH_KEY }} - name: Send Slack notification if: failure() From ffc1fdbe0f86bf8e9dfc8fba5d31495da5eb58fa Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:38:03 -0300 Subject: [PATCH 8/8] fix(rn_cli_wallet): safely migrate wallet secrets --- .../__tests__/mmkvEncryptionKey.test.ts | 75 ++++++++ .../rn_cli_wallet/__tests__/storage.test.ts | 165 ++++++++++++++++ .../src/utils/BitcoinWalletUtil.ts | 2 +- .../src/utils/StellarWalletUtil.ts | 2 +- .../src/utils/mmkvEncryptionKey.ts | 96 ++++++++++ .../src/utils/secureEncryptionKey.ts | 90 --------- wallets/rn_cli_wallet/src/utils/storage.ts | 178 +++++++++++++----- 7 files changed, 471 insertions(+), 137 deletions(-) create mode 100644 wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts create mode 100644 wallets/rn_cli_wallet/__tests__/storage.test.ts create mode 100644 wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts delete mode 100644 wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts diff --git a/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts b/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts new file mode 100644 index 000000000..013510f03 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts @@ -0,0 +1,75 @@ +const mockSecureStoreGet = jest.fn(); +const mockSecureStoreSet = jest.fn(); +let mockTestMode: string | undefined; + +jest.mock('expo-secure-store', () => ({ + getItemAsync: mockSecureStoreGet, + setItemAsync: mockSecureStoreSet, +})); + +jest.mock('../src/utils/env', () => ({ + ENV: { TEST_MODE: mockTestMode }, +})); + +function loadGetEncryptionKey() { + let result: typeof import('../src/utils/mmkvEncryptionKey').getEncryptionKey; + jest.isolateModules(() => { + result = ( + require('../src/utils/mmkvEncryptionKey') as typeof import('../src/utils/mmkvEncryptionKey') + ).getEncryptionKey; + }); + return result!; +} + +describe('MMKV encryption key', () => { + beforeEach(() => { + mockSecureStoreGet.mockReset(); + mockSecureStoreSet.mockReset(); + mockTestMode = undefined; + }); + + it('reuses an existing Keychain key', async () => { + mockSecureStoreGet.mockResolvedValue('existing-key'); + + await expect(loadGetEncryptionKey()()).resolves.toBe('existing-key'); + expect(mockSecureStoreSet).not.toHaveBeenCalled(); + }); + + it('generates and persists a valid MMKV key on first use', async () => { + mockSecureStoreGet.mockResolvedValue(null); + mockSecureStoreSet.mockResolvedValue(undefined); + + const key = await loadGetEncryptionKey()(); + + expect(key).toHaveLength(16); + expect(mockSecureStoreSet).toHaveBeenCalledWith( + 'mmkv_encryption_key', + key, + ); + }); + + it('fails closed when Keychain is unavailable in a normal build', async () => { + mockSecureStoreGet.mockRejectedValue(new Error('missing entitlement')); + + await expect(loadGetEncryptionKey()()).rejects.toThrow( + 'refusing to access wallet secrets: missing entitlement', + ); + }); + + it('does not replace a missing key when encrypted wallet data exists', async () => { + mockSecureStoreGet.mockResolvedValue(null); + + await expect(loadGetEncryptionKey()(true)).rejects.toThrow( + 'the encryption key is missing for existing wallet data', + ); + expect(mockSecureStoreSet).not.toHaveBeenCalled(); + }); + + it('uses an encrypted disposable store only in explicit E2E mode', async () => { + mockTestMode = 'true'; + mockSecureStoreGet.mockRejectedValue(new Error('missing entitlement')); + + await expect(loadGetEncryptionKey()()).resolves.toBe('wallet-e2e-key!!'); + expect(mockSecureStoreGet).not.toHaveBeenCalled(); + }); +}); diff --git a/wallets/rn_cli_wallet/__tests__/storage.test.ts b/wallets/rn_cli_wallet/__tests__/storage.test.ts new file mode 100644 index 000000000..f43f2a371 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/storage.test.ts @@ -0,0 +1,165 @@ +const mockStores = new Map>(); +const mockDroppedWrites = new Set(); +const mockConfigurations: Array<{ + id: string; + encryptionKey?: string; +}> = []; + +jest.mock('react-native-mmkv', () => ({ + MMKV: class { + private values: Map; + + constructor(config: { id?: string; encryptionKey?: string } = {}) { + const id = config.id ?? 'default'; + if (!mockStores.has(id)) { + mockStores.set(id, new Map()); + } + this.values = mockStores.get(id)!; + mockConfigurations.push({ id, encryptionKey: config.encryptionKey }); + } + + getString(key: string) { + return this.values.get(key); + } + + set(key: string, value: string) { + if (mockDroppedWrites.has(key)) { + return; + } + this.values.set(key, value); + } + + delete(key: string) { + this.values.delete(key); + } + + getAllKeys() { + return [...this.values.keys()]; + } + }, +})); + +const mockGetEncryptionKey = jest.fn(); +jest.mock('../src/utils/mmkvEncryptionKey', () => ({ + getEncryptionKey: mockGetEncryptionKey, +})); + +function getMockStore(id: string): Map { + if (!mockStores.has(id)) { + mockStores.set(id, new Map()); + } + return mockStores.get(id)!; +} + +const defaultStore = () => getMockStore('default'); +const encryptedMmkv = () => getMockStore('wallet-secure'); +function loadStorage() { + let result: typeof import('../src/utils/storage').storage; + jest.isolateModules(() => { + result = ( + require('../src/utils/storage') as typeof import('../src/utils/storage') + ).storage; + }); + return result!; +} + +describe('wallet secret storage', () => { + beforeEach(() => { + mockStores.forEach(store => store.clear()); + mockDroppedWrites.clear(); + mockConfigurations.length = 0; + mockGetEncryptionKey.mockReset().mockResolvedValue('test-secret-key'); + }); + + it('migrates a legacy mnemonic before deleting the plaintext value', async () => { + const storage = loadStorage(); + defaultStore().set('EIP155_MNEMONIC_1', 'seed phrase'); + + await expect(storage.getItem('EIP155_MNEMONIC_1')).resolves.toBe( + 'seed phrase', + ); + + expect(encryptedMmkv().get('EIP155_MNEMONIC_1')).toBe('seed phrase'); + expect(defaultStore().has('EIP155_MNEMONIC_1')).toBe(false); + expect(mockConfigurations).toContainEqual({ + id: 'wallet-secure', + encryptionKey: 'test-secret-key', + }); + }); + + it('prefers an encrypted value and cleans up an interrupted migration', async () => { + const storage = loadStorage(); + encryptedMmkv().set('SOLANA_MNEMONIC_1', 'new phrase'); + defaultStore().set('SOLANA_MNEMONIC_1', 'old phrase'); + + await expect(storage.getItem('SOLANA_MNEMONIC_1')).resolves.toBe( + 'new phrase', + ); + expect(defaultStore().has('SOLANA_MNEMONIC_1')).toBe(false); + }); + + it('leaves WalletConnect and preference records in the default store', async () => { + const storage = loadStorage(); + await storage.setItem('wc@2:client:0.3//session', { topic: 'abc' }); + await storage.setItem('TEST_NETS', 'YES'); + + expect(defaultStore().get('wc@2:client:0.3//session')).toBe( + JSON.stringify({ topic: 'abc' }), + ); + expect(defaultStore().get('TEST_NETS')).toBe('YES'); + await expect(storage.getKeys()).resolves.toEqual([ + 'wc@2:client:0.3//session', + 'TEST_NETS', + ]); + expect(mockGetEncryptionKey).not.toHaveBeenCalled(); + }); + + it('keeps legacy secrets out of WalletConnect storage scans', async () => { + const storage = loadStorage(); + defaultStore().set('BITCOIN_MNEMONIC_1', 'seed phrase'); + defaultStore().set('wc@2:core:0.3//pairing', JSON.stringify({ topic: 'abc' })); + + await expect(storage.getKeys()).resolves.toEqual([ + 'wc@2:core:0.3//pairing', + ]); + await expect(storage.getEntries()).resolves.toEqual([ + ['wc@2:core:0.3//pairing', { topic: 'abc' }], + ]); + expect(defaultStore().has('BITCOIN_MNEMONIC_1')).toBe(true); + }); + + it('keeps the plaintext value when encrypted-write verification fails', async () => { + const storage = loadStorage(); + defaultStore().set('TON_SECRET_KEY_1', 'legacy secret'); + mockDroppedWrites.add('TON_SECRET_KEY_1'); + + await expect(storage.getItem('TON_SECRET_KEY_1')).rejects.toThrow( + 'Failed to verify encrypted wallet storage', + ); + expect(defaultStore().get('TON_SECRET_KEY_1')).toBe('legacy secret'); + }); + + it('requires the existing encryption key after encrypted data was written', async () => { + encryptedMmkv().set('STELLAR_SECRET_KEY_1', 'secret'); + getMockStore('wallet-secure-metadata').set( + 'has-encrypted-wallet-data', + 'true', + ); + const storage = loadStorage(); + + await expect(storage.getItem('STELLAR_SECRET_KEY_1')).resolves.toBe( + 'secret', + ); + expect(mockGetEncryptionKey).toHaveBeenCalledWith(true); + }); + + it('does not write a secret to plaintext when encryption-key storage fails', async () => { + const storage = loadStorage(); + mockGetEncryptionKey.mockRejectedValueOnce(new Error('Keychain unavailable')); + + await expect( + storage.setItem('STELLAR_SECRET_KEY_1', 'secret'), + ).rejects.toThrow('Keychain unavailable'); + expect(defaultStore().has('STELLAR_SECRET_KEY_1')).toBe(false); + }); +}); diff --git a/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts index 44d307105..54d528a43 100644 --- a/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts @@ -73,7 +73,7 @@ export async function loadBitcoinWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Bitcoin key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Bitcoin mnemonic stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts index cb55e5ad4..8ef158c65 100644 --- a/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts @@ -81,7 +81,7 @@ export async function loadStellarWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Stellar key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Stellar 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/mmkvEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts new file mode 100644 index 000000000..eab76707f --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts @@ -0,0 +1,96 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; +import { ENV } from './env'; + +// Owns the lifecycle of the MMKV encryption key. The key is generated once and +// persisted in the OS Keychain (iOS) / Keystore (Android), so the key that +// protects wallet secrets never sits in plaintext MMKV. + +const ENCRYPTION_KEY_ENTRY = 'mmkv_encryption_key'; +const EXPO_SECURE_STORE_TIMEOUT_MS = 4000; +const E2E_ENCRYPTION_KEY = 'wallet-e2e-key!!'; + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`SecureStore timed out after ${ms}ms`)), + ms, + ); + + promise.then( + value => { + clearTimeout(timeout); + resolve(value); + }, + error => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +// MMKV encryption keys cannot exceed 16 bytes. Twelve random bytes encode to +// exactly 16 base64 characters, providing 96 bits of entropy. +function generateKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + // Buffer is installed globally by @walletconnect/react-native-compat. + return Buffer.from(bytes).toString('base64'); +} + +let keyPromise: Promise | undefined; + +async function loadEncryptionKey( + mustHaveExistingKey: boolean, +): Promise { + // SecureStore and MMKV encryption are unavailable in the browser. The web + // storage shim remains explicitly best-effort and unencrypted. + if (Platform.OS === 'web') { + return undefined; + } + + // The unsigned iOS E2E app has no Keychain entitlement. Test mode uses a + // known key for its disposable simulator data and never attempts to persist + // production wallet material. + if (ENV.TEST_MODE === 'true') { + return E2E_ENCRYPTION_KEY; + } + + try { + const existing = await withTimeout( + SecureStore.getItemAsync(ENCRYPTION_KEY_ENTRY), + EXPO_SECURE_STORE_TIMEOUT_MS, + ); + if (existing) { + return existing; + } + + if (mustHaveExistingKey) { + throw new Error('the encryption key is missing for existing wallet data'); + } + + const key = generateKey(); + await withTimeout( + SecureStore.setItemAsync(ENCRYPTION_KEY_ENTRY, key), + EXPO_SECURE_STORE_TIMEOUT_MS, + ); + return key; + } catch (error) { + // Never downgrade wallet secrets to plaintext. WalletConnect itself no + // longer waits for this path, and wallet restoration can surface the + // secure-storage error independently. + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new Error( + `OS-backed MMKV encryption-key storage is unavailable; refusing to access wallet secrets${reason}`, + ); + } +} + +export function getEncryptionKey( + mustHaveExistingKey = false, +): Promise { + if (!keyPromise) { + keyPromise = loadEncryptionKey(mustHaveExistingKey); + } + return keyPromise; +} diff --git a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts deleted file mode 100644 index 62c925617..000000000 --- a/wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 6d9118c42..4977c5781 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -1,67 +1,155 @@ import { MMKV } from 'react-native-mmkv'; import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json'; -import { getEncryptionKey } from './secureEncryptionKey'; - -// 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. +import { getEncryptionKey } from './mmkvEncryptionKey'; + +// WalletConnect Core and ordinary app preferences must remain in the default +// MMKV store. Several modules open that store synchronously without an +// encryption key, so encrypting it in place makes those readers fail. +const defaultStore = new MMKV(); + +// The persisted ids must stay stable across upgrades even though the constant +// names now make it explicit that these are MMKV databases, not SecureStore. +const ENCRYPTED_MMKV_ID = 'wallet-secure'; +const ENCRYPTED_MMKV_METADATA_ID = 'wallet-secure-metadata'; +const ENCRYPTED_DATA_MARKER = 'has-encrypted-wallet-data'; +const encryptionMetadata = new MMKV({ id: ENCRYPTED_MMKV_METADATA_ID }); + +// Keep this list explicit: only wallet key material belongs in the encrypted +// store. Everything else, including WalletConnect sessions, retains its +// existing storage location and upgrade behavior. +const SECRET_KEYS = new Set([ + 'EIP155_MNEMONIC_1', + 'EIP155_PRIVATE_KEY_1', + 'SUI_MNEMONIC_1', + 'TON_SECRET_KEY_1', + 'TRON_PrivateKey_1', + 'CANTON_SECRET_KEY_1', + 'SOLANA_MNEMONIC_1', + 'SOLANA_SECRET_KEY_1', + 'BITCOIN_MNEMONIC_1', + 'STELLAR_MNEMONIC_1', + 'STELLAR_SECRET_KEY_1', +]); + +let encryptedMmkvPromise: Promise | undefined; + +function isSecretKey(key: string): boolean { + return SECRET_KEYS.has(key); +} + +function getNonSecretKeys(): string[] { + return defaultStore.getAllKeys().filter(key => !isSecretKey(key)); +} + +function getEncryptedMmkv(): Promise { + if (!encryptedMmkvPromise) { + const mustHaveExistingKey = + encryptionMetadata.getString(ENCRYPTED_DATA_MARKER) === 'true'; + encryptedMmkvPromise = getEncryptionKey(mustHaveExistingKey).then(key => { + // The web MMKV shim is localStorage-backed and does not support + // encryption. A separate id still keeps secret and ordinary data apart. if (!key) { - return new MMKV({ id: SECURE_STORE_ID }); + return new MMKV({ id: ENCRYPTED_MMKV_ID }); } - return new MMKV({ id: SECURE_STORE_ID, encryptionKey: key }); - })(); + return new MMKV({ id: ENCRYPTED_MMKV_ID, encryptionKey: key }); + }); + } + return encryptedMmkvPromise; +} + +function parseItem(item: string | undefined): T | undefined { + if (typeof item === 'undefined' || item === null) { + return undefined; + } + return safeJsonParse(item) as T; +} + +function setAndVerify(store: MMKV, key: string, serialized: string): void { + store.set(key, serialized); + if (store.getString(key) !== serialized) { + throw new Error(`Failed to verify encrypted wallet storage for ${key}`); + } +} + +function markEncryptedDataPresent(): void { + encryptionMetadata.set(ENCRYPTED_DATA_MARKER, 'true'); + if (encryptionMetadata.getString(ENCRYPTED_DATA_MARKER) !== 'true') { + throw new Error('Failed to persist encrypted MMKV metadata'); } - return mmkvPromise; +} + +// Migrate one secret at a time. Writing and verifying before deleting makes +// this safe to retry if the app exits at any point during an upgrade. +async function getSecretItem(key: string): Promise { + const encryptedMmkv = await getEncryptedMmkv(); + const encryptedValue = encryptedMmkv.getString(key); + const legacy = defaultStore.getString(key); + + if (typeof encryptedValue !== 'undefined') { + const parsed = parseItem(encryptedValue); + markEncryptedDataPresent(); + // Clean up a legacy copy left by an interrupted migration. + if (typeof legacy !== 'undefined') { + defaultStore.delete(key); + } + return parsed; + } + + if (typeof legacy === 'undefined') { + return undefined; + } + + const parsed = parseItem(legacy); + setAndVerify(encryptedMmkv, key, legacy); + markEncryptedDataPresent(); + defaultStore.delete(key); + return parsed; } export const storage = { - getKeys: async () => { - const mmkv = await getStore(); - return mmkv.getAllKeys(); - }, + // WalletConnect uses these methods to hydrate its own records. Returning the + // default store preserves existing sessions. Filtering also prevents legacy + // plaintext secrets from entering WalletConnect's in-memory storage cache + // while their background migration is still pending. + getKeys: async () => getNonSecretKeys(), getEntries: async (): Promise<[string, T][]> => { - const mmkv = await getStore(); - function parseEntry(key: string): [string, any] { - const value = mmkv.getString(key); - return [key, safeJsonParse(value ?? '')]; + return [key, safeJsonParse(defaultStore.getString(key) ?? '')]; } - const keys = mmkv.getAllKeys(); - return keys.map(parseEntry); + return getNonSecretKeys().map(parseEntry); }, setItem: async (key: string, value: T) => { - const mmkv = await getStore(); - return mmkv.set(key, safeJsonStringify(value)); + const serialized = safeJsonStringify(value); + if (!isSecretKey(key)) { + return defaultStore.set(key, serialized); + } + + const encryptedMmkv = await getEncryptedMmkv(); + setAndVerify(encryptedMmkv, key, serialized); + markEncryptedDataPresent(); + // Remove a previous plaintext value only after the encrypted write has + // been verified. + defaultStore.delete(key); }, getItem: async (key: string): Promise => { - const mmkv = await getStore(); - const item = mmkv.getString(key); - if (typeof item === 'undefined' || item === null) { - return undefined; + if (isSecretKey(key)) { + return getSecretItem(key); } - - return safeJsonParse(item) as T; + return parseItem(defaultStore.getString(key)); }, removeItem: async (key: string) => { - const mmkv = await getStore(); - return mmkv.delete(key); + if (!isSecretKey(key)) { + return defaultStore.delete(key); + } + + // Do not report success while a secret may still exist in either store. + const encryptedMmkv = await getEncryptedMmkv(); + encryptedMmkv.delete(key); + if (typeof encryptedMmkv.getString(key) !== 'undefined') { + throw new Error(`Failed to remove ${key} from encrypted wallet storage`); + } + defaultStore.delete(key); }, };