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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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()