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
95 changes: 91 additions & 4 deletions modules/bitgo/test/v2/unit/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import nock = require('nock');
import should = require('should');
import * as sinon from 'sinon';

import { common, decodeOrElse, ECDSAUtils, EDDSAUtils, Keychains, OvcShare } from '@bitgo/sdk-core';
import { common, decodeOrElse, ECDSAUtils, EDDSAUtils, Keychain, Keychains, OvcShare } from '@bitgo/sdk-core';
import { TestBitGo } from '@bitgo/sdk-test';
import { BitGo } from '../../../src/bitgo';
import { SinonStub } from 'sinon';

describe('V2 Keychains', function () {
let bitgo;
Expand Down Expand Up @@ -173,9 +174,7 @@ describe('V2 Keychains', function () {
'expected new password to be a string'
);

(() => keychains.updateSingleKeychainPassword({ oldPassword: '1234', newPassword: 5678 })).should.throw(
'expected new password to be a string'
);
(() => keychains.updateSingleKeychainPassword({ oldPassword: '1234', newPassword: 5678 })).should.throw();

(() => keychains.updateSingleKeychainPassword({ oldPassword: '1234', newPassword: '5678' })).should.throw(
'expected keychain to be an object with an encryptedPrv property'
Expand Down Expand Up @@ -829,4 +828,92 @@ describe('V2 Keychains', function () {
const decryptedPrv = bitgo.decrypt({ input: backup.encryptedPrv, password: 't3stSicretly!' });
decryptedPrv.should.startWith('xprv');
});

describe('Rotate OFC multi-user-key keychains', function () {
let ofcBaseCoin;
let ofcKeychains;
const mockOfcKeychain: Keychain = {
id: 'ofcKeychainId',
pub: 'ofcKeychainPub',
encryptedPrv: 'ofcEncryptedPrv',
source: 'user',
coinSpecific: {
ofc: {
features: ['multi-user-key'],
},
},
type: 'tss',
};
let nonOfcBaseCoin;
let nonOfcKeychains;
const mockNonOfcKeychain: Keychain = {
id: 'nonOfcKeychainId',
pub: 'nonOfcKeychainPub',
source: 'user',
type: 'tss',
};

const mockNewKeypair = {
pub: 'newPub',
prv: 'newPrv',
};

let sandbox;
let updateKeychainStub: SinonStub;
let createKeypairStub: SinonStub;
let encryptionStub: SinonStub;

beforeEach(function () {
ofcBaseCoin = bitgo.coin('ofc');
ofcKeychains = ofcBaseCoin.keychains();

nonOfcBaseCoin = bitgo.coin('hteth');
nonOfcKeychains = nonOfcBaseCoin.keychains();

sandbox = sinon.createSandbox();
updateKeychainStub = sandbox.stub().returns({ result: sandbox.stub().resolves() });
sandbox.stub(BitGo.prototype, 'put').returns({ send: updateKeychainStub });
createKeypairStub = sandbox.stub(ofcKeychains, 'create').returns(mockNewKeypair);
encryptionStub = sandbox.stub(BitGo.prototype, 'encrypt').returns('newEncryptedPrv');
});

afterEach(function () {
sandbox.restore();
});

it('should rotate ofc multi-user-key properly', async function () {
nock(bgUrl).get(`/api/v2/ofc/key/${mockOfcKeychain.id}`).query(true).reply(200, mockOfcKeychain);

await ofcKeychains.rotateKeychain({ id: mockOfcKeychain.id, password: '1234' });
sinon.assert.called(createKeypairStub);
sinon.assert.calledWith(encryptionStub, { input: mockNewKeypair.prv, password: '1234' });
sinon.assert.calledWith(updateKeychainStub, {
pub: mockNewKeypair.pub,
encryptedPrv: 'newEncryptedPrv',
reqId: undefined,
});
});

it('should allow user to supply pub and encryptedPrv directly', async function () {
nock(bgUrl).get(`/api/v2/ofc/key/${mockOfcKeychain.id}`).query(true).reply(200, mockOfcKeychain);

await ofcKeychains.rotateKeychain({ id: mockOfcKeychain.id, pub: 'pub', encryptedPrv: 'encryptedPrv' });
sinon.assert.notCalled(createKeypairStub);
sinon.assert.notCalled(encryptionStub);
sinon.assert.calledWith(updateKeychainStub, {
pub: 'pub',
encryptedPrv: 'encryptedPrv',
reqId: undefined,
});
});

it('should throw when trying to rotate non-ofc keychain', async function () {
nock(bgUrl).get(`/api/v2/hteth/key/${mockNonOfcKeychain.id}`).query(true).reply(200, mockNonOfcKeychain);

await assert.rejects(
async () => await nonOfcKeychains.rotateKeychain({ id: mockNonOfcKeychain.id, password: '1234' }),
(err: Error) => err.message === 'rotateKeychain is only permitted for ofc multi-user-key wallet'
);
});
});
});
20 changes: 20 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ export interface UpdateSingleKeychainPasswordOptions {
newPassword?: string;
}

/**
* Parameters for the rotateKeychain method for ofc multi-user-key
* @property {string} id - the public id of the keychain
* @property {string} password - the user password use to encrypt/decrypt the private key
* @property {IRequestTracer} reqId - optional reqId
*/
export type RotateKeychainOptions =
| {
id: string;
password: string;
reqId?: IRequestTracer;
}
| {
id: string;
pub: string;
encryptedPrv: string;
reqId?: IRequestTracer;
};

export interface AddKeychainOptions {
pub?: string;
commonPub?: string;
Expand Down Expand Up @@ -214,4 +233,5 @@ export interface IKeychains {
recreateMpc(params: RecreateMpcOptions): Promise<KeychainsTriplet>;
createTssBitGoKeyFromOvcShares(ovcOutput: OvcToBitGoJSON, enterprise?: string): Promise<BitGoKeyFromOvcShares>;
createUserKeychain(userPassword: string): Promise<Keychain>;
rotateKeychain(params: RotateKeychainOptions): Promise<Keychain>;
}
48 changes: 48 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ListKeychainOptions,
ListKeychainsResult,
RecreateMpcOptions,
RotateKeychainOptions,
UpdatePasswordOptions,
UpdateSingleKeychainPasswordOptions,
} from './iKeychains';
Expand Down Expand Up @@ -517,4 +518,51 @@ export class Keychains implements IKeychains {
prv: newKeychain.prv,
};
}

/**
* Rotate an ofc multi-user-keychain by recreating a new keypair, encrypt it using the user password and sent it to WP
* This is only meant to be called by ofc multi-user-wallet's key, and will throw if otherwise.
*
* Requires 2fa auth before calling. Call the bitgo.unlock() SDK method to unlock the session first.
* @param params parameters for the rotate keychain method
* @return rotatedKeychain
*/
async rotateKeychain(params: RotateKeychainOptions): Promise<Keychain> {
const keyChain = await this.get({ id: params.id });
if (!Keychains.isMultiUserKey(keyChain)) {
throw new Error(`rotateKeychain is only permitted for ofc multi-user-key wallet`);
}

let pub: string, encryptedPrv: string;
if ('encryptedPrv' in params) {
// client passed in pub and encryptedPrv directly
pub = params.pub;
encryptedPrv = params.encryptedPrv;
} else {
// bitgo generate pub and prv and encrypt it
const { pub: keyPub, prv: keyPrv } = this.create();
if (!keyPub) {
throw Error('Expected a public key to be generated');
}
pub = keyPub;
encryptedPrv = this.bitgo.encrypt({ input: keyPrv, password: params.password });
}

return this.bitgo
.put(this.baseCoin.url(`/key/${params.id}`))
.send({
encryptedPrv,
pub,
reqId: params.reqId,
})
.result();
}

/**
* Static helper method to determine if a keychain is a ofc multi-user-key
* @param keychain
*/
static isMultiUserKey(keychain: Keychain): boolean {
return (keychain.coinSpecific?.ofc?.['features'] ?? []).includes('multi-user-key');
}
}