Skip to content

Commit adda4c3

Browse files
committed
Encryption: Fix - prevent data loss during encryption migration
1 parent a0b5384 commit adda4c3

8 files changed

Lines changed: 220 additions & 29 deletions

File tree

index.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const ReduxProvider = () => {
146146
}, [isPrimary]);
147147

148148
const [storeReady, setStoreReady] = useState(false);
149+
const [startupAttempt, setStartupAttempt] = useState(0);
149150
const [{store: reduxStore, persistor: reduxPersistor}, setStore] = useState({
150151
store: null,
151152
persistor: null,
@@ -158,22 +159,45 @@ const ReduxProvider = () => {
158159

159160
let cancelled = false;
160161

161-
getStore().then(({store, persistor}) => {
162-
if (cancelled) {
163-
return;
164-
}
162+
getStore()
163+
.then(({store, persistor}) => {
164+
if (cancelled) {
165+
persistor.pause();
166+
return;
167+
}
165168

166-
setStore({store, persistor});
167-
setStoreReady(true);
168-
setJSExceptionHandler(makeErrorHandler(store), true);
169-
// executeDefaultHandler=true chains to Sentry's UncaughtExceptionHandler so native crashes are captured
170-
setNativeExceptionHandler(makeNativeExceptionHandler(store), true, true);
171-
});
169+
setStore({store, persistor});
170+
setStoreReady(true);
171+
setJSExceptionHandler(makeErrorHandler(store), true);
172+
// executeDefaultHandler=true chains to Sentry's UncaughtExceptionHandler so native crashes are captured
173+
setNativeExceptionHandler(
174+
makeNativeExceptionHandler(store),
175+
true,
176+
true,
177+
);
178+
})
179+
.catch(error => {
180+
if (cancelled) {
181+
return;
182+
}
183+
Sentry.captureException(error, {level: 'error'});
184+
Alert.alert(
185+
'Wallet data could not be opened',
186+
'Your local data was preserved. Please try again.',
187+
[
188+
{
189+
text: 'Retry',
190+
onPress: () => setStartupAttempt(attempt => attempt + 1),
191+
},
192+
],
193+
{cancelable: false},
194+
);
195+
});
172196

173197
return () => {
174198
cancelled = true;
175199
};
176-
}, [isPrimary]);
200+
}, [isPrimary, startupAttempt]);
177201

