Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 67 additions & 16 deletions modules/sdk-api/src/encryptV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -59,6 +66,25 @@ const V2EnvelopeCodec = t.intersection([

export type V2Envelope = t.TypeOf<typeof V2EnvelopeCodec>;

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(
Expand Down Expand Up @@ -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<CryptoKey> {
async function passwordToHkdfKey(password: string): Promise<CryptoKey> {
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<CryptoKey> {
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string> {
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'));
Expand Down
131 changes: 130 additions & 1 deletion modules/sdk-api/test/unit/encrypt.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down
Loading