Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions wallets/rn_cli_wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"expo-application": "~56.0.3",
"expo-clipboard": "~56.0.4",
"expo-navigation-bar": "~56.0.3",
"expo-secure-store": "~56.0.4",
"expo-system-ui": "56.0.5",
"lottie-react-native": "7.3.5",
"pressto": "0.7.0",
Expand Down
2 changes: 1 addition & 1 deletion wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
2 changes: 1 addition & 1 deletion wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
2 changes: 1 addition & 1 deletion wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
2 changes: 1 addition & 1 deletion wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
2 changes: 1 addition & 1 deletion wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
90 changes: 90 additions & 0 deletions wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';

// Owns the lifecycle of the MMKV encryption key. The key is generated once and
// persisted in the OS Keychain (iOS) / Keystore (Android) via expo-secure-store,
// so the key that protects the wallet secrets never sits in plaintext MMKV.
//
// Native-only: SecureStore is unavailable on web, and the react-native-mmkv web
// shim (localStorage) ignores encryption anyway — so on web we return undefined
// and the secure store stays best-effort/unencrypted by design.

const SECURE_STORE_KEY = 'mmkv_encryption_key';

// Upper bound on how long we wait for the Keychain/Keystore before giving up.
// On real signed builds SecureStore resolves in milliseconds; on an unsigned
// build (E2E) the Keychain can stall on the securityd/KeychainMigrator path,
// and this call sits on the app-init critical path — so we cap it to avoid the
// splash screen hanging, falling back to unencrypted storage on timeout.
const SECURE_STORE_TIMEOUT_MS = 4000;

class SecureStoreTimeoutError extends Error {}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
// 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<T>((_, 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<string | undefined> | 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<string | undefined> {
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;
}
36 changes: 35 additions & 1 deletion wallets/rn_cli_wallet/src/utils/storage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
import { MMKV } from 'react-native-mmkv';
import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json';
import { getEncryptionKey } from './secureEncryptionKey';

const mmkv = new MMKV();
// Wallet secrets (mnemonics/private keys) and WalletConnect Core data are kept
// in a DEDICATED, encrypted MMKV instance — never the default store. Other
// modules (SettingsStore, WalletStore, LogStore) open the default `new MMKV()`
// without a key; if this data shared that instance, encrypting it would make
// their reads/writes fail. Isolating it under its own id avoids that conflict.
const SECURE_STORE_ID = 'wallet-secure';

// Created lazily because the encryption key must be fetched from the OS
// Keychain/Keystore first (async). On native the store is encrypted at rest; on
// web (no key) it falls back to the localStorage shim, unencrypted by design.
let mmkvPromise: Promise<MMKV> | undefined;

function getStore(): Promise<MMKV> {
if (!mmkvPromise) {
mmkvPromise = (async () => {
const key = await getEncryptionKey();

// No key on web (SecureStore unavailable) — fall back to the unencrypted
// localStorage shim, which is best-effort by design.
if (!key) {
return new MMKV({ id: SECURE_STORE_ID });
}

return new MMKV({ id: SECURE_STORE_ID, encryptionKey: key });
})();
}
return mmkvPromise;
}

export const storage = {
getKeys: async () => {
const mmkv = await getStore();
return mmkv.getAllKeys();
},
getEntries: async <T = any>(): Promise<[string, T][]> => {
const mmkv = await getStore();

function parseEntry(key: string): [string, any] {
const value = mmkv.getString(key);
return [key, safeJsonParse(value ?? '')];
Expand All @@ -17,9 +48,11 @@ export const storage = {
return keys.map(parseEntry);
},
setItem: async <T = any>(key: string, value: T) => {
const mmkv = await getStore();
return mmkv.set(key, safeJsonStringify(value));
},
getItem: async <T = any>(key: string): Promise<T | undefined> => {
const mmkv = await getStore();
const item = mmkv.getString(key);
if (typeof item === 'undefined' || item === null) {
return undefined;
Expand All @@ -28,6 +61,7 @@ export const storage = {
return safeJsonParse(item) as T;
},
removeItem: async (key: string) => {
const mmkv = await getStore();
return mmkv.delete(key);
},
};
10 changes: 10 additions & 0 deletions wallets/rn_cli_wallet/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5256,6 +5256,7 @@ __metadata:
expo-application: ~56.0.3
expo-clipboard: ~56.0.4
expo-navigation-bar: ~56.0.3
expo-secure-store: ~56.0.4
expo-system-ui: 56.0.5
jest: ^29.2.1
jest-expo: 56.0.5
Expand Down Expand Up @@ -8387,6 +8388,15 @@ __metadata:
languageName: node
linkType: hard

"expo-secure-store@npm:~56.0.4":
version: 56.0.4
resolution: "expo-secure-store@npm:56.0.4"
peerDependencies:
expo: "*"
checksum: a47a5c0378fdc7676df9d11b3f2417bac4106fcc9b7b461ee4774c8ffb0277a52e6c35545a0dff82de2f48dc9c112bd14060cc20a019cca1ea7507286a6822ac
languageName: node
linkType: hard

"expo-server@npm:^56.0.5":
version: 56.0.5
resolution: "expo-server@npm:56.0.5"
Expand Down