Skip to content
Merged
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: 2 additions & 2 deletions modules/sdk-api/src/decryptV1.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
import { base64String, boundedInt, decodeWithCodec, LEGACY_V1_MAX_ITER } from '@bitgo/sdk-core';
import { createDecipheriv, pbkdf2 } from 'crypto';
import * as t from 'io-ts';
import { promisify } from 'util';
Expand All @@ -8,7 +8,7 @@ import { promisify } from 'util';
* v1 envelopes use 10,000; this cap is 10x that. Envelope validation enforces
* it up front before any KDF work runs.
*/
export const V1_MAX_ITER = 100_000;
export const V1_MAX_ITER = LEGACY_V1_MAX_ITER;

/**
* io-ts codec for a v1 (SJCL) envelope.
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-api/src/encrypt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as sjcl from '@bitgo/sjcl';
import { randomBytes } from 'crypto';

import { assertSafeLegacyV1Envelope } from '@bitgo/sdk-core';
import { decryptV1, parseV1Envelope } from './decryptV1';
import { decryptV2, encryptV2 } from './encryptV2';

Expand Down Expand Up @@ -117,6 +118,7 @@ export async function decryptV1WithFallback(
native: (pw: string, ct: string) => Promise<string> = decryptV1,
isBrowser: boolean = isBrowserRuntime()
): Promise<string> {
assertSafeLegacyV1Envelope(ciphertext);
if (isBrowser) {
try {
parseV1Envelope(ciphertext);
Expand Down
47 changes: 47 additions & 0 deletions modules/sdk-api/test/unit/decryptV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,52 @@ describe('decryptV1 (native, SJCL-free)', () => {
const ct = await encrypt(password, plaintext, { encryptionVersion: 1 });
await assert.rejects(() => decryptV1WithFallback('wrongPassword', ct, decryptV1, true));
});

it('rejects a non-ccm mode before SJCL runs', async () => {
const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 }));
envelope.mode = 'ocb2';
await assert.rejects(
() => decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, true),
/unsupported mode/
);
});

it('rejects a hostile iter or mode hidden by whitespace in the key', async () => {
const ct = await encrypt(password, plaintext, { encryptionVersion: 1 });
const spacedIter = ct.replace(/"iter":\d+/, '"it er":3000000');
const spacedMode = ct.replace('"mode":"ccm"', '"mo de":"ocb2"');
assert.notStrictEqual(spacedIter, ct);
assert.notStrictEqual(spacedMode, ct);
for (const isBrowser of [true, false]) {
const start = Date.now();
await assert.rejects(
() => decryptV1WithFallback(password, spacedIter, decryptV1, isBrowser),
/iter out of range/
);
await assert.rejects(
() => decryptV1WithFallback(password, spacedMode, decryptV1, isBrowser),
/unsupported mode/
);
assert.ok(Date.now() - start < 2000, 'must not run the inflated PBKDF2');
}
});

it('still decrypts a legacy envelope without a v field', async () => {
const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 }));
delete envelope.v;
const result = await decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, true);
assert.strictEqual(result, plaintext);
});
});

describe('cipher mode screen (node runtime)', () => {
it('rejects a non-ccm mode instead of falling back to SJCL', async () => {
const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 }));
envelope.mode = 'ocb2';
await assert.rejects(
() => decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, false),
/unsupported mode/
);
});
});
});
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export * from './notEmpty';
export * from './wallet';
export * from './messageTypes';
export * from './siws';
export * from './legacyV1Envelope';

export { openpgpUtils };
39 changes: 39 additions & 0 deletions modules/sdk-core/src/bitgo/utils/legacyV1Envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as sjcl from '@bitgo/sjcl';

/**
* Upper bound on PBKDF2 iterations accepted from a legacy v1 (SJCL) envelope.
* BitGo only ever wrote 10,000; this cap is 10x that.
*/
export const LEGACY_V1_MAX_ITER = 100_000;

/**
* Refuse hostile legacy v1 envelopes before SJCL sees them. SJCL reads `iter` and `mode`
* from the envelope and has no upper bound on the iteration count, so a tampered envelope
* could stall the caller with a huge PBKDF2 run or select the weak OCB2 mode. Absent
* `iter` and `mode` take SJCL's defaults.
*
* The check reads the envelope with SJCL's own decoder, not JSON.parse. SJCL strips all
* whitespace before decoding, so a key such as "it er" is `iter` to SJCL but unknown to
* JSON.parse. Input SJCL cannot decode is left for `sjcl.decrypt` to report.
*
* @param ciphertext serialized v1 envelope
* @throws if `iter` is not an integer between 1 and LEGACY_V1_MAX_ITER, or `mode` is not `ccm`
*/
export function assertSafeLegacyV1Envelope(ciphertext: string): void {
let envelope: { iter?: unknown; mode?: unknown };
try {
envelope = sjcl.json.decode(ciphertext);
} catch {
return;
}
const { iter, mode } = envelope;
if (
iter !== undefined &&
!(Number.isInteger(iter) && (iter as number) >= 1 && (iter as number) <= LEGACY_V1_MAX_ITER)
Comment thread
pranavjain97 marked this conversation as resolved.
Resolved
) {
throw new Error(`v1 envelope: iter out of range, expected an integer between 1 and ${LEGACY_V1_MAX_ITER}`);
}
if (mode !== undefined && mode !== 'ccm') {
throw new Error('v1 envelope: unsupported mode, expected ccm');
}
}
16 changes: 13 additions & 3 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '../../../tss/eddsa/eddsaMPCv2';
import { getInitializedMpcInstance } from '../../../tss/eddsa/eddsa';
import { generateGPGKeyPair } from '../../opengpgUtils';
import { assertSafeLegacyV1Envelope } from '../../legacyV1Envelope';
import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2';
import {
CustomEddsaMPCv2SigningRound1GeneratingFunction,
Expand Down Expand Up @@ -1381,6 +1382,15 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
// #endregion
}

