From 6e616d4b5e6af3434392c7c3425f3268d2cfffd0 Mon Sep 17 00:00:00 2001 From: Bahaa Desoky Date: Wed, 20 May 2026 17:00:25 -0400 Subject: [PATCH] feat(sdk-core): add v2 decryption support to password change Ticket: WCN-281 --- modules/bitgo/test/v2/unit/keychains.ts | 67 ++++++++++++++++- modules/express/src/clientRoutes.ts | 2 +- .../clientRoutes/changeKeychainPassword.ts | 10 +-- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 1 + .../sdk-core/src/bitgo/keychain/keychains.ts | 73 +++++++++++++++++-- 5 files changed, 137 insertions(+), 16 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index c55d8641e7..cd0d745ee2 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -225,7 +225,7 @@ describe('V2 Keychains', function () { ], }); - sandbox.stub(keychains, 'updateSingleKeychainPassword').throws('error', 'some random error'); + sandbox.stub(keychains, 'updateSingleKeychainPasswordAsync').throws('error', 'some random error'); await keychains.updatePassword({ oldPassword, newPassword }).should.be.rejectedWith('some random error'); }); @@ -313,19 +313,78 @@ describe('V2 Keychains', function () { validateKeys(keys, newPassword, 3); }); - it('single keychain password update', () => { + it('single keychain password update', async () => { const prv = 'xprvtest'; const keychain = { xpub: 'xpub123', encryptedPrv: bitgo.encrypt({ input: prv, password: oldPassword }), }; - const newKeychain = keychains.updateSingleKeychainPassword({ keychain, oldPassword, newPassword }); + const newKeychain = await keychains.updateSingleKeychainPassword({ keychain, oldPassword, newPassword }); const decryptedPrv = bitgo.decrypt({ input: newKeychain.encryptedPrv, password: newPassword }); decryptedPrv.should.equal(prv); }); + it('single keychain password update preserves v2 (Argon2id) envelope', async () => { + const prv = 'xprvtest-v2'; + const encryptedPrv = await bitgo.encryptAsync({ input: prv, password: oldPassword, encryptionVersion: 2 }); + const envelope = JSON.parse(encryptedPrv); + envelope.v.should.equal(2, 'pre-condition: keychain must be v2-encrypted'); + + const keychain = { xpub: 'xpub123', encryptedPrv }; + const newKeychain = await keychains.updateSingleKeychainPasswordAsync({ keychain, oldPassword, newPassword }); + + const newEnvelope = JSON.parse(newKeychain.encryptedPrv); + newEnvelope.v.should.equal(2, 're-encrypted keychain must still be v2'); + + const decryptedPrv = await bitgo.decryptAsync({ input: newKeychain.encryptedPrv, password: newPassword }); + decryptedPrv.should.equal(prv, 'new password must decrypt to original prv'); + + await bitgo.decryptAsync({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected(); + }); + + it('updatePassword handles a mix of v1 and v2 keychains', async function () { + const v1Prv = 'xprv-v1'; + const v2Prv = 'xprv-v2'; + + nock(bgUrl) + .get('/api/v2/tltc/key') + .query(true) + .reply(200, { + keys: [ + { + pub: 'xpub-v1', + encryptedPrv: bitgo.encrypt({ input: v1Prv, password: oldPassword }), + }, + { + pub: 'xpub-v2', + encryptedPrv: await bitgo.encryptAsync({ input: v2Prv, password: oldPassword, encryptionVersion: 2 }), + }, + { + pub: 'xpub-other', + encryptedPrv: bitgo.encrypt({ input: 'xprv-other', password: 'different-password' }), + }, + ], + }); + + const updatedKeys = await keychains.updatePassword({ oldPassword, newPassword }); + + assert.strictEqual(Object.keys(updatedKeys).length, 2, 'only the two matching keychains should be updated'); + + const updatedV1 = updatedKeys['xpub-v1']; + const updatedV2 = updatedKeys['xpub-v2']; + assert.ok(updatedV1, 'v1 keychain must be in the result'); + assert.ok(updatedV2, 'v2 keychain must be in the result'); + + bitgo.decrypt({ input: updatedV1, password: newPassword }).should.equal(v1Prv); + + const updatedV2Envelope = JSON.parse(updatedV2); + updatedV2Envelope.v.should.equal(2, 'v2 keychain must remain v2 after password change'); + const decryptedV2 = await bitgo.decryptAsync({ input: updatedV2, password: newPassword }); + decryptedV2.should.equal(v2Prv); + }); + it('should return the updated keys with ids', async function () { nock(bgUrl) .get('/api/v2/tltc/key') @@ -502,7 +561,7 @@ describe('V2 Keychains', function () { }, }); - sandbox.stub(BitGo.prototype, 'decrypt').returns(decryptResult); + sandbox.stub(bitgo, 'decryptAsync').resolves(decryptResult); }); afterEach(function () { diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 169086b7cd..1e600eec7a 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1247,7 +1247,7 @@ export async function handleKeychainChangePassword( ); } - const updatedKeychain = coin.keychains().updateSingleKeychainPassword({ + const updatedKeychain = await coin.keychains().updateSingleKeychainPasswordAsync({ keychain, oldPassword, newPassword, diff --git a/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts b/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts index ae79965957..f7523854ca 100644 --- a/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts +++ b/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts @@ -16,7 +16,7 @@ describe('Change Wallet Password', function () { const newPassword = 'newPasswordString'; const keychainBaseCoinStub = { - keychains: () => ({ updateSingleKeychainPassword: () => Promise.resolve({ result: 'stubbed' }) }), + keychains: () => ({ updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }) }), }; it('should change wallet password', async function () { @@ -27,7 +27,7 @@ describe('Change Wallet Password', function () { const coinStub = { keychains: () => ({ get: () => Promise.resolve(keychainStub), - updateSingleKeychainPassword: () => ({ result: 'stubbed' }), + updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }), }), url: () => 'url', }; @@ -82,7 +82,7 @@ describe('Change Wallet Password', function () { const coinStub = { keychains: () => ({ get: () => Promise.resolve(keychainStub), - updateSingleKeychainPassword: () => ({ result: 'stubbed' }), + updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }), }), url: () => 'url', }; @@ -136,7 +136,7 @@ describe('Change Wallet Password', function () { const coinStub = { keychains: () => ({ get: () => Promise.resolve(keychainStub), - updateSingleKeychainPassword: () => ({ result: 'stubbed' }), + updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }), }), url: () => 'url', }; @@ -189,7 +189,7 @@ describe('Change Wallet Password', function () { const coinStub = { keychains: () => ({ get: () => Promise.resolve(keychainStub), - updateSingleKeychainPassword: () => ({ result: 'stubbed' }), + updateSingleKeychainPasswordAsync: () => Promise.resolve({ result: 'stubbed' }), }), url: () => 'url', }; diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 7a5f6e1380..82fbfe5014 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -240,6 +240,7 @@ export interface IKeychains { list(params?: ListKeychainOptions): Promise; updatePassword(params: UpdatePasswordOptions): Promise; updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Keychain; + updateSingleKeychainPasswordAsync(params?: UpdateSingleKeychainPasswordOptions): Promise; create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair; add(params?: AddKeychainOptions): Promise; createBitGo(params?: CreateBitGoOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index de5d6c9835..0f70a2862f 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -24,6 +24,7 @@ import { UpdateSingleKeychainPasswordOptions, } from './iKeychains'; import { BitGoKeyFromOvcShares, BitGoToOvcJSON, OvcToBitGoJSON } from './ovcJsonCodec'; +import { EncryptionVersion } from '../../api'; export class Keychains implements IKeychains { private readonly bitgo: BitGoBase; @@ -113,7 +114,7 @@ export class Keychains implements IKeychains { continue; } try { - const updatedKeychain = this.updateSingleKeychainPassword({ + const updatedKeychain = await this.updateSingleKeychainPasswordAsync({ keychain: key, oldPassword: params.oldPassword, newPassword: params.newPassword, @@ -145,12 +146,14 @@ export class Keychains implements IKeychains { } /** - * Update the password used to decrypt a single keychain + * TODO: Deprecate this function in favor of updateSingleKeychainPasswordAsync once v2 encryption is default + * Update the password used to decrypt a single keychain. + * Handles v1 (SJCL) envelopes only. For v2 (Argon2id) support use {@link updateSingleKeychainPasswordAsync}. * @param params * @param params.keychain - The keychain whose password should be updated * @param params.oldPassword - The old password used for encrypting the key * @param params.newPassword - The new password to be used for encrypting the key - * @returns {object} + * @returns {Keychain} */ updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Keychain { if (!_.isString(params.oldPassword)) { @@ -176,6 +179,64 @@ export class Keychains implements IKeychains { } } + /** + * Helper function to determine the encryption version of a ciphertext by parsing it as JSON and checking the "v" field. + * Return undefined if the ciphertext is not a valid JSON or does not contain a supported "v" field. + */ + private getEncryptionVersion(ciphertext: string): EncryptionVersion | undefined { + try { + const envelope = JSON.parse(ciphertext); + switch (envelope.v) { + case 1: + return 1; + case 2: + return 2; + default: + return undefined; + } + } catch (_) { + return undefined; + } + } + + /** + * Update the password used to decrypt a single keychain, with support for v2 (Argon2id) envelopes. + * Automatically detects and preserves the envelope version — a v2-encrypted key stays v2 after the password change. + * @param params + * @param params.keychain - The keychain whose password should be updated + * @param params.oldPassword - The old password used for encrypting the key + * @param params.newPassword - The new password to be used for encrypting the key + * @returns {Promise} + */ + async updateSingleKeychainPasswordAsync(params: UpdateSingleKeychainPasswordOptions = {}): Promise { + if (!_.isString(params.oldPassword)) { + throw new Error('expected old password to be a string'); + } + + if (!_.isString(params.newPassword)) { + throw new Error('expected new password to be a string'); + } + + if (!_.isObject(params.keychain) || !_.isString(params.keychain.encryptedPrv)) { + throw new Error('expected keychain to be an object with an encryptedPrv property'); + } + + const oldEncryptedPrv = params.keychain.encryptedPrv; + try { + const decryptedPrv = await this.bitgo.decryptAsync({ input: oldEncryptedPrv, password: params.oldPassword }); + const encryptionVersion = this.getEncryptionVersion(oldEncryptedPrv); + const newEncryptedPrv = await this.bitgo.encryptAsync({ + input: decryptedPrv, + password: params.newPassword, + encryptionVersion, + }); + return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv }); + } catch (e) { + // catching an error here means that the password was incorrect or, less likely, the input to decrypt is corrupted + throw new Error('password used to decrypt keychain private key is incorrect'); + } + } + /** * Create a public/private key pair * @param params - optional params @@ -359,17 +420,17 @@ export class Keychains implements IKeychains { throw new Error('failed to get recovery info'); } - const decryptedWalletPassphrase = this.bitgo.decrypt({ + const decryptedWalletPassphrase = await this.bitgo.decryptAsync({ input: params.encryptedMaterial.encryptedWalletPassphrase, password: recoveryInfo.passcodeEncryptionCode, }); - const decryptedUserKey = this.bitgo.decrypt({ + const decryptedUserKey = await this.bitgo.decryptAsync({ input: params.encryptedMaterial.encryptedUserKey, password: decryptedWalletPassphrase, }); - const decryptedBackupKey = this.bitgo.decrypt({ + const decryptedBackupKey = await this.bitgo.decryptAsync({ input: params.encryptedMaterial.encryptedBackupKey, password: decryptedWalletPassphrase, });