Skip to content
Open
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
10 changes: 9 additions & 1 deletion modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,7 +1163,7 @@ describe('Constructor', function () {
sinon.assert.calledWithMatch(v1UpdatePasswordStub, { encryptionVersion: 2 });
sinon.assert.calledWithMatch(v2UpdatePasswordStub, { encryptionVersion: 2 });
});
it('shares one encryption session across keychain password updates', async function () {
it('creates one encryption session, passes it to both keychain update calls, and destroys it', async function () {
nock(ROOT).get('/api/v2/user/checkBatchingPasswordFlow').query(true).reply(200, { isBatchingFlowEnabled: false });
nock(ROOT)
.post('/api/v1/user/changepassword', (body: unknown) => {
Expand All @@ -1186,6 +1186,14 @@ describe('Constructor', function () {

sinon.assert.calledOnce(createSession);
sinon.assert.calledWithExactly(createSession, 'newpw', 2);
// Call-site truth: the params object reaches both updatePassword calls, but the
// deprecated v1-leg (src/v1/keychains.ts) ignores encryptionSession/encryptionVersion
// and preserves each envelope's version -- see test/unit/v1/keychains.ts. Only the
// coin-leg consumes the session.
sinon.assert.calledWithMatch(v1UpdatePasswordStub, {
oldPassword: 'oldpw',
newPassword: 'newpw',
});
sinon.assert.calledWithMatch(v1UpdatePasswordStub, {
encryptionVersion: 2,
encryptionSession: session,
Expand Down
122 changes: 122 additions & 0 deletions modules/sdk-api/test/unit/v1/keychains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import 'should';
import * as sinon from 'sinon';
import nock from 'nock';
import type { IEncryptionSession } from '@bitgo/sdk-core';
// CJS module.exports assignment; require idiom matches src/bitgoAPI.ts. The
// structural contract below pins the surface this test exercises.
interface V1UpdatePasswordResult {
keychains: Record<string, string>;
version: number;
}

interface V1Bitgo {
url(path: string): string;
post(url: string): { result(): Promise<unknown> };
decrypt(params: { input: string; password: string }): Promise<string>;
encrypt(params: { input: string; password: string; encryptionVersion?: number }): Promise<string>;
}

type V1KeychainsCtor = new (bitgo: V1Bitgo) => {
updatePassword(params: {
oldPassword: string;
newPassword: string;
encryptionVersion?: number;
encryptionSession?: IEncryptionSession;
}): Promise<V1UpdatePasswordResult>;
};

const Keychains: V1KeychainsCtor = require('../../../src/v1/keychains');

const ROOT = 'https://app.example.local';

/**
* The deprecated v1 keychains updatePassword (src/v1/keychains.ts) fetches the user's
* server-stored encryptedXprvs, re-encrypts each with the new password, and preserves
* each envelope's version (isV2Envelope ? 2 : 1). It deliberately ignores both
* `encryptionVersion` and `encryptionSession` -- v1 stays v1 during a v2 rotation and
* no session is ever consumed. These tests pin that wire behavior so the rollback
* contract documented in WCN-2640 cannot regress.
*/
describe('v1 Keychains.updatePassword', function () {
const v1Envelope = JSON.stringify({ v: 1, iter: 10000, salt: 'c2FsdA==', iv: 'aXY=', ct: 'Y3Q=' });
const v2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'c2FsdA==', iv: 'aXY=', ct: 'Y3Q=' });
const undecryptableEnvelope = JSON.stringify({ v: 1, iter: 10000, salt: 'YmFk', iv: 'aXY=', ct: 'Y3Q=' });

function makeBitgo(
stored: Record<string, string>,
decryptResults: Record<string, string>,
encryptStub: sinon.SinonStub
) {
return {
url: (path: string) => `${ROOT}/api/v1${path}`,
post: () => ({ result: async () => ({ version: 25, keychains: stored }) }),
decrypt: async ({ input }: { input: string }) => {
const result = decryptResults[input];
if (result === undefined) {
throw new Error('decryption failed');
}
return result;
},
encrypt: encryptStub,
};
}

afterEach(function () {
nock.cleanAll();
sinon.restore();
});

it('ignores encryptionSession and preserves per-envelope versions under a v2 rotation', async function () {
const stored = { xpubV1: v1Envelope, xpubV2: v2Envelope };
nock(ROOT).post('/api/v1/user/encrypted').reply(200, { version: 25, keychains: stored });

const encryptStub = sinon.stub().resolves('re-encrypted');
const keychains = new Keychains(
makeBitgo(stored, { [v1Envelope]: 'plain-v1', [v2Envelope]: 'plain-v2' }, encryptStub)
);

const session: IEncryptionSession = {
encrypt: sinon.stub().resolves('session-encrypted'),
decrypt: sinon.stub().resolves('session-decrypted'),
destroy: sinon.stub(),
};

const result = await keychains.updatePassword({
oldPassword: 'oldpw',
newPassword: 'newpw',
encryptionVersion: 2,
encryptionSession: session,
});

// the v1-leg never touches the session: no encrypt, no destroy (destroy is the caller's job)
(session.encrypt as sinon.SinonStub).called.should.equal(false);
(session.destroy as sinon.SinonStub).called.should.equal(false);

// re-encryption preserves each envelope's own version: v1 -> encryptionVersion 1,
// v2 -> encryptionVersion 2, NOT the caller's encryptionVersion: 2 opt-in
encryptStub.calledTwice.should.equal(true);
encryptStub.getCall(0).args[0].encryptionVersion.should.equal(1);
encryptStub.getCall(1).args[0].encryptionVersion.should.equal(2);
encryptStub.alwaysCalledWithMatch({ password: 'newpw' }).should.equal(true);

result.keychains.should.deepEqual({ xpubV1: 're-encrypted', xpubV2: 're-encrypted' });
result.version.should.equal(25);
});

it('keeps keychains that cannot be decrypted with the old password unchanged', async function () {
const stored = { xpubV1: v1Envelope, xpubV2: v2Envelope, xpubUndecryptable: undecryptableEnvelope };
nock(ROOT).post('/api/v1/user/encrypted').reply(200, { version: 25, keychains: stored });

const encryptStub = sinon.stub().resolves('re-encrypted');
const keychains = new Keychains(
makeBitgo(stored, { [v1Envelope]: 'plain-v1', [v2Envelope]: 'plain-v2' }, encryptStub)
);

const result = await keychains.updatePassword({ oldPassword: 'oldpw', newPassword: 'newpw' });

encryptStub.calledTwice.should.equal(true);
result.keychains.xpubUndecryptable.should.equal(undecryptableEnvelope);
result.keychains.xpubV1.should.equal('re-encrypted');
result.keychains.xpubV2.should.equal('re-encrypted');
});
});
Loading