Skip to content

Commit 30936ff

Browse files
authored
Merge pull request #57 from BitGo/WP-5152-mpcv2-signing-e2e
feat(mbe): add MPCv2 signing support to master/sendMany
2 parents 0aaa530 + 30ba0c8 commit 30936ff

File tree

5 files changed

+520
-17
lines changed

5 files changed

+520
-17
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import 'should';
2+
import nock from 'nock';
3+
import * as sinon from 'sinon';
4+
import {
5+
BitGoBase,
6+
Wallet,
7+
TxRequest,
8+
IRequestTracer,
9+
TxRequestVersion,
10+
Environments,
11+
RequestTracer,
12+
EcdsaMPCv2Utils,
13+
openpgpUtils,
14+
SignatureShareRecord,
15+
SignatureShareType,
16+
TransactionState,
17+
} from '@bitgo/sdk-core';
18+
import { EnclavedExpressClient } from '../../../../src/api/master/clients/enclavedExpressClient';
19+
import { handleEcdsaSigning } from '../../../../src/api/master/handlers/ecdsa';
20+
import { BitGo } from 'bitgo';
21+
import { readKey } from 'openpgp';
22+
23+
describe('Ecdsa Signing Handler', () => {
24+
let bitgo: BitGoBase;
25+
let wallet: Wallet;
26+
let enclavedExpressClient: EnclavedExpressClient;
27+
let reqId: IRequestTracer;
28+
const bitgoApiUrl = Environments.local.uri;
29+
const enclavedExpressUrl = 'http://enclaved.invalid';
30+
const coin = 'hteth'; // Use hteth for ECDSA testing
31+
const walletId = 'test-wallet-id';
32+
33+
before(() => {
34+
// Disable all real network connections
35+
nock.disableNetConnect();
36+
});
37+
38+
beforeEach(() => {
39+
bitgo = new BitGo({ env: 'local' });
40+
wallet = {
41+
id: () => 'test-wallet-id',
42+
baseCoin: {
43+
getMPCAlgorithm: () => 'ecdsa',
44+
},
45+
multisigTypeVersion: () => 2,
46+
} as unknown as Wallet;
47+
enclavedExpressClient = new EnclavedExpressClient(
48+
{
49+
enclavedExpressUrl,
50+
enclavedExpressCert: 'dummy-cert',
51+
tlsMode: 'disabled',
52+
allowSelfSigned: true,
53+
} as any,
54+
coin,
55+
);
56+
reqId = new RequestTracer();
57+
});
58+
59+
afterEach(() => {
60+
nock.cleanAll();
61+
sinon.restore();
62+
});
63+
64+
after(() => {
65+
// Re-enable network connections after tests
66+
nock.enableNetConnect();
67+
});
68+
69+
it('should successfully sign an ECDSA MPCv2 transaction', async () => {
70+
const txRequest: TxRequest = {
71+
txRequestId: 'test-tx-request-id',
72+
apiVersion: '2.0.0' as TxRequestVersion,
73+
enterpriseId: 'test-enterprise-id',
74+
transactions: [],
75+
state: 'pendingUserSignature',
76+
walletId: 'test-wallet-id',
77+
walletType: 'hot',
78+
version: 2,
79+
date: new Date().toISOString(),
80+
userId: 'test-user-id',
81+
intent: {},
82+
policiesChecked: true,
83+
unsignedTxs: [],
84+
latest: true,
85+
};
86+
const userPubKey = 'test-user-pub-key';
87+
88+
const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('secp256k1');
89+
const pgpKey = await readKey({ armoredKey: bitgoGpgKey.publicKey });
90+
sinon.stub(EcdsaMPCv2Utils.prototype, 'getBitgoMpcv2PublicGpgKey').resolves(pgpKey);
91+
92+
// Mock getTxRequest call
93+
const getTxRequestNock = nock(bitgoApiUrl)
94+
.get(`/api/v2/wallet/${walletId}/txrequests`)
95+
.query({ txRequestIds: 'test-tx-request-id', latest: true })
96+
.matchHeader('any', () => true)
97+
.reply(200, {
98+
txRequests: [txRequest],
99+
});
100+
101+
// Mock sendSignatureShareV2 calls for each round
102+
const round1SignatureShare: SignatureShareRecord = {
103+
from: SignatureShareType.USER,
104+
to: SignatureShareType.BITGO,
105+
share: JSON.stringify({
106+
type: 'round1Input',
107+
data: {
108+
msg1: {
109+
from: 1,
110+
message: 'round1-message',
111+
},
112+
},
113+
}),
114+
};
115+
116+
const round1TxRequest: TxRequest = {
117+
...txRequest,
118+
transactions: [
119+
{
120+
unsignedTx: {
121+
derivationPath: 'm/0',
122+
signableHex: 'testMessage',
123+
serializedTxHex: 'testMessage',
124+
},
125+
signatureShares: [round1SignatureShare],
126+
state: 'pendingSignature' as TransactionState,
127+
},
128+
],
129+
};
130+
131+
const sendSignatureShareV2Round1Nock = nock(bitgoApiUrl)
132+
.post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`)
133+
.matchHeader('any', () => true)
134+
.reply(200, {
135+
txRequest: round1TxRequest,
136+
});
137+
138+
const round2SignatureShare: SignatureShareRecord = {
139+
from: SignatureShareType.USER,
140+
to: SignatureShareType.BITGO,
141+
share: JSON.stringify({
142+
type: 'round2Input',
143+
data: {
144+
msg2: {
145+
from: 1,
146+
to: 3,
147+
encryptedMessage: 'round2-encrypted-message',
148+
signature: 'round2-signature',
149+
},
150+
msg3: {
151+
from: 1,
152+
to: 3,
153+
encryptedMessage: 'round3-encrypted-message',
154+
signature: 'round3-signature',
155+
},
156+
},
157+
}),
158+
};
159+
160+
const round2TxRequest: TxRequest = {
161+
...round1TxRequest,
162+
transactions: [
163+
{
164+
...round1TxRequest.transactions![0],
165+
signatureShares: [round1SignatureShare, round2SignatureShare],
166+
},
167+
],
168+
};
169+
170+
const sendSignatureShareV2Round2Nock = nock(bitgoApiUrl)
171+
.post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`)
172+
.matchHeader('any', () => true)
173+
.reply(200, {
174+
txRequest: round2TxRequest,
175+
});
176+
177+
const round3SignatureShare: SignatureShareRecord = {
178+
from: SignatureShareType.USER,
179+
to: SignatureShareType.BITGO,
180+
share: JSON.stringify({
181+
type: 'round3Input',
182+
data: {
183+
msg4: {
184+
from: 1,
185+
message: 'round4-message',
186+
signature: 'round4-signature',
187+
signatureR: 'round4-signature-r',
188+
},
189+
},
190+
}),
191+
};
192+
193+
const sendSignatureShareV2Round3Nock = nock(bitgoApiUrl)
194+
.post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`)
195+
.matchHeader('any', () => true)
196+
.reply(200, {
197+
txRequest: {
198+
...round2TxRequest,
199+
transactions: [
200+
{
201+
...round2TxRequest.transactions![0],
202+
signatureShares: [round1SignatureShare, round2SignatureShare, round3SignatureShare],
203+
},
204+
],
205+
},
206+
});
207+
208+
// Mock sendTxRequest call
209+
const sendTxRequestNock = nock(bitgoApiUrl)
210+
.post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/send`)
211+
.matchHeader('any', () => true)
212+
.reply(200, {
213+
...txRequest,
214+
state: 'signed',
215+
});
216+
217+
// Mock MPCv2 Round 1 signing
218+
const signMpcV2Round1NockEbe = nock(enclavedExpressUrl)
219+
.post(`/api/${coin}/mpc/sign/mpcv2round1`)
220+
.reply(200, {
221+
signatureShareRound1: round1SignatureShare,
222+
userGpgPubKey: bitgoGpgKey.publicKey,
223+
encryptedRound1Session: 'encrypted-round1-session',
224+
encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv-key',
225+
encryptedDataKey: 'test-encrypted-data-key',
226+
});
227+
228+
// Mock MPCv2 Round 2 signing
229+
const signMpcV2Round2NockEbe = nock(enclavedExpressUrl)
230+
.post(`/api/${coin}/mpc/sign/mpcv2round2`)
231+
.reply(200, {
232+
signatureShareRound2: round2SignatureShare,
233+
encryptedRound2Session: 'encrypted-round2-session',
234+
});
235+
236+
// Mock MPCv2 Round 3 signing
237+
const signMpcV2Round3NockEbe = nock(enclavedExpressUrl)
238+
.post(`/api/${coin}/mpc/sign/mpcv2round3`)
239+
.reply(200, {
240+
signatureShareRound3: round3SignatureShare,
241+
});
242+
243+
const result = await handleEcdsaSigning(
244+
bitgo,
245+
wallet,
246+
txRequest.txRequestId,
247+
enclavedExpressClient,
248+
'user',
249+
userPubKey,
250+
reqId,
251+
);
252+
253+
result.should.eql({
254+
...txRequest,
255+
state: 'signed',
256+
});
257+
258+
getTxRequestNock.done();
259+
sendSignatureShareV2Round1Nock.done();
260+
sendSignatureShareV2Round2Nock.done();
261+
sendSignatureShareV2Round3Nock.done();
262+
sendTxRequestNock.done();
263+
signMpcV2Round1NockEbe.done();
264+
signMpcV2Round2NockEbe.done();
265+
signMpcV2Round3NockEbe.done();
266+
});
267+
});

