diff --git a/package.json b/package.json index c002073..4214005 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/express": "4.17.13", "@types/jasmine": "^5.1.8", "@types/jest": "^29.5.12", + "@types/keccak": "^3.0.5", "@types/lodash": "^4.14.121", "@types/mocha": "^10.0.10", "@types/morgan": "^1.7.35", @@ -65,6 +66,7 @@ "eslint-plugin-prettier": "^4.0.0", "jasmine": "^5.8.0", "jest": "^29.7.0", + "keccak": "^3.0.3", "mocha": "^11.6.0", "nock": "^13.3.1", "nodemon": "^3.1.10", diff --git a/src/__tests__/api/enclaved/ecdsaUtils.ts b/src/__tests__/api/enclaved/ecdsaUtils.ts new file mode 100644 index 0000000..aad2124 --- /dev/null +++ b/src/__tests__/api/enclaved/ecdsaUtils.ts @@ -0,0 +1,206 @@ +// ECDSA MPCv2 specific imports +import { DklsTypes, DklsComms, DklsDsg } from '@bitgo/sdk-lib-mpc'; + +import { TxRequest, SignatureShareRecord, SignatureShareType } from '@bitgo/sdk-core'; + +// MPCv2 type definitions +import { + MPCv2PartyFromStringOrNumber, + MPCv2SignatureShareRound1Input, + MPCv2SignatureShareRound1Output, + MPCv2SignatureShareRound2Input, + MPCv2SignatureShareRound2Output, + MPCv2SignatureShareRound3Input, +} from '@bitgo/public-types'; +import assert from 'assert'; +import { bitgoGpgKey } from '../../mocks/gpgKeys'; + +export async function signBitgoMPCv2Round1( + bitgoSession: DklsDsg.Dsg, + txRequest: TxRequest, + userShare: SignatureShareRecord, + userGPGPubKey: string, +): Promise { + assert( + txRequest.transactions && txRequest.transactions.length === 1, + 'txRequest.transactions is not an array of length 1', + ); + txRequest.transactions[0].signatureShares.push(userShare); + // Do the actual signing on BitGo's side based on User's messages + const signatureShare = JSON.parse(userShare.share) as MPCv2SignatureShareRound1Input; + const deserializedMessages = DklsTypes.deserializeMessages({ + p2pMessages: [], + broadcastMessages: [ + { + from: signatureShare.data.msg1.from, + payload: signatureShare.data.msg1.message, + }, + ], + }); + const bitgoToUserRound1BroadcastMsg = await bitgoSession.init(); + const bitgoToUserRound2Msg = bitgoSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: deserializedMessages.broadcastMessages, + }); + const serializedBitGoToUserRound1And2Msgs = DklsTypes.serializeMessages({ + p2pMessages: bitgoToUserRound2Msg.p2pMessages, + broadcastMessages: [bitgoToUserRound1BroadcastMsg], + }); + + const authEncMessages = await DklsComms.encryptAndAuthOutgoingMessages( + serializedBitGoToUserRound1And2Msgs, + [getUserPartyGpgKeyPublic(userGPGPubKey)], + [getBitGoPartyGpgKeyPrv(bitgoGpgKey.private)], + ); + + const bitgoToUserSignatureShare: MPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { + msg1: { + from: authEncMessages.broadcastMessages[0].from as MPCv2PartyFromStringOrNumber, + signature: authEncMessages.broadcastMessages[0].payload.signature, + message: authEncMessages.broadcastMessages[0].payload.message, + }, + msg2: { + from: authEncMessages.p2pMessages[0].from as MPCv2PartyFromStringOrNumber, + to: authEncMessages.p2pMessages[0].to as MPCv2PartyFromStringOrNumber, + encryptedMessage: authEncMessages.p2pMessages[0].payload.encryptedMessage, + signature: authEncMessages.p2pMessages[0].payload.signature, + }, + }, + }; + txRequest.transactions[0].signatureShares.push({ + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify(bitgoToUserSignatureShare), + }); + return txRequest; +} + +export async function signBitgoMPCv2Round2( + bitgoSession: DklsDsg.Dsg, + txRequest: TxRequest, + userShare: SignatureShareRecord, + userGPGPubKey: string, +): Promise<{ txRequest: TxRequest; bitgoMsg4: DklsTypes.SerializedBroadcastMessage }> { + assert( + txRequest.transactions && txRequest.transactions.length === 1, + 'txRequest.transactions is not an array of length 1', + ); + txRequest.transactions[0].signatureShares.push(userShare); + + // Do the actual signing on BitGo's side based on User's messages + const parsedSignatureShare = JSON.parse(userShare.share) as MPCv2SignatureShareRound2Input; + const serializedMessages = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [ + { + from: parsedSignatureShare.data.msg2.from, + to: parsedSignatureShare.data.msg2.to, + payload: { + encryptedMessage: parsedSignatureShare.data.msg2.encryptedMessage, + signature: parsedSignatureShare.data.msg2.signature, + }, + }, + { + from: parsedSignatureShare.data.msg3.from, + to: parsedSignatureShare.data.msg3.to, + payload: { + encryptedMessage: parsedSignatureShare.data.msg3.encryptedMessage, + signature: parsedSignatureShare.data.msg3.signature, + }, + }, + ], + broadcastMessages: [], + }, + [getUserPartyGpgKeyPublic(userGPGPubKey)], + [getBitGoPartyGpgKeyPrv(bitgoGpgKey.private)], + ); + const deserializedMessages2 = DklsTypes.deserializeMessages({ + p2pMessages: [serializedMessages.p2pMessages[0]], + broadcastMessages: [], + }); + + const bitgoToUserRound3Msg = bitgoSession.handleIncomingMessages(deserializedMessages2); + const serializedBitGoToUserRound3Msgs = DklsTypes.serializeMessages(bitgoToUserRound3Msg); + + const authEncMessages = await DklsComms.encryptAndAuthOutgoingMessages( + serializedBitGoToUserRound3Msgs, + [getUserPartyGpgKeyPublic(userGPGPubKey)], + [getBitGoPartyGpgKeyPrv(bitgoGpgKey.private)], + ); + + const bitgoToUserSignatureShare: MPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { + msg3: { + from: authEncMessages.p2pMessages[0].from as MPCv2PartyFromStringOrNumber, + to: authEncMessages.p2pMessages[0].to as MPCv2PartyFromStringOrNumber, + encryptedMessage: authEncMessages.p2pMessages[0].payload.encryptedMessage, + signature: authEncMessages.p2pMessages[0].payload.signature, + }, + }, + }; + + // handling user msg3 but not returning bitgo msg4 since its stored on bitgo side only + const deserializedMessages3 = DklsTypes.deserializeMessages({ + p2pMessages: [serializedMessages.p2pMessages[1]], + broadcastMessages: [], + }); + const deserializedBitgoMsg4 = bitgoSession.handleIncomingMessages(deserializedMessages3); + const serializedBitGoToUserRound4Msgs = DklsTypes.serializeMessages(deserializedBitgoMsg4); + + txRequest.transactions[0].signatureShares.push({ + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify(bitgoToUserSignatureShare), + }); + return { txRequest, bitgoMsg4: serializedBitGoToUserRound4Msgs.broadcastMessages[0] }; +} + +export async function signBitgoMPCv2Round3( + bitgoSession: DklsDsg.Dsg, + userShare: SignatureShareRecord, + userGPGPubKey: string, +): Promise<{ userMsg4: MPCv2SignatureShareRound3Input }> { + const parsedSignatureShare = JSON.parse(userShare.share) as MPCv2SignatureShareRound3Input; + const serializedMessages = await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [ + { + from: parsedSignatureShare.data.msg4.from, + payload: { + message: parsedSignatureShare.data.msg4.message, + signature: parsedSignatureShare.data.msg4.signature, + }, + }, + ], + }, + [getUserPartyGpgKeyPublic(userGPGPubKey)], + [getBitGoPartyGpgKeyPrv(bitgoGpgKey.private)], + ); + const deserializedMessages = DklsTypes.deserializeMessages({ + p2pMessages: [], + broadcastMessages: [serializedMessages.broadcastMessages[0]], + }); + bitgoSession.handleIncomingMessages(deserializedMessages); + + return { + userMsg4: parsedSignatureShare, + }; +} + +function getBitGoPartyGpgKeyPrv(bitgoPrvKey: string): DklsTypes.PartyGpgKey { + return { + partyId: 2, + gpgKey: bitgoPrvKey, + }; +} + +function getUserPartyGpgKeyPublic(userPubKey: string): DklsTypes.PartyGpgKey { + return { + partyId: 0, + gpgKey: userPubKey, + }; +} diff --git a/src/__tests__/api/enclaved/signMpcTransaction.test.ts b/src/__tests__/api/enclaved/signMpcTransaction.test.ts index 7114235..d9b2251 100644 --- a/src/__tests__/api/enclaved/signMpcTransaction.test.ts +++ b/src/__tests__/api/enclaved/signMpcTransaction.test.ts @@ -8,6 +8,13 @@ import express from 'express'; import * as sinon from 'sinon'; import * as configModule from '../../../initConfig'; import { Ed25519BIP32, Eddsa, SignatureShareType } from '@bitgo/sdk-core'; +import { TxRequest } from '@bitgo/public-types'; +import { DklsUtils, DklsDsg, DklsTypes } from '@bitgo/sdk-lib-mpc'; +import assert from 'assert'; +import { signBitgoMPCv2Round1, signBitgoMPCv2Round2, signBitgoMPCv2Round3 } from './ecdsaUtils'; +import { Hash } from 'crypto'; +import createKeccakHash from 'keccak'; +import { bitgoGpgKey } from '../../mocks/gpgKeys'; describe('signMpcTransaction', () => { let cfg: EnclavedConfig; @@ -326,4 +333,346 @@ describe('signMpcTransaction', () => { response.body.should.have.property('error'); }); }); + + describe('ECDSA MPCv2 Signing Integration Tests', () => { + const coin = 'hteth'; // Use hteth for ECDSA testing + + it('should successfully complete all MPCv2 rounds', async () => { + const walletID = '62fe536a6b4cf70007acb48c0e7bb0b0'; + const tMessage = 'testMessage'; + const derivationPath = 'm/0'; + + const [userShare, backupShare, bitgoShare] = await DklsUtils.generateDKGKeyShares(); + assert(backupShare, 'backupShare is not defined'); + + const userKeyShare = userShare.getKeyShare().toString('base64'); + + const mockKmsResponse = { + prv: JSON.stringify(userKeyShare), + pub: 'mock-ecdsa-public-key', + source: 'user', + type: 'independent', + }; + + const mockTxRequest: TxRequest = { + txRequestId: '123456', + apiVersion: 'full', + walletId: walletID, + transactions: [ + { + unsignedTx: { + derivationPath, + signableHex: tMessage, + serializedTxHex: tMessage, + }, + signatureShares: [], + state: 'initialized', + }, + ], + walletType: 'cold', + state: 'initialized', + date: new Date().toISOString(), + signatureShares: [], + version: 1, + userId: '123456', + intent: 'sign', + policiesChecked: true, + pendingApprovalId: '123456', + pendingTxHashes: [], + txHashes: [], + unsignedTxs: [], + latest: true, + }; + + // Round 1 test + const round1Input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: mockTxRequest, + bitgoGpgPubKey: bitgoGpgKey.public, + }; + + const mockDataKeyResponse = { + plaintextKey: 'mock-plaintext-data-key', + encryptedKey: 'mock-encrypted-data-key', + }; + + // Mock KMS responses for Round 1 + const kmsNock = nock(kmsUrl) + .get(`/key/${round1Input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const dataKeyNock = nock(kmsUrl).post('/generateDataKey').reply(200, mockDataKeyResponse); + + /* Signing Round 1 with User Key */ + const round1Response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round1`) + .set('Authorization', `Bearer ${accessToken}`) + .send(round1Input); + + round1Response.status.should.equal(200); + round1Response.body.should.have.property('signatureShareRound1'); + round1Response.body.should.have.property('userGpgPubKey'); + round1Response.body.should.have.property('encryptedRound1Session'); + round1Response.body.should.have.property('encryptedUserGpgPrvKey'); + round1Response.body.should.have.property('encryptedDataKey'); + + kmsNock.done(); + dataKeyNock.done(); + + /* Signing Round 1 with Bitgo Key */ + + const hashFn = createKeccakHash('keccak256') as Hash; + const hashBuffer = hashFn.update(Buffer.from(tMessage, 'hex')).digest(); + const bitgoSession = new DklsDsg.Dsg(bitgoShare.getKeyShare(), 2, derivationPath, hashBuffer); + + const txRequestRound1 = await signBitgoMPCv2Round1( + bitgoSession, + mockTxRequest, + round1Response.body.signatureShareRound1, + round1Response.body.userGpgPubKey, + ); + assert( + txRequestRound1.transactions && + txRequestRound1.transactions.length === 1 && + txRequestRound1.transactions[0].signatureShares.length === 2, + 'txRequestRound2.transactions is not an array of length 1 with 2 signatureShares', + ); + + // Round 2 Signing with User Key + const encryptedDataKey = round1Response.body.encryptedDataKey; + const encryptedUserGpgPrvKey = round1Response.body.encryptedUserGpgPrvKey; + const encryptedRound1Session = round1Response.body.encryptedRound1Session; + + const round2Input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: txRequestRound1, + bitgoGpgPubKey: bitgoGpgKey.public, + encryptedDataKey, + encryptedUserGpgPrvKey, + encryptedRound1Session, + }; + + const mockDecryptedDataKeyResponse = { + plaintextKey: 'mock-plaintext-data-key', + }; + + // Mock KMS responses for Round 2 + const r2KmsNock = nock(kmsUrl) + .get(`/key/${round2Input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const decryptDataKeyNock = nock(kmsUrl) + .post('/decryptDataKey') + .reply(200, mockDecryptedDataKeyResponse); + + const round2Response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round2`) + .set('Authorization', `Bearer ${accessToken}`) + .send(round2Input); + + round2Response.status.should.equal(200); + round2Response.body.should.have.property('signatureShareRound2'); + round2Response.body.should.have.property('encryptedRound2Session'); + r2KmsNock.done(); + decryptDataKeyNock.done(); + + // Round 2 Signing with Bitgo Key + const { txRequest: txRequestRound2, bitgoMsg4 } = await signBitgoMPCv2Round2( + bitgoSession, + txRequestRound1, + round2Response.body.signatureShareRound2, + round1Response.body.userGpgPubKey, + ); + assert( + txRequestRound2.transactions && + txRequestRound2.transactions.length === 1 && + txRequestRound2.transactions[0].signatureShares.length === 4, + 'txRequestRound2.transactions is not an array of length 1 with 4 signatureShares', + ); + + // Round 3 Signing with User Key + const encryptedRound2Session = round2Response.body.encryptedRound2Session; + + const round3Input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: txRequestRound2, + bitgoGpgPubKey: bitgoGpgKey.public, + encryptedDataKey, + encryptedUserGpgPrvKey, + encryptedRound2Session, + }; + + // Mock KMS responses for Round 3 + const r3KmsNock = nock(kmsUrl) + .get(`/key/${round3Input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const r3DecryptDataKeyNock = nock(kmsUrl) + .post('/decryptDataKey') + .reply(200, mockDecryptedDataKeyResponse); + + const round3Response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round3`) + .set('Authorization', `Bearer ${accessToken}`) + .send(round3Input); + + round3Response.status.should.equal(200); + round3Response.body.should.have.property('signatureShareRound3'); + + r3KmsNock.done(); + r3DecryptDataKeyNock.done(); + + const { userMsg4 } = await signBitgoMPCv2Round3( + bitgoSession, + round3Response.body.signatureShareRound3, + round1Response.body.userGpgPubKey, + ); + assert(userMsg4, 'userMsg4 is not defined'); + + // signature generation and validation + assert( + userMsg4.data.msg4.signatureR === bitgoMsg4.signatureR, + 'User and BitGo signaturesR do not match', + ); + + const deserializedBitgoMsg4 = DklsTypes.deserializeMessages({ + p2pMessages: [], + broadcastMessages: [bitgoMsg4], + }); + + const deserializedUserMsg4 = DklsTypes.deserializeMessages({ + p2pMessages: [], + broadcastMessages: [ + { + from: userMsg4.data.msg4.from, + payload: userMsg4.data.msg4.message, + }, + ], + }); + + const combinedSigUsingUtil = DklsUtils.combinePartialSignatures( + [ + deserializedUserMsg4.broadcastMessages[0].payload, + deserializedBitgoMsg4.broadcastMessages[0].payload, + ], + Buffer.from(userMsg4.data.msg4.signatureR, 'base64').toString('hex'), + ); + + const convertedSignature = DklsUtils.verifyAndConvertDklsSignature( + Buffer.from(tMessage, 'hex'), + combinedSigUsingUtil, + DklsTypes.getCommonKeychain(userShare.getKeyShare()), + derivationPath, + createKeccakHash('keccak256') as Hash, + ); + assert(convertedSignature, 'Signature is not valid'); + assert(convertedSignature.split(':').length === 4, 'Signature is not valid'); + }); + + it('should fail when required fields are missing for Round 2', async () => { + const mockKmsResponse = { + prv: 'mock-ecdsa-private-key', + pub: 'mock-ecdsa-public-key', + source: 'user', + type: 'independent', + }; + + const input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: mockTxRequest, + // Missing encryptedDataKey, encryptedUserGpgPrvKey, encryptedRound1Session + }; + + const kmsNock = nock(kmsUrl) + .get(`/key/${input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round2`) + .set('Authorization', `Bearer ${accessToken}`) + .send(input); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.details.should.equal( + 'encryptedDataKey from Round 1 is required for MPCv2 Round 2', + ); + + kmsNock.done(); + }); + + it('should fail when required fields are missing for Round 3', async () => { + const mockKmsResponse = { + prv: 'mock-ecdsa-private-key', + pub: 'mock-ecdsa-public-key', + source: 'user', + type: 'independent', + }; + + const input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: mockTxRequest, + encryptedDataKey: 'mock-encrypted-data-key', + // Missing bitgoGpgPubKey, encryptedUserGpgPrvKey, encryptedRound2Session + }; + + const kmsNock = nock(kmsUrl) + .get(`/key/${input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round3`) + .set('Authorization', `Bearer ${accessToken}`) + .send(input); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.details.should.equal('bitgoGpgPubKey is required for MPCv2 Round 3'); + + kmsNock.done(); + }); + + it('should fail for unsupported share type', async () => { + const mockKmsResponse = { + prv: 'mock-ecdsa-private-key', + pub: 'mock-ecdsa-public-key', + source: 'user', + type: 'independent', + }; + + const input = { + source: 'user', + pub: 'mock-ecdsa-public-key', + txRequest: mockTxRequest, + }; + + const kmsNock = nock(kmsUrl) + .get(`/key/${input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsResponse); + + const response = await agent + .post(`/api/${coin}/mpc/sign/invalid`) + .set('Authorization', `Bearer ${accessToken}`) + .send(input); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.details.should.equal( + 'Share type invalid not supported for MPCv2, only MPCv2Round1, MPCv2Round2 and MPCv2Round3 is supported.', + ); + + kmsNock.done(); + }); + }); }); diff --git a/src/__tests__/mocks/gpgKeys.ts b/src/__tests__/mocks/gpgKeys.ts new file mode 100644 index 0000000..a95732c --- /dev/null +++ b/src/__tests__/mocks/gpgKeys.ts @@ -0,0 +1,34 @@ +export const bitgoGpgKey = { + private: + '-----BEGIN PGP PRIVATE KEY BLOCK-----\n' + + '\n' + + 'xXQEZo2rshMFK4EEAAoCAwQC6HQa7PXiX2nnpZr/asCcEbgCOcjsR8gcSI8v\n' + + 'vMADk59KsFweg+kIzCR3UqfMe2uG6JHwOYpvDREHp/hqtA+hAAD/XgjiTu4D\n' + + '0d9YzSx3ZP8lUAcruvJbyMIIlr26QIeHq5kRQM0FYml0Z2/CjAQQEwgAPgWC\n' + + 'Zo2rsgQLCQcICZA8EZGJCCwPOAMVCAoEFgACAQIZAQKbAwIeARYhBLSGUeOq\n' + + 'bM5ym4aSnjwRkYkILA84AABXoQD+KkO5kWGw8GgWN142t+pGULPLzGo6353r\n' + + 'H8FwgKxe9ikBAKEjJI17aVlozG0RzFVxctBLLVqjYO5tBZQhoQbHHkGdx3gE\n' + + 'Zo2rshIFK4EEAAoCAwTBwmMa+htUmjUoqlKTuQaoWcY0Det+ee/6fV9+vnis\n' + + 'EyphRUFXnA0K0LyGpSnNlqKisSoArwUkZTiWwTbMWjTdAwEIBwABAJmAlxnB\n' + + 'IZ5bw88Duvw0yaRRcgXt5tDP0z23l6cvJWgKEJbCeAQYEwgAKgWCZo2rsgmQ\n' + + 'PBGRiQgsDzgCmwwWIQS0hlHjqmzOcpuGkp48EZGJCCwPOAAA3/4BAIozuxF1\n' + + 'JEoSQXe8YFIFqowwCiVwr2K6NqqRn+mGM1NjAQCYWIsZq+4+UCBIKScVknTG\n' + + 'uu2Utd5ZMyNYZTWCxLk9+g==\n' + + '=iXOB\n' + + '-----END PGP PRIVATE KEY BLOCK-----\n', + public: + '-----BEGIN PGP PUBLIC KEY BLOCK-----\n' + + '\n' + + 'xk8EZo2rshMFK4EEAAoCAwQC6HQa7PXiX2nnpZr/asCcEbgCOcjsR8gcSI8v\n' + + 'vMADk59KsFweg+kIzCR3UqfMe2uG6JHwOYpvDREHp/hqtA+hzQViaXRnb8KM\n' + + 'BBATCAA+BYJmjauyBAsJBwgJkDwRkYkILA84AxUICgQWAAIBAhkBApsDAh4B\n' + + 'FiEEtIZR46psznKbhpKePBGRiQgsDzgAAFehAP4qQ7mRYbDwaBY3Xja36kZQ\n' + + 's8vMajrfnesfwXCArF72KQEAoSMkjXtpWWjMbRHMVXFy0EstWqNg7m0FlCGh\n' + + 'BsceQZ3OUwRmjauyEgUrgQQACgIDBMHCYxr6G1SaNSiqUpO5BqhZxjQN6355\n' + + '7/p9X36+eKwTKmFFQVecDQrQvIalKc2WoqKxKgCvBSRlOJbBNsxaNN0DAQgH\n' + + 'wngEGBMIACoFgmaNq7IJkDwRkYkILA84ApsMFiEEtIZR46psznKbhpKePBGR\n' + + 'iQgsDzgAAN/+AQCKM7sRdSRKEkF3vGBSBaqMMAolcK9iujaqkZ/phjNTYwEA\n' + + 'mFiLGavuPlAgSCknFZJ0xrrtlLXeWTMjWGU1gsS5Pfo=\n' + + '=7uRX\n' + + '-----END PGP PUBLIC KEY BLOCK-----\n', +}; diff --git a/src/api/enclaved/handlers/signMpcTransaction.ts b/src/api/enclaved/handlers/signMpcTransaction.ts index 8918618..155c5d1 100644 --- a/src/api/enclaved/handlers/signMpcTransaction.ts +++ b/src/api/enclaved/handlers/signMpcTransaction.ts @@ -4,6 +4,7 @@ import logger from '../../../logger'; import { TxRequest, EddsaUtils, + EcdsaMPCv2Utils, CommitmentShareRecord, EncryptedSignerShareRecord, SignShare, @@ -20,7 +21,10 @@ enum ShareType { R = 'r', G = 'g', - // TODO: Add ECDSA share types + // ECDSA MPCv2 share types + MPCv2Round1 = 'mpcv2round1', + MPCv2Round2 = 'mpcv2round2', + MPCv2Round3 = 'mpcv2round3', } // Define MPC algorithm types @@ -67,6 +71,19 @@ interface EddsaSigningParams { bitgoGpgPubKey?: string; } +// Unified parameters for handleEcdsaSigning - includes all possible fields +interface EcdsaSigningParams { + coin: BaseCoin; + shareType: string; + txRequest: TxRequest; + prv: string; + bitgoGpgPubKey?: string; + encryptedDataKey?: string; + encryptedUserGpgPrvKey?: string; + encryptedRound1Session?: string; + encryptedRound2Session?: string; +} + export async function signMpcTransaction(req: EnclavedApiSpecRouteRequest<'v1.mpc.sign', 'post'>) { const { source, pub, coin, encryptedDataKey, shareType } = req.decoded; @@ -99,7 +116,17 @@ export async function signMpcTransaction(req: EnclavedApiSpecRouteRequest<'v1.mp bitgoGpgPubKey: req.decoded.bitgoGpgPubKey, }); } else if (mpcAlgorithm === MPCType.ECDSA) { - throw new Error('ECDSA MPC is not supported yet'); + return await handleEcdsaMpcV2Signing(req.bitgo, req.config, { + coin: coinInstance, + shareType, + txRequest: req.decoded.txRequest, + prv, + bitgoGpgPubKey: req.decoded.bitgoGpgPubKey, + encryptedDataKey: req.decoded.encryptedDataKey, + encryptedUserGpgPrvKey: req.decoded.encryptedUserGpgPrvKey, + encryptedRound1Session: req.decoded.encryptedRound1Session, + encryptedRound2Session: req.decoded.encryptedRound2Session, + }); } else { throw new Error(`MPC Algorithm ${mpcAlgorithm} is not supported.`); } @@ -197,3 +224,84 @@ async function handleEddsaSigning( ); } } + +async function handleEcdsaMpcV2Signing( + bitgo: BitGoBase, + cfg: EnclavedConfig, + params: EcdsaSigningParams, +): Promise { + const { coin, shareType } = params; + + // Create EcdsaMPCv2Utils instance using the coin's bitgo instance + const ecdsaMPCv2Utils = new EcdsaMPCv2Utils(bitgo, coin); + + switch (shareType.toLowerCase()) { + case ShareType.MPCv2Round1: { + const dataKey = await generateDataKey({ keyType: 'AES-256', cfg }); + return { + ...(await ecdsaMPCv2Utils.createOfflineRound1Share({ + txRequest: params.txRequest, + prv: params.prv, + walletPassphrase: dataKey.plaintextKey, + })), + encryptedDataKey: dataKey.encryptedKey, + }; + } + case ShareType.MPCv2Round2: { + if (!params.encryptedDataKey) { + throw new Error('encryptedDataKey from Round 1 is required for MPCv2 Round 2'); + } + if (!params.bitgoGpgPubKey) { + throw new Error('bitgoGpgPubKey is required for MPCv2 Round 2'); + } + if (!params.encryptedUserGpgPrvKey) { + throw new Error('encryptedUserGpgPrvKey is required for MPCv2 Round 2'); + } + if (!params.encryptedRound1Session) { + throw new Error('encryptedRound1Session is required for MPCv2 Round 2'); + } + const plaintextDataKey = await decryptDataKey({ + encryptedDataKey: params.encryptedDataKey, + cfg, + }); + return await ecdsaMPCv2Utils.createOfflineRound2Share({ + txRequest: params.txRequest, + prv: params.prv, + walletPassphrase: plaintextDataKey, + bitgoPublicGpgKey: params.bitgoGpgPubKey, + encryptedUserGpgPrvKey: params.encryptedUserGpgPrvKey, + encryptedRound1Session: params.encryptedRound1Session, + }); + } + case ShareType.MPCv2Round3: { + if (!params.encryptedDataKey) { + throw new Error('encryptedDataKey from Round 1 is required for MPCv2 Round 3'); + } + if (!params.bitgoGpgPubKey) { + throw new Error('bitgoGpgPubKey is required for MPCv2 Round 3'); + } + if (!params.encryptedUserGpgPrvKey) { + throw new Error('encryptedUserGpgPrvKey is required for MPCv2 Round 3'); + } + if (!params.encryptedRound2Session) { + throw new Error('encryptedRound2Session is required for MPCv2 Round 3'); + } + const plaintextDataKey = await decryptDataKey({ + encryptedDataKey: params.encryptedDataKey, + cfg, + }); + return await ecdsaMPCv2Utils.createOfflineRound3Share({ + txRequest: params.txRequest, + prv: params.prv, + walletPassphrase: plaintextDataKey, + bitgoPublicGpgKey: params.bitgoGpgPubKey, + encryptedUserGpgPrvKey: params.encryptedUserGpgPrvKey, + encryptedRound2Session: params.encryptedRound2Session, + }); + } + default: + throw new Error( + `Share type ${shareType} not supported for MPCv2, only MPCv2Round1, MPCv2Round2 and MPCv2Round3 is supported.`, + ); + } +} diff --git a/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts b/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts index 9dc6b8a..dd435c6 100644 --- a/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts +++ b/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts @@ -89,27 +89,49 @@ const SignMpcRequest = { bitgoToUserCommitment: t.union([t.undefined, t.any]), bitgoGpgPubKey: t.union([t.undefined, t.string]), encryptedDataKey: t.union([t.undefined, t.string]), + + // ECDSA MPCv2 specific fields + encryptedUserGpgPrvKey: t.union([t.undefined, t.string]), + encryptedRound1Session: t.union([t.undefined, t.string]), + encryptedRound2Session: t.union([t.undefined, t.string]), }; // Response type for /mpc/sign endpoint const SignMpcResponse: HttpResponse = { // Response type for MPC transaction signing 200: t.union([ - // Commitment share response + // EDDSA Commitment share response t.type({ userToBitgoCommitment: t.any, encryptedSignerShare: t.any, encryptedUserToBitgoRShare: t.any, encryptedDataKey: t.string, }), - // R share response + // EDDSA R share response t.type({ rShare: t.any, }), - // G share response + // EDDSA G share response t.type({ gShare: t.any, }), + // ECDSA MPCv2 Round 1 response + t.type({ + signatureShareRound1: t.any, + userGpgPubKey: t.string, + encryptedRound1Session: t.string, + encryptedUserGpgPrvKey: t.string, + encryptedDataKey: t.string, + }), + // ECDSA MPCv2 Round 2 response + t.type({ + signatureShareRound2: t.any, + encryptedRound2Session: t.string, + }), + // ECDSA MPCv2 Round 3 response + t.type({ + signatureShareRound3: t.any, + }), ]), 500: t.type({ error: t.string, diff --git a/yarn.lock b/yarn.lock index 5e2dbf0..9b6ad32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5300,6 +5300,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/keccak@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/keccak/-/keccak-3.0.5.tgz#76db7c4fa73f1706cc396754cc890bb5d71398a7" + integrity sha512-Mvu4StIJ9KyfPXDVRv3h0fWNBAjHPBQZ8EPcxhqA8FG6pLzxtytVXU5owB6J2/8xZ+ZspWTXJEUjAHt0pk0I1Q== + dependencies: + "@types/node" "*" + "@types/keyv@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"