Skip to content

Commit 8b0c201

Browse files
committed
Encryption: Fix - reject plaintext in protected fields
1 parent adda4c3 commit 8b0c201

2 files changed

Lines changed: 149 additions & 20 deletions

File tree

src/store/transforms/encrypt.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import crypto from 'crypto';
22
import Aes from 'crypto-js/aes.js';
33
import {
4+
decryptAppStore,
45
decryptPersistValue,
56
deserializePersistValue,
7+
decryptShopStore,
68
decryptValue,
79
decryptWalletStore,
10+
encryptAppStore,
811
encryptPersistValue,
12+
encryptShopStore,
913
encryptValue,
1014
encryptWalletStore,
1115
} from './encrypt';
@@ -67,6 +71,130 @@ describe('encrypted field values', () => {
6771
};
6872
expect(() => decryptWalletStore(swapped, secretKey)).toThrow();
6973
});
74+
75+
type ProtectedStoreCase = {
76+
name: string;
77+
fields: string[];
78+
buildState: (field: string, value: unknown) => any;
79+
encrypt: (state: any) => any;
80+
decrypt: (state: any) => any;
81+
read: (state: any, field: string) => unknown;
82+
readPublic: (state: any) => unknown;
83+
};
84+
85+
const protectedStoreCases: ProtectedStoreCase[] = [
86+
{
87+
name: 'WALLET',
88+
fields: [
89+
'mnemonic',
90+
'mnemonicEncrypted',
91+
'xPrivKey',
92+
'xPrivKeyEncrypted',
93+
'xPrivKeyEDDSA',
94+
'xPrivKeyEDDSAEncrypted',
95+
],
96+
buildState: (field, value) => ({
97+
keys: {
98+
keyA: {properties: {[field]: value, fingerPrint: 'public-value'}},
99+
},
100+
}),
101+
encrypt: state => encryptWalletStore(state, secretKey),
102+
decrypt: state => decryptWalletStore(state, secretKey),
103+
read: (state, field) => state.keys.keyA.properties[field],
104+
readPublic: state => state.keys.keyA.properties.fingerPrint,
105+
},
106+
{
107+
name: 'APP',
108+
fields: ['priv'],
109+
buildState: (field, value) => ({
110+
identity: {livenet: {[field]: value, pub: 'public-value'}},
111+
}),
112+
encrypt: state => encryptAppStore(state, secretKey),
113+
decrypt: state => decryptAppStore(state, secretKey),
114+
read: (state, field) => state.identity.livenet[field],
115+
readPublic: state => state.identity.livenet.pub,
116+
},
117+
{
118+
name: 'SHOP',
119+
fields: [
120+
'accessKey',
121+
'barcodeData',
122+
'barcodeImage',
123+
'claimCode',
124+
'claimLink',
125+
'pin',
126+
],
127+
buildState: (field, value) => ({
128+
giftCards: {
129+
livenet: [{[field]: value, displayName: 'public-value'}],
130+
},
131+
}),
132+
encrypt: state => encryptShopStore(state, secretKey),
133+
decrypt: state => decryptShopStore(state, secretKey),
134+
read: (state, field) => state.giftCards.livenet[0][field],
135+
readPublic: state => state.giftCards.livenet[0].displayName,
136+
},
137+
];
138+
139+
describe.each(protectedStoreCases)('$name protected fields', storeCase => {
140+
const firstField = storeCase.fields[0];
141+
142+
it.each(storeCase.fields)('rejects plaintext in %s', field => {
143+
expect(() =>
144+
storeCase.decrypt(storeCase.buildState(field, 'attacker-controlled')),
145+
).toThrow(field);
146+
});
147+
148+
it('rejects a non-string value', () => {
149+
expect(() =>
150+
storeCase.decrypt(storeCase.buildState(firstField, {injected: true})),
151+
).toThrow('Expected encrypted protected value');
152+
});
153+
154+
it.each(storeCase.fields)(
155+
'accepts legacy CBC and modern GCM in %s without changing public fields',
156+
field => {
157+
const plaintext = `${storeCase.name}-secret`;
158+
const legacy = `encrypted:${Aes.encrypt(
159+
plaintext,
160+
secretKey,
161+
).toString()}`;
162+
const legacyState = storeCase.decrypt(
163+
storeCase.buildState(field, legacy),
164+
);
165+
expect(storeCase.read(legacyState, field)).toBe(plaintext);
166+
167+
const modernState = storeCase.encrypt(
168+
storeCase.buildState(field, plaintext),
169+
);
170+
expect(storeCase.read(modernState, field)).toMatch(/^field-aesgcm-v1:/);
171+
const decrypted = storeCase.decrypt(modernState);
172+
expect(storeCase.read(decrypted, field)).toBe(plaintext);
173+
expect(storeCase.readPublic(decrypted)).toBe('public-value');
174+
},
175+
);
176+
177+
it.each([undefined, null, ''])('allows an absent value (%p)', value => {
178+
const state = storeCase.buildState(firstField, value);
179+
expect(storeCase.read(storeCase.decrypt(state), firstField)).toBe(value);
180+
});
181+
});
182+
183+
it('does not include rejected plaintext in the error', () => {
184+
const plaintext = 'attacker-controlled-secret';
185+
let capturedError: Error | undefined;
186+
187+
try {
188+
protectedStoreCases[0].decrypt(
189+
protectedStoreCases[0].buildState('mnemonic', plaintext),
190+
);
191+
} catch (err) {
192+
capturedError = err as Error;
193+
}
194+
195+
expect(capturedError).toBeDefined();
196+
expect(capturedError!.message).not.toContain(plaintext);
197+
});
70198
});
71199

