Skip to content

Commit 654a92d

Browse files
authored
Merge pull request #9508 from BitGo/pranavjain/wcn-43-remove-sjcl-decrypt-pr1
feat(sdk-api): SJCL-free v1 decrypt with temp fallback
2 parents 91db748 + 49c9cbb commit 654a92d

8 files changed

Lines changed: 505 additions & 7 deletions

File tree

modules/sdk-api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@
5858
"secrets.js-grempe": "^1.1.0",
5959
"superagent": "^9.0.1"
6060
},
61+
"devDependencies": {
62+
"crypto-browserify": "^3.12.0"
63+
},
6164
"overrides": {
6265
"degenerator": "5.0.0"
6366
},

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,8 @@ export class BitGoAPI implements BitGoBase {
874874
} catch (error) {
875875
if (
876876
error.message.includes("ccm: tag doesn't match") ||
877-
error.message.includes('The operation failed for an operation-specific reason')
877+
error.message.includes('The operation failed for an operation-specific reason') ||
878+
error.message.includes('Unsupported state or unable to authenticate data')
878879
) {
879880
throw new Error('incorrect password');
880881
}

modules/sdk-api/src/decryptV1.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
2+
import { createDecipheriv, pbkdf2 } from 'crypto';
3+
import * as t from 'io-ts';
4+
import { promisify } from 'util';
5+
6+
/**
7+
* Minimal shape the decrypt path needs from a crypto module. Both `node:crypto`
8+
* and `crypto-browserify` satisfy this. Passing this in from tests lets the
9+
* browser-shim test suite exercise the real decrypt code instead of a copy.
10+
*/
11+
export interface CryptoModule {
12+
pbkdf2: typeof pbkdf2;
13+
createDecipheriv: typeof createDecipheriv;
14+
}
15+
16+
const defaultCrypto: CryptoModule = { pbkdf2, createDecipheriv };
17+
18+
/**
19+
* Upper bound on PBKDF2 iterations accepted from a v1 envelope. BitGo-produced
20+
* v1 envelopes use 10,000; this cap is 10x that. Envelope validation enforces
21+
* it up front before any KDF work runs.
22+
*/
23+
export const V1_MAX_ITER = 100_000;
24+
25+
/**
26+
* io-ts codec for a v1 (SJCL) envelope.
27+
*
28+
* Enforces the shape and the `iter` cap up front, before any KDF work runs.
29+
*/
30+
const V1EnvelopeCodec = t.intersection([
31+
t.type({
32+
v: t.literal(1),
33+
iter: boundedInt(1, V1_MAX_ITER, 'iter'),
34+
ks: t.union([t.literal(128), t.literal(256)]),
35+
ts: t.union([t.literal(64), t.literal(96), t.literal(128)]),
36+
mode: t.literal('ccm'),
37+
cipher: t.literal('aes'),
38+
salt: base64String,
39+
iv: base64String,
40+
ct: base64String,
41+
}),
42+
t.partial({
43+
adata: t.string,
44+
}),
45+
]);
46+
47+
export type V1Envelope = t.TypeOf<typeof V1EnvelopeCodec>;
48+
49+
export function parseV1Envelope(ciphertext: string): V1Envelope {
50+
let parsed: unknown;
51+
try {
52+
parsed = JSON.parse(ciphertext);
53+
} catch {
54+
throw new Error('v1 decrypt: invalid JSON envelope');
55+
}
56+
return decodeWithCodec(V1EnvelopeCodec, parsed, 'v1 decrypt: invalid envelope');
57+
}
58+
59+
/**
60+
* CCM length field size L, in bytes, chosen to encode the plaintext length.
61+
*
62+
* SJCL picks the smallest L in [2, 4) that can represent the plaintext length,
63+
* then derives the nonce length as (15 - L). We mirror that so Node's CCM
64+
* uses the same nonce framing as the SJCL encoder produced.
65+
*/
66+
function ccmNonceLength(plaintextLen: number): number {
67+
let L = 2;
68+
while (L < 4 && plaintextLen >= Math.pow(2, 8 * L)) L++;
69+
return 15 - L;
70+
}
71+
72+
/**
73+
* Decrypt a parsed v1 envelope given a crypto module.
74+
*
75+
* v1 = PBKDF2-SHA256(password, salt, iter, keyLen) then AES-CCM(key, nonce, ct||tag).
76+
* Byte-for-byte compatible with `sjcl.decrypt` output for the same envelope.
77+
*
78+
* Exported so tests can inject `crypto-browserify` and exercise the exact
79+
* runtime path the webpack browser bundle produces, without duplicating the
80+
* decrypt logic.
81+
*/
82+
export async function decryptV1WithCrypto(password: string, ciphertext: string, crypto: CryptoModule): Promise<string> {
83+
const env = parseV1Envelope(ciphertext);
84+
const salt = Buffer.from(env.salt, 'base64');
85+
const ivFull = Buffer.from(env.iv, 'base64');
86+
const full = Buffer.from(env.ct, 'base64');
87+
const tagBytes = env.ts / 8;
88+
if (full.length < tagBytes) throw new Error('v1 decrypt: ciphertext shorter than tag');
89+
90+
const cipher = full.subarray(0, full.length - tagBytes);
91+
const authTag = full.subarray(full.length - tagBytes);
92+
const nonceLen = ccmNonceLength(cipher.length);
93+
if (ivFull.length < nonceLen) throw new Error('v1 decrypt: iv shorter than nonce');
94+
const iv = ivFull.subarray(0, nonceLen);
95+
96+
const keyBytes = env.ks / 8;
97+
const key: Buffer = await promisify(crypto.pbkdf2)(password, salt, env.iter, keyBytes, 'sha256');
98+
99+
const decipher = crypto.createDecipheriv(`aes-${env.ks}-ccm`, key, iv, { authTagLength: tagBytes });
100+
decipher.setAuthTag(authTag);
101+
const aad = env.adata ? Buffer.from(env.adata, 'utf8') : Buffer.alloc(0);
102+
decipher.setAAD(aad, { plaintextLength: cipher.length });
103+
104+
const pt = Buffer.concat([decipher.update(cipher), decipher.final()]);
105+
return pt.toString('utf8');
106+
}
107+
108+
/**
109+
* Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope.
110+
*
111+
* Runs the same `node:crypto` code on server and browser. The BitGoJS webpack
112+
* config already maps `crypto` -> `crypto-browserify`, whose `aes-256-ccm` and
113+
* `pbkdf2` implementations are byte-compatible with Node's native ones and
114+
* with SJCL's envelope format. Parity is guarded by tests in
115+
* `test/unit/decryptV1.browser.ts`.
116+
*/
117+
export async function decryptV1(password: string, ciphertext: string): Promise<string> {
118+
return decryptV1WithCrypto(password, ciphertext, defaultCrypto);
119+
}

modules/sdk-api/src/encrypt.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as sjcl from '@bitgo/sjcl';
22
import { randomBytes } from 'crypto';
33

4+
import { decryptV1 } from './decryptV1';
45
import { decryptV2, encryptV2 } from './encryptV2';
56

67
/**
@@ -65,15 +66,61 @@ export async function encrypt(
6566
}
6667

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

7579
/**
76-
* Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt.
80+
* v1 decrypt with an SJCL safety net.
81+
*
82+
* Design intent during rollout: zero false negatives. Any native failure
83+
* (envelope shape our stricter codec rejects, framing bug, unsupported
84+
* algorithm, auth-tag mismatch, etc.) falls through to `sjcl.decrypt` so the
85+
* caller is never blocked. The only exception is an iter-cap violation,
86+
* which is rethrown to preserve DoS protection.
87+
*
88+
* The console.warn only fires when native fails AND SJCL succeeds -- i.e.
89+
* when the two engines disagree, which is the only signal worth
90+
* investigating. Wrong password fails both engines silently and surfaces
91+
* SJCL's auth error (mapped upstream to "incorrect password").
92+
*
93+
* `native` defaults to the module's `decryptV1` but is exposed as a parameter
94+
* so tests can inject a throwing version to exercise the fallback path.
95+
*/
96+
export async function decryptV1WithFallback(
97+
password: string,
98+
ciphertext: string,
99+
native: (pw: string, ct: string) => Promise<string> = decryptV1
100+
): Promise<string> {
101+
try {
102+
return await native(password, ciphertext);
103+
} catch (nativeErr) {
104+
if (isIterCapViolation(nativeErr)) throw nativeErr;
105+
let result: string;
106+
try {
107+
result = sjcl.decrypt(password, ciphertext);
108+
} catch (sjclErr) {
109+
// Both engines rejected -- almost certainly a real auth failure.
110+
// Rethrow SJCL's error so BitGoAPI.decrypt maps it to "incorrect password".
111+
throw sjclErr;
112+
}
113+
// Native failed but SJCL succeeded -- real signal, log for the operator.
114+
const message = nativeErr instanceof Error ? nativeErr.message : String(nativeErr);
115+
// eslint-disable-next-line no-console
116+
console.warn('[bitgo-sdk] v1 native decrypt failed; SJCL fallback succeeded:', message);
117+
return result;
118+
}
119+
}
120+
121+
/**
122+
* Auto-detect v1 (PBKDF2-SHA256 + AES-CCM) or v2 (Argon2id + AES-256-GCM)
123+
* from the envelope `v` field and decrypt.
77124
*/
78125
export async function decrypt(password: string, ciphertext: string): Promise<string> {
79126
let envelopeVersion: number | undefined;
@@ -90,5 +137,5 @@ export async function decrypt(password: string, ciphertext: string): Promise<str
90137
if (envelopeVersion !== undefined && envelopeVersion !== 1) {
91138
throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`);
92139
}
93-
return decryptV1(password, ciphertext);
140+
return decryptV1WithFallback(password, ciphertext);
94141
}

modules/sdk-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './api';
22
export * from './bitgoAPI';
3+
export * from './decryptV1';
34
export * from './encrypt';
45
export * from './encryptionSession';
56
export * from './encryptV2';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import * as sjcl from '@bitgo/sjcl';
2+
import assert from 'assert';
3+
4+
import { decryptV1WithCrypto, V1_MAX_ITER } from '../../src';
5+
import { KEYCARD_BOX_A, KEYCARD_BOX_B, KEYCARD_PASSWORD, KEYCARD_PLAINTEXT_PREFIX } from './fixtures/keycard';
6+
// eslint-disable-next-line @typescript-eslint/no-var-requires
7+
const browserCrypto = require('crypto-browserify');
8+
9+
/**
10+
* sjcl.encrypt's typings require salt/iv, but the runtime picks them from
11+
* sjcl.random when omitted. Feed real random words so the call type-checks
12+
* without an `as` cast.
13+
*/
14+
function sjclEncrypt(password: string, plaintext: string, params: sjcl.SjclCipherParams): string {
15+
const salt = sjcl.random.randomWords(2);
16+
const iv = sjcl.random.randomWords(4);
17+
return sjcl.encrypt(password, plaintext, { ...params, salt, iv });
18+
}
19+
20+
/**
21+
* Exercises the real `decryptV1WithCrypto` code path with `crypto-browserify`
22+
* injected as the crypto module. This is exactly what webpack bundles for the
23+
* browser (its `crypto` shim), so a green test here proves the browser build
24+
* stays byte-compatible with SJCL-produced envelopes and the Node path.
25+
*/
26+
function decryptV1Browser(password: string, ciphertext: string): Promise<string> {
27+
return decryptV1WithCrypto(password, ciphertext, browserCrypto);
28+
}
29+
30+
describe('decryptV1 browser path (crypto-browserify shim)', () => {
31+
const password = 'myPassword';
32+
const plaintext = 'Hello, Browser!';
33+
34+
it('produces the same plaintext as sjcl.decrypt', async () => {
35+
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
36+
assert.strictEqual(await decryptV1Browser(password, ciphertext), sjcl.decrypt(password, ciphertext));
37+
});
38+
39+
it('handles adata (AAD)', async () => {
40+
const ciphertext = sjclEncrypt(password, plaintext, {
41+
iter: 10000,
42+
ks: 256,
43+
ts: 64,
44+
mode: 'ccm',
45+
adata: 'ctx-A',
46+
});
47+
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
48+
});
49+
50+
it('handles UTF-8 password + plaintext', async () => {
51+
const utf8Password = 'pässwörd中文🔐';
52+
const utf8Plaintext = 'passphrase: 秘密キー ☃🔑';
53+
const ciphertext = sjclEncrypt(utf8Password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
54+
assert.strictEqual(await decryptV1Browser(utf8Password, ciphertext), utf8Plaintext);
55+
});
56+
57+
it('handles large plaintext (>64 KiB, forces L=3 nonce framing)', async () => {
58+
const large = 'x'.repeat(70_000);
59+
const ciphertext = sjclEncrypt(password, large, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' });
60+
assert.strictEqual(await decryptV1Browser(password, ciphertext), large);
61+
});
62+
63+
it('handles aes-128 envelopes', async () => {
64+
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 128, ts: 64, mode: 'ccm' });
65+
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
66+
});
67+
68+
it('handles 128-bit tag envelopes', async () => {
69+
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 128, mode: 'ccm' });
70+
assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext);
71+
});
72+
73+
it('rejects wrong password', async () => {
74+
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
75+
await assert.rejects(() => decryptV1Browser('wrongPassword', ciphertext));
76+
});
77+
78+
it('rejects envelope with iter above cap before running PBKDF2', async () => {
79+
const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' });
80+
const envelope = JSON.parse(ciphertext);
81+
envelope.iter = V1_MAX_ITER + 1;
82+
const start = Date.now();
83+
await assert.rejects(() => decryptV1Browser(password, JSON.stringify(envelope)), /iter/);
84+
assert.ok(Date.now() - start < 100, 'must reject before any KDF work');
85+
});
86+
87+
it('parity across 50 randomised inputs', async () => {
88+
const { randomBytes } = await import('crypto');
89+
for (let i = 0; i < 50; i++) {
90+
const pw = randomBytes(16).toString('hex');
91+
const pt = randomBytes(1 + Math.floor(Math.random() * 500)).toString('base64');
92+
const ciphertext = sjclEncrypt(pw, pt, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' });
93+
assert.strictEqual(await decryptV1Browser(pw, ciphertext), pt, `iteration ${i}`);
94+
}
95+
});
96+
97+
it('Box A + Box B: shim decrypt matches SJCL byte-for-byte', async () => {
98+
for (const [label, ct] of [
99+
['A', KEYCARD_BOX_A],
100+
['B', KEYCARD_BOX_B],
101+
] as const) {
102+
const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, ct);
103+
const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, ct);
104+
assert.strictEqual(shimResult, sjclResult, `Box ${label} mismatch`);
105+
assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX));
106+
}
107+
});
108+
});

0 commit comments

Comments
 (0)