Skip to content

Commit a9be0b8

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

File tree

6 files changed

+103
-4
lines changed

6 files changed

+103
-4
lines changed

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/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: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { argon2id } from '@bitgo/argon2';
21
import * as sjcl from '@bitgo/sjcl';
32
import { randomBytes } from 'crypto';
43

5-
/** Default Argon2id parameters per RFC 9106 second recommendation */
4+
/** Default Argon2id parameters per RFC 9106 second recommendation
5+
* @see https://www.rfc-editor.org/rfc/rfc9106#section-4
6+
*/
67
const ARGON2_DEFAULTS = {
78
memorySize: 65536, // 64 MiB in KiB
89
iterations: 3,
@@ -84,6 +85,30 @@ export function decrypt(password: string, ciphertext: string): string {
8485
return sjcl.decrypt(password, ciphertext);
8586
}
8687

88+
/**
89+
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM)
90+
* from the JSON envelope's `v` field.
91+
*
92+
* This is the migration path from sync `decrypt()`. Clients should move to
93+
* `await decryptAsync()` before the breaking release that makes `decrypt()` async.
94+
*/
95+
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
96+
let isV2 = false;
97+
try {
98+
// Only peeking at the v field to route; this is an internal format we produce, not external input.
99+
const envelope = JSON.parse(ciphertext);
100+
isV2 = envelope.v === 2;
101+
} catch {
102+
// Not valid JSON -- fall through to v1
103+
}
104+
if (isV2) {
105+
// Do not catch errors here: a wrong password or corrupt envelope on v2 data
106+
// should propagate, not silently fall through to a v1 decrypt attempt.
107+
return decryptV2(password, ciphertext);
108+
}
109+
return sjcl.decrypt(password, ciphertext);
110+
}
111+
87112
/**
88113
* Derive a 256-bit key from a password using Argon2id.
89114
*/
@@ -92,6 +117,7 @@ async function deriveKeyV2(
92117
salt: Uint8Array,
93118
params: { memorySize: number; iterations: number; parallelism: number }
94119
): Promise<CryptoKey> {
120+
const { argon2id } = await import('@bitgo/argon2');
95121
const keyBytes = await argon2id({
96122
password,
97123
salt,

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'assert';
22
import { randomBytes } from 'crypto';
33

4-
import { decrypt, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src';
4+
import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope } from '../../src';
55

66
describe('encryption methods tests', () => {
77
describe('encrypt', () => {
@@ -138,4 +138,43 @@ describe('encryption methods tests', () => {
138138
await assert.rejects(() => decryptV2(password, v1ct), /unsupported envelope version/);
139139
});
140140
});
141+
142+
describe('decryptAsync (auto-detect v1/v2)', () => {
143+
const password = 'myPassword';
144+
const plaintext = 'Hello, World!';
145+
146+
it('decrypts v1 data', async () => {
147+
const v1ct = encrypt(password, plaintext);
148+
const result = await decryptAsync(password, v1ct);
149+
assert.strictEqual(result, plaintext);
150+
});
151+
152+
it('decrypts v2 data', async () => {
153+
const v2ct = await encryptV2(password, plaintext);
154+
const result = await decryptAsync(password, v2ct);
155+
assert.strictEqual(result, plaintext);
156+
});
157+
158+
it('throws on wrong password for v1', async () => {
159+
const v1ct = encrypt(password, plaintext);
160+
await assert.rejects(() => decryptAsync('wrong', v1ct));
161+
});
162+
163+
it('throws on wrong password for v2', async () => {
164+
const v2ct = await encryptV2(password, plaintext);
165+
await assert.rejects(() => decryptAsync('wrong', v2ct));
166+
});
167+
168+
it('wrong password on v2 data does not fall through to v1 decrypt', async () => {
169+
const v2ct = await encryptV2(password, plaintext, { memorySize: 1024, iterations: 1, parallelism: 1 });
170+
let caughtError: Error | undefined;
171+
try {
172+
await decryptAsync('wrong', v2ct);
173+
} catch (e) {
174+
caughtError = e as Error;
175+
}
176+
assert.ok(caughtError, 'should have thrown');
177+
assert.ok(!caughtError.message?.includes('sjcl'), 'error must not be from SJCL');
178+
});
179+
});
141180
});

modules/sdk-core/src/bitgo/bitgoBase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface BitGoBase {
1515
wallets(): any; // TODO - define v1 wallets type
1616
coin(coinName: string): IBaseCoin; // need to change it to BaseCoin once it's moved to @bitgo/sdk-core
1717
decrypt(params: DecryptOptions): string;
18+
decryptAsync(params: DecryptOptions): Promise<string>;
1819
decryptKeys(params: DecryptKeysOptions): string[];
1920
del(url: string): BitGoRequest;
2021
encrypt(params: EncryptOptions): string;

0 commit comments

Comments
 (0)