Skip to content

Commit f4ae573

Browse files
committed
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) <noreply@anthropic.com> TICKET: WCN-30
1 parent 24e53b8 commit f4ae573

5 files changed

Lines changed: 86 additions & 25 deletions

File tree

modules/sdk-api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"dependencies": {
4343
"@bitgo/argon2": "^1.0.0",
4444
"@bitgo/sdk-core": "^36.40.0",
45+
"io-ts": "npm:@bitgo-forks/io-ts@2.1.4",
4546
"@bitgo/sdk-hmac": "^1.9.0",
4647
"@bitgo/sjcl": "^1.1.0",
4748
"@bitgo/unspents": "^0.51.3",

modules/sdk-api/src/encrypt.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
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';
35

46
/** Default Argon2id parameters per RFC 9106 second recommendation
57
* @see https://www.rfc-editor.org/rfc/rfc9106#section-4
@@ -26,15 +28,17 @@ const ARGON2_MAX = {
2628
/** AES-256-GCM IV length in bytes */
2729
const GCM_IV_LENGTH = 12;
2830

29-
export interface V2Envelope {
30-
v: 2;
31-
m: number;
32-
t: number;
33-
p: number;
34-
salt: string;
35-
iv: string;
36-
ct: string;
37-
}
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>;
3842

3943
/**
4044
* convert a 4 element Uint8Array to a 4 byte Number
@@ -197,25 +201,14 @@ export async function encryptV2(
197201
* The envelope must contain: v, m, t, p, salt, iv, ct.
198202
*/
199203
export async function decryptV2(password: string, ciphertext: string): Promise<string> {
200-
let envelope: V2Envelope;
204+
let parsed: unknown;
201205
try {
202-
envelope = JSON.parse(ciphertext);
206+
parsed = JSON.parse(ciphertext);
203207
} catch {
204208
throw new Error('v2 decrypt: invalid JSON envelope');
205209
}
206210

207-
if (envelope.v !== 2) {
208-
throw new Error(`v2 decrypt: unsupported envelope version ${envelope.v}`);
209-
}
210-
if (envelope.m > ARGON2_MAX.memorySize || envelope.m < 1) {
211-
throw new Error(`v2 decrypt: memorySize ${envelope.m} exceeds allowed range [1, ${ARGON2_MAX.memorySize}]`);
212-
}
213-
if (envelope.t > ARGON2_MAX.iterations || envelope.t < 1) {
214-
throw new Error(`v2 decrypt: iterations ${envelope.t} exceeds allowed range [1, ${ARGON2_MAX.iterations}]`);
215-
}
216-
if (envelope.p > ARGON2_MAX.parallelism || envelope.p < 1) {
217-
throw new Error(`v2 decrypt: parallelism ${envelope.p} exceeds allowed range [1, ${ARGON2_MAX.parallelism}]`);
218-
}
211+
const envelope = decodeWithCodec(V2EnvelopeCodec, parsed, 'v2 decrypt: invalid envelope');
219212

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

modules/sdk-api/test/unit/encrypt.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('encryption methods tests', () => {
122122
});
123123

124124
it('throws on wrong envelope version', async () => {
125-
await assert.rejects(() => decryptV2(password, JSON.stringify({ v: 99 })), /unsupported envelope version/);
125+
await assert.rejects(() => decryptV2(password, JSON.stringify({ v: 99 })), /invalid envelope/);
126126
});
127127

128128
it('throws on invalid salt length', async () => {
@@ -135,7 +135,37 @@ describe('encryption methods tests', () => {
135135

136136
it('v1 and v2 are independent (v1 data does not decrypt with v2)', async () => {
137137
const v1ct = encrypt(password, plaintext);
138-
await assert.rejects(() => decryptV2(password, v1ct), /unsupported envelope version/);
138+
await assert.rejects(() => decryptV2(password, v1ct), /invalid envelope/);
139+
});
140+
141+
it('rejects envelope with memorySize exceeding max', async () => {
142+
const envelope = { v: 2, m: 999999999, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' };
143+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
144+
});
145+
146+
it('rejects envelope with iterations exceeding max', async () => {
147+
const envelope = { v: 2, m: 65536, t: 100, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' };
148+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
149+
});
150+
151+
it('rejects envelope with parallelism exceeding max', async () => {
152+
const envelope = { v: 2, m: 65536, t: 3, p: 100, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' };
153+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
154+
});
155+
156+
it('rejects envelope with zero-valued parameters', async () => {
157+
const envelope = { v: 2, m: 0, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' };
158+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
159+
});
160+
161+
it('rejects envelope with non-numeric parameter types', async () => {
162+
const envelope = { v: 2, m: '65536', t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' };
163+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
164+
});
165+
166+
it('rejects envelope with empty salt', async () => {
167+
const envelope = { v: 2, m: 65536, t: 3, p: 4, salt: '', iv: 'AAAA', ct: 'AAAA' };
168+
await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /invalid envelope/);
139169
});
140170
});
141171

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as E from 'fp-ts/Either';
2+
import * as t from 'io-ts';
3+
4+
/** io-ts codec for an integer within [min, max]. Rejects non-numbers, floats, and out-of-range values. */
5+
export const boundedInt = (min: number, max: number, label: string) =>
6+
new t.Type<number, number, unknown>(
7+
label,
8+
(u): u is number => typeof u === 'number' && Number.isInteger(u) && u >= min && u <= max,
9+
(u, c) =>
10+
typeof u === 'number' && Number.isInteger(u) && u >= min && u <= max
11+
? t.success(u)
12+
: t.failure(u, c, `${label}: expected integer in [${min}, ${max}], got ${JSON.stringify(u)}`),
13+
t.identity
14+
);
15+
16+
/** io-ts codec for a non-empty string (intended for base64-encoded binary fields). */
17+
export const base64String = new t.Type<string, string, unknown>(
18+
'Base64String',
19+
(u): u is string => typeof u === 'string' && u.length > 0,
20+
(u, c) =>
21+
typeof u === 'string' && u.length > 0 ? t.success(u) : t.failure(u, c, 'expected non-empty base64 string'),
22+
t.identity
23+
);
24+
25+
/**
26+
* Decode unknown input with an io-ts codec. Returns the decoded value or throws
27+
* with a descriptive error message. Use when callers should not depend on fp-ts/io-ts directly.
28+
*/
29+
export function decodeWithCodec<A>(codec: t.Type<A, unknown, unknown>, input: unknown, label: string): A {
30+
const result = codec.decode(input);
31+
if (E.isLeft(result)) {
32+
const errors = result.left.map((e) => e.message ?? 'unknown').join('; ');
33+
throw new Error(`${label}: ${errors}`);
34+
}
35+
return result.right;
36+
}

modules/sdk-core/src/bitgo/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './promise-utils';
77
export * from './triple';
88
export * from './tss';
99
export * from './util';
10+
export * from './codecs';
1011
export * from './decode';
1112
export * from './notEmpty';
1213
export * from './wallet';

0 commit comments

Comments
 (0)