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
49 changes: 43 additions & 6 deletions modules/bitgo/test/v2/unit/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};
Expand Down Expand Up @@ -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);
Expand All @@ -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';

Expand Down Expand Up @@ -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);
});
Expand Down
11 changes: 11 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
11 changes: 7 additions & 4 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Keychain>}
*/
async updateSingleKeychainPassword(params: UpdateSingleKeychainPasswordOptions = {}): Promise<Keychain> {
Expand All @@ -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) {
Expand Down
Loading