src/api/master/clients/enclavedExpressClient.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,51 @@ interface SignMpcGShareResponse {
124124
gShare: GShare;
125125
}
126126

127+
// ECDSA MPCv2 interfaces
128+
interface SignMpcV2Round1Params {
129+
txRequest: TxRequest;
130+
bitgoGpgPubKey: string;
131+
source: 'user' | 'backup';
132+
pub: string;
133+
}
134+
135+
interface SignMpcV2Round1Response {
136+
signatureShareRound1: SignatureShareRecord;
137+
userGpgPubKey: string;
138+
encryptedRound1Session: string;
139+
encryptedUserGpgPrvKey: string;
140+
encryptedDataKey: string;
141+
}
142+
143+
interface SignMpcV2Round2Params {
144+
txRequest: TxRequest;
145+
bitgoGpgPubKey: string;
146+
encryptedDataKey: string;
147+
encryptedUserGpgPrvKey: string;
148+
encryptedRound1Session: string;
149+
source: 'user' | 'backup';
150+
pub: string;
151+
}
152+
153+
interface SignMpcV2Round2Response {
154+
signatureShareRound2: SignatureShareRecord;
155+
encryptedRound2Session: string;
156+
}
157+
158+
interface SignMpcV2Round3Params {
159+
txRequest: TxRequest;
160+
bitgoGpgPubKey: string;
161+
encryptedDataKey: string;
162+
encryptedUserGpgPrvKey: string;
163+
encryptedRound2Session: string;
164+
source: 'user' | 'backup';
165+
pub: string;
166+
}
167+
168+
interface SignMpcV2Round3Response {
169+
signatureShareRound3: SignatureShareRecord;
170+
}
171+
127172
export class EnclavedExpressClient {
128173
private readonly baseUrl: string;
129174
private readonly enclavedExpressCert: string;
@@ -456,6 +501,78 @@ export class EnclavedExpressClient {
456501
throw err;
457502
}
458503
}
504+
505+
async signMpcV2Round1(params: SignMpcV2Round1Params): Promise<SignMpcV2Round1Response> {
506+
if (!this.coin) {
507+
throw new Error('Coin must be specified to sign an MPCv2 Round 1');
508+
}
509+
510+
try {
511+
let request = this.apiClient['v1.mpc.sign'].post({
512+
coin: this.coin,
513+
shareType: 'mpcv2round1',
514+
...params,
515+
});
516+
517+
if (this.tlsMode === TlsMode.MTLS) {
518+
request = request.agent(this.createHttpsAgent());
519+
}
520+
const response = await request.decodeExpecting(200);
521+
return response.body;
522+
} catch (error) {
523+
const err = error as Error;
524+
debugLogger('Failed to sign mpcv2 round 1: %s', err.message);
525+
throw err;
526+
}
527+
}
528+
529+
async signMpcV2Round2(params: SignMpcV2Round2Params): Promise<SignMpcV2Round2Response> {
530+
if (!this.coin) {
531+
throw new Error('Coin must be specified to sign an MPCv2 Round 2');
532+
}
533+
534+
try {
535+
let request = this.apiClient['v1.mpc.sign'].post({
536+
coin: this.coin,
537+
shareType: 'mpcv2round2',
538+
...params,
539+
});
540+
541+
if (this.tlsMode === TlsMode.MTLS) {
542+
request = request.agent(this.createHttpsAgent());
543+
}
544+
const response = await request.decodeExpecting(200);
545+
return response.body;
546+
} catch (error) {
547+
const err = error as Error;
548+
debugLogger('Failed to sign mpcv2 round 2: %s', err.message);
549+
throw err;
550+
}
551+
}
552+
553+
async signMpcV2Round3(params: SignMpcV2Round3Params): Promise<SignMpcV2Round3Response> {
554+
if (!this.coin) {
555+
throw new Error('Coin must be specified to sign an MPCv2 Round 3');
556+
}
557+
558+
try {
559+
let request = this.apiClient['v1.mpc.sign'].post({
560+
coin: this.coin,
561+
shareType: 'mpcv2round3',
562+
...params,
563+
});
564+
565+
if (this.tlsMode === TlsMode.MTLS) {
566+
request = request.agent(this.createHttpsAgent());
567+
}
568+
const response = await request.decodeExpecting(200);
569+
return response.body;
570+
} catch (error) {
571+
const err = error as Error;
572+
debugLogger('Failed to sign mpcv2 round 3: %s', err.message);
573+
throw err;
574+
}
575+
}
459576
}
460577

461578
/**

0 commit comments

Comments
 (0)