Skip to content

Commit 49c9cbb

Browse files
committed
feat(sdk-api): drop-in native replacement for sjcl v1 decrypt
Introduces decryptV1, a native (SJCL-free) replacement for v1 envelope decrypt using node:crypto on the server and crypto-browserify via the existing webpack shim in browser bundles. Byte-for-byte compatible with SJCL's envelope format. Public decrypt() wraps the native path in a temporary SJCL fallback so callers are never blocked if native fails on an unexpected envelope shape. The fallback uses a two-engine vote to decide when to warn: - Native succeeds -> return, silent - Iter cap violation -> rethrown (preserves DoS protection) - Both engines fail -> rethrow SJCL error (mapped upstream to "incorrect password"), no warn - Native fails but SJCL succeeds -> return SJCL result + console.warn (engines disagree, real signal for the operator) This design has zero false-negative risk: any envelope shape our stricter io-ts codec rejects that SJCL can still decrypt (Scenario D), and any hypothetical native bug that produces an auth-tag-like error (Scenario E), both get rescued by the fallback. decryptV1WithCrypto accepts an injected crypto module so the browser shim test can exercise the real decrypt code with crypto-browserify instead of duplicating the CCM logic. decryptV1WithFallback is exported and accepts an injected native fn so tests can trigger the fallback path deterministically without stub frameworks. An io-ts codec enforces an iter cap of 100k on v1 envelopes up front, before any KDF work runs. BitGoAPI.decrypt() adds "Unsupported state or unable to authenticate data" to the mapping that surfaces as "incorrect password", so the error contract downstream coin packages depend on stays stable. Test coverage (222 total, all passing): - Node parity tests (aes-128/256, adata, UTF-8, >64KiB L=3, 128-bit tags, 50 randomised inputs, one representative codec rejection, tampered ct, iter cap enforcement) - Browser-shim parity tests via crypto-browserify (same shape) - Real BitGo keycard fixture assertions on both engines - Fallback behavior tests including an injected-native-fn test that triggers the SJCL-rescue path deterministically TICKET: WCN-2079
1 parent fc53f1a commit 49c9cbb

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)