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 @@ -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",
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
51 changes: 51 additions & 0 deletions wallets/rn_cli_wallet/src/utils/secureEncryptionKey.ts
Original file line number Diff line number Diff line change
@@ -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<EncryptionKeyResult> | undefined;

export function getEncryptionKey(): Promise<EncryptionKeyResult> {
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;
}
48 changes: 47 additions & 1 deletion wallets/rn_cli_wallet/src/utils/storage.ts
Original file line number Diff line number Diff line change
@@ -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<MMKV> | undefined;

function getStore(): Promise<MMKV> {
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 <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 +60,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 +73,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 @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading