Skip to content
Open
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
4 changes: 4 additions & 0 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const SILENCE_WASM_PATH = path.resolve(
const ALIASES = {
tslib: path.resolve(__dirname, 'node_modules/tslib/tslib.es6.js'),
crypto: require.resolve('react-native-quick-crypto'),
bitauth: path.resolve(
__dirname,
'node_modules/bitauth/lib/bitauth-browserify.js',
),
};

/**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"bs58": "6.0.0",
"buffer": "4.9.2",
"countries-list": "2.6.1",
"crypto-js": "3.1.9-1",
"eth-sig-util": "3.0.1",
"ethers": "5.7.2",
"events": "3.3.0",
Expand Down Expand Up @@ -191,7 +192,6 @@
"redux-immutable-state-invariant": "2.1.0",
"redux-logger": "3.0.6",
"redux-persist": "6.0.0",
"redux-persist-transform-encrypt": "3.0.1",
"redux-thunk": "2.4.0",
"reselect": "4.1.5",
"rn-nodeify": "10.3.0",
Expand Down
43 changes: 0 additions & 43 deletions patches/redux-persist-transform-encrypt+3.0.1.patch

This file was deleted.

104 changes: 78 additions & 26 deletions src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {DISABLE_DEVELOPMENT_LOGGING} from '@env';
import crypto from 'crypto';
import {
Action,
AnyAction,
Expand All @@ -14,7 +15,6 @@ import {getUniqueId} from 'react-native-device-info';
import * as Keychain from 'react-native-keychain';
import {createTransform, persistStore, persistReducer} from 'redux-persist'; // https://github.com/rt2zz/redux-persist
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
import {encryptTransform} from 'redux-persist-transform-encrypt'; // https://github.com/maxdeviant/redux-persist-transform-encrypt
import thunkMiddleware, {ThunkAction} from 'redux-thunk'; // https://github.com/reduxjs/redux-thunk
import {Selector} from 'reselect';
import {
Expand All @@ -28,6 +28,7 @@ import {
transformPortfolioPopulateStatus,
encryptSpecificFields,
} from './transforms/transforms';
import {decryptPersistValue, encryptPersistValue} from './transforms/encrypt';
import {appReducer, appReduxPersistBlackList} from './app/app.reducer';
import {
bitPayIdReducer,
Expand Down Expand Up @@ -100,6 +101,16 @@ import * as Sentry from '@sentry/react-native';

export const storage = new MMKV();

const unencryptedPersistStores = new Set([
'APP',
'MARKET_STATS',
'PORTFOLIO',
'RATE',
'SHOP',
'SHOP_CATALOG',
'WALLET',
]);

const FS_BACKUP_TRIGGER_ACTIONS = new Set<string>([
WalletActionTypes.SUCCESS_CREATE_KEY,
WalletActionTypes.SUCCESS_IMPORT,
Expand Down Expand Up @@ -466,7 +477,7 @@ const getStore = async () => {
// middlewares.push(inmmutableMiddleware);
}

const secretKey = await getEncryptionKey().catch(() => getUniqueId());
const secretKey = await getEncryptionKey();

const rootPersistConfig = {
...basePersistConfig,
Expand All @@ -491,31 +502,56 @@ const getStore = async () => {
return inboundState;
}),
encryptSpecificFields(secretKey),
encryptTransform({
secretKey,
onError: err => {
const errStr =
err instanceof Error ? err.message : JSON.stringify(err);
createTransform<any, any, RootState>(
(inboundState, key) => {
if (typeof key === 'string' && unencryptedPersistStores.has(key)) {
return JSON.stringify(inboundState);
}

store.dispatch(
LogActions.persistLog(
LogActions.error(`Encrypt transform failed - ${errStr}`),
),
return encryptPersistValue(
inboundState,
secretKey,
`persist:${String(key)}`,
);
Sentry.captureException(err, {
level: 'error',
});
},
unencryptedStores: [
'APP',
'MARKET_STATS',
'PORTFOLIO',
'RATE',
'SHOP',
'SHOP_CATALOG',
'WALLET',
],
}),
(outboundState, key) => {
if (typeof key === 'string' && unencryptedPersistStores.has(key)) {
if (typeof outboundState === 'string') {
try {
return JSON.parse(outboundState);
} catch {}
} else {
return outboundState;
}
}

if (typeof outboundState !== 'string') {
return outboundState;
}

try {
return decryptPersistValue(
outboundState,
secretKey,
`persist:${String(key)}`,
);
} catch (err) {
const errStr =
err instanceof Error ? err.message : JSON.stringify(err);
store.dispatch(
LogActions.persistLog(
LogActions.error(
`Decrypt persist transform failed - ${errStr}`,
),
),
);
Sentry.captureException(err, {
level: 'error',
});
throw err;
}
},
),
],
};

Expand Down Expand Up @@ -645,10 +681,25 @@ export async function getEncryptionKey(): Promise<string> {
Sentry.captureException(err, {
level: 'error',
});
throw err;
}

let hasLegacyPersistedState = false;
try {
hasLegacyPersistedState =
storage.contains('persist:root') || (await backupFileExists());
} catch {
hasLegacyPersistedState = await backupFileExists();
}

logManager.warn('getEncryptionKey: generating new key (no existing key)');
const newKey = getUniqueId();
const newKey = hasLegacyPersistedState
? getUniqueId()
: crypto.randomBytes(32).toString('base64');
logManager.warn(
`getEncryptionKey: generating ${
hasLegacyPersistedState ? 'legacy-compatible' : 'random'
} key (no existing key)`,
);

try {
// Save to keychain
Expand All @@ -667,6 +718,7 @@ export async function getEncryptionKey(): Promise<string> {
Sentry.captureException(err, {
level: 'error',
});
throw err;
}

return newKey;
Expand Down
121 changes: 121 additions & 0 deletions src/store/transforms/encrypt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import crypto from 'crypto';
import Aes from 'crypto-js/aes.js';
import {
decryptPersistValue,
decryptValue,
decryptWalletStore,
encryptPersistValue,
encryptValue,
encryptWalletStore,
} from './encrypt';

const secretKey = 'test-device-secret';

describe('encrypted field values', () => {
it('round-trips with AES-GCM and produces randomized ciphertext', () => {
const first = encryptValue('mnemonic words', secretKey);
const second = encryptValue('mnemonic words', secretKey);

expect(first).toMatch(/^field-aesgcm-v1:/);
expect(second).not.toBe(first);
expect(decryptValue(first, secretKey)).toBe('mnemonic words');
});

it('rejects tampering and the wrong context', () => {
const encrypted = encryptValue('private key', secretKey, 'wallet:key-a');
const tampered = `${encrypted.slice(0, -1)}${
encrypted.endsWith('A') ? 'B' : 'A'
}`;

expect(() => decryptValue(tampered, secretKey, 'wallet:key-a')).toThrow();
expect(() => decryptValue(encrypted, secretKey, 'wallet:key-b')).toThrow();
});

it('reads the legacy CBC field format', () => {
const legacyCbc = `encrypted:${Aes.encrypt(
'legacy mnemonic',
secretKey,
).toString()}`;
expect(decryptValue(legacyCbc, secretKey)).toBe('legacy mnemonic');
});

it('binds wallet ciphertext to its persisted field location', () => {
const state = {
keys: {
keyA: {
properties: {mnemonic: 'alpha', xPrivKey: 'xpriv'},
},
},
};
const encrypted = encryptWalletStore(state, secretKey);

expect(encrypted.keys.keyA.properties.mnemonic).toMatch(
/^field-aesgcm-v1:/,
);
expect(decryptWalletStore(encrypted, secretKey)).toEqual(state);

const swapped = {
keys: {
keyA: {
properties: {
mnemonic: encrypted.keys.keyA.properties.xPrivKey,
xPrivKey: encrypted.keys.keyA.properties.mnemonic,
},
},
},
};
expect(() => decryptWalletStore(swapped, secretKey)).toThrow();
});
});

describe('persisted reducer values', () => {
const state = {token: 'secret', nested: {enabled: true}};
const context = 'persist:BITPAY_ID';

it('round-trips with a versioned authenticated envelope', () => {
const encrypted = encryptPersistValue(state, secretKey, context);

expect(encrypted).toMatch(/^persist-aesgcm-v1:/);
expect(decryptPersistValue(encrypted, secretKey, context)).toEqual(state);
});

it('rejects the wrong key, context, malformed payloads, and extra segments', () => {
const encrypted = encryptPersistValue(state, secretKey, context);

expect(() =>
decryptPersistValue(encrypted, 'wrong-secret', context),
).toThrow();
expect(() =>
decryptPersistValue(encrypted, secretKey, 'persist:CARD'),
).toThrow();
expect(() =>
decryptPersistValue(`${encrypted}.extra`, secretKey, context),
).toThrow('Invalid encrypted payload format');
expect(() =>
decryptPersistValue(
'persist-aesgcm-v1:not-base64.x.y',
secretKey,
context,
),
).toThrow();
});

it('migrates bare CBC values produced by older releases', () => {
const legacy = Aes.encrypt(JSON.stringify(state), secretKey).toString();

expect(decryptPersistValue(legacy, secretKey, context)).toEqual(state);
});

it('does not fall back to CBC when GCM encryption fails', () => {
const randomBytes = jest
.spyOn(crypto, 'randomBytes')
.mockImplementationOnce(() => {
throw new Error('random source unavailable');
});

expect(() => encryptPersistValue(state, secretKey, context)).toThrow(
'random source unavailable',
);
randomBytes.mockRestore();
});
});
Loading
Loading