1- import { base64String , boundedInt , decodeWithCodec } from '@bitgo/sdk-core' ;
21import * as sjcl from '@bitgo/sjcl' ;
32import { 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). */
5720export 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. */
9946export 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 */
11056export 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