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
72 changes: 29 additions & 43 deletions modules/sdk-api/src/decryptV1.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { base64String, boundedInt, decodeWithCodec, LEGACY_V1_MAX_ITER } from '@bitgo/sdk-core';
import { createDecipheriv, pbkdf2 } from 'crypto';
import * as sjcl from '@bitgo/sjcl';
import {
assertSafeLegacyV1Envelope,
base64String,
boundedInt,
decodeWithCodec,
LEGACY_V1_MAX_ITER,
} from '@bitgo/sdk-core';
import * as t from 'io-ts';
import { promisify } from 'util';

/**
* 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.
* v1 envelopes use 10,000; this cap is 10x that. The v1 decrypt path enforces
* it before handing the envelope to SJCL, which has no upper bound of its own.
*
* @deprecated Kept exported for compatibility only. Callers should use
* `decrypt` or `decryptV1WithSjcl` instead of enforcing the cap themselves.
*/
export const V1_MAX_ITER = LEGACY_V1_MAX_ITER;

Expand Down Expand Up @@ -34,6 +42,11 @@ const V1EnvelopeCodec = t.intersection([

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

/**
* @deprecated Kept exported for compatibility only. The v1 decrypt path no
* longer uses this strict codec: it rejects envelope shapes (such as a missing
* `v` field) that SJCL's own decoder still accepts. Use `decryptV1WithSjcl`.
*/
export function parseV1Envelope(ciphertext: string): V1Envelope {
let parsed: unknown;
try {
Expand All @@ -45,49 +58,22 @@ export function parseV1Envelope(ciphertext: string): V1Envelope {
}

/**
* CCM length field size L, in bytes, chosen to encode the plaintext length.
* Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope.
*
* 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.
* The single v1 decrypt engine on every runtime (Node, browser, worker):
* `assertSafeLegacyV1Envelope` screens the envelope, then SJCL's own decoder
* handles it, accepting every historical shape including envelopes without
* a `v` field.
*/
function ccmNonceLength(plaintextLen: number): number {
let L = 2;
while (L < 4 && plaintextLen >= Math.pow(2, 8 * L)) L++;
return 15 - L;
export async function decryptV1WithSjcl(password: string, ciphertext: string): Promise<string> {
assertSafeLegacyV1Envelope(ciphertext);
return sjcl.decrypt(password, ciphertext);
}

/**
* Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope with Node's native
* `node:crypto`.
*
* 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.
* @deprecated Kept exported for compatibility only. Equivalent to
* `decryptV1WithSjcl`; prefer `decrypt`, which auto-detects v1/v2 envelopes.
*/
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');
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(pbkdf2)(password, salt, env.iter, keyBytes, 'sha256');

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');
return decryptV1WithSjcl(password, ciphertext);
}
91 changes: 2 additions & 89 deletions modules/sdk-api/src/encrypt.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import * as sjcl from '@bitgo/sjcl';
import { randomBytes } from 'crypto';

import { assertSafeLegacyV1Envelope } from '@bitgo/sdk-core';
import { decryptV1, parseV1Envelope } from './decryptV1';
import { decryptV1WithSjcl } from './decryptV1';
import { decryptV2, encryptV2 } from './encryptV2';

/**
* 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.
*/
function isBrowserRuntime(): boolean {
return typeof window !== 'undefined' || typeof self !== 'undefined';
}

/**
* convert a 4 element Uint8Array to a 4 byte Number
*
Expand Down Expand Up @@ -77,81 +65,6 @@ export async function encrypt(
return encryptV2(password, plaintext, { adata: options?.adata });
}

/**
* 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 isIterCapViolation(err: unknown): boolean {
return err instanceof Error && /iter:\s*expected integer|iter out of range/i.test(err.message);
}

/**
* v1 decrypt with an SJCL safety net.
*
* 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
* 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.
* `isBrowser` defaults to a real runtime check but is exposed so tests can
* exercise the browser-only branch under Node.
*/
export async function decryptV1WithFallback(
password: string,
ciphertext: string,
native: (pw: string, ct: string) => Promise<string> = decryptV1,
isBrowser: boolean = isBrowserRuntime()
): Promise<string> {
assertSafeLegacyV1Envelope(ciphertext);
if (isBrowser) {
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 {
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;
}
}

/**
* Auto-detect v1 (PBKDF2-SHA256 + AES-CCM) or v2 (Argon2id + AES-256-GCM)
* from the envelope `v` field and decrypt.
Expand All @@ -171,5 +84,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 decryptV1WithFallback(password, ciphertext);
return decryptV1WithSjcl(password, ciphertext);
}
Loading
Loading