From 933e2f8b204c3b24b627c736871669860934144d Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Tue, 14 Apr 2026 14:54:46 -0400 Subject: [PATCH 1/2] feat(sdk-api): add HKDF session caching layer for multi-call operations Introduces createEncryptionSession() that runs Argon2id once and derives per-call AES-256-GCM keys via HKDF (<1ms each), eliminating repeated expensive KDF calls in multi-encrypt/decrypt flows. - createEncryptionSession() in encrypt.ts: Argon2id -> HKDF CryptoKey - EncryptionSession interface: encrypt(), decrypt(), destroy() - V2Envelope extended with optional hkdfSalt for session-produced envelopes - decryptV2 handles both standalone and session envelopes - decryptAsync fix: v2 errors no longer fall through silently to v1 WCN-31 Co-Authored-By: Claude Sonnet 4.6 --- modules/sdk-api/src/encrypt.ts | 186 ++-------------------- modules/sdk-api/src/encryptV2.ts | 188 +++++++++++++++++++++++ modules/sdk-api/src/encryptionSession.ts | 110 +++++++++++++ modules/sdk-api/src/index.ts | 2 + modules/sdk-api/test/unit/encrypt.ts | 111 ++++++++++++- modules/sdk-core/src/bitgo/bitgoBase.ts | 1 + 6 files changed, 426 insertions(+), 172 deletions(-) create mode 100644 modules/sdk-api/src/encryptV2.ts create mode 100644 modules/sdk-api/src/encryptionSession.ts diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 43d64e2c1a..5df103b263 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,44 +1,7 @@ -import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; import * as sjcl from '@bitgo/sjcl'; import { randomBytes } from 'crypto'; -import * as t from 'io-ts'; -/** Default Argon2id parameters per RFC 9106 second recommendation - * @see https://www.rfc-editor.org/rfc/rfc9106#section-4 - */ -const ARGON2_DEFAULTS = { - memorySize: 65536, // 64 MiB in KiB - iterations: 3, - parallelism: 4, - hashLength: 32, // 256-bit key - saltLength: 16, // 128-bit salt -} as const; - -/** Maximum allowed Argon2id parameters to prevent DoS via crafted envelopes. - * memorySize: 256 MiB (4x default) -- caps memory allocation on untrusted input. - * iterations: 16 -- caps CPU time. - * parallelism: 16 -- caps thread count. - */ -const ARGON2_MAX = { - memorySize: 262144, - iterations: 16, - parallelism: 16, -} as const; - -/** AES-256-GCM IV length in bytes */ -const GCM_IV_LENGTH = 12; - -const V2EnvelopeCodec = t.type({ - v: t.literal(2), - m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'), - t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'), - p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'), - salt: base64String, - iv: base64String, - ct: base64String, -}); - -export type V2Envelope = t.TypeOf; +import { decryptV2 } from './encryptV2'; /** * convert a 4 element Uint8Array to a 4 byte Number @@ -50,34 +13,21 @@ export function bytesToWord(bytes?: Uint8Array | number[]): number { if (!(bytes instanceof Uint8Array) || bytes.length !== 4) { throw new Error('bytes must be a Uint8Array with length 4'); } - return bytes.reduce((num, byte) => num * 0x100 + byte, 0); } +/** Encrypt using legacy v1 SJCL (PBKDF2-SHA256 + AES-256-CCM). */ export function encrypt( password: string, plaintext: string, - options?: { - salt?: Buffer; - iv?: Buffer; - adata?: string; - } + options?: { salt?: Buffer; iv?: Buffer; adata?: string } ): string { const salt = options?.salt || randomBytes(8); - if (salt.length !== 8) { - throw new Error(`salt must be 8 bytes`); - } + if (salt.length !== 8) throw new Error('salt must be 8 bytes'); const iv = options?.iv || randomBytes(16); - if (iv.length !== 16) { - throw new Error(`iv must be 16 bytes`); - } - const encryptOptions: { - iter: number; - ks: number; - salt: number[]; - iv: number[]; - adata?: string; - } = { + if (iv.length !== 16) throw new Error('iv must be 16 bytes'); + + const encryptOptions: { iter: number; ks: number; salt: number[]; iv: number[]; adata?: string } = { iter: 10000, ks: 256, salt: [bytesToWord(salt.slice(0, 4)), bytesToWord(salt.slice(4))], @@ -88,139 +38,33 @@ export function encrypt( bytesToWord(iv.slice(12, 16)), ], }; - - if (options?.adata) { - encryptOptions.adata = options.adata; - } - + if (options?.adata) encryptOptions.adata = options.adata; return sjcl.encrypt(password, plaintext, encryptOptions); } +/** Decrypt a v1 SJCL envelope. */ export function decrypt(password: string, ciphertext: string): string { return sjcl.decrypt(password, ciphertext); } /** - * Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM) - * from the JSON envelope's `v` field. + * Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt. * - * This is the migration path from sync `decrypt()`. Clients should move to - * `await decryptAsync()` before the breaking release that makes `decrypt()` async. + * Migration path from sync `decrypt()`. Move call sites to `decryptAsync()` before + * the breaking release that flips the default to v2. */ export async function decryptAsync(password: string, ciphertext: string): Promise { let isV2 = false; try { - // Only peeking at the v field to route; this is an internal format we produce, not external input. + // Peek at v field only to route -- internal format we produce, not external input. const envelope = JSON.parse(ciphertext); isV2 = envelope.v === 2; } catch { - // Not valid JSON -- fall through to v1 + // Not valid JSON -- fall through to v1. } if (isV2) { - // Do not catch errors here: a wrong password or corrupt envelope on v2 data - // should propagate, not silently fall through to a v1 decrypt attempt. + // Do not catch: wrong password on v2 must not silently fall through to v1. return decryptV2(password, ciphertext); } return sjcl.decrypt(password, ciphertext); } - -/** - * Derive a 256-bit key from a password using Argon2id. - */ -async function deriveKeyV2( - password: string, - salt: Uint8Array, - params: { memorySize: number; iterations: number; parallelism: number } -): Promise { - const { argon2id } = await import('@bitgo/argon2'); - const keyBytes = await argon2id({ - password, - salt, - memorySize: params.memorySize, - iterations: params.iterations, - parallelism: params.parallelism, - hashLength: ARGON2_DEFAULTS.hashLength, - outputType: 'binary', - }); - - return crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); -} - -/** - * Encrypt plaintext using Argon2id KDF + AES-256-GCM. - * - * Returns a JSON string containing a self-describing v2 envelope - * with Argon2id parameters, salt, IV, and ciphertext. - */ -export async function encryptV2( - password: string, - plaintext: string, - options?: { - salt?: Uint8Array; - iv?: Uint8Array; - memorySize?: number; - iterations?: number; - parallelism?: number; - } -): Promise { - const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize; - const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations; - const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism; - - const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength)); - if (salt.length !== ARGON2_DEFAULTS.saltLength) { - throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`); - } - - const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH)); - if (iv.length !== GCM_IV_LENGTH) { - throw new Error(`iv must be ${GCM_IV_LENGTH} bytes`); - } - - const key = await deriveKeyV2(password, salt, { memorySize, iterations, parallelism }); - - const plaintextBytes = new TextEncoder().encode(plaintext); - const ctBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes); - - const envelope: V2Envelope = { - v: 2, - m: memorySize, - t: iterations, - p: parallelism, - salt: Buffer.from(salt).toString('base64'), - iv: Buffer.from(iv).toString('base64'), - ct: Buffer.from(ctBuffer).toString('base64'), - }; - - return JSON.stringify(envelope); -} - -/** - * Decrypt a v2 envelope (Argon2id KDF + AES-256-GCM). - * - * The envelope must contain: v, m, t, p, salt, iv, ct. - */ -export async function decryptV2(password: string, ciphertext: string): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(ciphertext); - } catch { - throw new Error('v2 decrypt: invalid JSON envelope'); - } - - const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope'); - - const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64')); - const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64')); - const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64')); - - const key = await deriveKeyV2(password, salt, { - memorySize: envelope.m, - iterations: envelope.t, - parallelism: envelope.p, - }); - - const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); - - return new TextDecoder().decode(plaintextBuffer); -} diff --git a/modules/sdk-api/src/encryptV2.ts b/modules/sdk-api/src/encryptV2.ts new file mode 100644 index 0000000000..b2aca03164 --- /dev/null +++ b/modules/sdk-api/src/encryptV2.ts @@ -0,0 +1,188 @@ +import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; +import { randomBytes } from 'crypto'; +import * as t from 'io-ts'; + +/** Default Argon2id parameters per RFC 9106 second recommendation + * @see https://www.rfc-editor.org/rfc/rfc9106#section-4 + */ +export const ARGON2_DEFAULTS = { + memorySize: 65536, // 64 MiB in KiB + iterations: 3, + parallelism: 4, + hashLength: 32, // 256-bit key + saltLength: 16, // 128-bit salt +} as const; + +/** Maximum allowed Argon2id parameters to prevent DoS via crafted envelopes. + * memorySize: 256 MiB (4x default) -- caps memory allocation on untrusted input. + * iterations: 16 -- caps CPU time. + * parallelism: 16 -- caps thread count. + */ +const ARGON2_MAX = { + memorySize: 262144, + iterations: 16, + parallelism: 16, +} as const; + +/** AES-256-GCM IV length in bytes */ +export const GCM_IV_LENGTH = 12; + +/** HKDF per-call salt length in bytes */ +export const HKDF_SALT_LENGTH = 32; + +/** Fixed HKDF info string for domain separation across BitGo v2 session keys */ +const HKDF_INFO = new TextEncoder().encode('bitgo-v2-session'); + +// Envelope codec + +const V2EnvelopeCodec = t.intersection([ + t.type({ + v: t.literal(2), + m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'), + t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'), + p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'), + salt: base64String, + iv: base64String, + ct: base64String, + }), + t.partial({ + /** Base64-encoded per-call HKDF salt -- present only in session-produced envelopes */ + hkdfSalt: base64String, + }), +]); + +export type V2Envelope = t.TypeOf; + +// Crypto helpers + +async function argon2Hash( + password: string, + salt: Uint8Array, + params: { memorySize: number; iterations: number; parallelism: number } +): Promise { + const { argon2id } = await import('@bitgo/argon2'); + return argon2id({ + password, + salt, + memorySize: params.memorySize, + iterations: params.iterations, + parallelism: params.parallelism, + hashLength: ARGON2_DEFAULTS.hashLength, + outputType: 'binary', + }); +} + +async function argon2ToAesKey( + password: string, + salt: Uint8Array, + params: { memorySize: number; iterations: number; parallelism: number } +): Promise { + const keyBytes = await argon2Hash(password, salt, params); + return crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +export async function argon2ToHkdfKey( + password: string, + salt: Uint8Array, + params: { memorySize: number; iterations: number; parallelism: number } +): Promise { + const keyBytes = await argon2Hash(password, salt, params); + return crypto.subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']); +} + +export function hkdfDeriveAesKey(hkdfKey: CryptoKey, hkdfSalt: Uint8Array, usage: KeyUsage): Promise { + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, + [usage] + ); +} + +export async function aesGcmEncrypt(key: CryptoKey, iv: Uint8Array, plaintext: string): Promise { + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext)); + return new Uint8Array(ct); +} + +export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ct: Uint8Array): Promise { + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return new TextDecoder().decode(plaintext); +} + +export function parseV2Envelope(ciphertext: string): V2Envelope { + let parsed: unknown; + try { + parsed = JSON.parse(ciphertext); + } catch { + throw new Error('v2 decrypt: invalid JSON envelope'); + } + return decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope'); +} + +// Public API + +/** + * Encrypt plaintext using Argon2id KDF + AES-256-GCM. + * + * Returns a self-describing JSON v2 envelope containing all Argon2id parameters, + * salt, IV, and ciphertext -- fully standalone for decryption. + * + * For multi-call operations (MPC signing, wallet creation), prefer + * createEncryptionSession to run Argon2id once and derive per-call keys via HKDF. + */ +export async function encryptV2( + password: string, + plaintext: string, + options?: { salt?: Uint8Array; iv?: Uint8Array; memorySize?: number; iterations?: number; parallelism?: number } +): Promise { + const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize; + const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations; + const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism; + + const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength)); + if (salt.length !== ARGON2_DEFAULTS.saltLength) throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`); + + const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH)); + if (iv.length !== GCM_IV_LENGTH) throw new Error(`iv must be ${GCM_IV_LENGTH} bytes`); + + const key = await argon2ToAesKey(password, salt, { memorySize, iterations, parallelism }); + const ct = await aesGcmEncrypt(key, iv, plaintext); + + return JSON.stringify({ + v: 2, + m: memorySize, + t: iterations, + p: parallelism, + salt: Buffer.from(salt).toString('base64'), + iv: Buffer.from(iv).toString('base64'), + ct: Buffer.from(ct).toString('base64'), + } satisfies V2Envelope); +} + +/** + * Decrypt a v2 envelope (Argon2id + AES-256-GCM). + * + * Handles both envelope types automatically: + * - Standard (no hkdfSalt): Argon2id -> AES-GCM + * - Session (hkdfSalt present): Argon2id -> HKDF -> AES-GCM + * + * All parameters are stored in the envelope -- no session context required. + */ +export async function decryptV2(password: string, ciphertext: string): Promise { + const envelope = parseV2Envelope(ciphertext); + const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64')); + const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64')); + const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64')); + const params = { memorySize: envelope.m, iterations: envelope.t, parallelism: envelope.p }; + + if (envelope.hkdfSalt) { + const hkdfKey = await argon2ToHkdfKey(password, salt, params); + const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64')); + const aesKey = await hkdfDeriveAesKey(hkdfKey, hkdfSalt, 'decrypt'); + return aesGcmDecrypt(aesKey, iv, ct); + } + + const key = await argon2ToAesKey(password, salt, params); + return aesGcmDecrypt(key, iv, ct); +} diff --git a/modules/sdk-api/src/encryptionSession.ts b/modules/sdk-api/src/encryptionSession.ts new file mode 100644 index 0000000000..8f479a7233 --- /dev/null +++ b/modules/sdk-api/src/encryptionSession.ts @@ -0,0 +1,110 @@ +import { randomBytes } from 'crypto'; + +import { + aesGcmDecrypt, + aesGcmEncrypt, + ARGON2_DEFAULTS, + argon2ToHkdfKey, + GCM_IV_LENGTH, + hkdfDeriveAesKey, + HKDF_SALT_LENGTH, + parseV2Envelope, + V2Envelope, +} from './encryptV2'; + +/** + * Runs Argon2id once on creation, then derives per-call AES-256-GCM keys via HKDF. + * Use when encrypting or decrypting multiple values with the same password. + * + * Session envelopes are self-describing and can be decrypted standalone via decryptV2. + * Call destroy() when done to clear the cached key from memory. + */ +export class EncryptionSession { + private hkdfKey: CryptoKey | null; + private readonly argon2SaltB64: string; + private readonly memorySize: number; + private readonly iterations: number; + private readonly parallelism: number; + /** Use createEncryptionSession() instead of calling this directly. */ + constructor( + hkdfKey: CryptoKey, + argon2SaltB64: string, + params: { memorySize: number; iterations: number; parallelism: number } + ) { + this.hkdfKey = hkdfKey; + this.argon2SaltB64 = argon2SaltB64; + this.memorySize = params.memorySize; + this.iterations = params.iterations; + this.parallelism = params.parallelism; + } + + async encrypt(plaintext: string): Promise { + const key = this.getKeyOrThrow(); + const hkdfSalt = new Uint8Array(randomBytes(HKDF_SALT_LENGTH)); + const iv = new Uint8Array(randomBytes(GCM_IV_LENGTH)); + const aesKey = await hkdfDeriveAesKey(key, hkdfSalt, 'encrypt'); + const ct = await aesGcmEncrypt(aesKey, iv, plaintext); + return JSON.stringify(this.buildEnvelope(hkdfSalt, iv, ct)); + } + + async decrypt(ciphertext: string): Promise { + const key = this.getKeyOrThrow(); + const envelope = parseV2Envelope(ciphertext); + if (!envelope.hkdfSalt) { + throw new Error('envelope was not encrypted with a session; use decryptV2 instead'); + } + if (envelope.salt !== this.argon2SaltB64) { + throw new Error('envelope was not encrypted with this session'); + } + const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64')); + const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64')); + const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64')); + const aesKey = await hkdfDeriveAesKey(key, hkdfSalt, 'decrypt'); + return aesGcmDecrypt(aesKey, iv, ct); + } + + destroy(): void { + this.hkdfKey = null; + } + + private getKeyOrThrow(): CryptoKey { + if (this.hkdfKey === null) { + throw new Error('EncryptionSession has been destroyed'); + } + return this.hkdfKey; + } + + private buildEnvelope(hkdfSalt: Uint8Array, iv: Uint8Array, ct: Uint8Array): V2Envelope { + return { + v: 2, + m: this.memorySize, + t: this.iterations, + p: this.parallelism, + salt: this.argon2SaltB64, + hkdfSalt: Buffer.from(hkdfSalt).toString('base64'), + iv: Buffer.from(iv).toString('base64'), + ct: Buffer.from(ct).toString('base64'), + }; + } +} + +/** Create an EncryptionSession. Runs Argon2id once; all subsequent calls derive keys via HKDF. */ +export async function createEncryptionSession( + password: string, + options?: { memorySize?: number; iterations?: number; parallelism?: number; salt?: Uint8Array } +): Promise { + const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize; + const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations; + const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism; + const params = { memorySize, iterations, parallelism }; + + const argon2Salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength)); + if (argon2Salt.length !== ARGON2_DEFAULTS.saltLength) { + throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`); + } + + const hkdfKey = await argon2ToHkdfKey(password, argon2Salt, params); + const argon2SaltB64 = Buffer.from(argon2Salt).toString('base64'); + + return new EncryptionSession(hkdfKey, argon2SaltB64, params); +} diff --git a/modules/sdk-api/src/index.ts b/modules/sdk-api/src/index.ts index 5a62953b29..c4dd518485 100644 --- a/modules/sdk-api/src/index.ts +++ b/modules/sdk-api/src/index.ts @@ -1,5 +1,7 @@ export * from './api'; export * from './bitgoAPI'; export * from './encrypt'; +export * from './encryptionSession'; +export * from './encryptV2'; export * from './util'; export * from './types'; diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index 124028e538..f725ee67c5 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; -import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src'; +import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope, createEncryptionSession } from '../../src'; describe('encryption methods tests', () => { describe('encrypt', () => { @@ -207,4 +207,113 @@ describe('encryption methods tests', () => { assert.ok(!caughtError.message?.includes('sjcl'), 'error must not be from SJCL'); }); }); + + describe('EncryptionSession (HKDF caching)', () => { + const opts = { memorySize: 1024, iterations: 1, parallelism: 1 }; + const password = 'test-password'; + const plaintext = 'hello session'; + + it('session-produced envelope contains salt and hkdfSalt', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + session.destroy(); + + const envelope: V2Envelope = JSON.parse(ct); + assert.strictEqual(envelope.v, 2); + assert.ok(envelope.salt, 'must have argon2 salt'); + assert.ok(envelope.hkdfSalt, 'must have hkdf salt'); + assert.ok(envelope.iv, 'must have iv'); + assert.ok(envelope.ct, 'must have ciphertext'); + }); + + it('session round-trip via session.decrypt', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + const result = await session.decrypt(ct); + session.destroy(); + assert.strictEqual(result, plaintext); + }); + + it('session envelope can be decrypted standalone via decryptV2', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + session.destroy(); + const result = await decryptV2(password, ct); + assert.strictEqual(result, plaintext); + }); + + it('session envelope can be decrypted via decryptAsync', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + session.destroy(); + const result = await decryptAsync(password, ct); + assert.strictEqual(result, plaintext); + }); + + it('multiple encrypts produce different hkdfSalt values', async () => { + const session = await createEncryptionSession(password, opts); + const ct1 = await session.encrypt(plaintext); + const ct2 = await session.encrypt(plaintext); + session.destroy(); + const e1: V2Envelope = JSON.parse(ct1); + const e2: V2Envelope = JSON.parse(ct2); + assert.notStrictEqual(e1.hkdfSalt, e2.hkdfSalt); + }); + + it('all session encrypts share the same argon2 salt', async () => { + const session = await createEncryptionSession(password, opts); + const ct1 = await session.encrypt(plaintext); + const ct2 = await session.encrypt(plaintext); + session.destroy(); + const e1: V2Envelope = JSON.parse(ct1); + const e2: V2Envelope = JSON.parse(ct2); + assert.strictEqual(e1.salt, e2.salt); + }); + + it('wrong password rejected by decryptV2', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + session.destroy(); + await assert.rejects(() => decryptV2('wrong-password', ct)); + }); + + it('destroy prevents further encrypt calls', async () => { + const session = await createEncryptionSession(password, opts); + session.destroy(); + await assert.rejects(() => session.encrypt(plaintext), /destroyed/); + }); + + it('destroy prevents further decrypt calls', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext); + session.destroy(); + await assert.rejects(() => session.decrypt(ct), /destroyed/); + }); + + it('session rejects envelopes from a different session', async () => { + const session1 = await createEncryptionSession(password, opts); + const session2 = await createEncryptionSession(password, opts); + const ct = await session1.encrypt(plaintext); + await assert.rejects(() => session2.decrypt(ct), /not encrypted with this session/); + session1.destroy(); + session2.destroy(); + }); + + it('session rejects standard v2 envelopes (no hkdfSalt)', async () => { + const v2ct = await encryptV2(password, plaintext, opts); + const session = await createEncryptionSession(password, opts); + await assert.rejects(() => session.decrypt(v2ct), /use decryptV2/); + session.destroy(); + }); + + it('Argon2id params are stored in envelope', async () => { + const session = await createEncryptionSession(password, { memorySize: 2048, iterations: 2, parallelism: 2 }); + const ct = await session.encrypt(plaintext); + session.destroy(); + const envelope: V2Envelope = JSON.parse(ct); + assert.strictEqual(envelope.m, 2048); + assert.strictEqual(envelope.t, 2); + assert.strictEqual(envelope.p, 2); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index ac03322b92..6d7c3c07c4 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -14,6 +14,7 @@ import { EcdhDerivedKeypair, GetSigningKeyApi } from './keychain'; export interface BitGoBase { wallets(): any; // TODO - define v1 wallets type coin(coinName: string): IBaseCoin; // need to change it to BaseCoin once it's moved to @bitgo/sdk-core + /** @deprecated Use decryptAsync instead */ decrypt(params: DecryptOptions): string; decryptAsync(params: DecryptOptions): Promise; decryptKeys(params: DecryptKeysOptions): string[]; From 49267e83b3560f82d4561e0d8aa6e14c2995a658 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Fri, 17 Apr 2026 15:40:39 -0400 Subject: [PATCH 2/2] fix(sdk-api): address PR review nits for v2 encrypt - Throw on invalid JSON in decryptAsync instead of silently falling through - Use static import for @bitgo/argon2 instead of dynamic import - Remove @deprecated from decrypt() on BitGoBase interface - Add test for invalid JSON input in decryptAsync WCN-31 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/sdk-api/src/encrypt.ts | 3 +-- modules/sdk-api/src/encryptV2.ts | 2 +- modules/sdk-api/test/unit/encrypt.ts | 4 ++++ modules/sdk-core/src/bitgo/bitgoBase.ts | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 5df103b263..378714af60 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -56,11 +56,10 @@ export function decrypt(password: string, ciphertext: string): string { export async function decryptAsync(password: string, ciphertext: string): Promise { let isV2 = false; try { - // Peek at v field only to route -- internal format we produce, not external input. const envelope = JSON.parse(ciphertext); isV2 = envelope.v === 2; } catch { - // Not valid JSON -- fall through to v1. + throw new Error('decrypt: ciphertext is not valid JSON'); } if (isV2) { // Do not catch: wrong password on v2 must not silently fall through to v1. diff --git a/modules/sdk-api/src/encryptV2.ts b/modules/sdk-api/src/encryptV2.ts index b2aca03164..6ae7ef3189 100644 --- a/modules/sdk-api/src/encryptV2.ts +++ b/modules/sdk-api/src/encryptV2.ts @@ -1,3 +1,4 @@ +import { argon2id } from '@bitgo/argon2'; import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; import { randomBytes } from 'crypto'; import * as t from 'io-ts'; @@ -60,7 +61,6 @@ async function argon2Hash( salt: Uint8Array, params: { memorySize: number; iterations: number; parallelism: number } ): Promise { - const { argon2id } = await import('@bitgo/argon2'); return argon2id({ password, salt, diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index f725ee67c5..205ad18f81 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -195,6 +195,10 @@ describe('encryption methods tests', () => { await assert.rejects(() => decryptAsync('wrong', v2ct)); }); + it('throws on invalid JSON input', async () => { + await assert.rejects(() => decryptAsync(password, 'not-json'), /ciphertext is not valid JSON/); + }); + it('wrong password on v2 data does not fall through to v1 decrypt', async () => { const v2ct = await encryptV2(password, plaintext, { memorySize: 1024, iterations: 1, parallelism: 1 }); let caughtError: Error | undefined; diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index 6d7c3c07c4..ac03322b92 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -14,7 +14,6 @@ import { EcdhDerivedKeypair, GetSigningKeyApi } from './keychain'; export interface BitGoBase { wallets(): any; // TODO - define v1 wallets type coin(coinName: string): IBaseCoin; // need to change it to BaseCoin once it's moved to @bitgo/sdk-core - /** @deprecated Use decryptAsync instead */ decrypt(params: DecryptOptions): string; decryptAsync(params: DecryptOptions): Promise; decryptKeys(params: DecryptKeysOptions): string[];