diff --git a/modules/sdk-api/src/decryptV1.ts b/modules/sdk-api/src/decryptV1.ts index 24c3dc86b0f..06f316e30d4 100644 --- a/modules/sdk-api/src/decryptV1.ts +++ b/modules/sdk-api/src/decryptV1.ts @@ -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'; @@ -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. diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index f635711b8f6..a882075e6d6 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -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'; @@ -117,6 +118,7 @@ export async function decryptV1WithFallback( native: (pw: string, ct: string) => Promise = decryptV1, isBrowser: boolean = isBrowserRuntime() ): Promise { + assertSafeLegacyV1Envelope(ciphertext); if (isBrowser) { try { parseV1Envelope(ciphertext); diff --git a/modules/sdk-api/test/unit/decryptV1.ts b/modules/sdk-api/test/unit/decryptV1.ts index a5b8a5917db..2f2ae5e499f 100644 --- a/modules/sdk-api/test/unit/decryptV1.ts +++ b/modules/sdk-api/test/unit/decryptV1.ts @@ -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/ + ); + }); }); }); diff --git a/modules/sdk-core/src/bitgo/utils/index.ts b/modules/sdk-core/src/bitgo/utils/index.ts index 0a4f026543b..f4716aa5e1a 100644 --- a/modules/sdk-core/src/bitgo/utils/index.ts +++ b/modules/sdk-core/src/bitgo/utils/index.ts @@ -13,5 +13,6 @@ export * from './notEmpty'; export * from './wallet'; export * from './messageTypes'; export * from './siws'; +export * from './legacyV1Envelope'; export { openpgpUtils }; diff --git a/modules/sdk-core/src/bitgo/utils/legacyV1Envelope.ts b/modules/sdk-core/src/bitgo/utils/legacyV1Envelope.ts new file mode 100644 index 00000000000..8ce18fa0008 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/legacyV1Envelope.ts @@ -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) + ) { + 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'); + } +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 411a5802877..15c441139ba 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -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, @@ -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. * @@ -1417,7 +1427,7 @@ export async function isEddsaMpcV1SigningMaterial( ): Promise { const decrypted = bitgo ? await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase }) - : sjcl.decrypt(walletPassphrase, encryptedKeyShare); + : decryptLegacyV1(walletPassphrase, encryptedKeyShare); return parseEddsaMpcV1Material(decrypted) !== null; } @@ -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 { @@ -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)}`); } diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index dd46d159e32..a5ff5c3879e 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -32,6 +32,7 @@ import { getEddsaSigningMaterial, isEddsaSigningMaterial, signEddsaMpcV2RecoveryTx, + assertSafeLegacyV1Envelope, } from '../../../../../../src'; import { getSignatureShareRoundOne, @@ -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, change: Record): 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';