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
33 changes: 33 additions & 0 deletions docs/sjcl-replacement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SJCL Replacement — v1 Decrypt Boundary

Part of the SJCL deprecation effort ([WCN-43](https://linear.app/bitgo/issue/WCN-43/big-23-003-wp1-unmaintained-cryptographic-library), [WCN-1067](https://linear.app/bitgo/issue/WCN-1067/big-26-004-deprecated-cryptographic-library-utilized-sjcl)). This document states precisely where each crypto engine runs for local encrypt/decrypt, and why the browser v1 path intentionally still uses SJCL.

## Current boundary

| Envelope | Node runtime | Browser / worker runtime |
| ------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
| v1 (SJCL PBKDF2-SHA256 + AES-256-CCM) | Native `node:crypto` AES-CCM (`decryptV1`), with SJCL as a rollout safety net | Frozen `@bitgo/sjcl` decoder, straight — no native attempt |
| v2 (Argon2id + AES-256-GCM) | Argon2id + WebCrypto AES-GCM (`encryptV2`/`decryptV2`) | Argon2id + WebCrypto AES-GCM — identical code |

- **Node v1**: `decryptV1WithFallback` tries `decryptV1` (native `node:crypto` `aes-*-ccm`) first. Any native failure falls through to `sjcl.decrypt` so callers are never blocked during rollout; only an iter-cap violation is rethrown. A `console.warn` fires only when the two engines disagree (native fails, SJCL succeeds).
- **Browser/worker v1**: `isBrowserRuntime()` (true when `window` or `self` exists) routes `decryptV1WithFallback` straight to `sjcl.decrypt`. Before handing over, it runs the same `parseV1Envelope` envelope check the native path uses, rethrowing only iter-cap violations to preserve DoS protection; any other shape rejection (e.g. a legacy envelope without a `v` field) is a form SJCL still accepts and is left to SJCL to decide. The native attempt is skipped entirely: webpack substitutes `crypto-browserify` (backed by `browserify-aes`) for `node:crypto` in browser bundles, and its AES-CCM does not work, so the attempt would only pay for a doomed PBKDF2 run before falling back.
- **v2 everywhere**: v2 is Argon2id + WebCrypto AES-GCM in both Node and browser bundles; it never touches SJCL.

The same boundary is enforced by tests: Node behavior in `modules/sdk-api/test/unit/decryptV1.ts`, and real-browser dispatch (including the absent-`v` and iter-cap boundary cases) by the Cypress component spec `modules/web-demo/src/components/BitGoAPI/decrypt.spec.tsx`.

## Why there is no WebCrypto CCM implementation for browser v1

An alternative was considered: replace the SJCL decoder with a hand-written WebCrypto composition. It was rejected:

- WebCrypto has **no AES-CCM primitive**. A replacement would have to be hand-assembled from AES-CTR/CBC plus manual CCM formatting — i.e. brand-new, unreviewed cryptographic code.
- The threat model is local self-decrypt of the user's own envelope; there is no real security benefit to offset the risk of new crypto.
- It would not remove the `@bitgo/sjcl` dependency anyway: `encryptV1` and the `eddsaMPCv2` fallback still use SJCL (tracked as follow-up work).

Instead, `@bitgo/sjcl` is kept as a frozen, decrypt-only shim for browser v1, scoped to the SJCL envelope format and iteration-capped via `V1_MAX_ITER` (`100_000`, 10x the 10,000 iterations BitGo-produced envelopes use).

## Remaining SJCL surface (follow-ups)

- `encryptV1` — v1 encryption is legacy; still calls `sjcl.encrypt`.
- `eddsaMPCv2` — retains an SJCL fallback path.

These are intentionally out of scope of the browser v1 decrypt routing work.
3 changes: 0 additions & 3 deletions modules/sdk-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@
"secrets.js-grempe": "^1.1.0",
"superagent": "^9.0.1"
},
"devDependencies": {
"crypto-browserify": "^3.12.0"
},
"overrides": {
"degenerator": "5.0.0"
},
Expand Down
44 changes: 9 additions & 35 deletions modules/sdk-api/src/decryptV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,6 @@ 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
Expand Down Expand Up @@ -70,16 +58,15 @@ function ccmNonceLength(plaintextLen: number): number {
}

/**
* 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.
* Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope with Node's native
* `node:crypto`.
*
* Exported so tests can inject `crypto-browserify` and exercise the exact
* runtime path the webpack browser bundle produces, without duplicating the
* decrypt logic.
* Node runtimes only: the `crypto` webpack substitutes in browser bundles
* (`crypto-browserify`) has no working AES-CCM, so `decryptV1WithFallback`
* routes browser runtimes straight to the frozen SJCL decoder instead of
* ever calling this.
*/
export async function decryptV1WithCrypto(password: string, ciphertext: string, crypto: CryptoModule): Promise<string> {
export async function decryptV1(password: string, ciphertext: string): Promise<string> {
const env = parseV1Envelope(ciphertext);
const salt = Buffer.from(env.salt, 'base64');
const ivFull = Buffer.from(env.iv, 'base64');
Expand All @@ -94,26 +81,13 @@ export async function decryptV1WithCrypto(password: string, ciphertext: string,
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 key: Buffer = await promisify(pbkdf2)(password, salt, env.iter, keyBytes, 'sha256');

const decipher = crypto.createDecipheriv(`aes-${env.ks}-ccm`, key, iv, { authTagLength: tagBytes });
const decipher = 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);
}
60 changes: 29 additions & 31 deletions modules/sdk-api/src/encrypt.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
import * as sjcl from '@bitgo/sjcl';
import { randomBytes } from 'crypto';

import { decryptV1, V1_MAX_ITER } from './decryptV1';
import { decryptV1, parseV1Envelope } from './decryptV1';
import { decryptV2, encryptV2 } from './encryptV2';

/**
* True when running in a bundled browser build. `browserify-aes` -- the AES
* polyfill webpack substitutes for `node:crypto` in browser bundles -- has no
* AES-CCM mode at all, so native v1 decrypt fails on literally every call
* there. There's no point paying for the doomed native attempt (a full
* PBKDF2 run) before falling back; go straight to SJCL.
* True in a browser main thread (`window`) or worker (`self`) runtime. The
* `crypto` webpack substitutes for `node:crypto` there (`crypto-browserify`,
* backed by `browserify-aes`) has no working AES-CCM, so a native v1 attempt
* would only pay for a doomed PBKDF2 run before falling back. Browser
* runtimes route v1 decrypt straight to SJCL instead.
*/
const isBrowserRuntime = typeof window !== 'undefined';

/**
* SJCL has no upper bound on `iter`, so a hostile v1 envelope could burn CPU
* running an inflated PBKDF2. Native decrypt's codec normally catches this
* before any KDF work runs; the browser path skips native entirely, so it
* needs this narrow check to preserve the same DoS protection.
*/
function assertIterWithinCap(ciphertext: string): void {
let iter: unknown;
try {
iter = JSON.parse(ciphertext)?.iter;
} catch {
return; // malformed JSON -- let sjcl.decrypt raise its own error
}
if (typeof iter === 'number' && iter > V1_MAX_ITER) {
throw new Error(`v1 decrypt: iter ${iter} exceeds cap of ${V1_MAX_ITER}`);
}
function isBrowserRuntime(): boolean {
return typeof window !== 'undefined' || typeof self !== 'undefined';
}

/**
Expand Down Expand Up @@ -106,11 +90,16 @@ function isIterCapViolation(err: unknown): boolean {
/**
* 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.
* Node: native `node:crypto` first, SJCL fallback. 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.
*
* Browser/worker: skip the native attempt entirely (its polyfilled AES-CCM
* is broken) and go straight to `sjcl.decrypt`, after running the same
* `parseV1Envelope` iter-cap check the native path would run.
*
* The console.warn only fires when native fails AND SJCL succeeds -- i.e.
* when the two engines disagree, which is the only signal worth
Expand All @@ -126,10 +115,19 @@ export async function decryptV1WithFallback(
password: string,
ciphertext: string,
native: (pw: string, ct: string) => Promise<string> = decryptV1,
isBrowser: boolean = isBrowserRuntime
isBrowser: boolean = isBrowserRuntime()
): Promise<string> {
if (isBrowser) {
assertIterWithinCap(ciphertext);
try {
parseV1Envelope(ciphertext);
} catch (parseErr) {
// Only honor the iter-cap violation: SJCL has no upper bound on `iter`,
// so falling through would let a hostile envelope burn CPU running an
// inflated PBKDF2. Any other shape rejection (e.g. a legacy envelope
// without a `v` field) is a form SJCL still accepts, so let
// sjcl.decrypt make the final call.
if (isIterCapViolation(parseErr)) throw parseErr;
}
return sjcl.decrypt(password, ciphertext);
}
try {
Expand Down
108 changes: 0 additions & 108 deletions modules/sdk-api/test/unit/decryptV1.browser.ts

This file was deleted.

Loading
Loading