Skip to content

Commit fafc5ef

Browse files
pranavjain97claude
andcommitted
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 <noreply@anthropic.com>
1 parent 4561449 commit fafc5ef

6 files changed

Lines changed: 426 additions & 172 deletions

File tree

modules/sdk-api/src/encrypt.ts

Lines changed: 15 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,7 @@
1-
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
21
import * as sjcl from '@bitgo/sjcl';
32
import { randomBytes } from 'crypto';
4-
import * as t from 'io-ts';
53

6-
/** Default Argon2id parameters per RFC 9106 second recommendation
7-
* @see https://www.rfc-editor.org/rfc/rfc9106#section-4
8-
*/
9-
const ARGON2_DEFAULTS = {
10-
memorySize: 65536, // 64 MiB in KiB
11-
iterations: 3,
12-
parallelism: 4,
13-
hashLength: 32, // 256-bit key
14-
saltLength: 16, // 128-bit salt
15-
} as const;
16-
17-
/** Maximum allowed Argon2id parameters to prevent DoS via crafted envelopes.
18-
* memorySize: 256 MiB (4x default) -- caps memory allocation on untrusted input.
19-
* iterations: 16 -- caps CPU time.
20-
* parallelism: 16 -- caps thread count.
21-
*/
22-
const ARGON2_MAX = {
23-
memorySize: 262144,
24-
iterations: 16,
25-
parallelism: 16,
26-
} as const;
27-
28-
/** AES-256-GCM IV length in bytes */
29-
const GCM_IV_LENGTH = 12;
30-
31-
const V2EnvelopeCodec = t.type({
32-
v: t.literal(2),
33-
m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'),
34-
t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'),
35-
p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'),
36-
salt: base64String,
37-
iv: base64String,
38-
ct: base64String,
39-
});
40-
41-
export type V2Envelope = t.TypeOf<typeof V2EnvelopeCodec>;
4+
import { decryptV2 } from './encryptV2';
425

