Skip to content

Commit 7cbd571

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 65c1b5d commit 7cbd571

4 files changed

Lines changed: 69 additions & 2 deletions

File tree

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 21 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,26 @@ 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 (error.message.includes("ccm: tag doesn't match")) {
751+
error.message = 'password error - ' + error.message;
752+
}
753+
throw error;
754+
}
755+
}
756+
737757
/**
738758
* Attempt to decrypt multiple wallet keys with the provided passphrase
739759
* @param {DecryptKeysOptions} params - Parameters object containing wallet key pairs and password

modules/sdk-api/src/encrypt.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ export function decrypt(password: string, ciphertext: string): string {
8484
return sjcl.decrypt(password, ciphertext);
8585
}
8686

87+
/**
88+
* Async decrypt that auto-detects v1 (SJCL) or v2 (Argon2id + AES-256-GCM)
89+
* from the JSON envelope's `v` field.
90+
*
91+
* This is the migration path from sync `decrypt()`. Clients should move to
92+
* `await decryptAsync()` before the breaking release that makes `decrypt()` async.
93+
*/
94+
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
95+
try {
96+
const envelope = JSON.parse(ciphertext);
97+
if (envelope.v === 2) {
98+
return await decryptV2(password, ciphertext);
99+
}
100+
} catch {
101+
// Not valid JSON or no v field -- fall through to v1
102+
}
103+
return sjcl.decrypt(password, ciphertext);
104+
}
105+
87106
/**
88107
* Derive a 256-bit key from a password using Argon2id.
89108
*/

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

Lines changed: 28 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,31 @@ 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+
});
141168
});

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)