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
202 changes: 202 additions & 0 deletions src/__tests__/api/advancedWalletManager/signMpcTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
102 changes: 100 additions & 2 deletions src/advancedWalletManager/handlers/ecdsaEddsaSignTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BaseCoin,
CommitmentShareRecord,
EcdsaMPCv2Utils,
EddsaMPCv2Utils,
EddsaUtils,
EncryptedSignerShareRecord,
GShare,
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -338,3 +357,82 @@ async function handleEcdsaMpcV2Signing(
);
}
}

async function handleEddsaMPCv2Signing(
bitgo: BitGoAPI,
cfg: AdvancedWalletManagerConfig,
params: EcdsaSigningParams,
): Promise<any> {
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.`,
);
}
}
Loading