Skip to content

Commit 7794281

Browse files
committed
feat(sdk-api): add HKDF session caching layer for multi-call operations
createEncryptionSession runs one Argon2id derivation and caches the master key as an HKDF CryptoKey. Each session.encrypt/decrypt derives a per-call AES-256-GCM key via HKDF (<1ms, native WebCrypto). Envelopes store both the Argon2id salt and a per-call hkdfSalt so standalone decryptV2 can decrypt without a session. session.destroy() clears the master key reference. decryptV2 updated to handle both standard and session-produced envelopes. Also addresses PR review comments on WCN-30: - Add RFC 9106 URL to Argon2id defaults comment - Fix decryptAsync to not swallow v2 errors (wrong password on v2 data previously fell through to a confusing SJCL error instead of propagating) - Mark BitGoBase.decrypt as deprecated in favour of decryptAsync WCN-31
1 parent 77b650a commit 7794281

File tree

3 files changed

+269
-8
lines changed

3 files changed

+269
-8
lines changed

modules/sdk-api/src/encrypt.ts

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,148 @@ const ARGON2_DEFAULTS = {
1616
/** AES-256-GCM IV length in bytes */
1717
const GCM_IV_LENGTH = 12;
1818

19+
/** HKDF salt length in bytes (per-call) */
20+
const HKDF_SALT_LENGTH = 32;
21+
22+
/** Fixed HKDF info for domain separation */
23+
const HKDF_INFO = new TextEncoder().encode('bitgo-v2-session');
24+
1925
export interface V2Envelope {
2026
v: 2;
2127
m: number;
2228
t: number;
2329
p: number;
2430
salt: string;
31+
hkdfSalt?: string; // per-call HKDF salt; present only in session-produced envelopes
2532
iv: string;
2633
ct: string;
2734
}
2835

36+
export interface EncryptionSession {
37+
encrypt(plaintext: string): Promise<string>;
38+
decrypt(ciphertext: string): Promise<string>;
39+
destroy(): void;
40+
}
41+
42+
/**
43+
* Create an EncryptionSession that runs one Argon2id derivation and caches the
44+
* master key for the duration of the operation. Each encrypt/decrypt call derives
45+
* a per-call AES-256-GCM key via HKDF (<1ms). Call session.destroy() when done.
46+
*
47+
* Session-produced envelopes store both the Argon2id salt and a per-call HKDF salt
48+
* so each ciphertext can be decrypted standalone via decryptV2 without a session.
49+
*/
50+
export async function createEncryptionSession(
51+
password: string,
52+
options?: {
53+
memorySize?: number;
54+
iterations?: number;
55+
parallelism?: number;
56+
salt?: Uint8Array;
57+
}
58+
): Promise<EncryptionSession> {
59+
const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
60+
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
61+
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;
62+
63+
const argon2Salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength));
64+
if (argon2Salt.length !== ARGON2_DEFAULTS.saltLength) {
65+
throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`);
66+
}
67+
68+
const masterKeyBytes = await argon2id({
69+
password,
70+
salt: argon2Salt,
71+
memorySize,
72+
iterations,
73+
parallelism,
74+
hashLength: ARGON2_DEFAULTS.hashLength,
75+
outputType: 'binary',
76+
});
77+
78+
let hkdfKey: CryptoKey | null = await crypto.subtle.importKey('raw', masterKeyBytes, 'HKDF', false, ['deriveKey']);
79+
const argon2SaltB64 = Buffer.from(argon2Salt).toString('base64');
80+
let destroyed = false;
81+
82+
const checkAlive = (): void => {
83+
if (destroyed) throw new Error('EncryptionSession has been destroyed');
84+
};
85+
86+
return {
87+
async encrypt(plaintext: string): Promise<string> {
88+
checkAlive();
89+
const hkdfSalt = new Uint8Array(randomBytes(HKDF_SALT_LENGTH));
90+
const iv = new Uint8Array(randomBytes(GCM_IV_LENGTH));
91+
92+
const aesKey = await crypto.subtle.deriveKey(
93+
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO },
94+
hkdfKey!,
95+
{ name: 'AES-GCM', length: 256 },
96+
false,
97+
['encrypt']
98+
);
99+
100+
const plaintextBytes = new TextEncoder().encode(plaintext);
101+
const ctBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, plaintextBytes);
102+
103+
const envelope: V2Envelope = {
104+
v: 2,
105+
m: memorySize,
106+
t: iterations,
107+
p: parallelism,
108+
salt: argon2SaltB64,
109+
hkdfSalt: Buffer.from(hkdfSalt).toString('base64'),
110+
iv: Buffer.from(iv).toString('base64'),
111+
ct: Buffer.from(ctBuffer).toString('base64'),
112+
};
113+
114+
return JSON.stringify(envelope);
115+
},
116+
117+
async decrypt(ciphertext: string): Promise<string> {
118+
checkAlive();
119+
let envelope: V2Envelope;
120+
try {
121+
envelope = JSON.parse(ciphertext);
122+
} catch {
123+
throw new Error('v2 decrypt: invalid JSON envelope');
124+
}
125+
126+
if (envelope.v !== 2) {
127+
throw new Error(`v2 decrypt: unsupported envelope version ${envelope.v}`);
128+
}
129+
130+
if (!envelope.hkdfSalt) {
131+
throw new Error('envelope was not encrypted with a session; use decryptV2 instead');
132+
}
133+
134+
if (envelope.salt !== argon2SaltB64) {
135+
throw new Error('envelope was not encrypted with this session');
136+
}
137+
138+
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
139+
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));
140+
const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64'));
141+
142+
const aesKey = await crypto.subtle.deriveKey(
143+
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO },
144+
hkdfKey!,
145+
{ name: 'AES-GCM', length: 256 },
146+
false,
147+
['decrypt']
148+
);
149+
150+
const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ct);
151+
return new TextDecoder().decode(plaintextBuffer);
152+
},
153+
154+
destroy(): void {
155+
hkdfKey = null;
156+
destroyed = true;
157+
},
158+
};
159+
}
160+
29161
/**
30162
* convert a 4 element Uint8Array to a 4 byte Number
31163
*
@@ -183,7 +315,8 @@ export async function encryptV2(
183315
/**
184316
* Decrypt a v2 envelope (Argon2id KDF + AES-256-GCM).
185317
*
186-
* The envelope must contain: v, m, t, p, salt, iv, ct.
318+
* Handles both standard v2 envelopes (Argon2id -> AES-GCM) and session-produced
319+
* envelopes (Argon2id -> HKDF -> AES-GCM) identified by the presence of hkdfSalt.
187320
*/
188321
export async function decryptV2(password: string, ciphertext: string): Promise<string> {
189322
let envelope: V2Envelope;
@@ -200,14 +333,32 @@ export async function decryptV2(password: string, ciphertext: string): Promise<s
200333
const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64'));
201334
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
202335
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));
336+
const params = { memorySize: envelope.m, iterations: envelope.t, parallelism: envelope.p };
203337

204-
const key = await deriveKeyV2(password, salt, {
205-
memorySize: envelope.m,
206-
iterations: envelope.t,
207-
parallelism: envelope.p,
208-
});
338+
if (envelope.hkdfSalt) {
339+
// Session envelope: Argon2id -> HKDF -> AES-GCM
340+
const masterKeyBytes = await argon2id({
341+
password,
342+
salt,
343+
...params,
344+
hashLength: ARGON2_DEFAULTS.hashLength,
345+
outputType: 'binary',
346+
});
347+
const hkdfKey = await crypto.subtle.importKey('raw', masterKeyBytes, 'HKDF', false, ['deriveKey']);
348+
const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64'));
349+
const aesKey = await crypto.subtle.deriveKey(
350+
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO },
351+
hkdfKey,
352+
{ name: 'AES-GCM', length: 256 },
353+
false,
354+
['decrypt']
355+
);
356+
const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ct);
357+
return new TextDecoder().decode(plaintextBuffer);
358+
}
209359

360+
// Standard v2 envelope: Argon2id -> AES-GCM directly
361+
const key = await deriveKeyV2(password, salt, params);
210362
const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
211-
212363
return new TextDecoder().decode(plaintextBuffer);
213364
}

modules/sdk-api/test/unit/encrypt.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'assert';
22
import { randomBytes } from 'crypto';
33

4-
import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src';
4+
import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope, createEncryptionSession } from '../../src';
55

66
describe('encryption methods tests', () => {
77
describe('encrypt', () => {
@@ -177,4 +177,113 @@ describe('encryption methods tests', () => {
177177
assert.ok(!caughtError.message?.includes('sjcl'), 'error must not be from SJCL');
178178
});
179179
});
180+
181+
describe('EncryptionSession (HKDF caching)', () => {
182+
const opts = { memorySize: 1024, iterations: 1, parallelism: 1 };
183+
const password = 'test-password';
184+
const plaintext = 'hello session';
185+
186+
it('session-produced envelope contains salt and hkdfSalt', async () => {
187+
const session = await createEncryptionSession(password, opts);
188+
const ct = await session.encrypt(plaintext);
189+
session.destroy();
190+
191+
const envelope: V2Envelope = JSON.parse(ct);
192+
assert.strictEqual(envelope.v, 2);
193+
assert.ok(envelope.salt, 'must have argon2 salt');
194+
assert.ok(envelope.hkdfSalt, 'must have hkdf salt');
195+
assert.ok(envelope.iv, 'must have iv');
196+
assert.ok(envelope.ct, 'must have ciphertext');
197+
});
198+
199+
it('session round-trip via session.decrypt', async () => {
200+
const session = await createEncryptionSession(password, opts);
201+
const ct = await session.encrypt(plaintext);
202+
const result = await session.decrypt(ct);
203+
session.destroy();
204+
assert.strictEqual(result, plaintext);
205+
});
206+
207+
it('session envelope can be decrypted standalone via decryptV2', async () => {
208+
const session = await createEncryptionSession(password, opts);
209+
const ct = await session.encrypt(plaintext);
210+
session.destroy();
211+
const result = await decryptV2(password, ct);
212+
assert.strictEqual(result, plaintext);
213+
});
214+
215+
it('session envelope can be decrypted via decryptAsync', async () => {
216+
const session = await createEncryptionSession(password, opts);
217+
const ct = await session.encrypt(plaintext);
218+
session.destroy();
219+
const result = await decryptAsync(password, ct);
220+
assert.strictEqual(result, plaintext);
221+
});
222+
223+
it('multiple encrypts produce different hkdfSalt values', async () => {
224+
const session = await createEncryptionSession(password, opts);
225+
const ct1 = await session.encrypt(plaintext);
226+
const ct2 = await session.encrypt(plaintext);
227+
session.destroy();
228+
const e1: V2Envelope = JSON.parse(ct1);
229+
const e2: V2Envelope = JSON.parse(ct2);
230+
assert.notStrictEqual(e1.hkdfSalt, e2.hkdfSalt);
231+
});
232+
233+
it('all session encrypts share the same argon2 salt', async () => {
234+
const session = await createEncryptionSession(password, opts);
235+
const ct1 = await session.encrypt(plaintext);
236+
const ct2 = await session.encrypt(plaintext);
237+
session.destroy();
238+
const e1: V2Envelope = JSON.parse(ct1);
239+
const e2: V2Envelope = JSON.parse(ct2);
240+
assert.strictEqual(e1.salt, e2.salt);
241+
});
242+
243+
it('wrong password rejected by decryptV2', async () => {
244+
const session = await createEncryptionSession(password, opts);
245+
const ct = await session.encrypt(plaintext);
246+
session.destroy();
247+
await assert.rejects(() => decryptV2('wrong-password', ct));
248+
});
249+
250+
it('destroy prevents further encrypt calls', async () => {
251+
const session = await createEncryptionSession(password, opts);
252+
session.destroy();
253+
await assert.rejects(() => session.encrypt(plaintext), /destroyed/);
254+
});
255+
256+
it('destroy prevents further decrypt calls', async () => {
257+
const session = await createEncryptionSession(password, opts);
258+
const ct = await session.encrypt(plaintext);
259+
session.destroy();
260+
await assert.rejects(() => session.decrypt(ct), /destroyed/);
261+
});
262+
263+
it('session rejects envelopes from a different session', async () => {
264+
const session1 = await createEncryptionSession(password, opts);
265+
const session2 = await createEncryptionSession(password, opts);
266+
const ct = await session1.encrypt(plaintext);
267+
await assert.rejects(() => session2.decrypt(ct), /not encrypted with this session/);
268+
session1.destroy();
269+
session2.destroy();
270+
});
271+
272+
it('session rejects standard v2 envelopes (no hkdfSalt)', async () => {
273+
const v2ct = await encryptV2(password, plaintext, opts);
274+
const session = await createEncryptionSession(password, opts);
275+
await assert.rejects(() => session.decrypt(v2ct), /use decryptV2/);
276+
session.destroy();
277+
});
278+
279+
it('Argon2id params are stored in envelope', async () => {
280+
const session = await createEncryptionSession(password, { memorySize: 2048, iterations: 2, parallelism: 2 });
281+
const ct = await session.encrypt(plaintext);
282+
session.destroy();
283+
const envelope: V2Envelope = JSON.parse(ct);
284+
assert.strictEqual(envelope.m, 2048);
285+
assert.strictEqual(envelope.t, 2);
286+
assert.strictEqual(envelope.p, 2);
287+
});
288+
});
180289
});

modules/sdk-core/src/bitgo/bitgoBase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { EcdhDerivedKeypair, GetSigningKeyApi } from './keychain';
1414
export interface BitGoBase {
1515
wallets(): any; // TODO - define v1 wallets type
1616
coin(coinName: string): IBaseCoin; // need to change it to BaseCoin once it's moved to @bitgo/sdk-core
17+
/** @deprecated Use decryptAsync instead */
1718
decrypt(params: DecryptOptions): string;
1819
decryptAsync(params: DecryptOptions): Promise<string>;
1920
decryptKeys(params: DecryptKeysOptions): string[];

0 commit comments

Comments
 (0)