From 0c0c1f25fe1f9159cc52d00d3580769086cc89db Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Mon, 13 Apr 2026 16:47:16 -0400 Subject: [PATCH 1/4] feat(sdk-api): add v2 encrypt/decrypt using Argon2id + AES-256-GCM Add encryptV2() and decryptV2() alongside existing v1 SJCL functions. V2 uses Argon2id (m=64MiB, t=3, p=4) for KDF and WebCrypto AES-256-GCM for symmetric encryption. Self-describing JSON envelope stores all parameters for forward compatibility. Existing v1 encrypt/decrypt is untouched. No call site changes. WCN-30 Co-Authored-By: Claude Opus 4.6 (1M context) TICKET: WCN-30 --- modules/sdk-api/package.json | 1 + modules/sdk-api/src/encrypt.ts | 145 +++++++++++++++++++++++++++ modules/sdk-api/test/unit/encrypt.ts | 72 ++++++++++++- 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/modules/sdk-api/package.json b/modules/sdk-api/package.json index e50e3e0be2..6aadc6da3c 100644 --- a/modules/sdk-api/package.json +++ b/modules/sdk-api/package.json @@ -40,6 +40,7 @@ ] }, "dependencies": { + "@bitgo/argon2": "^1.0.0", "@bitgo/sdk-core": "^36.40.0", "@bitgo/sdk-hmac": "^1.9.0", "@bitgo/sjcl": "^1.1.0", diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 2f35d85959..71a0b41890 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,6 +1,40 @@ +import { argon2id } from '@bitgo/argon2'; import * as sjcl from '@bitgo/sjcl'; import { randomBytes } from 'crypto'; +/** Default Argon2id parameters per RFC 9106 second recommendation */ +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; + +export interface V2Envelope { + v: 2; + m: number; + t: number; + p: number; + salt: string; + iv: string; + ct: string; +} + /** * convert a 4 element Uint8Array to a 4 byte Number * @@ -60,3 +94,114 @@ export function encrypt( export function decrypt(password: string, ciphertext: string): string { 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 } +): Promise { + 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 { + 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 { + let envelope: V2Envelope; + try { + envelope = JSON.parse(ciphertext); + } catch { + throw new Error('v2 decrypt: invalid JSON envelope'); + } + + if (envelope.v !== 2) { + throw new Error(`v2 decrypt: unsupported envelope version ${envelope.v}`); + } + if (envelope.m > ARGON2_MAX.memorySize || envelope.m < 1) { + throw new Error(`v2 decrypt: memorySize ${envelope.m} exceeds allowed range [1, ${ARGON2_MAX.memorySize}]`); + } + if (envelope.t > ARGON2_MAX.iterations || envelope.t < 1) { + throw new Error(`v2 decrypt: iterations ${envelope.t} exceeds allowed range [1, ${ARGON2_MAX.iterations}]`); + } + if (envelope.p > ARGON2_MAX.parallelism || envelope.p < 1) { + throw new Error(`v2 decrypt: parallelism ${envelope.p} exceeds allowed range [1, ${ARGON2_MAX.parallelism}]`); + } + + 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); +} diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index c2d592261b..6a06a351a2 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; -import { decrypt, encrypt } from '../../src'; +import { decrypt, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src'; describe('encryption methods tests', () => { describe('encrypt', () => { @@ -68,4 +68,74 @@ describe('encryption methods tests', () => { assert(decrypted === plaintext, 'decrypted should be equal to plaintext'); }); }); + + describe('v2 encrypt/decrypt (Argon2id + AES-256-GCM)', () => { + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('encrypts and decrypts round-trip', async () => { + const ciphertext = await encryptV2(password, plaintext); + const decrypted = await decryptV2(password, ciphertext); + assert.strictEqual(decrypted, plaintext); + }); + + it('produces a valid v2 envelope', async () => { + const ciphertext = await encryptV2(password, plaintext); + const envelope: V2Envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.v, 2); + assert.strictEqual(envelope.m, 65536); + assert.strictEqual(envelope.t, 3); + assert.strictEqual(envelope.p, 4); + assert.ok(envelope.salt, 'envelope must have salt'); + assert.ok(envelope.iv, 'envelope must have iv'); + assert.ok(envelope.ct, 'envelope must have ct'); + }); + + it('returns different ciphertext for the same plaintext and password', async () => { + const ct1 = await encryptV2(password, plaintext); + const ct2 = await encryptV2(password, plaintext); + assert.notStrictEqual(ct1, ct2); + }); + + it('decrypts with custom Argon2id parameters', async () => { + const ciphertext = await encryptV2(password, plaintext, { + memorySize: 1024, + iterations: 1, + parallelism: 1, + }); + const envelope: V2Envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.m, 1024); + assert.strictEqual(envelope.t, 1); + assert.strictEqual(envelope.p, 1); + + const decrypted = await decryptV2(password, ciphertext); + assert.strictEqual(decrypted, plaintext); + }); + + it('throws on wrong password', async () => { + const ciphertext = await encryptV2(password, plaintext); + await assert.rejects(() => decryptV2('wrongPassword', ciphertext)); + }); + + it('throws on invalid JSON', async () => { + await assert.rejects(() => decryptV2(password, 'not-json'), /invalid JSON envelope/); + }); + + it('throws on wrong envelope version', async () => { + await assert.rejects(() => decryptV2(password, JSON.stringify({ v: 99 })), /unsupported envelope version/); + }); + + it('throws on invalid salt length', async () => { + await assert.rejects(() => encryptV2(password, plaintext, { salt: new Uint8Array(8) }), /salt must be 16 bytes/); + }); + + it('throws on invalid iv length', async () => { + await assert.rejects(() => encryptV2(password, plaintext, { iv: new Uint8Array(8) }), /iv must be 12 bytes/); + }); + + it('v1 and v2 are independent (v1 data does not decrypt with v2)', async () => { + const v1ct = encrypt(password, plaintext); + await assert.rejects(() => decryptV2(password, v1ct), /unsupported envelope version/); + }); + }); }); From eb01c198373ff2bb112de91be5a9d75cb0409156 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Tue, 14 Apr 2026 11:55:12 -0400 Subject: [PATCH 2/4] feat(sdk-api): add decryptAsync with v1/v2 auto-detection Add decryptAsync() that auto-detects v1 (SJCL) or v2 (Argon2id) envelopes. This is the non-breaking migration path for clients to move from sync decrypt() to async before the breaking release. - decryptAsync() on encrypt.ts and BitGoAPI - decryptAsync on BitGoBase interface - Tests for v1 and v2 auto-detection, wrong password rejection WCN-30 Co-Authored-By: Claude Opus 4.6 (1M context) TICKET: WCN-30 --- modules/argon2/index.mjs | 3 ++ modules/argon2/package.json | 7 +++++ modules/sdk-api/src/bitgoAPI.ts | 25 ++++++++++++++- modules/sdk-api/src/encrypt.ts | 30 ++++++++++++++++-- modules/sdk-api/test/unit/encrypt.ts | 41 ++++++++++++++++++++++++- modules/sdk-core/src/bitgo/bitgoBase.ts | 1 + 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 modules/argon2/index.mjs diff --git a/modules/argon2/index.mjs b/modules/argon2/index.mjs new file mode 100644 index 0000000000..d0960b87d6 --- /dev/null +++ b/modules/argon2/index.mjs @@ -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; diff --git a/modules/argon2/package.json b/modules/argon2/package.json index f7c7e33bfb..8757ba2ec3 100644 --- a/modules/argon2/package.json +++ b/modules/argon2/package.json @@ -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" diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index e80b2f8475..19d481893b 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -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, @@ -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 { + 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); + } 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 diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 71a0b41890..74692cd8b4 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,8 +1,9 @@ -import { argon2id } from '@bitgo/argon2'; import * as sjcl from '@bitgo/sjcl'; import { randomBytes } from 'crypto'; -/** Default Argon2id parameters per RFC 9106 second recommendation */ +/** 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, @@ -95,6 +96,30 @@ 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 { + 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); + isV2 = envelope.v === 2; + } catch { + // Not valid JSON -- fall through to v1 + } + 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. */ @@ -103,6 +128,7 @@ async function deriveKeyV2( salt: Uint8Array, params: { memorySize: number; iterations: number; parallelism: number } ): Promise { + const { argon2id } = await import('@bitgo/argon2'); const keyBytes = await argon2id({ password, salt, diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index 6a06a351a2..fb3049022b 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; -import { decrypt, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src'; +import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src'; describe('encryption methods tests', () => { describe('encrypt', () => { @@ -138,4 +138,43 @@ describe('encryption methods tests', () => { await assert.rejects(() => decryptV2(password, v1ct), /unsupported envelope version/); }); }); + + describe('decryptAsync (auto-detect v1/v2)', () => { + const password = 'myPassword'; + const plaintext = 'Hello, World!'; + + it('decrypts v1 data', async () => { + const v1ct = encrypt(password, plaintext); + const result = await decryptAsync(password, v1ct); + assert.strictEqual(result, plaintext); + }); + + it('decrypts v2 data', async () => { + const v2ct = await encryptV2(password, plaintext); + const result = await decryptAsync(password, v2ct); + assert.strictEqual(result, plaintext); + }); + + it('throws on wrong password for v1', async () => { + const v1ct = encrypt(password, plaintext); + await assert.rejects(() => decryptAsync('wrong', v1ct)); + }); + + it('throws on wrong password for v2', async () => { + const v2ct = await encryptV2(password, plaintext); + await assert.rejects(() => decryptAsync('wrong', v2ct)); + }); + + it('wrong password on v2 data does not fall through to v1 decrypt', async () => { + const v2ct = await encryptV2(password, plaintext, { memorySize: 1024, iterations: 1, parallelism: 1 }); + let caughtError: Error | undefined; + try { + await decryptAsync('wrong', v2ct); + } catch (e) { + caughtError = e as Error; + } + assert.ok(caughtError, 'should have thrown'); + assert.ok(!caughtError.message?.includes('sjcl'), 'error must not be from SJCL'); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index 8b687ab95e..ac03322b92 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -15,6 +15,7 @@ export interface BitGoBase { wallets(): any; // TODO - define v1 wallets type coin(coinName: string): IBaseCoin; // need to change it to BaseCoin once it's moved to @bitgo/sdk-core decrypt(params: DecryptOptions): string; + decryptAsync(params: DecryptOptions): Promise; decryptKeys(params: DecryptKeysOptions): string[]; del(url: string): BitGoRequest; encrypt(params: EncryptOptions): string; From 24e53b8b22fdad8216d3acea8476170aa68ce8ae Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Wed, 15 Apr 2026 15:28:38 -0400 Subject: [PATCH 3/4] fix(root): update Dockerfile to include @bitgo/argon2 module Co-Authored-By: Claude Opus 4.6 (1M context) TICKET: WCN-30 --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 49e234d06d..f7b0e5ddfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ @@ -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 && \ @@ -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 && \ From f4ae5730e6ea0479ecd2996c7529356449889ba3 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 16 Apr 2026 15:12:02 -0400 Subject: [PATCH 4/4] feat(sdk-api): replace manual v2 envelope validation with io-ts codec Replace hand-written if-checks and V2Envelope interface with a V2EnvelopeCodec that enforces type safety, Argon2id parameter caps, and non-empty base64 strings in a single decode step. WCN-30 Co-Authored-By: Claude Opus 4.6 (1M context) TICKET: WCN-30 --- modules/sdk-api/package.json | 1 + modules/sdk-api/src/encrypt.ts | 39 +++++++++------------- modules/sdk-api/test/unit/encrypt.ts | 34 +++++++++++++++++-- modules/sdk-core/src/bitgo/utils/codecs.ts | 36 ++++++++++++++++++++ modules/sdk-core/src/bitgo/utils/index.ts | 1 + 5 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/codecs.ts diff --git a/modules/sdk-api/package.json b/modules/sdk-api/package.json index 6aadc6da3c..b10892fc1e 100644 --- a/modules/sdk-api/package.json +++ b/modules/sdk-api/package.json @@ -42,6 +42,7 @@ "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", diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 74692cd8b4..43d64e2c1a 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -1,5 +1,7 @@ +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 @@ -26,15 +28,17 @@ const ARGON2_MAX = { /** AES-256-GCM IV length in bytes */ const GCM_IV_LENGTH = 12; -export interface V2Envelope { - v: 2; - m: number; - t: number; - p: number; - salt: string; - iv: string; - ct: string; -} +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; /** * convert a 4 element Uint8Array to a 4 byte Number @@ -197,25 +201,14 @@ export async function encryptV2( * The envelope must contain: v, m, t, p, salt, iv, ct. */ export async function decryptV2(password: string, ciphertext: string): Promise { - let envelope: V2Envelope; + let parsed: unknown; try { - envelope = JSON.parse(ciphertext); + parsed = JSON.parse(ciphertext); } catch { throw new Error('v2 decrypt: invalid JSON envelope'); } - if (envelope.v !== 2) { - throw new Error(`v2 decrypt: unsupported envelope version ${envelope.v}`); - } - if (envelope.m > ARGON2_MAX.memorySize || envelope.m < 1) { - throw new Error(`v2 decrypt: memorySize ${envelope.m} exceeds allowed range [1, ${ARGON2_MAX.memorySize}]`); - } - if (envelope.t > ARGON2_MAX.iterations || envelope.t < 1) { - throw new Error(`v2 decrypt: iterations ${envelope.t} exceeds allowed range [1, ${ARGON2_MAX.iterations}]`); - } - if (envelope.p > ARGON2_MAX.parallelism || envelope.p < 1) { - throw new Error(`v2 decrypt: parallelism ${envelope.p} exceeds allowed range [1, ${ARGON2_MAX.parallelism}]`); - } + const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope'); const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64')); const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64')); diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index fb3049022b..124028e538 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -122,7 +122,7 @@ describe('encryption methods tests', () => { }); it('throws on wrong envelope version', async () => { - await assert.rejects(() => decryptV2(password, JSON.stringify({ v: 99 })), /unsupported envelope version/); + await assert.rejects(() => decryptV2(password, JSON.stringify({ v: 99 })), /invalid envelope/); }); it('throws on invalid salt length', async () => { @@ -135,7 +135,37 @@ describe('encryption methods tests', () => { it('v1 and v2 are independent (v1 data does not decrypt with v2)', async () => { const v1ct = encrypt(password, plaintext); - await assert.rejects(() => decryptV2(password, v1ct), /unsupported envelope version/); + await assert.rejects(() => decryptV2(password, v1ct), /invalid envelope/); + }); + + it('rejects envelope with memorySize exceeding max', async () => { + const envelope = { v: 2, m: 999999999, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with iterations exceeding max', async () => { + const envelope = { v: 2, m: 65536, t: 100, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with parallelism exceeding max', async () => { + const envelope = { v: 2, m: 65536, t: 3, p: 100, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with zero-valued parameters', async () => { + const envelope = { v: 2, m: 0, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with non-numeric parameter types', async () => { + const envelope = { v: 2, m: '65536', t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); + }); + + it('rejects envelope with empty salt', async () => { + const envelope = { v: 2, m: 65536, t: 3, p: 4, salt: '', iv: 'AAAA', ct: 'AAAA' }; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/); }); }); diff --git a/modules/sdk-core/src/bitgo/utils/codecs.ts b/modules/sdk-core/src/bitgo/utils/codecs.ts new file mode 100644 index 0000000000..c5715ce3eb --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/codecs.ts @@ -0,0 +1,36 @@ +import * as E from 'fp-ts/Either'; +import * as t from 'io-ts'; + +/** io-ts codec for an integer within [min, max]. Rejects non-numbers, floats, and out-of-range values. */ +export const boundedInt = (min: number, max: number, label: string) => + new t.Type( + label, + (u): u is number => typeof u === 'number' && Number.isInteger(u) && u >= min && u <= max, + (u, c) => + typeof u === 'number' && Number.isInteger(u) && u >= min && u <= max + ? t.success(u) + : t.failure(u, c, `${label}: expected integer in [${min}, ${max}], got ${JSON.stringify(u)}`), + t.identity + ); + +/** io-ts codec for a non-empty string (intended for base64-encoded binary fields). */ +export const base64String = new t.Type( + 'Base64String', + (u): u is string => typeof u === 'string' && u.length > 0, + (u, c) => + typeof u === 'string' && u.length > 0 ? t.success(u) : t.failure(u, c, 'expected non-empty base64 string'), + t.identity +); + +/** + * Decode unknown input with an io-ts codec. Returns the decoded value or throws + * with a descriptive error message. Use when callers should not depend on fp-ts/io-ts directly. + */ +export function decodeWithCodec(codec: t.Type, input: unknown, label: string): A { + const result = codec.decode(input); + if (E.isLeft(result)) { + const errors = result.left.map((e) => e.message ?? 'unknown').join('; '); + throw new Error(`${label}: ${errors}`); + } + return result.right; +} diff --git a/modules/sdk-core/src/bitgo/utils/index.ts b/modules/sdk-core/src/bitgo/utils/index.ts index 5623fd151a..25cd271de5 100644 --- a/modules/sdk-core/src/bitgo/utils/index.ts +++ b/modules/sdk-core/src/bitgo/utils/index.ts @@ -7,6 +7,7 @@ export * from './promise-utils'; export * from './triple'; export * from './tss'; export * from './util'; +export * from './codecs'; export * from './decode'; export * from './notEmpty'; export * from './wallet';