Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ COPY --from=builder /tmp/bitgo/modules/bitgo /var/modules/bitgo/
COPY --from=builder /tmp/bitgo/modules/abstract-utxo /var/modules/abstract-utxo/
COPY --from=builder /tmp/bitgo/modules/blockapis /var/modules/blockapis/
COPY --from=builder /tmp/bitgo/modules/sdk-api /var/modules/sdk-api/
COPY --from=builder /tmp/bitgo/modules/argon2 /var/modules/argon2/
COPY --from=builder /tmp/bitgo/modules/sdk-hmac /var/modules/sdk-hmac/
COPY --from=builder /tmp/bitgo/modules/unspents /var/modules/unspents/
COPY --from=builder /tmp/bitgo/modules/utxo-core /var/modules/utxo-core/
Expand Down Expand Up @@ -156,6 +157,7 @@ cd /var/modules/bitgo && yarn link && \
cd /var/modules/abstract-utxo && yarn link && \
cd /var/modules/blockapis && yarn link && \
cd /var/modules/sdk-api && yarn link && \
cd /var/modules/argon2 && yarn link && \
cd /var/modules/sdk-hmac && yarn link && \
cd /var/modules/unspents && yarn link && \
cd /var/modules/utxo-core && yarn link && \
Expand Down Expand Up @@ -259,6 +261,7 @@ RUN cd /var/bitgo-express && \
yarn link @bitgo/abstract-utxo && \
yarn link @bitgo/blockapis && \
yarn link @bitgo/sdk-api && \
yarn link @bitgo/argon2 && \
yarn link @bitgo/sdk-hmac && \
yarn link @bitgo/unspents && \
yarn link @bitgo/utxo-core && \
Expand Down
3 changes: 3 additions & 0 deletions modules/argon2/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// CJS/ESM interop: import the UMD bundle as a default and re-export named functions
import argon2 from './argon2.umd.min.js';
export const { argon2d, argon2i, argon2id, argon2Verify } = argon2;
7 changes: 7 additions & 0 deletions modules/argon2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
"description": "Vendored argon2 (hash-wasm v4.12.0) for BitGo SDK",
"main": "argon2.umd.min.js",
"types": "index.d.ts",
"exports": {
".": {
"import": "./index.mjs",
"require": "./argon2.umd.min.js"
}
},
"scripts": {
"test": "mocha test/**/*.ts",
"verify": "./scripts/verify-vendor.sh"
},
"files": [
"argon2.umd.min.js",
"index.mjs",
"index.d.ts",
"LICENSE",
"PROVENANCE.md"
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
]
},
"dependencies": {
"@bitgo/argon2": "^1.0.0",
"@bitgo/sdk-core": "^36.40.0",
"io-ts": "npm:@bitgo-forks/io-ts@2.1.4",
"@bitgo/sdk-hmac": "^1.9.0",
"@bitgo/sjcl": "^1.1.0",
"@bitgo/unspents": "^0.51.3",
Expand Down
25 changes: 24 additions & 1 deletion modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
toBitgoRequest,
verifyResponseAsync,
} from './api';
import { decrypt, encrypt } from './encrypt';
import { decrypt, decryptAsync, encrypt } from './encrypt';
import { verifyAddress } from './v1/verifyAddress';
import {
AccessTokenOptions,
Expand Down Expand Up @@ -734,6 +734,29 @@ export class BitGoAPI implements BitGoBase {
}
}

/**
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id).
* Migration path from sync decrypt() -- use this before the breaking release.
*/
async decryptAsync(params: DecryptOptions): Promise<string> {
params = params || {};
common.validateParams(params, ['input', 'password'], []);
if (!params.password) {
throw new Error(`cannot decrypt without password`);
}
try {
return await decryptAsync(params.password, params.input);
Comment thread
zahin-mohammad marked this conversation as resolved.
} catch (error) {
if (
error.message.includes("ccm: tag doesn't match") ||
error.message.includes('The operation failed for an operation-specific reason')
) {
throw new Error('incorrect password');
}
throw error;
}
}

/**
* Attempt to decrypt multiple wallet keys with the provided passphrase
* @param {DecryptKeysOptions} params - Parameters object containing wallet key pairs and password
Expand Down
164 changes: 164 additions & 0 deletions modules/sdk-api/src/encrypt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core';
import * as sjcl from '@bitgo/sjcl';
import { randomBytes } from 'crypto';
import * as t from 'io-ts';

/** Default Argon2id parameters per RFC 9106 second recommendation
* @see https://www.rfc-editor.org/rfc/rfc9106#section-4
*/
const ARGON2_DEFAULTS = {
memorySize: 65536, // 64 MiB in KiB
iterations: 3,
parallelism: 4,
hashLength: 32, // 256-bit key
saltLength: 16, // 128-bit salt
} as const;

