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
75 changes: 75 additions & 0 deletions wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
165 changes: 165 additions & 0 deletions wallets/rn_cli_wallet/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
const mockStores = new Map<string, Map<string, string>>();
const mockDroppedWrites = new Set<string>();
const mockConfigurations: Array<{
id: string;
encryptionKey?: string;
}> = [];

jest.mock('react-native-mmkv', () => ({
MMKV: class {
private values: Map<string, string>;

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<string, string> {
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);
});
});
1 change: 1 addition & 0 deletions wallets/rn_cli_wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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/BitcoinWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

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/StellarWalletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

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
Loading
Loading