From f12d8da74ffdb2c1f2ea73ca3d6a14143dc46242 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Tue, 4 Aug 2026 14:33:29 -0400 Subject: [PATCH 1/2] feat(sdk-core): always upgrade to v2 on password change updateSingleKeychainPassword now always re-encrypts as v2 (Argon2id), regardless of the source envelope version. A password change is a natural upgrade point, so v1 (SJCL) keychains are transparently promoted; v2 stays v2. TICKET: WCN-1814 --- modules/bitgo/test/v2/unit/keychains.ts | 31 +++++++++++++++---- .../sdk-core/src/bitgo/keychain/keychains.ts | 8 ++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 07eed600e7..3f588db6b7 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -292,7 +292,8 @@ 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'); - const decryptedPrv = await bitgo.decrypt({ input: value, password: newPassword }); + 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'); } }; @@ -390,7 +391,7 @@ describe('V2 Keychains', function () { decryptedPrv.should.equal(prv); }); - it('single keychain password update preserves v2 (Argon2id) envelope', async () => { + it('single keychain password update emits a v2 (Argon2id) envelope for a v2 input', async () => { const prv = 'xprvtest-v2'; const encryptedPrv = await bitgo.encrypt({ input: prv, password: oldPassword, encryptionVersion: 2 }); const envelope = JSON.parse(encryptedPrv); @@ -408,7 +409,24 @@ describe('V2 Keychains', function () { await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected(); }); - it('updatePassword handles a mix of v1 and v2 keychains', async function () { + it('single keychain password update upgrades a v1 (SJCL) envelope to v2', 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'); + + 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'); + + const decryptedPrv = await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: newPassword }); + decryptedPrv.should.equal(prv, 'new password must decrypt to original prv'); + + await bitgo.decrypt({ input: newKeychain.encryptedPrv, password: oldPassword }).should.be.rejected(); + }); + + it('updatePassword upgrades v1 keychains to v2 and keeps v2 keychains as v2', async function () { const v1Prv = 'xprv-v1'; const v2Prv = 'xprv-v2'; @@ -445,10 +463,11 @@ describe('V2 Keychains', function () { assert.ok(updatedV1, 'v1 keychain must be in the result'); assert.ok(updatedV2, 'v2 keychain must be in the result'); - (await bitgo.decrypt({ input: updatedV1, password: newPassword })).should.equal(v1Prv); + JSON.parse(updatedV1).v.should.equal(2, 'v1 keychain must be upgraded to v2 on password change'); + const decryptedV1 = await bitgo.decrypt({ input: updatedV1, password: newPassword }); + decryptedV1.should.equal(v1Prv); - const updatedV2Envelope = JSON.parse(updatedV2); - updatedV2Envelope.v.should.equal(2, 'v2 keychain must remain v2 after password change'); + JSON.parse(updatedV2).v.should.equal(2, 'v2 keychain must remain v2 after password change'); const decryptedV2 = await bitgo.decrypt({ input: updatedV2, password: newPassword }); decryptedV2.should.equal(v2Prv); }); diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 63a7962efb..7c5dd0ba37 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -174,8 +174,9 @@ export class Keychains implements IKeychains { } /** - * 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. + * Update the password used to decrypt a single keychain, always re-encrypting as a v2 + * (Argon2id + AES-256-GCM) envelope. A password change is a natural upgrade point, so + * v1 (SJCL) keychains are transparently promoted to v2; v2 keychains stay v2. * @param params * @param params.keychain - The keychain whose password should be updated * @param params.oldPassword - The old password used for encrypting the key @@ -198,11 +199,10 @@ export class Keychains implements IKeychains { const oldEncryptedPrv = params.keychain.encryptedPrv; try { const decryptedPrv = await this.bitgo.decrypt({ input: oldEncryptedPrv, password: params.oldPassword }); - const encryptionVersion = this.getEncryptionVersion(oldEncryptedPrv); const newEncryptedPrv = await this.bitgo.encrypt({ input: decryptedPrv, password: params.newPassword, - encryptionVersion, + encryptionVersion: 2, }); return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv }); } catch (e) { From b635b5b0169fea7f49cc090c2d6f20105379a330 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Tue, 4 Aug 2026 15:41:23 -0400 Subject: [PATCH 2/2] feat(sdk-core): allow opting out of v2 on password change Parameterize updateSingleKeychainPassword/updatePassword with an optional encryptionVersion. Default stays at v2 (from the previous commit), so callers get the Argon2 upgrade for free. Callers that still need v1 output (the UI until the Sept 15 breaking-change window closes) opt out with encryptionVersion: 1. TICKET: WCN-1814 --- modules/bitgo/test/v2/unit/keychains.ts | 18 ++++++++++++++++++ .../sdk-core/src/bitgo/keychain/iKeychains.ts | 11 +++++++++++ .../sdk-core/src/bitgo/keychain/keychains.ts | 11 +++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 3f588db6b7..da7d9870c2 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -426,6 +426,24 @@ 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'; + const encryptedPrv = await bitgo.encrypt({ input: prv, password: oldPassword, encryptionVersion: 1 }); + + const keychain = { xpub: 'xpub123', encryptedPrv }; + const newKeychain = await keychains.updateSingleKeychainPassword({ + keychain, + oldPassword, + newPassword, + encryptionVersion: 1, + }); + + JSON.parse(newKeychain.encryptedPrv).should.not.have.property('v', 2, 'v1 opt-out must not emit v2'); + + 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 () { const v1Prv = 'xprv-v1'; const v2Prv = 'xprv-v2'; diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 70a4d12d73..8af222cc30 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -93,12 +93,23 @@ export interface ListKeychainOptions { 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). + */ + encryptionVersion?: EncryptionVersion; } export interface UpdateSingleKeychainPasswordOptions { keychain?: Keychain; oldPassword?: string; newPassword?: string; + /** + * Envelope version to emit. Defaults to `2` (Argon2id). + * Pass `1` to keep emitting legacy v1 (SJCL) envelopes. + */ + encryptionVersion?: EncryptionVersion; } /** diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 7c5dd0ba37..65ab406420 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -119,6 +119,7 @@ export class Keychains implements IKeychains { keychain: key, oldPassword: params.oldPassword, newPassword: params.newPassword, + encryptionVersion: params.encryptionVersion, }); if (updatedKeychain.encryptedPrv) { // Both TSS and multi-user-ofc keys have multiple public keys in their key document and thus need to use objectID @@ -174,13 +175,15 @@ export class Keychains implements IKeychains { } /** - * Update the password used to decrypt a single keychain, always re-encrypting as a v2 - * (Argon2id + AES-256-GCM) envelope. A password change is a natural upgrade point, so - * v1 (SJCL) keychains are transparently promoted to v2; v2 keychains stay v2. + * 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`. * @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) * @returns {Promise} */ async updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Promise { @@ -202,7 +205,7 @@ export class Keychains implements IKeychains { const newEncryptedPrv = await this.bitgo.encrypt({ input: decryptedPrv, password: params.newPassword, - encryptionVersion: 2, + encryptionVersion: params.encryptionVersion ?? 2, }); return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv }); } catch (e) {