436
/**
447
* convert a 4 element Uint8Array to a 4 byte Number
@@ -50,34 +13,21 @@ export function bytesToWord(bytes?: Uint8Array | number[]): number {
5013
if (!(bytes instanceof Uint8Array) || bytes.length !== 4) {
5114
throw new Error('bytes must be a Uint8Array with length 4');
5215
}
53-
5416
return bytes.reduce((num, byte) => num * 0x100 + byte, 0);
5517
}
5618

19+
/** Encrypt using legacy v1 SJCL (PBKDF2-SHA256 + AES-256-CCM). */
5720
export function encrypt(
5821
password: string,
5922
plaintext: string,
60-
options?: {
61-
salt?: Buffer;
62-
iv?: Buffer;
63-
adata?: string;
64-
}
23+
options?: { salt?: Buffer; iv?: Buffer; adata?: string }
6524
): string {
6625
const salt = options?.salt || randomBytes(8);
67-
if (salt.length !== 8) {
68-
throw new Error(`salt must be 8 bytes`);
69-
}
26+
if (salt.length !== 8) throw new Error('salt must be 8 bytes');
7027
const iv = options?.iv || randomBytes(16);
71-
if (iv.length !== 16) {
72-
throw new Error(`iv must be 16 bytes`);
73-
}
74-
const encryptOptions: {
75-
iter: number;
76-
ks: number;
77-
salt: number[];
78-
iv: number[];
79-
adata?: string;
80-
} = {
28+
if (iv.length !== 16) throw new Error('iv must be 16 bytes');
29+
30+
const encryptOptions: { iter: number; ks: number; salt: number[]; iv: number[]; adata?: string } = {
8131
iter: 10000,
8232
ks: 256,
8333
salt: [bytesToWord(salt.slice(0, 4)), bytesToWord(salt.slice(4))],
@@ -88,139 +38,33 @@ export function encrypt(
8838
bytesToWord(iv.slice(12, 16)),
8939
],
9040
};
91-
92-
if (options?.adata) {
93-
encryptOptions.adata = options.adata;
94-
}
95-
41+
if (options?.adata) encryptOptions.adata = options.adata;
9642
return sjcl.encrypt(password, plaintext, encryptOptions);
9743
}
9844

45+
/** Decrypt a v1 SJCL envelope. */
9946
export function decrypt(password: string, ciphertext: string): string {
10047
return sjcl.decrypt(password, ciphertext);
10148
}
10249

10350
/**
104-
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM)
105-
* from the JSON envelope's `v` field.
51+
* Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt.
10652
*
107-
* This is the migration path from sync `decrypt()`. Clients should move to
108-
* `await decryptAsync()` before the breaking release that makes `decrypt()` async.
53+
* Migration path from sync `decrypt()`. Move call sites to `decryptAsync()` before
54+
* the breaking release that flips the default to v2.
10955
*/
11056
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
11157
let isV2 = false;
11258
try {
113-
// Only peeking at the v field to route; this is an internal format we produce, not external input.
59+
// Peek at v field only to route -- internal format we produce, not external input.
11460
const envelope = JSON.parse(ciphertext);
11561
isV2 = envelope.v === 2;
11662
} catch {
117-
// Not valid JSON -- fall through to v1
63+
// Not valid JSON -- fall through to v1.
11864
}
11965
if (isV2) {
120-
// Do not catch errors here: a wrong password or corrupt envelope on v2 data
121-
// should propagate, not silently fall through to a v1 decrypt attempt.
66+
// Do not catch: wrong password on v2 must not silently fall through to v1.
12267
return decryptV2(password, ciphertext);
12368
}
12469
return sjcl.decrypt(password, ciphertext);
12570
}
126-
127-
/**
128-
* Derive a 256-bit key from a password using Argon2id.
129-
*/
130-
async function deriveKeyV2(
131-
password: string,
132-
salt: Uint8Array,
133-
params: { memorySize: number; iterations: number; parallelism: number }
134-
): Promise<CryptoKey> {
135-
const { argon2id } = await import('@bitgo/argon2');
136-
const keyBytes = await argon2id({
137-
password,
138-
salt,
139-
memorySize: params.memorySize,
140-
iterations: params.iterations,
141-
parallelism: params.parallelism,
142-
hashLength: ARGON2_DEFAULTS.hashLength,
143-
outputType: 'binary',
144-
});
145-
146-
return crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
147-
}
148-
149-
/**
150-
* Encrypt plaintext using Argon2id KDF + AES-256-GCM.
151-
*
152-
* Returns a JSON string containing a self-describing v2 envelope
153-
* with Argon2id parameters, salt, IV, and ciphertext.
154-
*/
155-
export async function encryptV2(
156-
password: string,
157-
plaintext: string,
158-
options?: {
159-
salt?: Uint8Array;
160-
iv?: Uint8Array;
161-
memorySize?: number;
162-
iterations?: number;
163-
parallelism?: number;
164-
}
165-
): Promise<string> {
166-
const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
167-
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
168-
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;
169-
170-
const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength));
171-
if (salt.length !== ARGON2_DEFAULTS.saltLength) {
172-
throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`);
173-
}
174-
175-
const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH));
176-
if (iv.length !== GCM_IV_LENGTH) {
177-
throw new Error(`iv must be ${GCM_IV_LENGTH} bytes`);
178-
}
179-
180-
const key = await deriveKeyV2(password, salt, { memorySize, iterations, parallelism });
181-
182-
const plaintextBytes = new TextEncoder().encode(plaintext);
183-
const ctBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes);
184-
185-
const envelope: V2Envelope = {
186-
v: 2,
187-
m: memorySize,
188-
t: iterations,
189-
p: parallelism,
190-
salt: Buffer.from(salt).toString('base64'),
191-
iv: Buffer.from(iv).toString('base64'),
192-
ct: Buffer.from(ctBuffer).toString('base64'),
193-
};
194-
195-
return JSON.stringify(envelope);
196-
}
197-
198-
/**
199-
* Decrypt a v2 envelope (Argon2id KDF + AES-256-GCM).
200-
*
201-
* The envelope must contain: v, m, t, p, salt, iv, ct.
202-
*/
203-
export async function decryptV2(password: string, ciphertext: string): Promise<string> {
204-
let parsed: unknown;
205-
try {
206-
parsed = JSON.parse(ciphertext);
207-
} catch {
208-
throw new Error('v2 decrypt: invalid JSON envelope');
209-
}
210-
211-
const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope');
212-
213-
const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64'));
214-
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
215-
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));
216-
217-
const key = await deriveKeyV2(password, salt, {
218-
memorySize: envelope.m,
219-
iterations: envelope.t,
220-
parallelism: envelope.p,
221-
});
222-
223-
const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
224-
225-
return new TextDecoder().decode(plaintextBuffer);
226-
}

0 commit comments

Comments
 (0)