From 49f07018762667ed1647fe438f7624be2cc5e5c6 Mon Sep 17 00:00:00 2001 From: Alex Tse Date: Fri, 21 Nov 2025 15:02:55 -0500 Subject: [PATCH] feat: added rotate keychain method for multi-user-key ofc wallet added a new method to rotate ofc multi-user-key wallet's keychain TICKET: WP-6902 --- modules/bitgo/test/v2/unit/keychains.ts | 95 ++++++++++++++++++- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 20 ++++ .../sdk-core/src/bitgo/keychain/keychains.ts | 48 ++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 61c2a6f37c..e86e89cf45 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -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; @@ -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' @@ -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' + ); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 77d59ac299..0242b7361a 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -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; @@ -214,4 +233,5 @@ export interface IKeychains { recreateMpc(params: RecreateMpcOptions): Promise; createTssBitGoKeyFromOvcShares(ovcOutput: OvcToBitGoJSON, enterprise?: string): Promise; createUserKeychain(userPassword: string): Promise; + rotateKeychain(params: RotateKeychainOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 0eb101f63f..bb4bf059dc 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -19,6 +19,7 @@ import { ListKeychainOptions, ListKeychainsResult, RecreateMpcOptions, + RotateKeychainOptions, UpdatePasswordOptions, UpdateSingleKeychainPasswordOptions, } from './iKeychains'; @@ -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 { + 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'); + } }