From 8a17db0e5b6eb306b6177bd6d581e410f6d531e3 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Fri, 9 Oct 2026 13:43:46 +0530 Subject: [PATCH] feat: add eddsa mpcv2 signing handler in awm Add handleEddsaMPCv2Signing() to ecdsaEddsaSignTransaction.ts, mirroring the ECDSA MPCv2 handler pattern with EddsaMPCv2Utils from sdk-core. Route mpcv2round1/2/3 share types on EdDSA coins to the new handler in signMpcTransaction(); keep MPCv1 commitment/r/g and ECDSA paths unchanged. Crypto and GPG handling stay encapsulated in EddsaMPCv2Utils. Ticket: WCI-922 --- .../signMpcTransaction.test.ts | 202 ++++++++++++++++++ .../handlers/ecdsaEddsaSignTransaction.ts | 102 ++++++++- 2 files changed, 302 insertions(+), 2 deletions(-) diff --git a/src/__tests__/api/advancedWalletManager/signMpcTransaction.test.ts b/src/__tests__/api/advancedWalletManager/signMpcTransaction.test.ts index 9d3bede0..b4a23081 100644 --- a/src/__tests__/api/advancedWalletManager/signMpcTransaction.test.ts +++ b/src/__tests__/api/advancedWalletManager/signMpcTransaction.test.ts @@ -10,6 +10,7 @@ import * as configModule from '../../../initConfig'; import { Ed25519BIP32, Eddsa, SignatureShareType } from '@bitgo-beta/sdk-core'; import { TxRequest } from '@bitgo/public-types'; import { DklsUtils, DklsDsg, DklsTypes } from '@bitgo-beta/sdk-lib-mpc'; +import { EddsaMPCv2Utils } from '@bitgo-beta/sdk-core'; import assert from 'assert'; import { signBitgoMPCv2Round1, signBitgoMPCv2Round2, signBitgoMPCv2Round3 } from './ecdsaUtils'; import { Hash } from 'crypto'; @@ -667,4 +668,205 @@ describe('signMpcTransaction', () => { response.body.should.have.property('error'); }); }); + + describe('EdDSA MPCv2 Signing Integration Tests', () => { + const coin = 'tsol'; // Use tsol for EdDSA testing + + let round1ShareStub: sinon.SinonStub; + let round2ShareStub: sinon.SinonStub; + let round3ShareStub: sinon.SinonStub; + + const mockEddsaMPCv2Round1Response = { + signatureShareRound1: { from: 'user', to: 'bitgo', payload: 'mock-round1-payload' }, + userGpgPubKey: 'mock-eddsa-user-gpg-pub-key', + encryptedRound1Session: 'mock-eddsa-encrypted-round1-session', + encryptedUserGpgPrvKey: 'mock-eddsa-encrypted-user-gpg-prv-key', + }; + const mockEddsaMPCv2Round2Response = { + signatureShareRound2: { from: 'user', to: 'bitgo', payload: 'mock-round2-payload' }, + encryptedRound2Session: 'mock-eddsa-encrypted-round2-session', + }; + const mockEddsaMPCv2Round3Response = { + signatureShareRound3: { from: 'user', to: 'bitgo', payload: 'mock-round3-payload' }, + }; + + const mockKeyProviderResponse = { + prv: 'mock-eddsa-private-key', + pub: 'mock-eddsa-public-key', + source: 'user', + type: 'independent', + }; + + beforeEach(() => { + round1ShareStub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound1Share'); + round2ShareStub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound2Share'); + round3ShareStub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound3Share'); + }); + + afterEach(() => { + round1ShareStub.restore(); + round2ShareStub.restore(); + round3ShareStub.restore(); + }); + + it('should successfully complete MPCv2 Round 1', async () => { + const mockDataKeyResponse = { + plaintextKey: 'mock-plaintext-data-key', + encryptedKey: 'mock-encrypted-data-key', + }; + + const keyProviderNock = nock(keyProviderUrl) + .get(`/key/${mockKeyProviderResponse.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderResponse); + const dataKeyNock = nock(keyProviderUrl) + .post('/generateDataKey') + .reply(200, mockDataKeyResponse); + + round1ShareStub.resolves(mockEddsaMPCv2Round1Response); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round1`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pub: mockKeyProviderResponse.pub, + txRequest: mockTxRequest, + }); + + response.status.should.equal(200); + response.body.should.have.property('signatureShareRound1'); + response.body.should.have.property('userGpgPubKey'); + response.body.should.have.property('encryptedRound1Session'); + response.body.should.have.property('encryptedUserGpgPrvKey'); + response.body.should.have.property('encryptedDataKey'); + response.body.encryptedDataKey.should.equal(mockDataKeyResponse.encryptedKey); + + keyProviderNock.done(); + dataKeyNock.done(); + sinon.assert.calledOnce(round1ShareStub); + }); + + it('should successfully complete MPCv2 Round 2', async () => { + const mockDecryptedDataKeyResponse = { + plaintextKey: 'mock-plaintext-data-key', + }; + + const keyProviderNock = nock(keyProviderUrl) + .get(`/key/${mockKeyProviderResponse.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderResponse); + const decryptDataKeyNock = nock(keyProviderUrl) + .post('/decryptDataKey') + .reply(200, mockDecryptedDataKeyResponse); + + round2ShareStub.resolves(mockEddsaMPCv2Round2Response); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round2`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pub: mockKeyProviderResponse.pub, + txRequest: mockTxRequest, + bitgoPublicGpgKey: 'mock-bitgo-gpg-key', + encryptedDataKey: 'mock-encrypted-data-key', + encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', + encryptedRound1Session: 'mock-encrypted-round1-session', + }); + + response.status.should.equal(200); + response.body.should.have.property('signatureShareRound2'); + response.body.should.have.property('encryptedRound2Session'); + + keyProviderNock.done(); + decryptDataKeyNock.done(); + sinon.assert.calledOnce(round2ShareStub); + }); + + it('should successfully complete MPCv2 Round 3', async () => { + const mockDecryptedDataKeyResponse = { + plaintextKey: 'mock-plaintext-data-key', + }; + + const keyProviderNock = nock(keyProviderUrl) + .get(`/key/${mockKeyProviderResponse.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderResponse); + const decryptDataKeyNock = nock(keyProviderUrl) + .post('/decryptDataKey') + .reply(200, mockDecryptedDataKeyResponse); + + round3ShareStub.resolves(mockEddsaMPCv2Round3Response); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round3`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pub: mockKeyProviderResponse.pub, + txRequest: mockTxRequest, + bitgoPublicGpgKey: 'mock-bitgo-gpg-key', + encryptedDataKey: 'mock-encrypted-data-key', + encryptedUserGpgPrvKey: 'mock-encrypted-user-gpg-prv-key', + encryptedRound2Session: 'mock-encrypted-round2-session', + }); + + response.status.should.equal(200); + response.body.should.have.property('signatureShareRound3'); + + keyProviderNock.done(); + decryptDataKeyNock.done(); + sinon.assert.calledOnce(round3ShareStub); + }); + + it('should fail when required fields are missing for Round 2', async () => { + const keyProviderNock = nock(keyProviderUrl) + .get(`/key/${mockKeyProviderResponse.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderResponse); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round2`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pub: mockKeyProviderResponse.pub, + txRequest: mockTxRequest, + // Missing encryptedDataKey, bitgoPublicGpgKey, encryptedUserGpgPrvKey, encryptedRound1Session + }); + + 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', + ); + + keyProviderNock.done(); + }); + + it('should fail when required fields are missing for Round 3', async () => { + const keyProviderNock = nock(keyProviderUrl) + .get(`/key/${mockKeyProviderResponse.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderResponse); + + const response = await agent + .post(`/api/${coin}/mpc/sign/mpcv2round3`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pub: mockKeyProviderResponse.pub, + txRequest: mockTxRequest, + encryptedDataKey: 'mock-encrypted-data-key', + // Missing bitgoPublicGpgKey, encryptedUserGpgPrvKey, encryptedRound2Session + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.details.should.equal('bitgoGpgPubKey is required for MPCv2 Round 3'); + + keyProviderNock.done(); + }); + }); }); diff --git a/src/advancedWalletManager/handlers/ecdsaEddsaSignTransaction.ts b/src/advancedWalletManager/handlers/ecdsaEddsaSignTransaction.ts index 5ccfe288..15a2b89e 100644 --- a/src/advancedWalletManager/handlers/ecdsaEddsaSignTransaction.ts +++ b/src/advancedWalletManager/handlers/ecdsaEddsaSignTransaction.ts @@ -8,6 +8,7 @@ import { BaseCoin, CommitmentShareRecord, EcdsaMPCv2Utils, + EddsaMPCv2Utils, EddsaUtils, EncryptedSignerShareRecord, GShare, @@ -22,12 +23,12 @@ import coinFactory from '../../shared/coinFactory'; // Define share types for different MPC algorithms enum ShareType { - // EDDSA share types + // EdDSA MPCv1 share types Commitment = 'commitment', R = 'r', G = 'g', - // ECDSA MPCv2 share types + // MPCv2 share types (ECDSA and EdDSA) MPCv2Round1 = 'mpcv2round1', MPCv2Round2 = 'mpcv2round2', MPCv2Round3 = 'mpcv2round3', @@ -106,8 +107,22 @@ export async function signMpcTransaction(req: AwmApiSpecRouteRequest<'v1.mpc.sig try { const mpcAlgorithm = coinInstance.getMPCAlgorithm?.() || MPCType.ECDSA; // Default to ECDSA if method doesn't exist + const shareTypeKey = shareType.toLowerCase() as ShareType; if (mpcAlgorithm === MPCType.EDDSA) { + if (isMPCv2ShareType(shareTypeKey)) { + return await handleEddsaMPCv2Signing(req.bitgo, req.config, { + coin: coinInstance, + shareType: shareTypeKey, + txRequest: req.decoded.txRequest, + prv, + bitgoPublicGpgKey: req.decoded.bitgoPublicGpgKey, + encryptedDataKey: req.decoded.encryptedDataKey, + encryptedUserGpgPrvKey: req.decoded.encryptedUserGpgPrvKey, + encryptedRound1Session: req.decoded.encryptedRound1Session, + encryptedRound2Session: req.decoded.encryptedRound2Session, + }); + } return await handleEddsaSigning(req.bitgo, req.config, { coin: coinInstance, shareType, @@ -140,6 +155,10 @@ export async function signMpcTransaction(req: AwmApiSpecRouteRequest<'v1.mpc.sig } } +function isMPCv2ShareType(shareType: ShareType): boolean { + return [ShareType.MPCv2Round1, ShareType.MPCv2Round2, ShareType.MPCv2Round3].includes(shareType); +} + async function handleEddsaSigning( bitgo: BitGoAPI, cfg: AdvancedWalletManagerConfig, @@ -338,3 +357,82 @@ async function handleEcdsaMpcV2Signing( ); } } + +async function handleEddsaMPCv2Signing( + bitgo: BitGoAPI, + cfg: AdvancedWalletManagerConfig, + params: EcdsaSigningParams, +): Promise { + const { coin, shareType } = params; + + // Create EddsaMPCv2Utils instance using the coin's bitgo instance + const eddsaMPCv2Utils = new EddsaMPCv2Utils(bitgo, coin); + + switch (shareType) { + case ShareType.MPCv2Round1: { + const dataKey = await generateDataKey({ keyType: 'AES-256', cfg }); + return { + ...(await eddsaMPCv2Utils.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.bitgoPublicGpgKey) { + throw new Error('bitgoPublicGpgKey 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 eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: params.txRequest, + walletPassphrase: plaintextDataKey, + bitgoPublicGpgKey: params.bitgoPublicGpgKey, + 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.bitgoPublicGpgKey) { + 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 eddsaMPCv2Utils.createOfflineRound3Share({ + txRequest: params.txRequest, + walletPassphrase: plaintextDataKey, + bitgoPublicGpgKey: params.bitgoPublicGpgKey, + encryptedUserGpgPrvKey: params.encryptedUserGpgPrvKey, + encryptedRound2Session: params.encryptedRound2Session, + }); + } + default: + throw new Error( + `Share type ${shareType} not supported for MPCv2, only MPCv2Round1, MPCv2Round2 and MPCv2Round3 is supported.`, + ); + } +}