Skip to content

Commit 012aa17

Browse files
committed
feat(sdk-core): add v2 decryption support to password change
Ticket: WCN-281
1 parent 66f2415 commit 012aa17

5 files changed

Lines changed: 125 additions & 16 deletions

File tree

modules/bitgo/test/v2/unit/keychains.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ describe('V2 Keychains', function () {
225225
],
226226
});
227227

228-
sandbox.stub(keychains, 'updateSingleKeychainPassword').throws('error', 'some random error');
228+
sandbox.stub(keychains, 'updateSingleKeychainPasswordAsync').throws('error', 'some random error');
229229

230230
await keychains.updatePassword({ oldPassword, newPassword }).should.be.rejectedWith('some random error');
231231
});
@@ -313,19 +313,78 @@ describe('V2 Keychains', function () {
313313
validateKeys(keys, newPassword, 3);
314314
});
315315

316-
it('single keychain password update', () => {
316+
it('single keychain password update', async () => {
317317
const prv = 'xprvtest';
318318
const keychain = {
319319
xpub: 'xpub123',
320320
encryptedPrv: bitgo.encrypt({ input: prv, password: oldPassword }),
321321
};
322322

323-
const newKeychain = keychains.updateSingleKeychainPassword({ keychain, oldPassword, newPassword });
323+
const newKeychain = await keychains.updateSingleKeychainPassword({ keychain, oldPassword, newPassword });
324324

325325
const decryptedPrv = bitgo.decrypt({ input: newKeychain.encryptedPrv, password: newPassword });
326326
decryptedPrv.should.equal(prv);
327327
});
328328

329+
it('single keychain password update preserves v2 (Argon2id) envelope', async () => {
330+
const prv = 'xprvtest-v2';
331+
const encryptedPrv = await bitgo.encryptAsync({ input: prv, password: oldPassword, encryptionVersion: 2 });
332+
const envelope = JSON.parse(encryptedPrv);
333+
envelope.v.should.equal(2, 'pre-condition: keychain must be v2-encrypted');
334+
335+
const keychain = { xpub: 'xpub123', encryptedPrv };
336+
const newKeychain = await keychains.updateSingleKeychainPasswordAsync({ keychain, oldPassword, newPassword });
337+
338+
const newEnvelope = JSON.parse(newKeychain.encryptedPrv);
339+
newEnvelope.v.should.equal(2, 're-encrypted keychain must still be v2');
340+
341+
const decryptedPrv = await bitgo.decryptAsync({ input: newKeychain.encryptedPrv, password: newPassword });
342+
decryptedPrv.should.equal(prv, 'new password must decrypt to original prv');
343+
344+
await bitgo.decryptAsync({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected();
345+
});
346+
347+
it('updatePassword handles a mix of v1 and v2 keychains', async function () {
348+
const v1Prv = 'xprv-v1';
349+
const v2Prv = 'xprv-v2';
350+
351+
nock(bgUrl)
352+
.get('/api/v2/tltc/key')
353+
.query(true)
354+
.reply(200, {
355+
keys: [
356+
{
357+
pub: 'xpub-v1',
358+
encryptedPrv: bitgo.encrypt({ input: v1Prv, password: oldPassword }),
359+
},
360+
{
361+
pub: 'xpub-v2',
362+
encryptedPrv: await bitgo.encryptAsync({ input: v2Prv, password: oldPassword, encryptionVersion: 2 }),
363+
},
364+
{
365+
pub: 'xpub-other',
366+
encryptedPrv: bitgo.encrypt({ input: 'xprv-other', password: 'different-password' }),
367+
},
368+
],
369+
});
370+
371+
const updatedKeys = await keychains.updatePassword({ oldPassword, newPassword });
372+
373+
assert.strictEqual(Object.keys(updatedKeys).length, 2, 'only the two matching keychains should be updated');
374+
375+
const updatedV1 = updatedKeys['xpub-v1'];
376+
const updatedV2 = updatedKeys['xpub-v2'];
377+
assert.ok(updatedV1, 'v1 keychain must be in the result');
378+
assert.ok(updatedV2, 'v2 keychain must be in the result');
379+
380+
bitgo.decrypt({ input: updatedV1, password: newPassword }).should.equal(v1Prv);
381+
382+
const updatedV2Envelope = JSON.parse(updatedV2);
383+
updatedV2Envelope.v.should.equal(2, 'v2 keychain must remain v2 after password change');
384+
const decryptedV2 = await bitgo.decryptAsync({ input: updatedV2, password: newPassword });
385+
decryptedV2.should.equal(v2Prv);
386+
});
387+
329388
it('should return the updated keys with ids', async function () {
330389
nock(bgUrl)
331390
.get('/api/v2/tltc/key')
@@ -502,7 +561,7 @@ describe('V2 Keychains', function () {
502561
},
503562
});
504563

505-
sandbox.stub(BitGo.prototype, 'decrypt').returns(decryptResult);
564+
sandbox.stub(bitgo, 'decryptAsync').resolves(decryptResult);
506565
});
507566

508567
afterEach(function () {

modules/express/src/clientRoutes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1247,7 +1247,7 @@ export async function handleKeychainChangePassword(
12471247
);
12481248
}
12491249

1250-
const updatedKeychain = coin.keychains().updateSingleKeychainPassword({
1250+
const updatedKeychain = await coin.keychains().updateSingleKeychainPasswordAsync({
12511251
keychain,
12521252
oldPassword,
12531253
newPassword,

modules/express/test/unit/clientRoutes/changeKeychainPassword.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('Change Wallet Password', function () {
1616
const newPassword = 'newPasswordString';
1717

1818
const keychainBaseCoinStub = {
19-
keychains: () => ({ updateSingleKeychainPassword: () => Promise.resolve({ result: 'stubbed' }) }),
19+
keychains: () => ({ updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }) }),
2020
};
2121

2222
it('should change wallet password', async function () {
@@ -27,7 +27,7 @@ describe('Change Wallet Password', function () {
2727
const coinStub = {
2828
keychains: () => ({
2929
get: () => Promise.resolve(keychainStub),
30-
updateSingleKeychainPassword: () => ({ result: 'stubbed' }),
30+
updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }),
3131
}),
3232
url: () => 'url',
3333
};
@@ -82,7 +82,7 @@ describe('Change Wallet Password', function () {
8282
const coinStub = {
8383
keychains: () => ({
8484
get: () => Promise.resolve(keychainStub),
85-
updateSingleKeychainPassword: () => ({ result: 'stubbed' }),
85+
updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }),
8686
}),
8787
url: () => 'url',
8888
};
@@ -136,7 +136,7 @@ describe('Change Wallet Password', function () {
136136
const coinStub = {
137137
keychains: () => ({
138138
get: () => Promise.resolve(keychainStub),
139-
updateSingleKeychainPassword: () => ({ result: 'stubbed' }),
139+
updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }),
140140
}),
141141
url: () => 'url',
142142
};
@@ -189,7 +189,7 @@ describe('Change Wallet Password', function () {
189189
const coinStub = {
190190
keychains: () => ({
191191
get: () => Promise.resolve(keychainStub),
192-
updateSingleKeychainPassword: () => ({ result: 'stubbed' }),
192+
updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }),
193193
}),
194194
url: () => 'url',
195195
};

modules/sdk-core/src/bitgo/keychain/iKeychains.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export interface IKeychains {
240240
list(params?: ListKeychainOptions): Promise<ListKeychainsResult>;
241241
updatePassword(params: UpdatePasswordOptions): Promise<ChangedKeychains>;
242242
updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Keychain;
243+
updateSingleKeychainPasswordAsync(params?: UpdateSingleKeychainPasswordOptions): Promise<Keychain>;
243244
create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair;
244245
add(params?: AddKeychainOptions): Promise<Keychain>;
245246
createBitGo(params?: CreateBitGoOptions): Promise<Keychain>;

modules/sdk-core/src/bitgo/keychain/keychains.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
UpdateSingleKeychainPasswordOptions,
2525
} from './iKeychains';
2626
import { BitGoKeyFromOvcShares, BitGoToOvcJSON, OvcToBitGoJSON } from './ovcJsonCodec';
27+
import { EncryptionVersion } from '../../api';
2728

2829
export class Keychains implements IKeychains {
2930
private readonly bitgo: BitGoBase;
@@ -113,7 +114,7 @@ export class Keychains implements IKeychains {
113114
continue;
114115
}
115116
try {
116-
const updatedKeychain = this.updateSingleKeychainPassword({
117+
const updatedKeychain = await this.updateSingleKeychainPasswordAsync({
117118
keychain: key,
118119
oldPassword: params.oldPassword,
119120
newPassword: params.newPassword,
@@ -145,12 +146,13 @@ export class Keychains implements IKeychains {
145146
}
146147

147148
/**
148-
* Update the password used to decrypt a single keychain
149+
* Update the password used to decrypt a single keychain.
150+
* Handles v1 (SJCL) envelopes only. For v2 (Argon2id) support use {@link updateSingleKeychainPasswordAsync}.
149151
* @param params
150152
* @param params.keychain - The keychain whose password should be updated
151153
* @param params.oldPassword - The old password used for encrypting the key
152154
* @param params.newPassword - The new password to be used for encrypting the key
153-
* @returns {object}
155+
* @returns {Keychain}
154156
*/
155157
updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Keychain {
156158
if (!_.isString(params.oldPassword)) {
@@ -176,6 +178,53 @@ export class Keychains implements IKeychains {
176178
}
177179
}
178180

181+
/**
182+
* Update the password used to decrypt a single keychain, with support for v2 (Argon2id) envelopes.
183+
* Automatically detects and preserves the envelope version — a v2-encrypted key stays v2 after the password change.
184+
* @param params
185+
* @param params.keychain - The keychain whose password should be updated
186+
* @param params.oldPassword - The old password used for encrypting the key
187+
* @param params.newPassword - The new password to be used for encrypting the key
188+
* @returns {Promise<Keychain>}
189+
*/
190+
async updateSingleKeychainPasswordAsync(params: UpdateSingleKeychainPasswordOptions = {}): Promise<Keychain> {
191+
if (!_.isString(params.oldPassword)) {
192+
throw new Error('expected old password to be a string');
193+
}
194+
195+
if (!_.isString(params.newPassword)) {
196+
throw new Error('expected new password to be a string');
197+
}
198+
199+
if (!_.isObject(params.keychain) || !_.isString(params.keychain.encryptedPrv)) {
200+
throw new Error('expected keychain to be an object with an encryptedPrv property');
201+
}
202+
203+
const oldEncryptedPrv = params.keychain.encryptedPrv;
204+
try {
205+
const decryptedPrv = await this.bitgo.decryptAsync({ input: oldEncryptedPrv, password: params.oldPassword });
206+
// Preserve the original envelope's encryption version so v2-encrypted keys stay v2 after the password change.
207+
let encryptionVersion: EncryptionVersion | undefined;
208+
try {
209+
const parsed = JSON.parse(oldEncryptedPrv);
210+
if (parsed.v === 2) {
211+
encryptionVersion = 2;
212+
}
213+
} catch {
214+
// non-JSON input — default to v1 (no encryptionVersion)
215+
}
216+
const newEncryptedPrv = await this.bitgo.encryptAsync({
217+
input: decryptedPrv,
218+
password: params.newPassword,
219+
encryptionVersion,
220+
});
221+
return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv });
222+
} catch (e) {
223+
// catching an error here means that the password was incorrect or, less likely, the input to decrypt is corrupted
224+
throw new Error('password used to decrypt keychain private key is incorrect');
225+
}
226+
}
227+
179228
/**
180229
* Create a public/private key pair
181230
* @param params - optional params
@@ -359,17 +408,17 @@ export class Keychains implements IKeychains {
359408
throw new Error('failed to get recovery info');
360409
}
361410

362-
const decryptedWalletPassphrase = this.bitgo.decrypt({
411+
const decryptedWalletPassphrase = await this.bitgo.decryptAsync({
363412
input: params.encryptedMaterial.encryptedWalletPassphrase,
364413
password: recoveryInfo.passcodeEncryptionCode,
365414
});
366415

367-
const decryptedUserKey = this.bitgo.decrypt({
416+
const decryptedUserKey = await this.bitgo.decryptAsync({
368417
input: params.encryptedMaterial.encryptedUserKey,
369418
password: decryptedWalletPassphrase,
370419
});
371420

372-
const decryptedBackupKey = this.bitgo.decrypt({
421+
const decryptedBackupKey = await this.bitgo.decryptAsync({
373422
input: params.encryptedMaterial.encryptedBackupKey,
374423
password: decryptedWalletPassphrase,
375424
});

0 commit comments

Comments
 (0)