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-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 () { 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) {