Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions modules/bitgo/test/v2/unit/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -409,46 +408,45 @@ 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');

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');

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 };
const newKeychain = await keychains.updateSingleKeychainPassword({
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' });

Expand Down Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
async changePassword({ oldPassword, newPassword, encryptionVersion }: ChangePasswordOptions): Promise<any> {
if (!_.isString(oldPassword)) {
throw new Error('expected string oldPassword');
}
Expand All @@ -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);

Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
20 changes: 18 additions & 2 deletions modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
});
Expand Down Expand Up @@ -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 () {
Expand Down
11 changes: 6 additions & 5 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
13 changes: 7 additions & 6 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Keychain>}
*/
async updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Promise<Keychain> {
Expand All @@ -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) {
Expand Down
Loading