diff --git a/index.js b/index.js index e6500891c1..447bdd57db 100644 --- a/index.js +++ b/index.js @@ -146,6 +146,7 @@ const ReduxProvider = () => { }, [isPrimary]); const [storeReady, setStoreReady] = useState(false); + const [startupAttempt, setStartupAttempt] = useState(0); const [{store: reduxStore, persistor: reduxPersistor}, setStore] = useState({ store: null, persistor: null, @@ -158,22 +159,45 @@ const ReduxProvider = () => { let cancelled = false; - getStore().then(({store, persistor}) => { - if (cancelled) { - return; - } + getStore() + .then(({store, persistor}) => { + if (cancelled) { + persistor.pause(); + return; + } - setStore({store, persistor}); - setStoreReady(true); - setJSExceptionHandler(makeErrorHandler(store), true); - // executeDefaultHandler=true chains to Sentry's UncaughtExceptionHandler so native crashes are captured - setNativeExceptionHandler(makeNativeExceptionHandler(store), true, true); - }); + setStore({store, persistor}); + setStoreReady(true); + setJSExceptionHandler(makeErrorHandler(store), true); + // executeDefaultHandler=true chains to Sentry's UncaughtExceptionHandler so native crashes are captured + setNativeExceptionHandler( + makeNativeExceptionHandler(store), + true, + true, + ); + }) + .catch(error => { + if (cancelled) { + return; + } + Sentry.captureException(error, {level: 'error'}); + Alert.alert( + 'Wallet data could not be opened', + 'Your local data was preserved. Please try again.', + [ + { + text: 'Retry', + onPress: () => setStartupAttempt(attempt => attempt + 1), + }, + ], + {cancelable: false}, + ); + }); return () => { cancelled = true; }; - }, [isPrimary]); + }, [isPrimary, startupAttempt]); if (!isPrimary || !storeReady) { return null; diff --git a/metro.config.js b/metro.config.js index cc3b42deb0..65da09a299 100644 --- a/metro.config.js +++ b/metro.config.js @@ -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-node.js', + ), }; /** diff --git a/package.json b/package.json index 359df1f1f2..40bbf035a2 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "bs58": "6.0.0", "buffer": "4.9.2", "countries-list": "2.6.1", + "crypto-js": "4.2.0", "eth-sig-util": "3.0.1", "ethers": "5.7.2", "events": "3.3.0", @@ -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", diff --git a/patches/redux-persist-transform-encrypt+3.0.1.patch b/patches/redux-persist-transform-encrypt+3.0.1.patch deleted file mode 100644 index dee0c3b347..0000000000 --- a/patches/redux-persist-transform-encrypt+3.0.1.patch +++ /dev/null @@ -1,43 +0,0 @@ -diff --git a/node_modules/redux-persist-transform-encrypt/lib/sync.d.ts b/node_modules/redux-persist-transform-encrypt/lib/sync.d.ts -index 9951f2c..3f38ed2 100644 ---- a/node_modules/redux-persist-transform-encrypt/lib/sync.d.ts -+++ b/node_modules/redux-persist-transform-encrypt/lib/sync.d.ts -@@ -3,5 +3,6 @@ - export interface EncryptTransformConfig { - secretKey: string; - onError?: (err: Error) => void; -+ unencryptedStores?: string[]; - } - export declare const encryptTransform: (config: EncryptTransformConfig) => import("redux-persist").Transform; -diff --git a/node_modules/redux-persist-transform-encrypt/lib/sync.js b/node_modules/redux-persist-transform-encrypt/lib/sync.js -index cbb30ff..4b2a4fd 100644 ---- a/node_modules/redux-persist-transform-encrypt/lib/sync.js -+++ b/node_modules/redux-persist-transform-encrypt/lib/sync.js -@@ -39,12 +39,27 @@ exports.encryptTransform = function (config) { - throw makeError('No secret key provided.'); - } - var onError = typeof config.onError === 'function' ? config.onError : console.warn; -+ const unencryptedStores = config.unencryptedStores || []; -+ - return redux_persist_1.createTransform(function (inboundState, _key) { -+ // Skip encryption for unencrypted stores -+ if (unencryptedStores.includes(_key)) { -+ return json_stringify_safe_1.default(inboundState); -+ } - return Aes.encrypt(json_stringify_safe_1.default(inboundState), secretKey).toString(); - }, function (outboundState, _key) { - if (typeof outboundState !== 'string') { - return onError(makeError('Expected outbound state to be a string.')); - } -+ -+ // For unencrypted stores, try parsing first -+ if (unencryptedStores.includes(_key)) { -+ try { -+ return JSON.parse(outboundState); -+ } catch (_e) {} // If parsing fails, try decryption below -+ } -+ -+ // Handle both encrypted stores and failed unencrypted parses - try { - var decryptedString = Aes.decrypt(outboundState, secretKey).toString(CryptoJsCore.enc.Utf8); - if (!decryptedString) { diff --git a/src/store/backup/fs-backup.spec.ts b/src/store/backup/fs-backup.spec.ts index 5bcd53d805..e03c43fdd6 100644 --- a/src/store/backup/fs-backup.spec.ts +++ b/src/store/backup/fs-backup.spec.ts @@ -83,6 +83,29 @@ describe('backupFileExists', () => { }); }); +describe('backupFileExistsStrict', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns true when the file exists', async () => { + const {backupFileExistsStrict} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(true); + expect(await backupFileExistsStrict()).toBe(true); + }); + + it('returns false when the file does not exist', async () => { + const {backupFileExistsStrict} = getFreshModule(); + (mockedRNFS.exists as jest.Mock).mockResolvedValueOnce(false); + expect(await backupFileExistsStrict()).toBe(false); + }); + + it('rejects when RNFS.exists throws', async () => { + const {backupFileExistsStrict} = getFreshModule(); + const error = new Error('fs error'); + (mockedRNFS.exists as jest.Mock).mockRejectedValueOnce(error); + await expect(backupFileExistsStrict()).rejects.toBe(error); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // backupPersistRoot // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/store/backup/fs-backup.ts b/src/store/backup/fs-backup.ts index dc8a9fcb6b..db1b2c1c47 100644 --- a/src/store/backup/fs-backup.ts +++ b/src/store/backup/fs-backup.ts @@ -32,19 +32,28 @@ async function ensureDir(): Promise { } } -export async function backupFileExists(): Promise { +async function checkBackupFileExists(): Promise { if (cachedBackupExists) { return true; } + + const exists = await RNFS.exists(FINAL_FILE); + cachedBackupExists = exists; + return exists; +} + +export async function backupFileExists(): Promise { try { - const exists = await RNFS.exists(FINAL_FILE); - cachedBackupExists = exists; - return exists; - } catch (_) { + return await checkBackupFileExists(); + } catch { return false; } } +export async function backupFileExistsStrict(): Promise { + return checkBackupFileExists(); +} + export function backupPersistRoot(rawJson: string): Promise { backupQueue = backupQueue .then(() => _backupPersistRoot(rawJson)) diff --git a/src/store/bitauth.spec.ts b/src/store/bitauth.spec.ts new file mode 100644 index 0000000000..93a81f3c4d --- /dev/null +++ b/src/store/bitauth.spec.ts @@ -0,0 +1,46 @@ +jest.mock('secp256k1', () => jest.requireActual('secp256k1/elliptic')); + +const BitAuth = require('bitauth/lib/bitauth-node'); +const metroConfig = require('../../metro.config'); + +describe('BitAuth Metro implementation', () => { + it.each(['ios', 'android'])( + 'aliases bitauth to the CBC-free production signer on %s', + platform => { + const resolveRequest = jest.fn( + (_context: unknown, moduleName: string) => moduleName, + ); + + metroConfig.resolver.resolveRequest( + {resolveRequest}, + 'bitauth', + platform, + ); + + expect(resolveRequest).toHaveBeenCalledWith( + expect.any(Object), + expect.stringMatching(/bitauth\/lib\/bitauth-node\.js$/), + platform, + ); + }, + ); + + it('preserves signing without exporting the CBC helpers', () => { + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const publicKey = BitAuth.getPublicKeyFromPrivateKey(privateKey); + const signature = BitAuth.sign('bitpay-test-vector', privateKey); + + expect(publicKey).toBe( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ); + expect(signature.toString('hex')).toBe( + '304402205807d12cfc30686cca25b7e7e5e3154875e10e13ce0dd26f5b502d42bea7b83402204832b41c3bc3713511b48cd27ac661d9102357330b4c66f5112af592c3aa2b29', + ); + expect( + BitAuth.verifySignature('bitpay-test-vector', publicKey, signature), + ).toBe(true); + expect(BitAuth.encrypt).toBeUndefined(); + expect(BitAuth.decrypt).toBeUndefined(); + }); +}); diff --git a/src/store/encryption-key.spec.ts b/src/store/encryption-key.spec.ts new file mode 100644 index 0000000000..e4d5acab27 --- /dev/null +++ b/src/store/encryption-key.spec.ts @@ -0,0 +1,130 @@ +import {selectNewEncryptionKey, storeEncryptionKey} from './encryption-key'; + +describe('selectNewEncryptionKey', () => { + const getLegacyKey = jest.fn(() => 'legacy-key'); + const getRandomKey = jest.fn(() => 'random-key'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the legacy-compatible key when the persisted root exists', async () => { + const hasBackup = jest.fn, []>(); + + await expect( + selectNewEncryptionKey({ + hasPersistedRoot: () => true, + hasBackup, + getLegacyKey, + getRandomKey, + }), + ).resolves.toEqual({key: 'legacy-key', legacyCompatible: true}); + + expect(hasBackup).not.toHaveBeenCalled(); + expect(getLegacyKey).toHaveBeenCalledTimes(1); + expect(getRandomKey).not.toHaveBeenCalled(); + }); + + it('uses the legacy-compatible key when only the backup exists', async () => { + await expect( + selectNewEncryptionKey({ + hasPersistedRoot: () => false, + hasBackup: async () => true, + getLegacyKey, + getRandomKey, + }), + ).resolves.toEqual({key: 'legacy-key', legacyCompatible: true}); + + expect(getLegacyKey).toHaveBeenCalledTimes(1); + expect(getRandomKey).not.toHaveBeenCalled(); + }); + + it('uses a random key only when the root and backup are both absent', async () => { + await expect( + selectNewEncryptionKey({ + hasPersistedRoot: () => false, + hasBackup: async () => false, + getLegacyKey, + getRandomKey, + }), + ).resolves.toEqual({key: 'random-key', legacyCompatible: false}); + + expect(getLegacyKey).not.toHaveBeenCalled(); + expect(getRandomKey).toHaveBeenCalledTimes(1); + }); + + it('does not generate a key when checking the persisted root throws', async () => { + const error = new Error('MMKV contains failed'); + const hasBackup = jest.fn, []>(); + + await expect( + selectNewEncryptionKey({ + hasPersistedRoot: () => { + throw error; + }, + hasBackup, + getLegacyKey, + getRandomKey, + }), + ).rejects.toBe(error); + + expect(hasBackup).not.toHaveBeenCalled(); + expect(getLegacyKey).not.toHaveBeenCalled(); + expect(getRandomKey).not.toHaveBeenCalled(); + }); + + it('does not generate a key when checking the backup throws', async () => { + const error = new Error('backup check failed'); + + await expect( + selectNewEncryptionKey({ + hasPersistedRoot: () => false, + hasBackup: async () => { + throw error; + }, + getLegacyKey, + getRandomKey, + }), + ).rejects.toBe(error); + + expect(getLegacyKey).not.toHaveBeenCalled(); + expect(getRandomKey).not.toHaveBeenCalled(); + }); +}); + +describe('storeEncryptionKey', () => { + const encryptionKeyId = 'bitpay-app-encryption-key'; + const key = 'generated-key'; + + it('resolves after Keychain confirms the write', async () => { + const setGenericPassword = jest.fn().mockResolvedValue({ + service: encryptionKeyId, + storage: 'keychain', + }); + + await expect( + storeEncryptionKey(encryptionKeyId, key, setGenericPassword), + ).resolves.toBeUndefined(); + + expect(setGenericPassword).toHaveBeenCalledWith(encryptionKeyId, key, { + service: encryptionKeyId, + }); + }); + + it('rejects when Keychain reports that the key was not stored', async () => { + const setGenericPassword = jest.fn().mockResolvedValue(false); + + await expect( + storeEncryptionKey(encryptionKeyId, key, setGenericPassword), + ).rejects.toThrow('Keychain did not store the encryption key'); + }); + + it('preserves a Keychain rejection', async () => { + const error = new Error('Keychain unavailable'); + const setGenericPassword = jest.fn().mockRejectedValue(error); + + await expect( + storeEncryptionKey(encryptionKeyId, key, setGenericPassword), + ).rejects.toBe(error); + }); +}); diff --git a/src/store/encryption-key.ts b/src/store/encryption-key.ts new file mode 100644 index 0000000000..125b72dd5f --- /dev/null +++ b/src/store/encryption-key.ts @@ -0,0 +1,40 @@ +type SelectNewEncryptionKeyOptions = { + hasPersistedRoot: () => boolean; + hasBackup: () => Promise; + getLegacyKey: () => string; + getRandomKey: () => string; +}; + +export const selectNewEncryptionKey = async ({ + hasPersistedRoot, + hasBackup, + getLegacyKey, + getRandomKey, +}: SelectNewEncryptionKeyOptions): Promise<{ + key: string; + legacyCompatible: boolean; +}> => { + const legacyCompatible = hasPersistedRoot() || (await hasBackup()); + + return { + key: legacyCompatible ? getLegacyKey() : getRandomKey(), + legacyCompatible, + }; +}; + +type SetGenericPassword = + typeof import('react-native-keychain').setGenericPassword; + +export const storeEncryptionKey = async ( + encryptionKeyId: string, + key: string, + setGenericPassword: SetGenericPassword, +): Promise => { + const result = await setGenericPassword(encryptionKeyId, key, { + service: encryptionKeyId, + }); + + if (!result) { + throw new Error('Keychain did not store the encryption key'); + } +}; diff --git a/src/store/index.ts b/src/store/index.ts index 9113f57ad1..3941f76c7c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,5 @@ import {DISABLE_DEVELOPMENT_LOGGING} from '@env'; +import crypto from 'crypto'; import { Action, AnyAction, @@ -14,20 +15,26 @@ 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 { backupFileExists, + backupFileExistsStrict, backupPersistRoot, readBackupPersistRoot, } from './backup/fs-backup'; +import {selectNewEncryptionKey, storeEncryptionKey} from './encryption-key'; import { bindWalletKeys, transformContacts, transformPortfolioPopulateStatus, encryptSpecificFields, } from './transforms/transforms'; +import { + deserializePersistValue, + encryptPersistValue, +} from './transforms/encrypt'; +import {createRehydrationFailureMiddleware} from './persistence-guard'; import {appReducer, appReduxPersistBlackList} from './app/app.reducer'; import { bitPayIdReducer, @@ -100,6 +107,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([ WalletActionTypes.SUCCESS_CREATE_KEY, WalletActionTypes.SUCCESS_IMPORT, @@ -410,8 +427,15 @@ const logger = createLogger({ }); const getStore = async () => { + let rehydrationFailure: Error | null = null; const middlewares: Middleware[] = [thunkMiddleware as unknown as Middleware]; + middlewares.push( + createRehydrationFailureMiddleware(error => { + rehydrationFailure ??= error; + }), + ); + const cleanupPortfolioOnDeleteKeyMiddleware: Middleware = store => next => { return (action: AnyAction) => { if (action?.type !== WalletActionTypes.DELETE_KEY) { @@ -466,7 +490,7 @@ const getStore = async () => { // middlewares.push(inmmutableMiddleware); } - const secretKey = await getEncryptionKey().catch(() => getUniqueId()); + const secretKey = await getEncryptionKey(); const rootPersistConfig = { ...basePersistConfig, @@ -491,31 +515,43 @@ const getStore = async () => { return inboundState; }), encryptSpecificFields(secretKey), - encryptTransform({ - secretKey, - onError: err => { - const errStr = - err instanceof Error ? err.message : JSON.stringify(err); + createTransform( + (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) => { + try { + return deserializePersistValue( + outboundState, + secretKey, + `persist:${String(key)}`, + typeof key === 'string' && unencryptedPersistStores.has(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; + } + }, + ), ], }; @@ -584,7 +620,18 @@ const getStore = async () => { storeDispatch(LogActions.clear()); initLogs.drainAndDispatch(storeDispatch); - const persistor = persistStore(store); + let resolveBootstrap: () => void = () => {}; + const bootstrapped = new Promise(resolve => { + resolveBootstrap = resolve; + }); + const persistor = persistStore(store, undefined, resolveBootstrap); + + await bootstrapped; + if (rehydrationFailure) { + persistor.pause(); + Sentry.captureException(rehydrationFailure, {level: 'error'}); + throw rehydrationFailure; + } if (__DEV__) { // persistor.purge().then(() => console.log('purged persistence')); @@ -645,16 +692,43 @@ export async function getEncryptionKey(): Promise { Sentry.captureException(err, { level: 'error', }); + throw err; } - logManager.warn('getEncryptionKey: generating new key (no existing key)'); - const newKey = getUniqueId(); - + let selectedKey: {key: string; legacyCompatible: boolean}; try { - // Save to keychain - await Keychain.setGenericPassword(encryptionKeyId, newKey, { - service: encryptionKeyId, + selectedKey = await selectNewEncryptionKey({ + hasPersistedRoot: () => storage.contains('persist:root'), + hasBackup: backupFileExistsStrict, + getLegacyKey: getUniqueId, + getRandomKey: () => crypto.randomBytes(32).toString('base64'), + }); + } catch (err) { + initLogs.add( + LogActions.persistLog( + LogActions.error( + `getEncryptionKey: key selection failed - ${getErrorString(err)}`, + ), + ), + ); + Sentry.captureException(err, { + level: 'error', }); + throw err; + } + + logManager.warn( + `getEncryptionKey: generating ${ + selectedKey.legacyCompatible ? 'legacy-compatible' : 'random' + } key (no existing key)`, + ); + + try { + await storeEncryptionKey( + encryptionKeyId, + selectedKey.key, + Keychain.setGenericPassword, + ); logManager.info('getEncryptionKey: stored new key in Keychain'); } catch (err) { initLogs.add( @@ -667,7 +741,8 @@ export async function getEncryptionKey(): Promise { Sentry.captureException(err, { level: 'error', }); + throw err; } - return newKey; + return selectedKey.key; } diff --git a/src/store/persistence-guard.spec.ts b/src/store/persistence-guard.spec.ts new file mode 100644 index 0000000000..0e2577d140 --- /dev/null +++ b/src/store/persistence-guard.spec.ts @@ -0,0 +1,50 @@ +import {applyMiddleware, createStore, Middleware} from 'redux'; +import {createTransform, persistReducer, persistStore} from 'redux-persist'; +import {createRehydrationFailureMiddleware} from './persistence-guard'; + +describe('rehydration failure protection', () => { + it('pauses persistence before a decrypt error can overwrite the original root', async () => { + const originalRoot = JSON.stringify({ + TEST: JSON.stringify('corrupted-ciphertext'), + }); + let storedRoot = originalRoot; + const storage = { + getItem: jest.fn(async () => storedRoot), + setItem: jest.fn(async (_key: string, value: string) => { + storedRoot = value; + }), + removeItem: jest.fn(async () => undefined), + }; + const transform = createTransform( + state => state, + () => { + throw new Error('decrypt failed'); + }, + ); + const onFailure = jest.fn(); + const persistedReducer = persistReducer( + {key: 'root', storage, transforms: [transform], timeout: 0}, + (state = {TEST: 'initial'}, action) => + action.type === 'CHANGE' ? {TEST: 'changed'} : state, + ); + const store = createStore( + persistedReducer, + applyMiddleware( + createRehydrationFailureMiddleware(onFailure) as Middleware, + ), + ); + + let persistor: ReturnType | undefined; + await new Promise(resolve => { + persistor = persistStore(store, undefined, resolve); + }); + + store.dispatch({type: 'CHANGE'}); + await persistor!.flush(); + + expect(onFailure).toHaveBeenCalledWith(expect.any(Error)); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storedRoot).toBe(originalRoot); + expect(store.getState().TEST).toBe('changed'); + }); +}); diff --git a/src/store/persistence-guard.ts b/src/store/persistence-guard.ts new file mode 100644 index 0000000000..69610ea53a --- /dev/null +++ b/src/store/persistence-guard.ts @@ -0,0 +1,22 @@ +import {AnyAction, Middleware} from 'redux'; +import {PAUSE, REHYDRATE} from 'redux-persist'; + +const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)); + +export const createRehydrationFailureMiddleware = + (onFailure: (error: Error) => void): Middleware => + store => + next => + (action: AnyAction) => { + if ( + action.type === REHYDRATE && + action.key === 'root' && + action.err != null + ) { + const error = toError(action.err); + store.dispatch({type: PAUSE}); + onFailure(error); + } + return next(action); + }; diff --git a/src/store/transforms/encrypt.spec.ts b/src/store/transforms/encrypt.spec.ts new file mode 100644 index 0000000000..bc8f42779f --- /dev/null +++ b/src/store/transforms/encrypt.spec.ts @@ -0,0 +1,267 @@ +import crypto from 'crypto'; +import { + decryptAppStore, + decryptPersistValue, + deserializePersistValue, + decryptShopStore, + decryptValue, + decryptWalletStore, + encryptAppStore, + encryptPersistValue, + encryptShopStore, + encryptValue, + encryptWalletStore, +} from './encrypt'; + +const secretKey = 'test-device-secret'; +const cryptoJs319FieldFixture = + 'encrypted:U2FsdGVkX1/EPMhpSvHpnaBttHW7Aqj83wQ6ik7Jgl8='; +const cryptoJs319PersistFixture = + 'U2FsdGVkX19mVHQCu4aZtdIU0+n2LBW8Hor2eiDb8jHd1IeHDH4ydP1GmWptXm4xOXAPbdRgGqMnRjxv7CcIHw=='; + +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 a fixed CBC field produced by CryptoJS 3.1.9-1', () => { + expect(decryptValue(cryptoJs319FieldFixture, 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(); + }); + + type ProtectedStoreCase = { + name: string; + fields: string[]; + buildState: (field: string, value: unknown) => any; + encrypt: (state: any) => any; + decrypt: (state: any) => any; + read: (state: any, field: string) => unknown; + readPublic: (state: any) => unknown; + }; + + const protectedStoreCases: ProtectedStoreCase[] = [ + { + name: 'WALLET', + fields: [ + 'mnemonic', + 'mnemonicEncrypted', + 'xPrivKey', + 'xPrivKeyEncrypted', + 'xPrivKeyEDDSA', + 'xPrivKeyEDDSAEncrypted', + ], + buildState: (field, value) => ({ + keys: { + keyA: {properties: {[field]: value, fingerPrint: 'public-value'}}, + }, + }), + encrypt: state => encryptWalletStore(state, secretKey), + decrypt: state => decryptWalletStore(state, secretKey), + read: (state, field) => state.keys.keyA.properties[field], + readPublic: state => state.keys.keyA.properties.fingerPrint, + }, + { + name: 'APP', + fields: ['priv'], + buildState: (field, value) => ({ + identity: {livenet: {[field]: value, pub: 'public-value'}}, + }), + encrypt: state => encryptAppStore(state, secretKey), + decrypt: state => decryptAppStore(state, secretKey), + read: (state, field) => state.identity.livenet[field], + readPublic: state => state.identity.livenet.pub, + }, + { + name: 'SHOP', + fields: [ + 'accessKey', + 'barcodeData', + 'barcodeImage', + 'claimCode', + 'claimLink', + 'pin', + ], + buildState: (field, value) => ({ + giftCards: { + livenet: [{[field]: value, displayName: 'public-value'}], + }, + }), + encrypt: state => encryptShopStore(state, secretKey), + decrypt: state => decryptShopStore(state, secretKey), + read: (state, field) => state.giftCards.livenet[0][field], + readPublic: state => state.giftCards.livenet[0].displayName, + }, + ]; + + describe.each(protectedStoreCases)('$name protected fields', storeCase => { + const firstField = storeCase.fields[0]; + + it.each(storeCase.fields)('rejects plaintext in %s', field => { + expect(() => + storeCase.decrypt(storeCase.buildState(field, 'attacker-controlled')), + ).toThrow(field); + }); + + it('rejects a non-string value', () => { + expect(() => + storeCase.decrypt(storeCase.buildState(firstField, {injected: true})), + ).toThrow('Expected encrypted protected value'); + }); + + it.each(storeCase.fields)( + 'accepts legacy CBC and modern GCM in %s without changing public fields', + field => { + const legacyState = storeCase.decrypt( + storeCase.buildState(field, cryptoJs319FieldFixture), + ); + expect(storeCase.read(legacyState, field)).toBe('legacy mnemonic'); + + const plaintext = `${storeCase.name}-secret`; + const modernState = storeCase.encrypt( + storeCase.buildState(field, plaintext), + ); + expect(storeCase.read(modernState, field)).toMatch(/^field-aesgcm-v1:/); + const decrypted = storeCase.decrypt(modernState); + expect(storeCase.read(decrypted, field)).toBe(plaintext); + expect(storeCase.readPublic(decrypted)).toBe('public-value'); + }, + ); + + it.each([undefined, null, ''])('allows an absent value (%p)', value => { + const state = storeCase.buildState(firstField, value); + expect(storeCase.read(storeCase.decrypt(state), firstField)).toBe(value); + }); + }); + + it('does not include rejected plaintext in the error', () => { + const plaintext = 'attacker-controlled-secret'; + let capturedError: Error | undefined; + + try { + protectedStoreCases[0].decrypt( + protectedStoreCases[0].buildState('mnemonic', plaintext), + ); + } catch (err) { + capturedError = err as Error; + } + + expect(capturedError).toBeDefined(); + expect(capturedError!.message).not.toContain(plaintext); + }); +}); + +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 a fixed reducer CBC produced by CryptoJS 3.1.9-1', () => { + expect( + decryptPersistValue(cryptoJs319PersistFixture, 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(); + }); + + it.each([false, true])( + 'rejects non-string reducers instead of bypassing decryption (plain JSON: %s)', + allowPlainJson => { + expect(() => + deserializePersistValue( + {token: 'injected'}, + secretKey, + context, + allowPlainJson, + ), + ).toThrow('to be a string'); + }, + ); + + it('reads production JSON only for reducers configured as plaintext', () => { + expect( + deserializePersistValue(JSON.stringify(state), secretKey, context, true), + ).toEqual(state); + }); +}); diff --git a/src/store/transforms/encrypt.ts b/src/store/transforms/encrypt.ts index d346f51a3b..de90b45306 100644 --- a/src/store/transforms/encrypt.ts +++ b/src/store/transforms/encrypt.ts @@ -1,49 +1,237 @@ +import crypto from 'crypto'; import Aes from 'crypto-js/aes.js'; import CryptoJsCore from 'crypto-js/core.js'; import {Network} from '../../constants'; const encryptedPrefix = 'encrypted:'; +const modernEncryptedPrefix = 'field-aesgcm-v1:'; +const persistEncryptedPrefix = 'persist-aesgcm-v1:'; -export const encryptValue = (value: any, secretKey: string): string => { - // Skip encryption for already encrypted values - if (typeof value === 'string' && value.startsWith(encryptedPrefix)) { - return value; +const aesGcmIvBytes = 12; +const aesGcmTagBytes = 16; +const defaultFieldContext = 'field'; +const defaultPersistContext = 'persist'; + +const isModernEncryptedValue = (value: string) => + value.startsWith(modernEncryptedPrefix); + +const isLegacyEncryptedValue = (value: string) => + value.startsWith(encryptedPrefix); + +const isEncryptedValue = (value: string) => + isModernEncryptedValue(value) || isLegacyEncryptedValue(value); + +const hasProtectedValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== ''; + +const buildAesKey = (secretKey: string): Buffer => { + return crypto.createHash('sha256').update(secretKey).digest(); +}; + +const decodeCanonicalBase64 = (value: string, label: string): Buffer => { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + throw new Error(`Invalid ${label} encoding`); + } + + const decoded = Buffer.from(value, 'base64'); + if (decoded.toString('base64') !== value) { + throw new Error(`Invalid ${label} encoding`); + } + return decoded; +}; + +const parseEncryptedPayload = ( + value: string, + prefix: string, +): {iv: Buffer; tag: Buffer; payload: Buffer} => { + const chunks = value.slice(prefix.length).split('.'); + if (chunks.length !== 3) { + throw new Error('Invalid encrypted payload format'); + } + + const iv = decodeCanonicalBase64(chunks[0], 'IV'); + const tag = decodeCanonicalBase64(chunks[1], 'authentication tag'); + const payload = decodeCanonicalBase64(chunks[2], 'ciphertext'); + + if (iv.length !== aesGcmIvBytes || tag.length !== aesGcmTagBytes) { + throw new Error('Invalid encrypted payload dimensions'); + } + + return {iv, tag, payload}; +}; + +const serializeEncryptedPayload = ( + iv: Buffer, + tag: Buffer, + payload: Buffer, +) => { + return `${iv.toString('base64')}.${tag.toString('base64')}.${payload.toString( + 'base64', + )}`; +}; + +const serializePersistPayload = (iv: Buffer, tag: Buffer, payload: Buffer) => { + return `${persistEncryptedPrefix}${serializeEncryptedPayload( + iv, + tag, + payload, + )}`; +}; + +const encryptWithAesGcm = ( + value: string, + secretKey: string, + context: string, +): {iv: Buffer; tag: Buffer; payload: Buffer} => { + const iv = crypto.randomBytes(aesGcmIvBytes); + const key = buildAesKey(secretKey); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(context, 'utf8')); + const payload = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return {iv, tag, payload}; +}; + +const decryptWithAesGcm = ( + value: string, + secretKey: string, + prefix: string, + context?: string, +): string => { + const {iv, tag, payload} = parseEncryptedPayload(value, prefix); + const key = buildAesKey(secretKey); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + if (context) { + decipher.setAAD(Buffer.from(context, 'utf8')); } + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(payload), decipher.final()]).toString( + 'utf8', + ); +}; + +const decryptLegacy = (value: string, secretKey: string): string => { + const encryptedText = value.startsWith(encryptedPrefix) + ? value.slice(encryptedPrefix.length) + : value; + return Aes.decrypt(encryptedText, secretKey).toString(CryptoJsCore.enc.Utf8); +}; + +const tryDecryptPersistWithLegacy = ( + value: string, + secretKey: string, +): string => { + const decoded = Aes.decrypt(value, secretKey).toString(CryptoJsCore.enc.Utf8); + if (!decoded) { + throw new Error('Decrypted value is empty'); + } + return decoded; +}; - try { - const encrypted = Aes.encrypt(String(value), secretKey).toString(); - const result = `${encryptedPrefix}${encrypted}`; - return result; - } catch (err) { +export const encryptValue = ( + value: any, + secretKey: string, + context = defaultFieldContext, +): string => { + if (typeof value === 'string' && isEncryptedValue(value)) { return value; } + + const {iv, tag, payload} = encryptWithAesGcm( + String(value), + secretKey, + context, + ); + return `${modernEncryptedPrefix}${serializeEncryptedPayload( + iv, + tag, + payload, + )}`; }; -export const decryptValue = (value: any, secretKey: string): any => { - // Skip decryption for non-encrypted values - if (typeof value !== 'string' || !value.startsWith(encryptedPrefix)) { +export const decryptValue = ( + value: any, + secretKey: string, + context = defaultFieldContext, +): any => { + if (!hasProtectedValue(value)) { return value; } - try { - const encryptedText = value.replace(encryptedPrefix, ''); - const result = Aes.decrypt(encryptedText, secretKey).toString( - CryptoJsCore.enc.Utf8, + + if (typeof value !== 'string' || !isEncryptedValue(value)) { + throw new Error(`Expected encrypted protected value at ${context}`); + } + + if (value.startsWith(modernEncryptedPrefix)) { + return decryptWithAesGcm(value, secretKey, modernEncryptedPrefix, context); + } + + const legacy = decryptLegacy(value, secretKey); + if (!legacy) { + throw new Error('Decrypted string is empty'); + } + return legacy; +}; + +export const encryptPersistValue = ( + value: any, + secretKey: string, + context = defaultPersistContext, +): string => { + const serialized = JSON.stringify(value); + + if (typeof serialized === 'undefined') { + return serialized as unknown as string; + } + + const {iv, tag, payload} = encryptWithAesGcm(serialized, secretKey, context); + return serializePersistPayload(iv, tag, payload); +}; + +export const decryptPersistValue = ( + value: string, + secretKey: string, + context = defaultPersistContext, +): any => { + if (value.startsWith(persistEncryptedPrefix)) { + return JSON.parse( + decryptWithAesGcm(value, secretKey, persistEncryptedPrefix, context), ); - if (!result) { - throw new Error('Decrypted string is empty'); - } - return result; - } catch (err) { - return value; } + + const legacy = tryDecryptPersistWithLegacy(value, secretKey); + return JSON.parse(legacy); +}; + +export const deserializePersistValue = ( + value: unknown, + secretKey: string, + context: string, + allowPlainJson: boolean, +): any => { + if (typeof value !== 'string') { + throw new Error('Expected persisted reducer to be a string'); + } + + if (allowPlainJson) { + try { + return JSON.parse(value); + } catch {} + } + + return decryptPersistValue(value, secretKey, context); }; // Generic function to transform wallet store (encrypt or decrypt) const transformWalletStore = ( state: any, secretKey: string, - transformer: (value: any, secretKey: string) => any, - checkCondition: (value: string) => boolean, + transformer: (value: any, secretKey: string, context: string) => any, + checkCondition: (value: any) => boolean, ): any => { if (!state || !state.keys) { return state; @@ -69,8 +257,12 @@ const transformWalletStore = ( const updatedProperties = fieldsToTransform.reduce( (latestProperties, field) => { const value = properties[field]; - if (value && typeof value === 'string' && checkCondition(value)) { - latestProperties[field] = transformer(value, secretKey); + if (hasProtectedValue(value) && checkCondition(value)) { + latestProperties[field] = transformer( + value, + secretKey, + `WALLET.keys.${keyId}.properties.${field}`, + ); } return latestProperties; }, @@ -93,41 +285,43 @@ export const encryptWalletStore = (state: any, secretKey: string): any => { state, secretKey, encryptValue, - value => !value.startsWith(encryptedPrefix), + value => typeof value === 'string' && !isEncryptedValue(value), ); }; export const decryptWalletStore = (state: any, secretKey: string): any => { - return transformWalletStore(state, secretKey, decryptValue, value => - value.startsWith(encryptedPrefix), - ); + return transformWalletStore(state, secretKey, decryptValue, () => true); }; // Generic function to transform app store (encrypt or decrypt) const transformAppStore = ( state: any, secretKey: string, - transformer: (value: any, secretKey: string) => any, - checkCondition: (value: string) => boolean, + transformer: (value: any, secretKey: string, context: string) => any, + checkCondition: (value: any) => boolean, ): any => { if (!state || !state.identity) { return state; } const identity = state.identity[Network.mainnet]; - if (!identity || !identity.priv) { + if (!identity) { return state; } const privValue = identity.priv; - if (privValue && typeof privValue === 'string' && checkCondition(privValue)) { + if (hasProtectedValue(privValue) && checkCondition(privValue)) { return { ...state, identity: { ...state.identity, [Network.mainnet]: { ...identity, - priv: transformer(privValue, secretKey), + priv: transformer( + privValue, + secretKey, + `APP.identity.${Network.mainnet}.priv`, + ), }, }, }; @@ -140,22 +334,20 @@ export const encryptAppStore = (state: any, secretKey: string): any => { state, secretKey, encryptValue, - value => !value.startsWith(encryptedPrefix), + value => typeof value === 'string' && !isEncryptedValue(value), ); }; export const decryptAppStore = (state: any, secretKey: string): any => { - return transformAppStore(state, secretKey, decryptValue, value => - value.startsWith(encryptedPrefix), - ); + return transformAppStore(state, secretKey, decryptValue, () => true); }; // Generic function to transform shop store (encrypt or decrypt) const transformShopStore = ( state: any, secretKey: string, - transformer: (value: any, secretKey: string) => any, - checkCondition: (value: string) => boolean, + transformer: (value: any, secretKey: string, context: string) => any, + checkCondition: (value: any) => boolean, ): any => { if (!state || !state.giftCards || !state.giftCards[Network.mainnet]) { return state; @@ -176,12 +368,16 @@ const transformShopStore = ( ]; // Transform each gift card in mainnet - const newGiftCards = giftCards.map((card: any) => { + const newGiftCards = giftCards.map((card: any, cardIndex: number) => { const updatedCard = {...card}; fieldsToTransform.forEach(field => { const value = card[field]; - if (value && typeof value === 'string' && checkCondition(value)) { - updatedCard[field] = transformer(value, secretKey); + if (hasProtectedValue(value) && checkCondition(value)) { + updatedCard[field] = transformer( + value, + secretKey, + `SHOP.giftCards.${Network.mainnet}.${cardIndex}.${field}`, + ); } }); // Always set invoice to undefined for persisted state @@ -203,12 +399,10 @@ export const encryptShopStore = (state: any, secretKey: string): any => { state, secretKey, encryptValue, - value => !value.startsWith(encryptedPrefix), + value => typeof value === 'string' && !isEncryptedValue(value), ); }; export const decryptShopStore = (state: any, secretKey: string): any => { - return transformShopStore(state, secretKey, decryptValue, value => - value.startsWith(encryptedPrefix), - ); + return transformShopStore(state, secretKey, decryptValue, () => true); }; diff --git a/src/store/transforms/transforms.spec.ts b/src/store/transforms/transforms.spec.ts index 96769e820f..9e94aa5728 100644 --- a/src/store/transforms/transforms.spec.ts +++ b/src/store/transforms/transforms.spec.ts @@ -582,22 +582,21 @@ describe('encryptSpecificFields', () => { expect(result).toBe(state); }); - it('inbound: handles encrypt error by calling logTransformFailure', () => { + it('inbound: reports and rethrows encryption errors', () => { (encryptWalletStore as jest.Mock).mockImplementationOnce(() => { throw new Error('encrypt failed'); }); const {inFn} = getTransform(); const state: any = {keys: {}}; - // Should not throw — the try/catch inside swallows it - expect(() => inFn(state, 'WALLET')).not.toThrow(); + expect(() => inFn(state, 'WALLET')).toThrow('encrypt failed'); }); - it('outbound: handles decrypt error by calling logTransformFailure', () => { + it('outbound: reports and rethrows decryption errors', () => { (decryptWalletStore as jest.Mock).mockImplementationOnce(() => { throw new Error('decrypt failed'); }); const {outFn} = getTransform(); const state: any = {keys: {}}; - expect(() => outFn(state, 'WALLET')).not.toThrow(); + expect(() => outFn(state, 'WALLET')).toThrow('decrypt failed'); }); }); diff --git a/src/store/transforms/transforms.ts b/src/store/transforms/transforms.ts index a8b923123e..43911a9886 100644 --- a/src/store/transforms/transforms.ts +++ b/src/store/transforms/transforms.ts @@ -243,6 +243,7 @@ export const encryptSpecificFields = (secretKey: string) => { return encryptWalletStore(inboundState, secretKey); } catch (error) { logTransformFailure('encrypt', 'Wallet', error); + throw error; } } if (key === 'APP') { @@ -250,6 +251,7 @@ export const encryptSpecificFields = (secretKey: string) => { return encryptAppStore(inboundState, secretKey); } catch (error) { logTransformFailure('encrypt', 'App', error); + throw error; } } if (key === 'SHOP') { @@ -257,6 +259,7 @@ export const encryptSpecificFields = (secretKey: string) => { return encryptShopStore(inboundState, secretKey); } catch (error) { logTransformFailure('encrypt', 'Shop', error); + throw error; } } return inboundState; @@ -268,6 +271,7 @@ export const encryptSpecificFields = (secretKey: string) => { return decryptWalletStore(outboundState, secretKey); } catch (error) { logTransformFailure('decrypt', 'Wallet', error); + throw error; } } if (key === 'APP') { @@ -275,6 +279,7 @@ export const encryptSpecificFields = (secretKey: string) => { return decryptAppStore(outboundState, secretKey); } catch (error) { logTransformFailure('decrypt', 'App', error); + throw error; } } if (key === 'SHOP') { @@ -282,6 +287,7 @@ export const encryptSpecificFields = (secretKey: string) => { return decryptShopStore(outboundState, secretKey); } catch (error) { logTransformFailure('decrypt', 'Shop', error); + throw error; } } return outboundState; diff --git a/yarn.lock b/yarn.lock index 553996ace9..04793fed95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7928,10 +7928,10 @@ crossws@^0.3.4: dependencies: uncrypto "^0.1.3" -crypto-js@3.1.9-1: - version "3.1.9-1" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" - integrity sha512-W93aKztssqf29OvUlqfikzGyYbD1rpkXvGP9IQ1JchLY3bxaLXZSWYbwrtib2vk8DobrDzX7PIXcDWHp0B6Ymw== +crypto-js@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== css-color-keywords@^1.0.0: version "1.0.0" @@ -14966,14 +14966,6 @@ redux-logger@3.0.6: dependencies: deep-diff "^0.3.5" -redux-persist-transform-encrypt@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/redux-persist-transform-encrypt/-/redux-persist-transform-encrypt-3.0.1.tgz#d9428a649a6eefa69f88f61ed9d846f4b8ea0d5b" - integrity sha512-09cgNeTnCTzTjMqmbNty+7wPQeQ5YLnKilbVyeKc/YeTvR0vHGo5hnal3+hiQiJMnYRS/qLkII2jhxSfb5Lw6Q== - dependencies: - crypto-js "3.1.9-1" - json-stringify-safe "^5.0.1" - redux-persist@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"