diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 07eed600e7..da7d9870c2 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,42 @@ 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('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'; @@ -445,10 +481,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/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 63a7962efb..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,12 +175,15 @@ 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. 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 { @@ -198,11 +202,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: params.encryptionVersion ?? 2, }); return _.assign({}, params.keychain, { encryptedPrv: newEncryptedPrv }); } catch (e) {