diff --git a/modules/sdk-api/package.json b/modules/sdk-api/package.json index 79cd0308d0..144f6d66ac 100644 --- a/modules/sdk-api/package.json +++ b/modules/sdk-api/package.json @@ -58,6 +58,9 @@ "secrets.js-grempe": "^1.1.0", "superagent": "^9.0.1" }, + "devDependencies": { + "crypto-browserify": "^3.12.0" + }, "overrides": { "degenerator": "5.0.0" }, diff --git a/modules/sdk-api/src/decryptV1.ts b/modules/sdk-api/src/decryptV1.ts new file mode 100644 index 0000000000..5ea26d3ff6 --- /dev/null +++ b/modules/sdk-api/src/decryptV1.ts @@ -0,0 +1,119 @@ +import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; +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 + * it up front before any KDF work runs. + */ +export const V1_MAX_ITER = 100_000; + +/** + * io-ts codec for a v1 (SJCL) envelope. + * + * Enforces the shape and the `iter` cap up front, before any KDF work runs. + */ +const V1EnvelopeCodec = t.intersection([ + t.type({ + v: t.literal(1), + iter: boundedInt(1, V1_MAX_ITER, 'iter'), + ks: t.union([t.literal(128), t.literal(256)]), + ts: t.union([t.literal(64), t.literal(96), t.literal(128)]), + mode: t.literal('ccm'), + cipher: t.literal('aes'), + salt: base64String, + iv: base64String, + ct: base64String, + }), + t.partial({ + adata: t.string, + }), +]); + +export type V1Envelope = t.TypeOf; + +export function parseV1Envelope(ciphertext: string): V1Envelope { + let parsed: unknown; + try { + parsed = JSON.parse(ciphertext); + } catch { + throw new Error('v1 decrypt: invalid JSON envelope'); + } + return decodeWithCodec(V1EnvelopeCodec, parsed, 'v1 decrypt: invalid envelope'); +} + +/** + * CCM length field size L, in bytes, chosen to encode the plaintext length. + * + * 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. + */ +function ccmNonceLength(plaintextLen: number): number { + let L = 2; + while (L < 4 && plaintextLen >= Math.pow(2, 8 * L)) L++; + return 15 - L; +} + +/** + * 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. + * + * Exported so tests can inject `crypto-browserify` and exercise the exact + * runtime path the webpack browser bundle produces, without duplicating the + * decrypt logic. + */ +export async function decryptV1WithCrypto(password: string, ciphertext: string, crypto: CryptoModule): Promise { + 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(crypto.pbkdf2)(password, salt, env.iter, keyBytes, 'sha256'); + + const decipher = crypto.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 { + return decryptV1WithCrypto(password, ciphertext, defaultCrypto); +} diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 685ad3383a..04550ae723 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,6 +1,7 @@ import * as sjcl from '@bitgo/sjcl'; import { randomBytes } from 'crypto'; +import { decryptV1, parseV1Envelope } from './decryptV1'; import { decryptV2, encryptV2 } from './encryptV2'; /** @@ -65,15 +66,63 @@ export async function encrypt( } /** - * Internal v1 (SJCL) decrypt helper. Not part of the public surface: callers use - * the auto-detecting `decrypt` instead. + * Auth-tag / integrity failure signatures across the crypto engines we might + * see the fallback wrap. Wrong password and tampered ciphertext are both + * legitimate outcomes -- they should surface to the caller, not fall through + * to SJCL (which would just fail with the same reason after doubling the KDF + * cost) and not emit a console.warn (they are not a bug in the native path). */ -function decryptV1(password: string, ciphertext: string): string { - return sjcl.decrypt(password, ciphertext); +function isAuthFailure(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const m = err.message; + return ( + m.includes('Unsupported state or unable to authenticate data') || // Node native + m.includes("ccm: tag doesn't match") || // SJCL / crypto-browserify variants + m.includes('unable to authenticate') + ); } /** - * Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt. + * v1 decrypt with an SJCL safety net. + * + * Envelope validation runs BEFORE the try/catch, so malformed input (bad + * JSON, wrong mode/cipher, iter above cap) still throws immediately. + * + * Auth failures (wrong password, tampered ciphertext) are rethrown as-is: + * they are not a bug in the native path, and running SJCL again would just + * throw the same reason after doubling the KDF cost. + * + * Any other native failure on a well-formed envelope (framing bug, unsupported + * algorithm from the crypto module) falls through to `sjcl.decrypt` so the + * caller is not blocked, and emits a console.warn for operators. The fallback + * is temporary and will be removed in a follow-up once the signal is clean. + * + * `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. + */ +export async function decryptV1WithFallback( + password: string, + ciphertext: string, + native: (pw: string, ct: string) => Promise = decryptV1 +): Promise { + // Rethrows synchronously on malformed envelope / iter cap violation. Do NOT + // wrap this in the try/catch below -- the fallback must not swallow schema + // errors, otherwise the iter cap can be bypassed via SJCL. + parseV1Envelope(ciphertext); + try { + return await native(password, ciphertext); + } catch (nativeErr) { + if (isAuthFailure(nativeErr)) throw nativeErr; + const message = nativeErr instanceof Error ? nativeErr.message : String(nativeErr); + // eslint-disable-next-line no-console + console.warn('[bitgo-sdk] v1 native decrypt failed on well-formed envelope; using SJCL fallback:', message); + return sjcl.decrypt(password, ciphertext); + } +} + +/** + * Auto-detect v1 (PBKDF2-SHA256 + AES-CCM) or v2 (Argon2id + AES-256-GCM) + * from the envelope `v` field and decrypt. */ export async function decrypt(password: string, ciphertext: string): Promise { let envelopeVersion: number | undefined; @@ -90,5 +139,5 @@ export async function decrypt(password: string, ciphertext: string): Promise { + return decryptV1WithCrypto(password, ciphertext, browserCrypto); +} + +describe('decryptV1 browser path (crypto-browserify shim)', () => { + const password = 'myPassword'; + const plaintext = 'Hello, Browser!'; + + it('decrypts an SJCL-produced envelope via the browser shim', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); + }); + + it('produces the same plaintext as sjcl.decrypt', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + const browserResult = await decryptV1Browser(password, ciphertext); + const sjclResult = sjcl.decrypt(password, ciphertext); + assert.strictEqual(browserResult, sjclResult); + }); + + it('handles adata (AAD)', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + adata: 'ctx-A', + }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); + }); + + it('handles empty adata (SJCL default)', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); + }); + + it('handles UTF-8 passwords', async () => { + const utf8Password = 'pässwörd中文🔐'; + const ciphertext = sjclEncrypt(utf8Password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(utf8Password, ciphertext), plaintext); + }); + + it('handles UTF-8 plaintext', async () => { + const utf8Plaintext = 'passphrase: 秘密キー ☃🔑'; + const ciphertext = sjclEncrypt(password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), utf8Plaintext); + }); + + it('handles large plaintext (>64 KiB, forces L=3 nonce framing)', async () => { + const large = 'x'.repeat(70_000); + const ciphertext = sjclEncrypt(password, large, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), large); + }); + + it('handles aes-128 envelopes', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 128, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); + }); + + it('handles 128-bit tag envelopes', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 128, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); + }); + + it('rejects wrong password', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + await assert.rejects(() => decryptV1Browser('wrongPassword', ciphertext)); + }); + + it('rejects envelope with iter above cap before running PBKDF2', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + const envelope = JSON.parse(ciphertext); + envelope.iter = V1_MAX_ITER + 1; + const start = Date.now(); + await assert.rejects(() => decryptV1Browser(password, JSON.stringify(envelope)), /iter/); + assert.ok(Date.now() - start < 100, 'must reject before any KDF work'); + }); + + it('parity across 50 randomised inputs', async () => { + const { randomBytes } = await import('crypto'); + for (let i = 0; i < 50; i++) { + const pw = randomBytes(16).toString('hex'); + const pt = randomBytes(1 + Math.floor(Math.random() * 500)).toString('base64'); + const ciphertext = sjclEncrypt(pw, pt, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1Browser(pw, ciphertext), pt, `iteration ${i}`); + } + }); + + describe('real BitGo keycard parity via shim', () => { + it('Box A: shim decrypt matches SJCL byte-for-byte', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_A); + const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, KEYCARD_BOX_A); + assert.strictEqual(shimResult, sjclResult); + assert.strictEqual(shimResult.length, KEYCARD_BOX_A_LENGTH); + assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); + }); + + it('Box B: shim decrypt matches SJCL byte-for-byte', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_B); + const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, KEYCARD_BOX_B); + assert.strictEqual(shimResult, sjclResult); + assert.strictEqual(shimResult.length, KEYCARD_BOX_B_LENGTH); + assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); + }); + + it('wrong password against real keycard envelope throws cleanly', async () => { + await assert.rejects(() => decryptV1Browser('wrong-password', KEYCARD_BOX_A)); + }); + }); +}); diff --git a/modules/sdk-api/test/unit/decryptV1.ts b/modules/sdk-api/test/unit/decryptV1.ts new file mode 100644 index 0000000000..f2bba8bd94 --- /dev/null +++ b/modules/sdk-api/test/unit/decryptV1.ts @@ -0,0 +1,381 @@ +import * as sjcl from '@bitgo/sjcl'; +import assert from 'assert'; +import { randomBytes } from 'crypto'; + +import { decrypt, decryptV1, decryptV1WithFallback, encrypt, parseV1Envelope, V1_MAX_ITER } from '../../src'; +import { + KEYCARD_BOX_A, + KEYCARD_BOX_A_LENGTH, + KEYCARD_BOX_B, + KEYCARD_BOX_B_LENGTH, + KEYCARD_PASSWORD, + KEYCARD_PLAINTEXT_PREFIX, +} from './fixtures/keycard'; + +/** + * sjcl.encrypt's typings require salt/iv, but the runtime picks them from + * sjcl.random when omitted. Feed real random words so the call type-checks + * without an `as` cast. + */ +function sjclEncrypt(password: string, plaintext: string, params: sjcl.SjclCipherParams): string { + const salt = sjcl.random.randomWords(2); // 8 bytes + const iv = sjcl.random.randomWords(4); // 16 bytes + return sjcl.encrypt(password, plaintext, { ...params, salt, iv }); +} + +/** + * These tests cover the native (SJCL-free) v1 decrypt path. + * + * Parity strategy: SJCL is the ground truth for v1 envelopes. Every parity + * test generates a real SJCL envelope, then decrypts via the new path and + * asserts the plaintext matches. If the new implementation ever diverges + * from SJCL, one of these tests fails before PR 2 removes SJCL. + */ +describe('decryptV1 (native, SJCL-free)', () => { + describe('parity with SJCL', () => { + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('decrypts an SJCL-produced envelope back to the original plaintext', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('decrypts an envelope produced by encrypt(..., { encryptionVersion: 1 })', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('matches sjcl.decrypt output for the same envelope', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + const nativeResult = await decryptV1(password, ciphertext); + const sjclResult = sjcl.decrypt(password, ciphertext); + assert.strictEqual(nativeResult, sjclResult); + }); + + it('decrypts with adata (AAD)', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + adata: 'context-A', + }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('decrypts with empty adata (SJCL default)', async () => { + // SJCL sets adata to "" when not passed. Verify both paths interpret it identically. + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + const envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.adata, ''); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('decrypts UTF-8 passwords', async () => { + const utf8Password = 'pässwörd中文🔐'; + const ciphertext = sjclEncrypt(utf8Password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1(utf8Password, ciphertext), plaintext); + }); + + it('decrypts UTF-8 plaintext (multibyte round-trip)', async () => { + const utf8Plaintext = 'passphrase: 秘密キー ☃🔑'; + const ciphertext = sjclEncrypt(password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1(password, ciphertext), utf8Plaintext); + }); + + it('decrypts large plaintext (>64 KiB, forces L=3 nonce framing)', async () => { + const large = 'x'.repeat(70_000); + const ciphertext = sjclEncrypt(password, large, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); + const result = await decryptV1(password, ciphertext); + assert.strictEqual(result.length, large.length); + assert.strictEqual(result, large); + }); + + it('decrypts aes-128 envelopes', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 128, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('decrypts envelopes with 128-bit tag size', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 128, mode: 'ccm' }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('parity across 50 randomised inputs', async () => { + for (let i = 0; i < 50; i++) { + const pw = randomBytes(16).toString('hex'); + const pt = randomBytes(1 + Math.floor(Math.random() * 500)).toString('base64'); + const ciphertext = sjclEncrypt(pw, pt, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); + assert.strictEqual(await decryptV1(pw, ciphertext), pt, `iteration ${i}`); + } + }); + }); + + describe('failure modes', () => { + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('throws on wrong password', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + await assert.rejects(() => decryptV1('wrongPassword', ciphertext)); + }); + + it('throws on invalid JSON', async () => { + await assert.rejects(() => decryptV1(password, 'not-json'), /invalid JSON envelope/); + }); + + it('rejects envelope with unknown version', async () => { + const ciphertext = JSON.stringify({ v: 99, iter: 10000, ks: 256, ts: 64, mode: 'ccm', cipher: 'aes' }); + await assert.rejects(() => decryptV1(password, ciphertext), /invalid envelope/); + }); + + it('rejects envelope with non-ccm mode', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.mode = 'gcm'; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with non-aes cipher', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.cipher = 'chacha20'; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with unsupported key size', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.ks = 192; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with unsupported tag size', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.ts = 32; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope missing salt', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + delete envelope.salt; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope missing iv', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + delete envelope.iv; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with empty ciphertext', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.ct = ''; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects tampered ciphertext (auth-tag mismatch)', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + const envelope = JSON.parse(ciphertext); + const tampered = Buffer.from(envelope.ct, 'base64'); + tampered[0] ^= 0x01; + envelope.ct = tampered.toString('base64'); + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope))); + }); + + it('rejects tampered adata (auth-tag mismatch)', async () => { + const ciphertext = sjclEncrypt(password, plaintext, { + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + adata: 'context-A', + }); + const envelope = JSON.parse(ciphertext); + envelope.adata = 'context-B'; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope))); + }); + }); + + describe('iter cap enforcement', () => { + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('rejects an envelope with iter above V1_MAX_ITER 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(() => decryptV1(password, JSON.stringify(envelope)), /iter/); + // Must fail fast (schema check) without running PBKDF2 at all. + assert.ok(Date.now() - start < 100, 'must reject before any KDF work'); + }); + + it('rejects an envelope with iter of 1 billion', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = 1_000_000_000; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /iter/); + }); + + it('accepts the standard BitGo iter value (10,000)', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + const envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.iter, 10000); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + }); + + it('rejects zero iter', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = 0; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /iter/); + }); + + it('rejects negative iter', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = -1; + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /iter/); + }); + }); + + describe('parseV1Envelope', () => { + it('parses a valid envelope', async () => { + const password = 'pw'; + const plaintext = 'hello'; + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + const env = parseV1Envelope(ciphertext); + assert.strictEqual(env.v, 1); + assert.strictEqual(env.iter, 10000); + assert.strictEqual(env.ks, 256); + assert.strictEqual(env.mode, 'ccm'); + assert.strictEqual(env.cipher, 'aes'); + }); + + it('throws with a descriptive error on invalid JSON', () => { + assert.throws(() => parseV1Envelope('{not-json'), /invalid JSON envelope/); + }); + }); + + describe('routes through public decrypt() (native with SJCL fallback)', () => { + // `decrypt()` tries the native `decryptV1` first, then falls back to + // `sjcl.decrypt` on any native failure of a well-formed envelope. Envelope + // validation runs BEFORE the fallback, so malformed input (bad codec, iter + // above cap) is still rejected loudly. + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('decrypt() handles v1 envelopes via native path', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + assert.strictEqual(await decrypt(password, ciphertext), plaintext); + }); + + it('decrypt() enforces iter cap on v1 envelopes (fallback does not bypass the cap)', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = V1_MAX_ITER + 1; + await assert.rejects(() => decrypt(password, JSON.stringify(envelope)), /iter/); + }); + + it('decrypt() rejects malformed envelope before touching either engine', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.mode = 'gcm'; + await assert.rejects(() => decrypt(password, JSON.stringify(envelope)), /invalid envelope/); + }); + }); + + describe('real BitGo keycard parity', () => { + // Real production-shape v1 envelopes from a purpose-built test wallet. + // See test/unit/fixtures/keycard.ts for provenance. + it('Box A: native decrypt matches SJCL byte-for-byte', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_A); + const nativeResult = await decryptV1(KEYCARD_PASSWORD, KEYCARD_BOX_A); + assert.strictEqual(nativeResult, sjclResult); + assert.strictEqual(nativeResult.length, KEYCARD_BOX_A_LENGTH); + assert.ok(nativeResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); + }); + + it('Box B: native decrypt matches SJCL byte-for-byte', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_B); + const nativeResult = await decryptV1(KEYCARD_PASSWORD, KEYCARD_BOX_B); + assert.strictEqual(nativeResult, sjclResult); + assert.strictEqual(nativeResult.length, KEYCARD_BOX_B_LENGTH); + assert.ok(nativeResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); + }); + + it('Box A: public decrypt() routes through native on Node and matches SJCL', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_A); + const result = await decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_A); + assert.strictEqual(result, sjclResult); + }); + + it('Box B: public decrypt() routes through native on Node and matches SJCL', async () => { + const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_B); + const result = await decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_B); + assert.strictEqual(result, sjclResult); + }); + + it('wrong password against real keycard envelope throws cleanly', async () => { + await assert.rejects(() => decryptV1('wrong-password', KEYCARD_BOX_A)); + }); + }); + + describe('SJCL fallback behavior', () => { + // These tests capture the temporary safety-net contract: + // - Native success on a well-formed envelope -> no warn, no fallback + // - Native failure on a malformed envelope -> throws before fallback fires + // - Auth failure (wrong password, tampered ciphertext) -> rethrown, no warn + // - Any other native failure on a well-formed envelope -> SJCL fallback + warn + // The fallback is removed in a follow-up PR after real-env soak. + + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + // eslint-disable-next-line no-console + const originalWarn = console.warn; + let warnings: string[] = []; + + beforeEach(() => { + warnings = []; + // eslint-disable-next-line no-console + console.warn = (...args: unknown[]) => { + warnings.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')); + }; + }); + + afterEach(() => { + // eslint-disable-next-line no-console + console.warn = originalWarn; + }); + + it('success path emits no console.warn', async () => { + const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); + await decrypt(password, ct); + assert.deepStrictEqual(warnings, [], 'success on native path must not warn'); + }); + + it('wrong password rethrows the auth failure without falling back or warning', async () => { + const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); + await assert.rejects(() => decrypt('wrong', ct)); + assert.deepStrictEqual(warnings, [], 'auth failures must not enter the fallback'); + }); + + it('malformed envelope rejects loudly and does not warn (fallback not entered)', async () => { + const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); + envelope.iter = V1_MAX_ITER + 1; + await assert.rejects(() => decrypt(password, JSON.stringify(envelope)), /iter/); + assert.deepStrictEqual(warnings, [], 'schema errors must not enter the fallback'); + }); + + it('falls back to SJCL when native throws a non-auth error', async () => { + const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); + // Inject a native fn that simulates a framing bug / unsupported algorithm + // failure -> fallback runs -> SJCL succeeds + warn emitted. + const brokenNative = async () => { + throw new Error('unsupported algorithm'); + }; + const result = await decryptV1WithFallback(password, ct, brokenNative); + assert.strictEqual(result, plaintext); + assert.strictEqual(warnings.length, 1); + assert.ok(warnings[0].includes('v1 native decrypt failed')); + }); + }); +}); diff --git a/modules/sdk-api/test/unit/fixtures/keycard.ts b/modules/sdk-api/test/unit/fixtures/keycard.ts new file mode 100644 index 0000000000..947a892c1a --- /dev/null +++ b/modules/sdk-api/test/unit/fixtures/keycard.ts @@ -0,0 +1,31 @@ +/** + * Fixtures for real-world v1 decrypt parity tests. + * + * These come from a purpose-built BitGo test keycard (wallet name: "sol") + * created 2026-08-14 solely for this test suite. The wallet is a throwaway + * testnet Solana wallet with no real funds; the ciphertexts, wallet, and + * password below are safe to check in. + * + * Boxes A + B are the user and backup MPC key shares (uShare), encrypted + * client-side with the wallet password using the exact envelope shape BitGo + * produces in production (`iter=10000, ks=256, ts=64, mode=ccm, cipher=aes, + * adata=""`). + * + * Purpose: catch any regression where the native/shim decrypt paths diverge + * from SJCL on real production-shaped envelopes. + */ + +export const KEYCARD_PASSWORD = '97IoV9Qs3fuhBKYCg6ju0y6LPBHTywBY'; + +export const KEYCARD_BOX_A = + '{"iv":"iury+nZhVfoWjpzbzrN1Yw==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"WiUMqHlpxiQ=","ct":"Qx6nr/Q+oEaHmmsIq75cHcR4hR4XzowpJvdgNuzcGD6v113dimT8tg2FAWAGj+syRfH19X5cDBhFX6k3ifWMbyQHneHs0OGh86kjm/v4ddNHaGgGEzhsQCWX2tAOyhxT+oG5PewnSr+xFGZePvo0GFakmPFLzF9gfvG8K3o30i0iU5UyzPdp5iAZGbZTseqp6d3KXcleA712Gnv5eT0mlDYJwoi9Io7TwCjdaa2M5r1PFkN8ZaC1aIzba3A+f7b7PDrfFckGGDIl6o2ytjNEpCKvvbfiTbk9/WOIXtMibW1e3ysmuPl5tiw2lSAtYXHQOl9rqwEvtoIMtM2uZqAE+ELf8UiabdBpdCYFiSVBgD+jjEfgUocd7xCRh2bW/Uqhu8ye7SDdZowMaTI3d/sGW8QubquPaHWQZTkhvDXttT0l1YXsIld3L2mQfDw+FpFD+SihNDiCf4fVYHFI/I9bYMx03QnUV+wXyo0dl1QXnkyVxTVOeaoJ7dM7Tp7h5cY2m5Dlp2YvAY2twRzOSPKjYu8M+rUuY0GsbodZW8mKXCLVfTyNmonSgqmLZ7V8MlZ4oR2gGfPn39XrGG9fRPj3lKb4gaFCSweHWfXAAXLN+MP9BquE57cphKn8RcuwwGPnH0j/tKOdcqKtn1XcAKtf8+s3L7utIXS+dijADqTLrjfG/2gKe4NbpykiW/vOPQ+2BbZO7d0F0YMnd2di/KD2VL5Y3M6Iib1zZAQX1r41X9Zb1/ShEsKtFyObi2zG0atYrQ6g4+f11nBbwJa6BD6n3P+UAaHvIJoXEFnaccvq3OWowlHQppQUOqCkdg5kwf2fXJqkR9hGGSBpXM48gm61hgwR12n2rDQxks6wwtdtm5BMKZrJy25x/ZAqYaQejm+0dT9SVa0qpNNOa1IdDzrPnI0AL1aLNGOVQ9EwEt9OOYoS/DBTp7JmVwhShDC5w8Br3KOKsm5qlWz/uslhfWBR7l6+BFft2jjfeCmfjZORCuhV5tX5nnQXuc13HjQkrdzaJE+LXZYtal+U83i2SfdwZnWuplTQqxKk+C0ikfGGJd7PDNb+8VEoR3rEIXk4YKJyI/HyPAVwNJN48tm/364SOm/iPV+wW5HvwPDmRJBan5vs66pTT2GtkZsZD1L+BxN4s8ycM4BWO1xyjgTI7mKjSatSnBqeJ0Cm0bIHq8hsbM9lH8yc0VsJ"}'; + +export const KEYCARD_BOX_B = + '{"iv":"QZw8ZmHqaBqQh6X6PSCcMg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"yM+BoSyVwaY=","ct":"oPtcvWYf287IRi9To/UobTLrm5m+YCs6Tp1CuYvPJ5oazTcbmzZkS+gn2hxAGCvojGGV61qieEgrKEKDdKLE4/jSWUhY/VXyFE6BRb2uB5pFny8NwSsVb5Gw5R38bgs0XZ0aL4RCEQgByIkWGKJn3ZbpfmzFcD+x226WZkL0gS1DN5RK8bwAfgrabVp6MupLa/NN7M/YiH9aO9PELdvu/mbyC0zdAwwY8MHpMdf5IPOJi3ieMorUtEsuzfP4SBI2EAHdFi3/VmzIKG69sZS1uIULHB5Xya5ury7+qp6nihlxJOB4S8zpri/rXow+0EQL2bKUSkQz6r6GTL5MclyfkxVcCptVMPrCcsDdVc9P0sR36iP/AsdCTJGuHbzv0AERs776+cUyqivlQj1CQ3GkIHNQKyYjktKLx1PmEMTo29hEEbKVavM8ZoxTbjL/uIaxpz9Di1zh3RB8f1HXmq2JD2XEAPAky+Ukh8FSavROG64GRkPJY4AtUxXvvzUc2M9QCy6wbDIC8prJEio5jWyKZSZ4KVpMSEK98/htMjgB8hH07CdfiJnvMVy7xe9hHOtl3zThMP6r+9g8WpGdT02Y2kWWOVZlhBaPohuiCiJLHOigiN86Tt7i5kgSTB6w8zT4tcZWzSeOBh68eAupDt0qOdfadNESQICJ3fAgfQfeR7pPzjt54npB7c9KpLnJgDhrl4LpwicwZwuYKv0gJ7t9KKKTLFHRp5RD5Nr+8tk10aIxDQxLWCVSwalie5HhTzPsF8OUqNrJJ9mSBGtsTh/Saf5osvE/ETYdU7PTJpVHDYCS5/DaCrV7acZn1ZBzCD9P9FzttzF8RlwPOideEsYDWLORvwdmNHYgTavNnOY4UnISgA07UtFsgzgJ4gC4ubCE32C5MNkX4hGIi/15O4Y0ZaiAeOu2Qnm9r6zfdYZP4TCeQAZEGKy5DsO+VYOLImvhRZD8aOoFvxmQJWM5MEMozEImms8cwJuDRP60WhZqUCgemmmyHHDRPG3+EmUGU2W4QWDQt7G6uADcDCFsYojyshg0+6QRMdc5pLR3wUa4MRug7t49kit0Q/yFMn8kHn6/jhokRdIHt8Yvo1/wnGmw7aGvKX3rtUKOpqZ4AkOakhKuqRJyJvh3nsfz6BXtjx0UnW80Dt8WgAol+x0IFLcbcnwjICotU9uupfn+ONGK0C4NUSiJVg=="}'; + +/** Prefix of the decrypted plaintext for structural sanity checking. Both boxes are MPC uShare JSON. */ +export const KEYCARD_PLAINTEXT_PREFIX = '{"uShare":{"i":'; + +/** Expected plaintext lengths, verified against sjcl.decrypt during test authoring. */ +export const KEYCARD_BOX_A_LENGTH = 895; +export const KEYCARD_BOX_B_LENGTH = 893;