|
| 1 | +import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; |
1 | 2 | import * as sjcl from '@bitgo/sjcl'; |
2 | 3 | import { randomBytes } from 'crypto'; |
| 4 | +import * as t from 'io-ts'; |
| 5 | + |
| 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>; |
3 | 42 |
|
4 | 43 | /** |
5 | 44 | * convert a 4 element Uint8Array to a 4 byte Number |
@@ -60,3 +99,128 @@ export function encrypt( |
60 | 99 | export function decrypt(password: string, ciphertext: string): string { |
61 | 100 | return sjcl.decrypt(password, ciphertext); |
62 | 101 | } |
| 102 | + |
| 103 | +/** |
| 104 | + * Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM) |
| 105 | + * from the JSON envelope's `v` field. |
| 106 | + * |
| 107 | + * This is the migration path from sync `decrypt()`. Clients should move to |
| 108 | + * `await decryptAsync()` before the breaking release that makes `decrypt()` async. |
| 109 | + */ |
| 110 | +export async function decryptAsync(password: string, ciphertext: string): Promise<string> { |
| 111 | + let isV2 = false; |
| 112 | + try { |
| 113 | + // Only peeking at the v field to route; this is an internal format we produce, not external input. |
| 114 | + const envelope = JSON.parse(ciphertext); |
| 115 | + isV2 = envelope.v === 2; |
| 116 | + } catch { |
| 117 | + // Not valid JSON -- fall through to v1 |
| 118 | + } |
| 119 | + 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. |
| 122 | + return decryptV2(password, ciphertext); |
| 123 | + } |
| 124 | + return sjcl.decrypt(password, ciphertext); |
| 125 | +} |
| 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