diff --git a/src/__tests__/api/master/ecdsa.test.ts b/src/__tests__/api/master/ecdsa.test.ts new file mode 100644 index 0000000..69292da --- /dev/null +++ b/src/__tests__/api/master/ecdsa.test.ts @@ -0,0 +1,267 @@ +import 'should'; +import nock from 'nock'; +import * as sinon from 'sinon'; +import { + BitGoBase, + Wallet, + TxRequest, + IRequestTracer, + TxRequestVersion, + Environments, + RequestTracer, + EcdsaMPCv2Utils, + openpgpUtils, + SignatureShareRecord, + SignatureShareType, + TransactionState, +} from '@bitgo/sdk-core'; +import { EnclavedExpressClient } from '../../../../src/api/master/clients/enclavedExpressClient'; +import { handleEcdsaSigning } from '../../../../src/api/master/handlers/ecdsa'; +import { BitGo } from 'bitgo'; +import { readKey } from 'openpgp'; + +describe('Ecdsa Signing Handler', () => { + let bitgo: BitGoBase; + let wallet: Wallet; + let enclavedExpressClient: EnclavedExpressClient; + let reqId: IRequestTracer; + const bitgoApiUrl = Environments.local.uri; + const enclavedExpressUrl = 'http://enclaved.invalid'; + const coin = 'hteth'; // Use hteth for ECDSA testing + const walletId = 'test-wallet-id'; + + before(() => { + // Disable all real network connections + nock.disableNetConnect(); + }); + + beforeEach(() => { + bitgo = new BitGo({ env: 'local' }); + wallet = { + id: () => 'test-wallet-id', + baseCoin: { + getMPCAlgorithm: () => 'ecdsa', + }, + multisigTypeVersion: () => 2, + } as unknown as Wallet; + enclavedExpressClient = new EnclavedExpressClient( + { + enclavedExpressUrl, + enclavedExpressCert: 'dummy-cert', + tlsMode: 'disabled', + allowSelfSigned: true, + } as any, + coin, + ); + reqId = new RequestTracer(); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + after(() => { + // Re-enable network connections after tests + nock.enableNetConnect(); + }); + + it('should successfully sign an ECDSA MPCv2 transaction', async () => { + const txRequest: TxRequest = { + txRequestId: 'test-tx-request-id', + apiVersion: '2.0.0' as TxRequestVersion, + enterpriseId: 'test-enterprise-id', + transactions: [], + state: 'pendingUserSignature', + walletId: 'test-wallet-id', + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }; + const userPubKey = 'test-user-pub-key'; + + const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('secp256k1'); + const pgpKey = await readKey({ armoredKey: bitgoGpgKey.publicKey }); + sinon.stub(EcdsaMPCv2Utils.prototype, 'getBitgoMpcv2PublicGpgKey').resolves(pgpKey); + + // Mock getTxRequest call + const getTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .matchHeader('any', () => true) + .reply(200, { + txRequests: [txRequest], + }); + + // Mock sendSignatureShareV2 calls for each round + const round1SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round1Input', + data: { + msg1: { + from: 1, + message: 'round1-message', + }, + }, + }), + }; + + const round1TxRequest: TxRequest = { + ...txRequest, + transactions: [ + { + unsignedTx: { + derivationPath: 'm/0', + signableHex: 'testMessage', + serializedTxHex: 'testMessage', + }, + signatureShares: [round1SignatureShare], + state: 'pendingSignature' as TransactionState, + }, + ], + }; + + const sendSignatureShareV2Round1Nock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + txRequest: round1TxRequest, + }); + + const round2SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round2Input', + data: { + msg2: { + from: 1, + to: 3, + encryptedMessage: 'round2-encrypted-message', + signature: 'round2-signature', + }, + msg3: { + from: 1, + to: 3, + encryptedMessage: 'round3-encrypted-message', + signature: 'round3-signature', + }, + }, + }), + }; + + const round2TxRequest: TxRequest = { + ...round1TxRequest, + transactions: [ + { + ...round1TxRequest.transactions![0], + signatureShares: [round1SignatureShare, round2SignatureShare], + }, + ], + }; + + const sendSignatureShareV2Round2Nock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + txRequest: round2TxRequest, + }); + + const round3SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round3Input', + data: { + msg4: { + from: 1, + message: 'round4-message', + signature: 'round4-signature', + signatureR: 'round4-signature-r', + }, + }, + }), + }; + + const sendSignatureShareV2Round3Nock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + txRequest: { + ...round2TxRequest, + transactions: [ + { + ...round2TxRequest.transactions![0], + signatureShares: [round1SignatureShare, round2SignatureShare, round3SignatureShare], + }, + ], + }, + }); + + // Mock sendTxRequest call + const sendTxRequestNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/send`) + .matchHeader('any', () => true) + .reply(200, { + ...txRequest, + state: 'signed', + }); + + // Mock MPCv2 Round 1 signing + const signMpcV2Round1NockEbe = nock(enclavedExpressUrl) + .post(`/api/${coin}/mpc/sign/mpcv2round1`) + .reply(200, { + signatureShareRound1: round1SignatureShare, + userGpgPubKey: bitgoGpgKey.publicKey, + encryptedRound1Session: 'encrypted-round1-session', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv-key', + encryptedDataKey: 'test-encrypted-data-key', + }); + + // Mock MPCv2 Round 2 signing + const signMpcV2Round2NockEbe = nock(enclavedExpressUrl) + .post(`/api/${coin}/mpc/sign/mpcv2round2`) + .reply(200, { + signatureShareRound2: round2SignatureShare, + encryptedRound2Session: 'encrypted-round2-session', + }); + + // Mock MPCv2 Round 3 signing + const signMpcV2Round3NockEbe = nock(enclavedExpressUrl) + .post(`/api/${coin}/mpc/sign/mpcv2round3`) + .reply(200, { + signatureShareRound3: round3SignatureShare, + }); + + const result = await handleEcdsaSigning( + bitgo, + wallet, + txRequest.txRequestId, + enclavedExpressClient, + 'user', + userPubKey, + reqId, + ); + + result.should.eql({ + ...txRequest, + state: 'signed', + }); + + getTxRequestNock.done(); + sendSignatureShareV2Round1Nock.done(); + sendSignatureShareV2Round2Nock.done(); + sendSignatureShareV2Round3Nock.done(); + sendTxRequestNock.done(); + signMpcV2Round1NockEbe.done(); + signMpcV2Round2NockEbe.done(); + signMpcV2Round3NockEbe.done(); + }); +}); diff --git a/src/api/master/clients/enclavedExpressClient.ts b/src/api/master/clients/enclavedExpressClient.ts index a3993c4..a7c77e5 100644 --- a/src/api/master/clients/enclavedExpressClient.ts +++ b/src/api/master/clients/enclavedExpressClient.ts @@ -124,6 +124,51 @@ interface SignMpcGShareResponse { gShare: GShare; } +// ECDSA MPCv2 interfaces +interface SignMpcV2Round1Params { + txRequest: TxRequest; + bitgoGpgPubKey: string; + source: 'user' | 'backup'; + pub: string; +} + +interface SignMpcV2Round1Response { + signatureShareRound1: SignatureShareRecord; + userGpgPubKey: string; + encryptedRound1Session: string; + encryptedUserGpgPrvKey: string; + encryptedDataKey: string; +} + +interface SignMpcV2Round2Params { + txRequest: TxRequest; + bitgoGpgPubKey: string; + encryptedDataKey: string; + encryptedUserGpgPrvKey: string; + encryptedRound1Session: string; + source: 'user' | 'backup'; + pub: string; +} + +interface SignMpcV2Round2Response { + signatureShareRound2: SignatureShareRecord; + encryptedRound2Session: string; +} + +interface SignMpcV2Round3Params { + txRequest: TxRequest; + bitgoGpgPubKey: string; + encryptedDataKey: string; + encryptedUserGpgPrvKey: string; + encryptedRound2Session: string; + source: 'user' | 'backup'; + pub: string; +} + +interface SignMpcV2Round3Response { + signatureShareRound3: SignatureShareRecord; +} + export class EnclavedExpressClient { private readonly baseUrl: string; private readonly enclavedExpressCert: string; @@ -456,6 +501,78 @@ export class EnclavedExpressClient { throw err; } } + + async signMpcV2Round1(params: SignMpcV2Round1Params): Promise { + if (!this.coin) { + throw new Error('Coin must be specified to sign an MPCv2 Round 1'); + } + + try { + let request = this.apiClient['v1.mpc.sign'].post({ + coin: this.coin, + shareType: 'mpcv2round1', + ...params, + }); + + if (this.tlsMode === TlsMode.MTLS) { + request = request.agent(this.createHttpsAgent()); + } + const response = await request.decodeExpecting(200); + return response.body; + } catch (error) { + const err = error as Error; + debugLogger('Failed to sign mpcv2 round 1: %s', err.message); + throw err; + } + } + + async signMpcV2Round2(params: SignMpcV2Round2Params): Promise { + if (!this.coin) { + throw new Error('Coin must be specified to sign an MPCv2 Round 2'); + } + + try { + let request = this.apiClient['v1.mpc.sign'].post({ + coin: this.coin, + shareType: 'mpcv2round2', + ...params, + }); + + if (this.tlsMode === TlsMode.MTLS) { + request = request.agent(this.createHttpsAgent()); + } + const response = await request.decodeExpecting(200); + return response.body; + } catch (error) { + const err = error as Error; + debugLogger('Failed to sign mpcv2 round 2: %s', err.message); + throw err; + } + } + + async signMpcV2Round3(params: SignMpcV2Round3Params): Promise { + if (!this.coin) { + throw new Error('Coin must be specified to sign an MPCv2 Round 3'); + } + + try { + let request = this.apiClient['v1.mpc.sign'].post({ + coin: this.coin, + shareType: 'mpcv2round3', + ...params, + }); + + if (this.tlsMode === TlsMode.MTLS) { + request = request.agent(this.createHttpsAgent()); + } + const response = await request.decodeExpecting(200); + return response.body; + } catch (error) { + const err = error as Error; + debugLogger('Failed to sign mpcv2 round 3: %s', err.message); + throw err; + } + } } /** diff --git a/src/api/master/handlers/ecdsa.ts b/src/api/master/handlers/ecdsa.ts new file mode 100644 index 0000000..524d455 --- /dev/null +++ b/src/api/master/handlers/ecdsa.ts @@ -0,0 +1,115 @@ +import { + BitGoBase, + getTxRequest, + Wallet, + IRequestTracer, + EcdsaMPCv2Utils, + commonTssMethods, + RequestType, +} from '@bitgo/sdk-core'; +import { EnclavedExpressClient } from '../clients/enclavedExpressClient'; +import logger from '../../../logger'; + +export async function handleEcdsaSigning( + bitgo: BitGoBase, + wallet: Wallet, + txRequestId: string, + enclavedExpressClient: EnclavedExpressClient, + source: 'user' | 'backup', + commonKeychain: string, + reqId?: IRequestTracer, +) { + const ecdsaMPCv2Utils = new EcdsaMPCv2Utils(bitgo, wallet.baseCoin); + const txRequest = await getTxRequest(bitgo, wallet.id(), txRequestId, reqId); + + // Get BitGo GPG key for MPCv2 + const bitgoGpgKey = await ecdsaMPCv2Utils.getBitgoMpcv2PublicGpgKey(); + + // Round 1: Generate user's Round 1 share + const { + signatureShareRound1, + userGpgPubKey, + encryptedRound1Session, + encryptedUserGpgPrvKey, + encryptedDataKey, + } = await enclavedExpressClient.signMpcV2Round1({ + txRequest, + bitgoGpgPubKey: bitgoGpgKey.armor(), + source, + pub: commonKeychain, + }); + + // Send Round 1 share to BitGo and get updated txRequest + const round1TxRequest = await commonTssMethods.sendSignatureShareV2( + bitgo, + wallet.id(), + txRequestId, + [signatureShareRound1], + RequestType.tx, + wallet.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + wallet.multisigTypeVersion(), + reqId, + ); + + // Round 2: Generate user's Round 2 share + const { signatureShareRound2, encryptedRound2Session } = + await enclavedExpressClient.signMpcV2Round2({ + txRequest: round1TxRequest, + bitgoGpgPubKey: bitgoGpgKey.armor(), + encryptedDataKey, + encryptedUserGpgPrvKey, + encryptedRound1Session, + source, + pub: commonKeychain, + }); + + // Send Round 2 share to BitGo and get updated txRequest + const round2TxRequest = await commonTssMethods.sendSignatureShareV2( + bitgo, + wallet.id(), + txRequestId, + [signatureShareRound2], + RequestType.tx, + wallet.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + wallet.multisigTypeVersion(), + reqId, + ); + + // Round 3: Generate user's Round 3 share + const { signatureShareRound3 } = await enclavedExpressClient.signMpcV2Round3({ + txRequest: round2TxRequest, + bitgoGpgPubKey: bitgoGpgKey.armor(), + encryptedDataKey, + encryptedUserGpgPrvKey, + encryptedRound2Session, + source, + pub: commonKeychain, + }); + + // Send Round 3 share to BitGo + await commonTssMethods.sendSignatureShareV2( + bitgo, + wallet.id(), + txRequestId, + [signatureShareRound3], + RequestType.tx, + wallet.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + wallet.multisigTypeVersion(), + reqId, + ); + + logger.debug('Successfully completed ECDSA MPCv2 signing!'); + return commonTssMethods.sendTxRequest( + bitgo, + txRequest.walletId, + txRequest.txRequestId, + RequestType.tx, + reqId, + ); +} diff --git a/src/api/master/handlers/eddsa.ts b/src/api/master/handlers/eddsa.ts index 3ebee3d..e01d5ea 100644 --- a/src/api/master/handlers/eddsa.ts +++ b/src/api/master/handlers/eddsa.ts @@ -5,7 +5,6 @@ import { getBitgoToUserRShare, sendUserToBitgoGShare, Wallet, - TxRequest, IRequestTracer, EddsaUtils, } from '@bitgo/sdk-core'; @@ -16,23 +15,15 @@ import logger from '../../../logger'; export async function handleEddsaSigning( bitgo: BitGoBase, wallet: Wallet, - txRequest: string | TxRequest, + txRequestId: string, enclavedExpressClient: EnclavedExpressClient, commonKeychain: string, reqId?: IRequestTracer, ) { - let txRequestResolved: TxRequest; - let txRequestId: string; const eddsaUtils = new EddsaUtils(bitgo, wallet.baseCoin); - if (typeof txRequest === 'string') { - txRequestResolved = await getTxRequest(bitgo, wallet.id(), txRequest, reqId); - txRequestId = txRequestResolved.txRequestId; - } else { - txRequestResolved = txRequest; - txRequestId = txRequest.txRequestId; - } + const txRequest = await getTxRequest(bitgo, wallet.id(), txRequestId, reqId); - const { apiVersion } = txRequestResolved; + const { apiVersion } = txRequest; const bitgoGpgKey = await eddsaUtils.getBitgoPublicGpgKey(); const { @@ -41,7 +32,7 @@ export async function handleEddsaSigning( encryptedUserToBitgoRShare, encryptedDataKey, } = await enclavedExpressClient.signMpcCommitment({ - txRequest: txRequestResolved, + txRequest, bitgoGpgPubKey: bitgoGpgKey.armor(), source: 'user', pub: commonKeychain, @@ -58,7 +49,7 @@ export async function handleEddsaSigning( ); const { rShare } = await enclavedExpressClient.signMpcRShare({ - txRequest: txRequestResolved, + txRequest, encryptedUserToBitgoRShare, encryptedDataKey, source: 'user', @@ -76,7 +67,7 @@ export async function handleEddsaSigning( ); const bitgoToUserRShare = await getBitgoToUserRShare(bitgo, wallet.id(), txRequestId, reqId); const gSignShareTransactionParams = { - txRequest: txRequestResolved, + txRequest, bitgoToUserRShare: bitgoToUserRShare, userToBitgoRShare: rShare, bitgoToUserCommitment, diff --git a/src/api/master/handlers/handleSendMany.ts b/src/api/master/handlers/handleSendMany.ts index e789e5c..d90e3aa 100644 --- a/src/api/master/handlers/handleSendMany.ts +++ b/src/api/master/handlers/handleSendMany.ts @@ -14,6 +14,7 @@ import { import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; import { handleEddsaSigning } from './eddsa'; +import { handleEcdsaSigning } from './ecdsa'; import { EnclavedExpressClient } from '../clients/enclavedExpressClient'; /** @@ -197,7 +198,9 @@ async function signAndSendTxRequests( } let signedTxRequest: TxRequest; - if (wallet.baseCoin.getMPCAlgorithm() === 'eddsa') { + const mpcAlgorithm = wallet.baseCoin.getMPCAlgorithm(); + + if (mpcAlgorithm === 'eddsa') { signedTxRequest = await handleEddsaSigning( bitgo, wallet, @@ -206,8 +209,18 @@ async function signAndSendTxRequests( signingKeychain.commonKeychain, reqId, ); + } else if (mpcAlgorithm === 'ecdsa') { + signedTxRequest = await handleEcdsaSigning( + bitgo, + wallet, + txRequestId, + enclavedExpressClient, + signingKeychain.source as 'user' | 'backup', + signingKeychain.commonKeychain, + reqId, + ); } else { - throw new Error('Unsupported MPC algorithm'); + throw new Error(`Unsupported MPC algorithm: ${mpcAlgorithm}`); } if (!signedTxRequest.txRequestId) {