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
3 changes: 3 additions & 0 deletions modules/sdk-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
"secrets.js-grempe": "^1.1.0",
"superagent": "^9.0.1"
},
"devDependencies": {
"crypto-browserify": "^3.12.0"
},
"overrides": {
"degenerator": "5.0.0"
},
Expand Down
3 changes: 2 additions & 1 deletion modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,8 @@ export class BitGoAPI implements BitGoBase {
} catch (error) {
if (
error.message.includes("ccm: tag doesn't match") ||
error.message.includes('The operation failed for an operation-specific reason')
error.message.includes('The operation failed for an operation-specific reason') ||
error.message.includes('Unsupported state or unable to authenticate data')
) {
throw new Error('incorrect password');
}
Expand Down
119 changes: 119 additions & 0 deletions modules/sdk-api/src/decryptV1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
import { createDecipheriv, pbkdf2 } from 'crypto';
import * as t from 'io-ts';
import { promisify } from 'util';

/**
* Minimal shape the decrypt path needs from a crypto module. Both `node:crypto`
* and `crypto-browserify` satisfy this. Passing this in from tests lets the
* browser-shim test suite exercise the real decrypt code instead of a copy.
*/
export interface CryptoModule {
pbkdf2: typeof pbkdf2;
createDecipheriv: typeof createDecipheriv;
}

const defaultCrypto: CryptoModule = { pbkdf2, createDecipheriv };

/**
* Upper bound on PBKDF2 iterations accepted from a v1 envelope. BitGo-produced
* 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;

/**
* io-ts codec for a v1 (SJCL) envelope.
*
* Enforces the shape and the `iter` cap up front, before any KDF work runs.
*/
const V1EnvelopeCodec = t.intersection([
t.type({
v: t.literal(1),
iter: boundedInt(1, V1_MAX_ITER, 'iter'),
ks: t.union([t.literal(128), t.literal(256)]),
ts: t.union([t.literal(64), t.literal(96), t.literal(128)]),
mode: t.literal('ccm'),
cipher: t.literal('aes'),
salt: base64String,
iv: base64String,
ct: base64String,
}),
t.partial({
adata: t.string,
}),
]);

export type V1Envelope = t.TypeOf<typeof V1EnvelopeCodec>;

export function parseV1Envelope(ciphertext: string): V1Envelope {
let parsed: unknown;
try {
parsed = JSON.parse(ciphertext);
} catch {
throw new Error('v1 decrypt: invalid JSON envelope');
}
return decodeWithCodec(V1EnvelopeCodec, parsed, 'v1 decrypt: invalid envelope');
}

/**
* CCM length field size L, in bytes, chosen to encode the plaintext length.
*
* SJCL picks the smallest L in [2, 4) that can represent the plaintext length,
* then derives the nonce length as (15 - L). We mirror that so Node's CCM
* uses the same nonce framing as the SJCL encoder produced.
*/
function ccmNonceLength(plaintextLen: number): number {
let L = 2;
while (L < 4 && plaintextLen >= Math.pow(2, 8 * L)) L++;
return 15 - L;
}

/**
* Decrypt a parsed v1 envelope given a crypto module.
*
* v1 = PBKDF2-SHA256(password, salt, iter, keyLen) then AES-CCM(key, nonce, ct||tag).
* Byte-for-byte compatible with `sjcl.decrypt` output for the same envelope.
*
* Exported so tests can inject `crypto-browserify` and exercise the exact
* runtime path the webpack browser bundle produces, without duplicating the
* decrypt logic.
*/
export async function decryptV1WithCrypto(password: string, ciphertext: string, crypto: CryptoModule): Promise<string> {
const env = parseV1Envelope(ciphertext);
const salt = Buffer.from(env.salt, 'base64');
const ivFull = Buffer.from(env.iv, 'base64');
const full = Buffer.from(env.ct, 'base64');
const tagBytes = env.ts / 8;
if (full.length < tagBytes) throw new Error('v1 decrypt: ciphertext shorter than tag');

const cipher = full.subarray(0, full.length - tagBytes);
const authTag = full.subarray(full.length - tagBytes);
const nonceLen = ccmNonceLength(cipher.length);
if (ivFull.length < nonceLen) throw new Error('v1 decrypt: iv shorter than nonce');
const iv = ivFull.subarray(0, nonceLen);

const keyBytes = env.ks / 8;
const key: Buffer = await promisify(crypto.pbkdf2)(password, salt, env.iter, keyBytes, 'sha256');

const decipher = crypto.createDecipheriv(`aes-${env.ks}-ccm`, key, iv, { authTagLength: tagBytes });
decipher.setAuthTag(authTag);
const aad = env.adata ? Buffer.from(env.adata, 'utf8') : Buffer.alloc(0);
decipher.setAAD(aad, { plaintextLength: cipher.length });

const pt = Buffer.concat([decipher.update(cipher), decipher.final()]);
return pt.toString('utf8');
}

/**
* Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope.
*
* Runs the same `node:crypto` code on server and browser. The BitGoJS webpack
* config already maps `crypto` -> `crypto-browserify`, whose `aes-256-ccm` and
* `pbkdf2` implementations are byte-compatible with Node's native ones and
* with SJCL's envelope format. Parity is guarded by tests in
* `test/unit/decryptV1.browser.ts`.
*/
export async function decryptV1(password: string, ciphertext: string): Promise<string> {
return decryptV1WithCrypto(password, ciphertext, defaultCrypto);
}
59 changes: 53 additions & 6 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 { decryptV1 } from './decryptV1';
import { decryptV2, encryptV2 } from './encryptV2';

/**
Expand Down Expand Up @@ -65,15 +66,61 @@ export async function encrypt(
}

/**
* Internal v1 (SJCL) decrypt helper. Not part of the public surface: callers use
* the auto-detecting `decrypt` instead.
* Iter-cap violations are the only error we refuse to fall back on: SJCL has
* no upper bound on `iter`, so falling through to it would let a hostile
* envelope burn CPU running an inflated PBKDF2. Everything else -- codec
* rejection of a shape SJCL would accept, native crypto bug, auth-tag
* mismatch -- is safe to fall through to SJCL.
*/
function decryptV1(password: string, ciphertext: string): string {
return sjcl.decrypt(password, ciphertext);
function isIterCapViolation(err: unknown): boolean {
return err instanceof Error && /iter:\s*expected integer|iter out of range/i.test(err.message);
}

/**
* Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt.
* v1 decrypt with an SJCL safety net.
*
* Design intent during rollout: zero false negatives. Any native failure
* (envelope shape our stricter codec rejects, framing bug, unsupported
* algorithm, auth-tag mismatch, etc.) falls through to `sjcl.decrypt` so the
* caller is never blocked. The only exception is an iter-cap violation,
* which is rethrown to preserve DoS protection.
*
* The console.warn only fires when native fails AND SJCL succeeds -- i.e.
* when the two engines disagree, which is the only signal worth
* investigating. Wrong password fails both engines silently and surfaces
* SJCL's auth error (mapped upstream to "incorrect password").
*
* `native` defaults to the module's `decryptV1` but is exposed as a parameter
* so tests can inject a throwing version to exercise the fallback path.
*/
export async function decryptV1WithFallback(
password: string,
ciphertext: string,
native: (pw: string, ct: string) => Promise<string> = decryptV1
): Promise<string> {
try {
return await native(password, ciphertext);
} catch (nativeErr) {
if (isIterCapViolation(nativeErr)) throw nativeErr;
let result: string;
try {
result = sjcl.decrypt(password, ciphertext);
} catch (sjclErr) {
// Both engines rejected -- almost certainly a real auth failure.
// Rethrow SJCL's error so BitGoAPI.decrypt maps it to "incorrect password".
throw sjclErr;
}
// Native failed but SJCL succeeded -- real signal, log for the operator.
const message = nativeErr instanceof Error ? nativeErr.message : String(nativeErr);
// eslint-disable-next-line no-console
console.warn('[bitgo-sdk] v1 native decrypt failed; SJCL fallback succeeded:', message);
return result;
}
Comment thread
danielpeng1 marked this conversation as resolved.
}

/**
* Auto-detect v1 (PBKDF2-SHA256 + AES-CCM) or v2 (Argon2id + AES-256-GCM)
* from the envelope `v` field and decrypt.
*/
export async function decrypt(password: string, ciphertext: string): Promise<string> {
let envelopeVersion: number | undefined;
Expand All @@ -90,5 +137,5 @@ export async function decrypt(password: string, ciphertext: string): Promise<str
if (envelopeVersion !== undefined && envelopeVersion !== 1) {
throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`);
}
return decryptV1(password, ciphertext);
return decryptV1WithFallback(password, ciphertext);
}
1 change: 1 addition & 0 deletions modules/sdk-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './api';
export * from './bitgoAPI';
export * from './decryptV1';
export * from './encrypt';
export * from './encryptionSession';
export * from './encryptV2';
Expand Down
108 changes: 108 additions & 0 deletions modules/sdk-api/test/unit/decryptV1.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as sjcl from '@bitgo/sjcl';
import assert from 'assert';

import { decryptV1WithCrypto, V1_MAX_ITER } from '../../src';
import { KEYCARD_BOX_A, KEYCARD_BOX_B, KEYCARD_PASSWORD, KEYCARD_PLAINTEXT_PREFIX } from './fixtures/keycard';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const browserCrypto = require('crypto-browserify');

/**
* sjcl.encrypt's typings require salt/iv, but the runtime picks them from
* sjcl.random when omitted. Feed real random words so the call type-checks
* without an `as` cast.
*/
function sjclEncrypt(password: string, plaintext: string, params: sjcl.SjclCipherParams): string {
const salt = sjcl.random.randomWords(2);
const iv = sjcl.random.randomWords(4);
return sjcl.encrypt(password, plaintext, { ...params, salt, iv });
}

/**
* Exercises the real `decryptV1WithCrypto` code path with `crypto-browserify`
* injected as the crypto module. This is exactly what webpack bundles for the
* browser (its `crypto` shim), so a green test here proves the browser build
* stays byte-compatible with SJCL-produced envelopes and the Node path.
*/
function decryptV1Browser(password: string, ciphertext: string): Promise<string> {
return decryptV1WithCrypto(password, ciphertext, browserCrypto);
}

describe('decryptV1 browser path (crypto-browserify shim)', () => {
const password = 'myPassword';
const plaintext = 'Hello, Browser!';

it('produces the same plaintext as sjcl.decrypt', async () => {
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(password, ciphertext), sjcl.decrypt(password, ciphertext));
});

it('handles adata (AAD)', async () => {
const ciphertext = sjclEncrypt(password, plaintext, {
iter: 10000,
ks: 256,
ts: 64,
mode: 'ccm',
adata: 'ctx-A',
});
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
});

it('handles UTF-8 password + plaintext', async () => {
const utf8Password = 'pässwörd中文🔐';
const utf8Plaintext = 'passphrase: 秘密キー ☃🔑';
const ciphertext = sjclEncrypt(utf8Password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(utf8Password, ciphertext), utf8Plaintext);
});

it('handles large plaintext (>64 KiB, forces L=3 nonce framing)', async () => {
const large = 'x'.repeat(70_000);
const ciphertext = sjclEncrypt(password, large, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(password, ciphertext), large);
});

it('handles aes-128 envelopes', async () => {
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 128, ts: 64, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
});

it('handles 128-bit tag envelopes', async () => {
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 128, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
});

it('rejects wrong password', async () => {
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
await assert.rejects(() => decryptV1Browser('wrongPassword', ciphertext));
});

it('rejects envelope with iter above cap before running PBKDF2', async () => {
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
const envelope = JSON.parse(ciphertext);
envelope.iter = V1_MAX_ITER + 1;
const start = Date.now();
await assert.rejects(() => decryptV1Browser(password, JSON.stringify(envelope)), /iter/);
assert.ok(Date.now() - start < 100, 'must reject before any KDF work');
});

it('parity across 50 randomised inputs', async () => {
const { randomBytes } = await import('crypto');
for (let i = 0; i < 50; i++) {
const pw = randomBytes(16).toString('hex');
const pt = randomBytes(1 + Math.floor(Math.random() * 500)).toString('base64');
const ciphertext = sjclEncrypt(pw, pt, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' });
assert.strictEqual(await decryptV1Browser(pw, ciphertext), pt, `iteration ${i}`);
}
});

it('Box A + Box B: shim decrypt matches SJCL byte-for-byte', async () => {
for (const [label, ct] of [
['A', KEYCARD_BOX_A],
['B', KEYCARD_BOX_B],
] as const) {
const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, ct);
const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, ct);
assert.strictEqual(shimResult, sjclResult, `Box ${label} mismatch`);
assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX));
}
});
});
Loading
Loading