Skip to content

Commit 2ece7cd

Browse files
authored
Merge pull request #8495 from BitGo/WCN-30-v2-encrypt-decrypt
feat(sdk-api): add v2 encrypt/decrypt using Argon2id
2 parents 9026945 + f4ae573 commit 2ece7cd

10 files changed

Lines changed: 381 additions & 2 deletions

File tree

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ COPY --from=builder /tmp/bitgo/modules/bitgo /var/modules/bitgo/
5656
COPY --from=builder /tmp/bitgo/modules/abstract-utxo /var/modules/abstract-utxo/
5757
COPY --from=builder /tmp/bitgo/modules/blockapis /var/modules/blockapis/
5858
COPY --from=builder /tmp/bitgo/modules/sdk-api /var/modules/sdk-api/
59+
COPY --from=builder /tmp/bitgo/modules/argon2 /var/modules/argon2/
5960
COPY --from=builder /tmp/bitgo/modules/sdk-hmac /var/modules/sdk-hmac/
6061
COPY --from=builder /tmp/bitgo/modules/unspents /var/modules/unspents/
6162
COPY --from=builder /tmp/bitgo/modules/utxo-core /var/modules/utxo-core/
@@ -156,6 +157,7 @@ cd /var/modules/bitgo && yarn link && \
156157
cd /var/modules/abstract-utxo && yarn link && \
157158
cd /var/modules/blockapis && yarn link && \
158159
cd /var/modules/sdk-api && yarn link && \
160+
cd /var/modules/argon2 && yarn link && \
159161
cd /var/modules/sdk-hmac && yarn link && \
160162
cd /var/modules/unspents && yarn link && \
161163
cd /var/modules/utxo-core && yarn link && \
@@ -259,6 +261,7 @@ RUN cd /var/bitgo-express && \
259261
yarn link @bitgo/abstract-utxo && \
260262
yarn link @bitgo/blockapis && \
261263
yarn link @bitgo/sdk-api && \
264+
yarn link @bitgo/argon2 && \
262265
yarn link @bitgo/sdk-hmac && \
263266
yarn link @bitgo/unspents && \
264267
yarn link @bitgo/utxo-core && \

modules/argon2/index.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// CJS/ESM interop: import the UMD bundle as a default and re-export named functions
2+
import argon2 from './argon2.umd.min.js';
3+
export const { argon2d, argon2i, argon2id, argon2Verify } = argon2;

modules/argon2/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
"description": "Vendored argon2 (hash-wasm v4.12.0) for BitGo SDK",
55
"main": "argon2.umd.min.js",
66
"types": "index.d.ts",
7+
"exports": {
8+
".": {
9+
"import": "./index.mjs",
10+
"require": "./argon2.umd.min.js"
11+
}
12+
},
713
"scripts": {
814
"test": "mocha test/**/*.ts",
915
"verify": "./scripts/verify-vendor.sh"
1016
},
1117
"files": [
1218
"argon2.umd.min.js",
19+
"index.mjs",
1320
"index.d.ts",
1421
"LICENSE",
1522
"PROVENANCE.md"

modules/sdk-api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
]
4141
},
4242
"dependencies": {
43+
"@bitgo/argon2": "^1.0.0",
4344
"@bitgo/sdk-core": "^36.40.0",
45+
"io-ts": "npm:@bitgo-forks/io-ts@2.1.4",
4446
"@bitgo/sdk-hmac": "^1.9.0",
4547
"@bitgo/sjcl": "^1.1.0",
4648
"@bitgo/unspents": "^0.51.3",

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
toBitgoRequest,
4141
verifyResponseAsync,
4242
} from './api';
43-
import { decrypt, encrypt } from './encrypt';
43+
import { decrypt, decryptAsync, encrypt } from './encrypt';
4444
import { verifyAddress } from './v1/verifyAddress';
4545
import {
4646
AccessTokenOptions,
@@ -734,6 +734,29 @@ export class BitGoAPI implements BitGoBase {
734734
}
735735
}
736736

737+
/**
738+
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id).
739+
* Migration path from sync decrypt() -- use this before the breaking release.
740+
*/
741+
async decryptAsync(params: DecryptOptions): Promise<string> {
742+
params = params || {};
743+
common.validateParams(params, ['input', 'password'], []);
744+
if (!params.password) {
745+
throw new Error(`cannot decrypt without password`);
746+
}
747+
try {
748+
return await decryptAsync(params.password, params.input);
749+
} catch (error) {
750+
if (
751+
error.message.includes("ccm: tag doesn't match") ||
752+
error.message.includes('The operation failed for an operation-specific reason')
753+
) {
754+
throw new Error('incorrect password');
755+
}
756+
throw error;
757+
}
758+
}
759+
737760
/**
738761
* Attempt to decrypt multiple wallet keys with the provided passphrase
739762
* @param {DecryptKeysOptions} params - Parameters object containing wallet key pairs and password

modules/sdk-api/src/encrypt.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
1+
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
12
import * as sjcl from '@bitgo/sjcl';
23
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>;
342

443
/**
544
* convert a 4 element Uint8Array to a 4 byte Number
@@ -60,3 +99,128 @@ export function encrypt(
6099
export function decrypt(password: string, ciphertext: string): string {
61100
return sjcl.decrypt(password, ciphertext);
62101
}
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

Comments
 (0)