diff --git a/modules/sdk-api/src/encryptV2.ts b/modules/sdk-api/src/encryptV2.ts index 67b6f0032c..6c232a7291 100644 --- a/modules/sdk-api/src/encryptV2.ts +++ b/modules/sdk-api/src/encryptV2.ts @@ -35,22 +35,29 @@ export const GCM_IV_LENGTH = 12; 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'); +const HKDF_SESSION_INFO = new TextEncoder().encode('bitgo-v2-session'); + +/** HKDF info for high-entropy IKM (ECDH secrets). Distinct from session info. */ +const HKDF_ONLY_INFO = new TextEncoder().encode('bitgo-v2-hkdf'); // Envelope codec +// +// Two valid v2 shapes (presence rule enforced after decode): +// - Argon2 / session: m, t, p, salt required; hkdfSalt optional (session) +// - HKDF-only: hkdfSalt required; Argon2 params absent 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 */ + m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'), + t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'), + p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'), + salt: base64String, + /** Base64-encoded HKDF salt -- session envelopes and HKDF-only envelopes */ hkdfSalt: base64String, /** Additional authenticated data for context binding (e.g. transaction hash + derivation path) */ adata: t.string, @@ -59,6 +66,25 @@ const V2EnvelopeCodec = t.intersection([ export type V2Envelope = t.TypeOf; +type Argon2Envelope = V2Envelope & { m: number; t: number; p: number; salt: string }; +type HkdfOnlyEnvelope = V2Envelope & { hkdfSalt: string }; + +function hasArgon2Params(envelope: V2Envelope): envelope is Argon2Envelope { + return ( + envelope.m !== undefined && envelope.t !== undefined && envelope.p !== undefined && envelope.salt !== undefined + ); +} + +function isHkdfOnlyEnvelope(envelope: V2Envelope): envelope is HkdfOnlyEnvelope { + return ( + envelope.hkdfSalt !== undefined && + envelope.m === undefined && + envelope.t === undefined && + envelope.p === undefined && + envelope.salt === undefined + ); +} + // Crypto helpers async function argon2Hash( @@ -95,9 +121,19 @@ export async function argon2ToHkdfKey( return subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']); } -export function hkdfDeriveAesKey(hkdfKey: CryptoKey, hkdfSalt: Uint8Array, usage: KeyUsage): Promise { +async function passwordToHkdfKey(password: string): Promise { + const ikm = new TextEncoder().encode(password); + return subtle.importKey('raw', ikm, 'HKDF', false, ['deriveKey']); +} + +export function hkdfDeriveAesKey( + hkdfKey: CryptoKey, + hkdfSalt: Uint8Array, + usage: KeyUsage, + info: Uint8Array = HKDF_SESSION_INFO +): Promise { return subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO }, + { name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, @@ -136,7 +172,11 @@ export function parseV2Envelope(ciphertext: string): V2Envelope { } catch { throw new Error('v2 decrypt: invalid JSON envelope'); } - return decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope'); + const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope'); + if (!hasArgon2Params(envelope) && !isHkdfOnlyEnvelope(envelope)) { + throw new Error('v2 decrypt: invalid envelope'); + } + return envelope; } // Public API @@ -190,22 +230,33 @@ export async function encryptV2( } /** - * 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 + * Decrypt a v2 envelope. Auto-detects shape: + * - Standard (Argon2 params, no hkdfSalt): Argon2id -> AES-GCM + * - Session (Argon2 params + hkdfSalt): Argon2id -> HKDF -> AES-GCM + * - HKDF-only (hkdfSalt, no Argon2 params): HKDF(password) -> 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 }; const adataBytes = envelope.adata ? new TextEncoder().encode(envelope.adata) : undefined; + if (isHkdfOnlyEnvelope(envelope)) { + const hkdfKey = await passwordToHkdfKey(password); + const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64')); + const aesKey = await hkdfDeriveAesKey(hkdfKey, hkdfSalt, 'decrypt', HKDF_ONLY_INFO); + return aesGcmDecrypt(aesKey, iv, ct, adataBytes); + } + + if (!hasArgon2Params(envelope)) { + throw new Error('v2 decrypt: invalid envelope'); + } + + const salt = new Uint8Array(Buffer.from(envelope.salt, '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')); diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index 52eacaf608..dfccba5526 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -1,9 +1,45 @@ import assert from 'assert'; -import { randomBytes } from 'crypto'; +import { randomBytes, webcrypto } from 'crypto'; import { decrypt, decryptV2, encrypt, encryptV2, V2Envelope, createEncryptionSession } from '../../src'; import { BitGoAPI } from '../../src/bitgoAPI'; +const subtle = globalThis.crypto?.subtle ?? webcrypto.subtle; + +/** + * Build an HKDF-only v2 envelope independently of the SDK. Nothing in the SDK emits this + * shape yet -- senders start doing so in WCN-2504 -- so the wire format is pinned here. + * The info string must stay in sync with HKDF_ONLY_INFO in encryptV2.ts. + */ +async function makeHkdfOnlyEnvelope( + password: string, + plaintext: string, + opts: { adata?: string; hkdfSalt?: Uint8Array } = {} +): Promise { + const hkdfSalt = opts.hkdfSalt ?? new Uint8Array(randomBytes(32)); + const iv = new Uint8Array(randomBytes(12)); + const ikm = await subtle.importKey('raw', new TextEncoder().encode(password), 'HKDF', false, ['deriveKey']); + const aesKey = await subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: new TextEncoder().encode('bitgo-v2-hkdf') }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 }; + if (opts.adata) params.additionalData = new TextEncoder().encode(opts.adata); + const ct = await subtle.encrypt(params, aesKey, new TextEncoder().encode(plaintext)); + + const envelope: V2Envelope = { + v: 2, + hkdfSalt: Buffer.from(hkdfSalt).toString('base64'), + iv: Buffer.from(iv).toString('base64'), + ct: Buffer.from(new Uint8Array(ct)).toString('base64'), + }; + if (opts.adata) envelope.adata = opts.adata; + return JSON.stringify(envelope); +} + describe('encryption methods tests', () => { describe('encrypt (async, default v2)', () => { const password = 'myPassword'; @@ -251,6 +287,97 @@ describe('encryption methods tests', () => { }); }); + describe('v2 HKDF-only decrypt (high-entropy IKM, no Argon2)', () => { + // ECDH-shaped input: 256-bit secret as hex, matching encryptPrvForUser. + const highEntropyPassword = Buffer.alloc(32, 0xab).toString('hex'); + const plaintext = 'shared-wallet-prv'; + + it('decrypts an HKDF-only envelope via decryptV2', async () => { + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext); + assert.strictEqual(await decryptV2(highEntropyPassword, ciphertext), plaintext); + }); + + it('decrypts an HKDF-only envelope via decrypt auto-detect', async () => { + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext); + assert.strictEqual(await decrypt(highEntropyPassword, ciphertext), plaintext); + }); + + it('decrypts a fixed HKDF-only envelope (wire format is pinned)', async () => { + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext, { + hkdfSalt: new Uint8Array(32).fill(0xcd), + }); + const envelope: V2Envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.v, 2); + assert.ok(envelope.hkdfSalt, 'must have hkdf salt'); + assert.strictEqual(envelope.m, undefined); + assert.strictEqual(envelope.t, undefined); + assert.strictEqual(envelope.p, undefined); + assert.strictEqual(envelope.salt, undefined); + assert.strictEqual(await decryptV2(highEntropyPassword, ciphertext), plaintext); + }); + + it('throws on wrong password', async () => { + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext); + await assert.rejects(() => decryptV2('wrong-password', ciphertext)); + }); + + it('decrypts an HKDF-only envelope carrying adata', async () => { + const adata = 'wallet-share'; + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext, { adata }); + assert.strictEqual(await decryptV2(highEntropyPassword, ciphertext), plaintext); + }); + + it('adata mismatch causes GCM decryption failure', async () => { + const ciphertext = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext, { adata: 'context-A' }); + const envelope = JSON.parse(ciphertext); + envelope.adata = 'context-B'; + await assert.rejects( + () => decryptV2(highEntropyPassword, JSON.stringify(envelope)), + /operation-specific reason|incorrect/i + ); + }); + + it('rejects mixed envelopes (hkdfSalt plus incomplete Argon2 params)', async () => { + const envelope = { v: 2, m: 1024, hkdfSalt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(highEntropyPassword, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelopes with neither Argon2 params nor hkdfSalt', async () => { + const envelope = { v: 2, iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(highEntropyPassword, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('Argon2 and HKDF-only envelopes stay distinguishable and both decrypt', async () => { + const argon2ct = await encryptV2(highEntropyPassword, plaintext, { + memorySize: 1024, + iterations: 1, + parallelism: 1, + }); + const hkdfct = await makeHkdfOnlyEnvelope(highEntropyPassword, plaintext); + assert.strictEqual(await decryptV2(highEntropyPassword, argon2ct), plaintext); + assert.strictEqual(await decryptV2(highEntropyPassword, hkdfct), plaintext); + const argon2env = JSON.parse(argon2ct); + const hkdfenv = JSON.parse(hkdfct); + assert.ok(argon2env.salt); + assert.ok(hkdfenv.hkdfSalt); + assert.strictEqual(argon2env.hkdfSalt, undefined); + assert.strictEqual(hkdfenv.salt, undefined); + }); + + it('no SDK encrypt path emits HKDF-only envelopes yet', async () => { + const fromEncrypt: V2Envelope = JSON.parse(await encrypt(highEntropyPassword, plaintext)); + const fromEncryptV2: V2Envelope = JSON.parse(await encryptV2(highEntropyPassword, plaintext)); + for (const envelope of [fromEncrypt, fromEncryptV2]) { + assert.strictEqual(envelope.v, 2); + assert.ok(envelope.m, 'must still emit Argon2 params'); + assert.ok(envelope.t); + assert.ok(envelope.p); + assert.ok(envelope.salt); + assert.strictEqual(envelope.hkdfSalt, undefined); + } + }); + }); + describe('EncryptionSession (HKDF caching)', () => { const opts = { memorySize: 1024, iterations: 1, parallelism: 1 }; const password = 'test-password'; @@ -401,6 +528,8 @@ describe('encryption methods tests', () => { const ct = await bitgo.encrypt({ input: plaintext, password }); const envelope: V2Envelope = JSON.parse(ct); assert.strictEqual(envelope.v, 2); + assert.ok(envelope.salt, 'public encrypt must still emit Argon2 params'); + assert.strictEqual(envelope.hkdfSalt, undefined); assert.strictEqual(await decrypt(password, ct), plaintext); });