72200
describe('persisted reducer values', () => {

src/store/transforms/encrypt.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const isLegacyEncryptedValue = (value: string) =>
2121
const isEncryptedValue = (value: string) =>
2222
isModernEncryptedValue(value) || isLegacyEncryptedValue(value);
2323

24+
const hasProtectedValue = (value: unknown): boolean =>
25+
value !== undefined && value !== null && value !== '';
26+
2427
const buildAesKey = (secretKey: string): Buffer => {
2528
return crypto.createHash('sha256').update(secretKey).digest();
2629
};
@@ -155,10 +158,14 @@ export const decryptValue = (
155158
secretKey: string,
156159
context = defaultFieldContext,
157160
): any => {
158-
if (typeof value !== 'string' || !isEncryptedValue(value)) {
161+
if (!hasProtectedValue(value)) {
159162
return value;
160163
}
161164

165+
if (typeof value !== 'string' || !isEncryptedValue(value)) {
166+
throw new Error(`Expected encrypted protected value at ${context}`);
167+
}
168+
162169
if (value.startsWith(modernEncryptedPrefix)) {
163170
return decryptWithAesGcm(value, secretKey, modernEncryptedPrefix, context);
164171
}
@@ -224,7 +231,7 @@ const transformWalletStore = (
224231
state: any,
225232
secretKey: string,
226233
transformer: (value: any, secretKey: string, context: string) => any,
227-
checkCondition: (value: string) => boolean,
234+
checkCondition: (value: any) => boolean,
228235
): any => {
229236
if (!state || !state.keys) {
230237
return state;
@@ -250,7 +257,7 @@ const transformWalletStore = (
250257
const updatedProperties = fieldsToTransform.reduce(
251258
(latestProperties, field) => {
252259
const value = properties[field];
253-
if (value && typeof value === 'string' && checkCondition(value)) {
260+
if (hasProtectedValue(value) && checkCondition(value)) {
254261
latestProperties[field] = transformer(
255262
value,
256263
secretKey,
@@ -278,34 +285,32 @@ export const encryptWalletStore = (state: any, secretKey: string): any => {
278285
state,
279286
secretKey,
280287
encryptValue,
281-
value => !isEncryptedValue(value),
288+
value => typeof value === 'string' && !isEncryptedValue(value),
282289
);
283290
};
284291

285292
export const decryptWalletStore = (state: any, secretKey: string): any => {
286-
return transformWalletStore(state, secretKey, decryptValue, value =>
287-
isEncryptedValue(value),
288-
);
293+
return transformWalletStore(state, secretKey, decryptValue, () => true);
289294
};
290295

291296
// Generic function to transform app store (encrypt or decrypt)
292297
const transformAppStore = (
293298
state: any,
294299
secretKey: string,
295300
transformer: (value: any, secretKey: string, context: string) => any,
296-
checkCondition: (value: string) => boolean,
301+
checkCondition: (value: any) => boolean,
297302
): any => {
298303
if (!state || !state.identity) {
299304
return state;
300305
}
301306

302307
const identity = state.identity[Network.mainnet];
303-
if (!identity || !identity.priv) {
308+
if (!identity) {
304309
return state;
305310
}
306311

307312
const privValue = identity.priv;
308-
if (privValue && typeof privValue === 'string' && checkCondition(privValue)) {
313+
if (hasProtectedValue(privValue) && checkCondition(privValue)) {
309314
return {
310315
...state,
311316
identity: {
@@ -329,22 +334,20 @@ export const encryptAppStore = (state: any, secretKey: string): any => {
329334
state,
330335
secretKey,
331336
encryptValue,
332-
value => !isEncryptedValue(value),
337+
value => typeof value === 'string' && !isEncryptedValue(value),
333338
);
334339
};
335340

336341
export const decryptAppStore = (state: any, secretKey: string): any => {
337-
return transformAppStore(state, secretKey, decryptValue, value =>
338-
isEncryptedValue(value),
339-
);
342+
return transformAppStore(state, secretKey, decryptValue, () => true);
340343
};
341344

342345
// Generic function to transform shop store (encrypt or decrypt)
343346
const transformShopStore = (
344347
state: any,
345348
secretKey: string,
346349
transformer: (value: any, secretKey: string, context: string) => any,
347-
checkCondition: (value: string) => boolean,
350+
checkCondition: (value: any) => boolean,
348351
): any => {
349352
if (!state || !state.giftCards || !state.giftCards[Network.mainnet]) {
350353
return state;
@@ -369,7 +372,7 @@ const transformShopStore = (
369372
const updatedCard = {...card};
370373
fieldsToTransform.forEach(field => {
371374
const value = card[field];
372-
if (value && typeof value === 'string' && checkCondition(value)) {
375+
if (hasProtectedValue(value) && checkCondition(value)) {
373376
updatedCard[field] = transformer(
374377
value,
375378
secretKey,
@@ -396,12 +399,10 @@ export const encryptShopStore = (state: any, secretKey: string): any => {
396399
state,
397400
secretKey,
398401
encryptValue,
399-
value => !isEncryptedValue(value),
402+
value => typeof value === 'string' && !isEncryptedValue(value),
400403
);
401404
};
402405

403406
export const decryptShopStore = (state: any, secretKey: string): any => {
404-
return transformShopStore(state, secretKey, decryptValue, value =>
405-
isEncryptedValue(value),
406-
);
407+
return transformShopStore(state, secretKey, decryptValue, () => true);
407408
};

0 commit comments

Comments
 (0)