From 597a5a888c4c7d7cf018f8eb1f81398a1d01523f Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Mon, 9 Nov 2026 11:48:15 -0500 Subject: [PATCH] refactor(sdk-api): make SJCL the single v1 decrypt engine Every runtime now screens the v1 envelope with the shared assertSafeLegacyV1Envelope guard from sdk-core (iter cap, ccm-only) and runs sjcl.decrypt; the native node:crypto decoder and the Node SJCL fallback are gone. decryptV1WithFallback is renamed decryptV1WithSjcl, and decryptV1, parseV1Envelope and V1_MAX_ITER stay as deprecated exports. Ticket: WCN-3873 --- modules/sdk-api/src/decryptV1.ts | 72 +++---- modules/sdk-api/src/encrypt.ts | 91 +-------- modules/sdk-api/test/unit/decryptV1.ts | 251 +++++++------------------ 3 files changed, 98 insertions(+), 316 deletions(-) diff --git a/modules/sdk-api/src/decryptV1.ts b/modules/sdk-api/src/decryptV1.ts index 06f316e30d..c205521616 100644 --- a/modules/sdk-api/src/decryptV1.ts +++ b/modules/sdk-api/src/decryptV1.ts @@ -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; @@ -34,6 +42,11 @@ const V1EnvelopeCodec = t.intersection([ export type V1Envelope = t.TypeOf; +/** + * @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 { @@ -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 { + 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 { - 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); } diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index a882075e6d..28dacf1f5e 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -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 * @@ -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 = decryptV1, - isBrowser: boolean = isBrowserRuntime() -): Promise { - 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. @@ -171,5 +84,5 @@ export async function decrypt(password: string, ciphertext: string): Promise { +describe('decryptV1WithSjcl (single v1 decrypt engine)', () => { const password = 'myPassword'; const plaintext = 'Hello, World!'; - describe('parity with SJCL', () => { - it('matches sjcl.decrypt output for the same envelope', async () => { + describe('historical envelope shapes', () => { + it('decrypts an SDK-produced v1 envelope', async () => { const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); - assert.strictEqual(await decryptV1(password, ciphertext), sjcl.decrypt(password, ciphertext)); + assert.strictEqual(await decryptV1WithSjcl(password, ciphertext), plaintext); + }); + + it('decrypts an envelope without a `v` field', async () => { + const envelope = JSON.parse(sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' })); + delete envelope.v; + assert.strictEqual(await decryptV1WithSjcl(password, JSON.stringify(envelope)), plaintext); + }); + + it('decrypts an envelope without a `mode` field (SJCL defaults to ccm)', async () => { + const envelope = JSON.parse(sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' })); + delete envelope.mode; + assert.strictEqual(await decryptV1WithSjcl(password, JSON.stringify(envelope)), plaintext); }); it('decrypts with adata (AAD)', async () => { @@ -41,56 +52,49 @@ describe('decryptV1 (native, SJCL-free)', () => { mode: 'ccm', adata: 'context-A', }); - assert.strictEqual(await decryptV1(password, ciphertext), plaintext); + assert.strictEqual(await decryptV1WithSjcl(password, ciphertext), plaintext); }); it('decrypts UTF-8 password + plaintext', async () => { const utf8Password = 'pässwörd中文🔐'; const utf8Plaintext = 'passphrase: 秘密キー ☃🔑'; const ciphertext = sjclEncrypt(utf8Password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); - assert.strictEqual(await decryptV1(utf8Password, 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' }); - assert.strictEqual(await decryptV1(password, ciphertext), large); + assert.strictEqual(await decryptV1WithSjcl(utf8Password, ciphertext), utf8Plaintext); }); - 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); - }); + for (const [label, params] of [ + ['aes-128', { iter: 10000, ks: 128, ts: 64, mode: 'ccm' }], + ['128-bit tag', { iter: 10000, ks: 256, ts: 128, mode: 'ccm' }], + ] as const) { + it(`decrypts ${label} envelopes`, async () => { + const ciphertext = sjclEncrypt(password, plaintext, params); + assert.strictEqual(await decryptV1WithSjcl(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('real BitGo keycards', () => { + for (const [label, ct, length] of [ + ['Box A', KEYCARD_BOX_A, KEYCARD_BOX_A_LENGTH], + ['Box B', KEYCARD_BOX_B, KEYCARD_BOX_B_LENGTH], + ] as const) { + it(`${label}: decrypts with SJCL`, async () => { + const result = await decryptV1WithSjcl(KEYCARD_PASSWORD, ct); + assert.strictEqual(result, sjcl.decrypt(KEYCARD_PASSWORD, ct)); + assert.strictEqual(result.length, length); + assert.ok(result.startsWith(KEYCARD_PLAINTEXT_PREFIX)); + }); + } }); describe('failure modes', () => { it('throws on wrong password', async () => { const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); - await assert.rejects(() => decryptV1('wrongPassword', ciphertext)); + await assert.rejects(() => decryptV1WithSjcl('wrongPassword', ciphertext)); }); it('throws on invalid JSON', async () => { - await assert.rejects(() => decryptV1(password, 'not-json'), /invalid JSON envelope/); - }); - - it('rejects malformed envelope (representative codec check)', async () => { - const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); - envelope.mode = 'gcm'; - await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /invalid envelope/); + await assert.rejects(() => decryptV1WithSjcl(password, 'not-json'), /json/); }); it('rejects tampered ciphertext (auth-tag mismatch)', async () => { @@ -99,174 +103,53 @@ describe('decryptV1 (native, SJCL-free)', () => { const tampered = Buffer.from(envelope.ct, 'base64'); tampered[0] ^= 0x01; envelope.ct = tampered.toString('base64'); - await assert.rejects(() => decryptV1(password, JSON.stringify(envelope))); + await assert.rejects(() => decryptV1WithSjcl(password, JSON.stringify(envelope))); }); }); - describe('iter cap enforcement', () => { - it('rejects an envelope with iter above V1_MAX_ITER before running PBKDF2', async () => { + describe('envelope screening before SJCL', () => { + it('rejects iter above V1_MAX_ITER before any KDF work', 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/); + await assert.rejects(() => decryptV1WithSjcl(password, JSON.stringify(envelope)), /iter/); assert.ok(Date.now() - start < 100, 'must reject before any KDF work'); }); - }); - - describe('real BitGo keycard parity', () => { - 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)); - }); + for (const mode of ['gcm', 'ocb2'] as const) { + it(`rejects ${mode} cipher mode even though SJCL supports it`, async () => { + // Produce a real envelope SJCL could decrypt, to prove the screen + // rejects it before SJCL ever runs. + const ciphertext = sjclEncrypt(password, plaintext, { iter: 1000, ks: 256, ts: 64, mode }); + await assert.rejects(() => decryptV1WithSjcl(password, ciphertext), /unsupported mode/); + assert.strictEqual(await sjcl.decrypt(password, ciphertext), plaintext); + }); + } }); - describe('SJCL fallback behavior', () => { - // Rollout contract: - // - Native success -> no warn, no fallback. - // - Iter cap violation -> rethrown (no fallback), preserves DoS protection. - // - Both engines fail (e.g. wrong password) -> SJCL error rethrown, NO warn. - // - Native fails, SJCL succeeds -> return SJCL result + warn (engines disagree). - - // 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, []); + describe('decrypt auto-detect dispatch', () => { + it('routes v1 envelopes through the single engine', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + assert.strictEqual(await decrypt(password, ciphertext), plaintext); }); - it('iter cap violation rethrows and does not warn', async () => { + it('enforces the iter cap through decrypt', 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, []); - }); - - it('wrong password does not warn (both engines fail, SJCL error rethrown)', async () => { - const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); - await assert.rejects(() => decrypt('wrong', ct)); - assert.deepStrictEqual(warnings, []); - }); - - it('warns and returns SJCL result when native fails but SJCL succeeds', async () => { - const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); - 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('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; - } + describe('deprecated decryptV1 alias', () => { + it('delegates to decryptV1WithSjcl', async () => { + const ciphertext = await encrypt(password, plaintext, { encryptionVersion: 1 }); + assert.strictEqual(await decryptV1(password, ciphertext), plaintext); }); - it('still enforces the iter cap before running PBKDF2', async () => { + it('enforces the iter cap', 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)); - }); - - it('rejects a non-ccm mode before SJCL runs', async () => { - const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); - envelope.mode = 'ocb2'; - await assert.rejects( - () => decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, true), - /unsupported mode/ - ); - }); - - it('rejects a hostile iter or mode hidden by whitespace in the key', async () => { - const ct = await encrypt(password, plaintext, { encryptionVersion: 1 }); - const spacedIter = ct.replace(/"iter":\d+/, '"it er":3000000'); - const spacedMode = ct.replace('"mode":"ccm"', '"mo de":"ocb2"'); - assert.notStrictEqual(spacedIter, ct); - assert.notStrictEqual(spacedMode, ct); - for (const isBrowser of [true, false]) { - const start = Date.now(); - await assert.rejects( - () => decryptV1WithFallback(password, spacedIter, decryptV1, isBrowser), - /iter out of range/ - ); - await assert.rejects( - () => decryptV1WithFallback(password, spacedMode, decryptV1, isBrowser), - /unsupported mode/ - ); - assert.ok(Date.now() - start < 2000, 'must not run the inflated PBKDF2'); - } - }); - - it('still decrypts a legacy envelope without a v field', async () => { - const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); - delete envelope.v; - const result = await decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, true); - assert.strictEqual(result, plaintext); - }); - }); - - describe('cipher mode screen (node runtime)', () => { - it('rejects a non-ccm mode instead of falling back to SJCL', async () => { - const envelope = JSON.parse(await encrypt(password, plaintext, { encryptionVersion: 1 })); - envelope.mode = 'ocb2'; - await assert.rejects( - () => decryptV1WithFallback(password, JSON.stringify(envelope), decryptV1, false), - /unsupported mode/ - ); + await assert.rejects(() => decryptV1(password, JSON.stringify(envelope)), /iter/); }); }); });