178202
if (!isPrimary || !storeReady) {
179203
return null;

metro.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const ALIASES = {
2222
crypto: require.resolve('react-native-quick-crypto'),
2323
bitauth: path.resolve(
2424
__dirname,
25-
'node_modules/bitauth/lib/bitauth-browserify.js',
25+
'node_modules/bitauth/lib/bitauth-node.js',
2626
),
2727
};
2828

src/store/bitauth.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
jest.mock('secp256k1', () => jest.requireActual('secp256k1/elliptic'));
2+
3+
const BitAuth = require('bitauth/lib/bitauth-node');
4+
const metroConfig = require('../../metro.config');
5+
6+
describe('BitAuth Metro implementation', () => {
7+
it.each(['ios', 'android'])(
8+
'aliases bitauth to the CBC-free production signer on %s',
9+
platform => {
10+
const resolveRequest = jest.fn(
11+
(_context: unknown, moduleName: string) => moduleName,
12+
);
13+
14+
metroConfig.resolver.resolveRequest(
15+
{resolveRequest},
16+
'bitauth',
17+
platform,
18+
);
19+
20+
expect(resolveRequest).toHaveBeenCalledWith(
21+
expect.any(Object),
22+
expect.stringMatching(/bitauth\/lib\/bitauth-node\.js$/),
23+
platform,
24+
);
25+
},
26+
);
27+
28+
it('preserves signing without exporting the CBC helpers', () => {
29+
const privateKey =
30+
'0000000000000000000000000000000000000000000000000000000000000001';
31+
const publicKey = BitAuth.getPublicKeyFromPrivateKey(privateKey);
32+
const signature = BitAuth.sign('bitpay-test-vector', privateKey);
33+
34+
expect(publicKey).toBe(
35+
'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
36+
);
37+
expect(signature.toString('hex')).toBe(
38+
'304402205807d12cfc30686cca25b7e7e5e3154875e10e13ce0dd26f5b502d42bea7b83402204832b41c3bc3713511b48cd27ac661d9102357330b4c66f5112af592c3aa2b29',
39+
);
40+
expect(
41+
BitAuth.verifySignature('bitpay-test-vector', publicKey, signature),
42+
).toBe(true);
43+
expect(BitAuth.encrypt).toBeUndefined();
44+
expect(BitAuth.decrypt).toBeUndefined();
45+
});
46+
});

src/store/index.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
transformPortfolioPopulateStatus,
2929
encryptSpecificFields,
3030
} from './transforms/transforms';
31-
import {decryptPersistValue, encryptPersistValue} from './transforms/encrypt';
31+
import {
32+
deserializePersistValue,
33+
encryptPersistValue,
34+
} from './transforms/encrypt';
35+
import {createRehydrationFailureMiddleware} from './persistence-guard';
3236
import {appReducer, appReduxPersistBlackList} from './app/app.reducer';
3337
import {
3438
bitPayIdReducer,
@@ -421,8 +425,15 @@ const logger = createLogger({
421425
});
422426

423427
const getStore = async () => {
428+
let rehydrationFailure: Error | null = null;
424429
const middlewares: Middleware[] = [thunkMiddleware as unknown as Middleware];
425430

431+
middlewares.push(
432+
createRehydrationFailureMiddleware(error => {
433+
rehydrationFailure ??= error;
434+
}),
435+
);
436+
426437
const cleanupPortfolioOnDeleteKeyMiddleware: Middleware = store => next => {
427438
return (action: AnyAction) => {
428439
if (action?.type !== WalletActionTypes.DELETE_KEY) {
@@ -515,25 +526,12 @@ const getStore = async () => {
515526
);
516527
},
517528
(outboundState, key) => {
518-
if (typeof key === 'string' && unencryptedPersistStores.has(key)) {
519-
if (typeof outboundState === 'string') {
520-
try {
521-
return JSON.parse(outboundState);
522-
} catch {}
523-
} else {
524-
return outboundState;
525-
}
526-
}
527-
528-
if (typeof outboundState !== 'string') {
529-
return outboundState;
530-
}
531-
532529
try {
533-
return decryptPersistValue(
530+
return deserializePersistValue(
534531
outboundState,
535532
secretKey,
536533
`persist:${String(key)}`,
534+
typeof key === 'string' && unencryptedPersistStores.has(key),
537535
);
538536
} catch (err) {
539537
const errStr =
@@ -620,7 +618,18 @@ const getStore = async () => {
620618
storeDispatch(LogActions.clear());
621619
initLogs.drainAndDispatch(storeDispatch);
622620

623-
const persistor = persistStore(store);
621+
let resolveBootstrap: () => void = () => {};
622+
const bootstrapped = new Promise<void>(resolve => {
623+
resolveBootstrap = resolve;
624+
});
625+
const persistor = persistStore(store, undefined, resolveBootstrap);
626+
627+
await bootstrapped;
628+
if (rehydrationFailure) {
629+
persistor.pause();
630+
Sentry.captureException(rehydrationFailure, {level: 'error'});
631+
throw rehydrationFailure;
632+
}
624633

625634
if (__DEV__) {
626635
// persistor.purge().then(() => console.log('purged persistence'));
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {applyMiddleware, createStore, Middleware} from 'redux';
2+
import {createTransform, persistReducer, persistStore} from 'redux-persist';
3+
import {createRehydrationFailureMiddleware} from './persistence-guard';
4+
5+
describe('rehydration failure protection', () => {
6+
it('pauses persistence before a decrypt error can overwrite the original root', async () => {
7+
const originalRoot = JSON.stringify({
8+
TEST: JSON.stringify('corrupted-ciphertext'),
9+
});
10+
let storedRoot = originalRoot;
11+
const storage = {
12+
getItem: jest.fn(async () => storedRoot),
13+
setItem: jest.fn(async (_key: string, value: string) => {
14+
storedRoot = value;
15+
}),
16+
removeItem: jest.fn(async () => undefined),
17+
};
18+
const transform = createTransform<any, any>(
19+
state => state,
20+
() => {
21+
throw new Error('decrypt failed');
22+
},
23+
);
24+
const onFailure = jest.fn();
25+
const persistedReducer = persistReducer(
26+
{key: 'root', storage, transforms: [transform], timeout: 0},
27+
(state = {TEST: 'initial'}, action) =>
28+
action.type === 'CHANGE' ? {TEST: 'changed'} : state,
29+
);
30+
const store = createStore(
31+
persistedReducer,
32+
applyMiddleware(
33+
createRehydrationFailureMiddleware(onFailure) as Middleware,
34+
),
35+
);
36+
37+
let persistor: ReturnType<typeof persistStore> | undefined;
38+
await new Promise<void>(resolve => {
39+
persistor = persistStore(store, undefined, resolve);
40+
});
41+
42+
store.dispatch({type: 'CHANGE'});
43+
await persistor!.flush();
44+
45+
expect(onFailure).toHaveBeenCalledWith(expect.any(Error));
46+
expect(storage.setItem).not.toHaveBeenCalled();
47+
expect(storedRoot).toBe(originalRoot);
48+
expect(store.getState().TEST).toBe('changed');
49+
});
50+
});

src/store/persistence-guard.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {AnyAction, Middleware} from 'redux';
2+
import {PAUSE, REHYDRATE} from 'redux-persist';
3+
4+
const toError = (error: unknown): Error =>
5+
error instanceof Error ? error : new Error(String(error));
6+
7+
export const createRehydrationFailureMiddleware =
8+
(onFailure: (error: Error) => void): Middleware =>
9+
store =>
10+
next =>
11+
(action: AnyAction) => {
12+
if (
13+
action.type === REHYDRATE &&
14+
action.key === 'root' &&
15+
action.err != null
16+
) {
17+
const error = toError(action.err);
18+
store.dispatch({type: PAUSE});
19+
onFailure(error);
20+
}
21+
return next(action);
22+
};

src/store/transforms/encrypt.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import crypto from 'crypto';
22
import Aes from 'crypto-js/aes.js';
33
import {
44
decryptPersistValue,
5+
deserializePersistValue,
56
decryptValue,
67
decryptWalletStore,
78
encryptPersistValue,
@@ -118,4 +119,24 @@ describe('persisted reducer values', () => {
118119
);
119120
randomBytes.mockRestore();
120121
});
122+
123+
it.each([false, true])(
124+
'rejects non-string reducers instead of bypassing decryption (plain JSON: %s)',
125+
allowPlainJson => {
126+
expect(() =>
127+
deserializePersistValue(
128+
{token: 'injected'},
129+
secretKey,
130+
context,
131+
allowPlainJson,
132+
),
133+
).toThrow('to be a string');
134+
},
135+
);
136+
137+
it('reads production JSON only for reducers configured as plaintext', () => {
138+
expect(
139+
deserializePersistValue(JSON.stringify(state), secretKey, context, true),
140+
).toEqual(state);
141+
});
121142
});

src/store/transforms/encrypt.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,25 @@ export const decryptPersistValue = (
200200
return JSON.parse(legacy);
201201
};
202202

203+
export const deserializePersistValue = (
204+
value: unknown,
205+
secretKey: string,
206+
context: string,
207+
allowPlainJson: boolean,
208+
): any => {
209+
if (typeof value !== 'string') {
210+
throw new Error('Expected persisted reducer to be a string');
211+
}
212+
213+
if (allowPlainJson) {
214+
try {
215+
return JSON.parse(value);
216+
} catch {}
217+
}
218+
219+
return decryptPersistValue(value, secretKey, context);
220+
};
221+
203222
// Generic function to transform wallet store (encrypt or decrypt)
204223
const transformWalletStore = (
205224
state: any,

0 commit comments

Comments
 (0)