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
267 changes: 267 additions & 0 deletions src/__tests__/api/master/ecdsa.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
Comment on lines +243 to +251

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be calling the express endpoint directly.


result.should.eql({
...txRequest,
state: 'signed',
});

getTxRequestNock.done();
sendSignatureShareV2Round1Nock.done();
sendSignatureShareV2Round2Nock.done();
sendSignatureShareV2Round3Nock.done();
sendTxRequestNock.done();
signMpcV2Round1NockEbe.done();
signMpcV2Round2NockEbe.done();
signMpcV2Round3NockEbe.done();
});
});
117 changes: 117 additions & 0 deletions src/api/master/clients/enclavedExpressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -456,6 +501,78 @@ export class EnclavedExpressClient {
throw err;
}
}

async signMpcV2Round1(params: SignMpcV2Round1Params): Promise<SignMpcV2Round1Response> {
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<SignMpcV2Round2Response> {
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<SignMpcV2Round3Response> {
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;
}
}
}

/**
Expand Down
Loading