/**
* v1-only decrypt used when no `bitgo` instance is available. Hostile envelopes are refused
* before SJCL runs.
*/
function decryptLegacyV1(walletPassphrase: string | undefined, ciphertext: string): string {
assertSafeLegacyV1Envelope(ciphertext);
return sjcl.decrypt(walletPassphrase, ciphertext);
}

/**
* Detect whether an encrypted keycard contains MPCv1 signing material.
*
Expand Down Expand Up @@ -1417,7 +1427,7 @@ export async function isEddsaMpcV1SigningMaterial(
): Promise<boolean> {
const decrypted = bitgo
? await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase })
: sjcl.decrypt(walletPassphrase, encryptedKeyShare);
: decryptLegacyV1(walletPassphrase, encryptedKeyShare);
return parseEddsaMpcV1Material(decrypted) !== null;
}

Expand Down Expand Up @@ -1446,7 +1456,7 @@ export async function getEddsaMpcV2RecoveryKeySharesFromReducedKey(
): Promise<{ reduced: MPSTypes.EddsaReducedKeyShare; vrfKeyShare?: Buffer }> => {
const decrypted = bitgo
? await bitgo.decrypt({ input: encryptedKey, password: walletPassphrase })
: sjcl.decrypt(walletPassphrase, encryptedKey);
: decryptLegacyV1(walletPassphrase, encryptedKey);
const parsed = parseMpcV2KeyShareEnvelope(decrypted);
let reduced: MPSTypes.EddsaReducedKeyShare;
try {
Expand Down Expand Up @@ -1566,7 +1576,7 @@ export async function getEddsaSigningMaterial(
try {
decrypted = bitgo
? await bitgo.decrypt({ input: normalized, password: walletPassphrase })
: sjcl.decrypt(walletPassphrase, normalized);
: decryptLegacyV1(walletPassphrase, normalized);
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
}
Expand Down
60 changes: 60 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getEddsaSigningMaterial,
isEddsaSigningMaterial,
signEddsaMpcV2RecoveryTx,
assertSafeLegacyV1Envelope,
} from '../../../../../../src';
import {
getSignatureShareRoundOne,
Expand Down Expand Up @@ -393,6 +394,65 @@ describe('EdDSA MPS DSG helper functions', async () => {
});
});

describe('legacy v1 decrypt without a bitgo instance', () => {
const PASSPHRASE = 'test-passphrase';
const tamper = (envelope: Record<string, unknown>, change: Record<string, unknown>): string =>
JSON.stringify({ ...envelope, ...change });
const valid = JSON.parse(sjcl.encrypt(PASSPHRASE, 'plaintext'));

it('rejects an iter above the cap before running PBKDF2', async () => {
const tampered = tamper(valid, { iter: 100_001 });
await assert.rejects(EDDSAUtils.isEddsaMpcV1SigningMaterial(tampered, PASSPHRASE), /iter out of range/);
await assert.rejects(EDDSAUtils.getEddsaSigningMaterial(tampered, PASSPHRASE), /iter out of range/);
await assert.rejects(
EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(tampered, tampered, PASSPHRASE),
/iter out of range/
);
});

it('rejects an iter that is not an integer within range', async () => {
for (const iter of ['10000', 0]) {
await assert.rejects(
EDDSAUtils.isEddsaMpcV1SigningMaterial(tamper(valid, { iter }), PASSPHRASE),
/iter out of range/,
`iter ${JSON.stringify(iter)} must be rejected`
);
}

// SJCL's decoder only accepts non-negative integers, so these never reach PBKDF2.
for (const iter of [1.5, -1]) {
await assert.rejects(EDDSAUtils.isEddsaMpcV1SigningMaterial(tamper(valid, { iter }), PASSPHRASE));
}
});

it('rejects a non-ccm mode', async () => {
await assert.rejects(
EDDSAUtils.isEddsaMpcV1SigningMaterial(tamper(valid, { mode: 'ocb2' }), PASSPHRASE),
/unsupported mode/
);
});

it('screens the envelope SJCL decodes, not the one JSON.parse sees', () => {
// SJCL strips all whitespace before decoding, so these keys are iter and mode to SJCL.
const base = JSON.stringify(valid);
const spacedIter = base.replace(/"iter":\d+/, '"it er":3000000');
const spacedMode = base.replace('"mode":"ccm"', '"mo de":"ocb2"');
assert.strictEqual(JSON.parse(spacedIter).iter, undefined);
assert.throws(() => assertSafeLegacyV1Envelope(spacedIter), /iter out of range/);
assert.throws(() => assertSafeLegacyV1Envelope(spacedMode), /unsupported mode/);
});

it('leaves input SJCL cannot decode for sjcl.decrypt to report', () => {
for (const input of ['not json', 'null', '"a string"', '42']) {
assert.doesNotThrow(() => assertSafeLegacyV1Envelope(input), `input ${input} must pass through`);
}
});

it('still decrypts an envelope with the default iter and mode', async () => {
assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(JSON.stringify(valid), PASSPHRASE), false);
});
});

describe('getEddsaMPCv2RecoveryKeyShares', () => {
const walletPassphrase = 'testPass';

Expand Down
Loading