Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
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 @@ -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
Loading