/** Maximum allowed Argon2id parameters to prevent DoS via crafted envelopes.
* memorySize: 256 MiB (4x default) -- caps memory allocation on untrusted input.
* iterations: 16 -- caps CPU time.
* parallelism: 16 -- caps thread count.
*/
const ARGON2_MAX = {
memorySize: 262144,
iterations: 16,
parallelism: 16,
} as const;

/** AES-256-GCM IV length in bytes */
const GCM_IV_LENGTH = 12;

const V2EnvelopeCodec = t.type({
v: t.literal(2),
m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'),
t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'),
p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'),
salt: base64String,
iv: base64String,
ct: base64String,
});

export type V2Envelope = t.TypeOf<typeof V2EnvelopeCodec>;

/**
* convert a 4 element Uint8Array to a 4 byte Number
Expand Down Expand Up @@ -60,3 +99,128 @@ export function encrypt(
export function decrypt(password: string, ciphertext: string): string {
return sjcl.decrypt(password, ciphertext);
}

/**
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM)
* from the JSON envelope's `v` field.
*
* This is the migration path from sync `decrypt()`. Clients should move to
* `await decryptAsync()` before the breaking release that makes `decrypt()` async.
*/
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
let isV2 = false;
try {
// Only peeking at the v field to route; this is an internal format we produce, not external input.
const envelope = JSON.parse(ciphertext);
Comment thread
pranavjain97 marked this conversation as resolved.
isV2 = envelope.v === 2;
} catch {
// Not valid JSON -- fall through to v1
Comment thread
pranavjain97 marked this conversation as resolved.
}
if (isV2) {
// Do not catch errors here: a wrong password or corrupt envelope on v2 data
// should propagate, not silently fall through to a v1 decrypt attempt.
return decryptV2(password, ciphertext);
}
return sjcl.decrypt(password, ciphertext);
}

/**
* Derive a 256-bit key from a password using Argon2id.
*/
async function deriveKeyV2(
password: string,
salt: Uint8Array,
params: { memorySize: number; iterations: number; parallelism: number }
Comment thread
zahin-mohammad marked this conversation as resolved.
): Promise<CryptoKey> {
const { argon2id } = await import('@bitgo/argon2');
Comment thread
pranavjain97 marked this conversation as resolved.
const keyBytes = await argon2id({
password,
salt,
memorySize: params.memorySize,
iterations: params.iterations,
parallelism: params.parallelism,
hashLength: ARGON2_DEFAULTS.hashLength,
outputType: 'binary',
});

return crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}

/**
* Encrypt plaintext using Argon2id KDF + AES-256-GCM.
*
* Returns a JSON string containing a self-describing v2 envelope
* with Argon2id parameters, salt, IV, and ciphertext.
*/
export async function encryptV2(
password: string,
plaintext: string,
options?: {
salt?: Uint8Array;
iv?: Uint8Array;
memorySize?: number;
iterations?: number;
parallelism?: number;
}
): Promise<string> {
const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;

const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength));
if (salt.length !== ARGON2_DEFAULTS.saltLength) {
throw new Error(`salt must be ${ARGON2_DEFAULTS.saltLength} bytes`);
}

const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH));
if (iv.length !== GCM_IV_LENGTH) {
throw new Error(`iv must be ${GCM_IV_LENGTH} bytes`);
}

const key = await deriveKeyV2(password, salt, { memorySize, iterations, parallelism });

const plaintextBytes = new TextEncoder().encode(plaintext);
const ctBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes);

const envelope: V2Envelope = {
v: 2,
m: memorySize,
t: iterations,
p: parallelism,
salt: Buffer.from(salt).toString('base64'),
iv: Buffer.from(iv).toString('base64'),
ct: Buffer.from(ctBuffer).toString('base64'),
};

return JSON.stringify(envelope);
}

/**
* Decrypt a v2 envelope (Argon2id KDF + AES-256-GCM).
*
* The envelope must contain: v, m, t, p, salt, iv, ct.
*/
export async function decryptV2(password: string, ciphertext: string): Promise<string> {
let parsed: unknown;
try {
parsed = JSON.parse(ciphertext);
} catch {
throw new Error('v2 decrypt: invalid JSON envelope');
}

const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope');
Comment thread
pranavjain97 marked this conversation as resolved.

const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64'));
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));

const key = await deriveKeyV2(password, salt, {
memorySize: envelope.m,
iterations: envelope.t,
parallelism: envelope.p,
});

const plaintextBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);

return new TextDecoder().decode(plaintextBuffer);
}
Loading
Loading