From 15d157b7721ec15f20dde532e6a369ada273210c Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 6 Aug 2026 13:10:33 -0400 Subject: [PATCH 1/2] feat(sdk-core): make v2 opt-in on password change Flip updateSingleKeychainPassword/updatePassword to preserve each keychain's existing envelope version by default (v1 stays v1, v2 stays v2). Callers opt in to the Argon2id upgrade with encryptionVersion: 2 once they're ready. Rolling out a v2 default before the Sept 15 breaking-change window closes could push integrations that only have v1 decrypt paths onto v2 blobs they can't read. Explicit opt-in lets each caller adopt on their own timeline; the default can be flipped back to v2 after the window. TICKET: WCN-1814 --- modules/bitgo/test/v2/unit/keychains.ts | 20 +++++++++---------- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 11 +++++----- .../sdk-core/src/bitgo/keychain/keychains.ts | 13 ++++++------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index da7d9870c2..09b60cd2f5 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -292,7 +292,6 @@ describe('V2 Keychains', function () { assert.ok(Object.keys(keys).length === expectedLength, 'should have the expected number of keys'); for (const [key, value] of Object.entries(keys)) { assert.ok(key.includes('xpub') || key.includes('randomid'), 'key should be xpub or randomid'); - JSON.parse(value as string).v.should.equal(2, 'password change must always emit v2 envelopes'); const decryptedPrv = await bitgo.decrypt({ input: value as string, password: newPassword }); decryptedPrv.should.startWith('xprv'); } @@ -409,7 +408,7 @@ describe('V2 Keychains', function () { await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected(); }); - it('single keychain password update upgrades a v1 (SJCL) envelope to v2', async () => { + it('single keychain password update preserves a v1 (SJCL) envelope by default', async () => { const prv = 'xprvtest-v1'; const encryptedPrv = await bitgo.encrypt({ input: prv, password: oldPassword, encryptionVersion: 1 }); JSON.parse(encryptedPrv).should.not.have.property('v', 2, 'pre-condition: input must not be v2'); @@ -417,8 +416,7 @@ describe('V2 Keychains', function () { const keychain = { xpub: 'xpub123', encryptedPrv }; const newKeychain = await keychains.updateSingleKeychainPassword({ keychain, oldPassword, newPassword }); - const newEnvelope = JSON.parse(newKeychain.encryptedPrv); - newEnvelope.v.should.equal(2, 'v1 keychain must be upgraded to v2 after password change'); + JSON.parse(newKeychain.encryptedPrv).should.not.have.property('v', 2, 'v1 keychain must stay v1 by default'); const decryptedPrv = await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: newPassword }); decryptedPrv.should.equal(prv, 'new password must decrypt to original prv'); @@ -426,8 +424,8 @@ describe('V2 Keychains', function () { await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected(); }); - it('single keychain password update keeps a v1 envelope as v1 when encryptionVersion: 1 is passed', async () => { - const prv = 'xprvtest-v1-opt-out'; + it('single keychain password update upgrades a v1 envelope to v2 when encryptionVersion: 2 is passed', async () => { + const prv = 'xprvtest-v1-opt-in'; const encryptedPrv = await bitgo.encrypt({ input: prv, password: oldPassword, encryptionVersion: 1 }); const keychain = { xpub: 'xpub123', encryptedPrv }; @@ -435,20 +433,20 @@ describe('V2 Keychains', function () { keychain, oldPassword, newPassword, - encryptionVersion: 1, + encryptionVersion: 2, }); - JSON.parse(newKeychain.encryptedPrv).should.not.have.property('v', 2, 'v1 opt-out must not emit v2'); + JSON.parse(newKeychain.encryptedPrv).v.should.equal(2, 'v2 opt-in must emit a v2 envelope'); const decryptedPrv = await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: newPassword }); decryptedPrv.should.equal(prv, 'new password must decrypt to original prv'); }); - it('updatePassword upgrades v1 keychains to v2 and keeps v2 keychains as v2', async function () { + it("updatePassword preserves each keychain's existing envelope version by default", async function () { const v1Prv = 'xprv-v1'; const v2Prv = 'xprv-v2'; - const encV1 = await bitgo.encrypt({ input: v1Prv, password: oldPassword }); + const encV1 = await bitgo.encrypt({ input: v1Prv, password: oldPassword, encryptionVersion: 1 }); const encV2 = await bitgo.encrypt({ input: v2Prv, password: oldPassword, encryptionVersion: 2 }); const encOther = await bitgo.encrypt({ input: 'xprv-other', password: 'different-password' }); @@ -481,7 +479,7 @@ describe('V2 Keychains', function () { assert.ok(updatedV1, 'v1 keychain must be in the result'); assert.ok(updatedV2, 'v2 keychain must be in the result'); - JSON.parse(updatedV1).v.should.equal(2, 'v1 keychain must be upgraded to v2 on password change'); + JSON.parse(updatedV1).should.not.have.property('v', 2, 'v1 keychain must stay v1 by default'); const decryptedV1 = await bitgo.decrypt({ input: updatedV1, password: newPassword }); decryptedV1.should.equal(v1Prv); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 8af222cc30..de5d03cdc5 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -94,9 +94,10 @@ export interface UpdatePasswordOptions { oldPassword: string; newPassword: string; /** - * Envelope version to emit for the re-encrypted keychains. Defaults to `2` (Argon2id), - * so v1 (SJCL) keychains are transparently upgraded to v2 as part of the password change. - * Pass `1` to keep emitting legacy v1 envelopes (used by the UI until the Sept 15 breaking-change window closes). + * Envelope version to emit for the re-encrypted keychains. Defaults to preserving each + * keychain's existing envelope version (no forced migration). Pass `2` to opt in to the + * Argon2id upgrade for v1 (SJCL) keychains once the caller is ready (e.g. after the + * Sept 15 breaking-change window closes). */ encryptionVersion?: EncryptionVersion; } @@ -106,8 +107,8 @@ export interface UpdateSingleKeychainPasswordOptions { oldPassword?: string; newPassword?: string; /** - * Envelope version to emit. Defaults to `2` (Argon2id). - * Pass `1` to keep emitting legacy v1 (SJCL) envelopes. + * Envelope version to emit. Defaults to preserving the source envelope's version. + * Pass `2` to opt in to the Argon2id upgrade. */ encryptionVersion?: EncryptionVersion; } diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 65ab406420..b8249b7aeb 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -175,15 +175,16 @@ export class Keychains implements IKeychains { } /** - * Update the password used to decrypt a single keychain. Defaults to re-encrypting as v2 - * (Argon2id + AES-256-GCM), so v1 (SJCL) keychains are transparently upgraded to v2 as part - * of the password change. Callers that still need v1 output (e.g. the UI until the Sept 15 - * breaking-change window closes) can pass `encryptionVersion: 1`. + * Update the password used to decrypt a single keychain. Defaults to preserving the + * source envelope version (v1 stays v1, v2 stays v2), so this is a no-op with respect to + * encryption version unless the caller opts in. Pass `encryptionVersion: 2` to explicitly + * upgrade a v1 (SJCL) keychain to v2 (Argon2id + AES-256-GCM) as part of the password change + * (e.g. once a caller is ready, after the Sept 15 breaking-change window closes). * @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 - * @param params.encryptionVersion - Optional envelope version to emit; defaults to 2 (Argon2id) + * @param params.encryptionVersion - Optional envelope version to emit; defaults to the source envelope's version * @returns {Promise} */ async updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Promise { @@ -205,7 +206,7 @@ export class Keychains implements IKeychains { const newEncryptedPrv = await this.bitgo.encrypt({ input: decryptedPrv, password: params.newPassword, - encryptionVersion: params.encryptionVersion ?? 2, + encryptionVersion: params.encryptionVersion ?? this.getEncryptionVersion(oldEncryptedPrv), }); return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv }); } catch (e) { From f8b1005a4f9650da70432d842889bb5c252bf919 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 6 Aug 2026 13:10:45 -0400 Subject: [PATCH 2/2] feat(sdk-api): allow changePassword to pass through encryptionVersion Thread an optional encryptionVersion through changePassword to both the v1 and v2 keychains().updatePassword calls, so callers can opt in to the Argon2id upgrade without reaching into keychains() directly. TICKET: WCN-1814 --- modules/sdk-api/src/bitgoAPI.ts | 4 ++-- modules/sdk-api/src/types.ts | 6 ++++++ modules/sdk-api/test/unit/bitgoAPI.ts | 20 ++++++++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 126d5e5c31..c4b2d480db 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -2006,7 +2006,7 @@ export class BitGoAPI implements BitGoBase { * @param oldPassword {String} - the current password * @param newPassword {String} - the new password */ - async changePassword({ oldPassword, newPassword }: ChangePasswordOptions): Promise { + async changePassword({ oldPassword, newPassword, encryptionVersion }: ChangePasswordOptions): Promise { if (!_.isString(oldPassword)) { throw new Error('expected string oldPassword'); } @@ -2029,7 +2029,7 @@ export class BitGoAPI implements BitGoBase { // we just need to choose a coin that exists in the current environment const coin = common.Environments[this.getEnv()].network === 'bitcoin' ? 'btc' : 'tbtc'; - const updateKeychainPasswordParams = { oldPassword, newPassword }; + const updateKeychainPasswordParams = { oldPassword, newPassword, encryptionVersion }; const v1KeychainUpdatePWResult = await this.keychains().updatePassword(updateKeychainPasswordParams); const v2Keychains = await this.coin(coin).keychains().updatePassword(updateKeychainPasswordParams); diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index 21466db7ae..a327e608a4 100644 --- a/modules/sdk-api/src/types.ts +++ b/modules/sdk-api/src/types.ts @@ -268,6 +268,12 @@ export interface GetEcdhSecretOptions { export interface ChangePasswordOptions { oldPassword: string; newPassword: string; + /** + * Envelope version to emit for the re-encrypted keychains. Defaults to preserving each + * keychain's existing envelope version (no forced migration). Pass `2` to opt in to the + * Argon2id upgrade for v1 (SJCL) keychains once the caller is ready. + */ + encryptionVersion?: EncryptionVersion; } /** diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 68c6a2f750..9a29e2e42a 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -1008,6 +1008,8 @@ describe('Constructor', function () { const ROOT = 'https://app.example.local'; let bitgo: BitGoAPI; let sandbox: sinon.SinonSandbox; + let v1UpdatePasswordStub: sinon.SinonStub; + let v2UpdatePasswordStub: sinon.SinonStub; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -1031,13 +1033,15 @@ describe('Constructor', function () { sandbox.stub(bitgo, 'verifyPassword').resolves(true); + v1UpdatePasswordStub = sandbox.stub().resolves({ keychains: { k1: 'v1enc' }, version: 25 }); sandbox.stub(bitgo, 'keychains').returns({ - updatePassword: sandbox.stub().resolves({ keychains: { k1: 'v1enc' }, version: 25 }), + updatePassword: v1UpdatePasswordStub, } as any); + v2UpdatePasswordStub = sandbox.stub().resolves({ v2k1: 'v2enc' }); sandbox.stub(bitgo, 'coin').returns({ keychains: () => ({ - updatePassword: sandbox.stub().resolves({ v2k1: 'v2enc' }), + updatePassword: v2UpdatePasswordStub, }), } as any); }); @@ -1088,6 +1092,18 @@ describe('Constructor', function () { legacyScope.isDone().should.be.true(); }); + + it('forwards encryptionVersion to both v1 and v2 keychains().updatePassword calls', async function () { + nock(ROOT).get('/api/v2/user/checkBatchingPasswordFlow').query(true).reply(200, { isBatchingFlowEnabled: false }); + nock(ROOT) + .post('/api/v1/user/changepassword', (body: any) => !!body.keychains && !!body.v2_keychains) + .reply(200, {}); + + await bitgo.changePassword({ oldPassword: 'oldpw', newPassword: 'newpw', encryptionVersion: 2 }); + + sinon.assert.calledWithMatch(v1UpdatePasswordStub, { encryptionVersion: 2 }); + sinon.assert.calledWithMatch(v2UpdatePasswordStub, { encryptionVersion: 2 }); + }); }); describe('createUserEcdhKeychain - encryptionVersion threading', function () {