From 33cf9a62a24fd10b84905fa86be92a750e21546b Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 20 Aug 2026 16:13:12 -0400 Subject: [PATCH] fix(sdk-api): skip doomed native v1 decrypt attempt in browser browserify-aes (webpack's node:crypto polyfill) has no AES-CCM mode, so native v1 decrypt fails on every call in a real browser bundle, paying for a wasted PBKDF2 run and logging a warning each time before falling back to SJCL. Detect browser runtime and go straight to SJCL, while still enforcing the iter cap to preserve DoS protection. TICKET: WCN-43 --- modules/sdk-api/src/encrypt.ts | 38 ++++++++++++++++++++++++-- modules/sdk-api/test/unit/decryptV1.ts | 37 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 0e285e4b05..556adf3fd4 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,9 +1,36 @@ import * as sjcl from '@bitgo/sjcl'; import { randomBytes } from 'crypto'; -import { decryptV1 } from './decryptV1'; +import { decryptV1, V1_MAX_ITER } 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. + */ +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}`); + } +} + /** * convert a 4 element Uint8Array to a 4 byte Number * @@ -92,12 +119,19 @@ function isIterCapViolation(err: unknown): boolean { * * `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 = decryptV1 + native: (pw: string, ct: string) => Promise = decryptV1, + isBrowser: boolean = isBrowserRuntime ): Promise { + if (isBrowser) { + assertIterWithinCap(ciphertext); + return sjcl.decrypt(password, ciphertext); + } try { return await native(password, ciphertext); } catch (nativeErr) { diff --git a/modules/sdk-api/test/unit/decryptV1.ts b/modules/sdk-api/test/unit/decryptV1.ts index c9a59e087d..a5b8a5917d 100644 --- a/modules/sdk-api/test/unit/decryptV1.ts +++ b/modules/sdk-api/test/unit/decryptV1.ts @@ -185,4 +185,41 @@ describe('decryptV1 (native, SJCL-free)', () => { assert.ok(warnings[0].includes('SJCL fallback succeeded')); }); }); + + describe('browser runtime (isBrowser = true)', () => { + it('skips native and decrypts via SJCL without warning', async () => { + const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); + const neverCalled = async (): Promise => { + throw new Error('native should not be attempted in the browser'); + }; + // eslint-disable-next-line no-console + const originalWarn = console.warn; + const warnings: string[] = []; + // eslint-disable-next-line no-console + console.warn = (...args: unknown[]) => { + warnings.push(args.join(' ')); + }; + try { + const result = await decryptV1WithFallback(password, ct, neverCalled, true); + assert.strictEqual(result, plaintext); + assert.deepStrictEqual(warnings, []); + } finally { + // eslint-disable-next-line no-console + console.warn = originalWarn; + } + }); + + it('still enforces the iter cap before running PBKDF2', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = V1_MAX_ITER + 1; + const start = Date.now(); + await assert.rejects(() => decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, true), /iter/); + assert.ok(Date.now() - start < 100, 'must reject before any KDF work'); + }); + + it('rejects wrong password via SJCL auth failure', async () => { + const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); + await assert.rejects(() => decryptV1WithFallback('wrongPassword', ct, decryptV1, true)); + }); + }); });