@@ -16,16 +16,148 @@ const ARGON2_DEFAULTS = {
1616/** AES-256-GCM IV length in bytes */
1717const 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+
1925export 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 */
188321export 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}
0 commit comments