Skip to content

Commit 43fcf25

Browse files
authored
Merge pull request #34 from BitGo/WP-4759-ecdsa-signing-advanced-wallets
feat(ebe): support mpc signing for eddsa
2 parents 0b883e1 + fabf21e commit 43fcf25

37 files changed

+1979
-878
lines changed

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
"dependencies": {
2222
"@api-ts/io-ts-http": "^3.2.1",
2323
"@api-ts/openapi-generator": "^5.7.0",
24-
"@api-ts/typed-express-router": "^1.1.13",
25-
"@api-ts/superagent-wrapper": "^1.3.3",
2624
"@api-ts/response": "^2.1.0",
27-
"@bitgo/sdk-core": "^35.2.0",
28-
"bitgo": "^48.0.0",
25+
"@api-ts/superagent-wrapper": "^1.3.3",
26+
"@api-ts/typed-express-router": "^1.1.13",
27+
"@bitgo/sdk-core": "^35.3.0",
28+
"bitgo": "^48.1.0",
2929
"body-parser": "^1.20.3",
3030
"connect-timeout": "^1.9.0",
3131
"debug": "^3.1.0",
@@ -52,9 +52,9 @@
5252
"@types/morgan": "^1.7.35",
5353
"@types/node": "^16.18.46",
5454
"@types/sinon": "^10.0.11",
55+
"@types/superagent": "^8.1.9",
5556
"@types/supertest": "^2.0.11",
5657
"@types/winston": "^2.4.4",
57-
"@types/superagent": "^8.1.9",
5858
"@typescript-eslint/eslint-plugin": "^5.0.0",
5959
"@typescript-eslint/parser": "^5.0.0",
6060
"eslint": "^8.0.0",
@@ -74,7 +74,7 @@
7474
"supertest": "^4.0.2",
7575
"ts-jest": "^29.1.2",
7676
"ts-node": "^10.9.2",
77-
"typescript": "^4.2.4",
77+
"typescript": "^5.0.0",
7878
"typescript-cached-transpile": "^0.0.6"
7979
},
8080
"engines": {

src/__tests__/postIndependentKey.test.ts renamed to src/__tests__/api/enclaved/postIndependentKey.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import 'should';
22

33
import * as request from 'supertest';
44
import nock from 'nock';
5-
import { app as enclavedApp } from '../enclavedApp';
6-
import { AppMode, EnclavedConfig, TlsMode } from '../types';
5+
import { app as enclavedApp } from '../../../enclavedApp';
6+
import { AppMode, EnclavedConfig, TlsMode } from '../../../shared/types';
77
import express from 'express';
88

99
import * as sinon from 'sinon';
10-
import * as configModule from '../initConfig';
10+
import * as configModule from '../../../initConfig';
1111

1212
describe('postIndependentKey', () => {
1313
let cfg: EnclavedConfig;
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import 'should';
2+
3+
import * as request from 'supertest';
4+
import nock from 'nock';
5+
import { app as enclavedApp } from '../../../enclavedApp';
6+
import { AppMode, EnclavedConfig, TlsMode } from '../../../shared/types';
7+
import express from 'express';
8+
import * as sinon from 'sinon';
9+
import * as configModule from '../../../initConfig';
10+
import { Ed25519BIP32, Eddsa, SignatureShareType } from '@bitgo/sdk-core';
11+
12+
describe('signMpcTransaction', () => {
13+
let cfg: EnclavedConfig;
14+
let app: express.Application;
15+
let agent: request.SuperAgentTest;
16+
17+
// test config
18+
const kmsUrl = 'http://kms.invalid';
19+
const coin = 'tsol';
20+
const accessToken = 'test-token';
21+
22+
// sinon stubs
23+
let configStub: sinon.SinonStub;
24+
25+
before(() => {
26+
// nock config
27+
nock.disableNetConnect();
28+
nock.enableNetConnect('127.0.0.1');
29+
30+
// app config
31+
cfg = {
32+
appMode: AppMode.ENCLAVED,
33+
port: 0, // Let OS assign a free port
34+
bind: 'localhost',
35+
timeout: 60000,
36+
logFile: '',
37+
kmsUrl: kmsUrl,
38+
tlsMode: TlsMode.DISABLED,
39+
mtlsRequestCert: false,
40+
allowSelfSigned: true,
41+
};
42+
43+
configStub = sinon.stub(configModule, 'initConfig').returns(cfg);
44+
45+
// app setup
46+
app = enclavedApp(cfg);
47+
agent = request.agent(app);
48+
});
49+
50+
afterEach(() => {
51+
nock.cleanAll();
52+
});
53+
54+
after(() => {
55+
configStub.restore();
56+
});
57+
58+
const mockTxRequest = {
59+
apiVersion: 'full',
60+
walletId: '68489ecff6fb16304670b327db8eb31a',
61+
transactions: [
62+
{
63+
unsignedTx: {
64+
derivationPath: 'm/0',
65+
signableHex: 'testMessage',
66+
},
67+
},
68+
],
69+
};
70+
71+
describe('EDDSA MPC Signing Integration Tests', () => {
72+
let hdTree: Ed25519BIP32;
73+
let MPC: Eddsa;
74+
let bitgoGpgPubKey: string;
75+
76+
before(async () => {
77+
hdTree = await Ed25519BIP32.initialize();
78+
MPC = await Eddsa.initialize(hdTree);
79+
bitgoGpgPubKey =
80+
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n' +
81+
'\n' +
82+
'xk8EZo2rshMFK4EEAAoCAwQC6HQa7PXiX2nnpZr/asCcEbgCOcjsR8gcSI8v\n' +
83+
'vMADk59KsFweg+kIzCR3UqfMe2uG6JHwOYpvDREHp/hqtA+hzQViaXRnb8KM\n' +
84+
'BBATCAA+BYJmjauyBAsJBwgJkDwRkYkILA84AxUICgQWAAIBAhkBApsDAh4B\n' +
85+
'FiEEtIZR46psznKbhpKePBGRiQgsDzgAAFehAP4qQ7mRYbDwaBY3Xja36kZQ\n' +
86+
's8vMajrfnesfwXCArF72KQEAoSMkjXtpWWjMbRHMVXFy0EstWqNg7m0FlCGh\n' +
87+
'BsceQZ3OUwRmjauyEgUrgQQACgIDBMHCYxr6G1SaNSiqUpO5BqhZxjQN6355\n' +
88+
'7/p9X36+eKwTKmFFQVecDQrQvIalKc2WoqKxKgCvBSRlOJbBNsxaNN0DAQgH\n' +
89+
'wngEGBMIACoFgmaNq7IJkDwRkYkILA84ApsMFiEEtIZR46psznKbhpKePBGR\n' +
90+
'iQgsDzgAAN/+AQCKM7sRdSRKEkF3vGBSBaqMMAolcK9iujaqkZ/phjNTYwEA\n' +
91+
'mFiLGavuPlAgSCknFZJ0xrrtlLXeWTMjWGU1gsS5Pfo=\n' +
92+
'=7uRX\n' +
93+
'-----END PGP PUBLIC KEY BLOCK-----\n';
94+
});
95+
96+
it('should successfully do all signing rounds with EBE', async () => {
97+
const user = MPC.keyShare(1, 2, 3);
98+
const backup = MPC.keyShare(2, 2, 3);
99+
const bitgo = MPC.keyShare(3, 2, 3);
100+
101+
const userSigningMaterial = {
102+
uShare: user.uShare,
103+
bitgoYShare: bitgo.yShares[1],
104+
backupYShare: backup.yShares[1],
105+
};
106+
107+
const mockKmsResponse = {
108+
prv: JSON.stringify(userSigningMaterial),
109+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
110+
source: 'user',
111+
type: 'independent',
112+
};
113+
114+
const input = {
115+
source: 'user',
116+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
117+
txRequest: mockTxRequest,
118+
bitgoGpgPubKey: bitgoGpgPubKey,
119+
};
120+
121+
const mockDataKeyResponse = {
122+
plaintextKey: 'mock-plaintext-data-key',
123+
encryptedKey: 'mock-encrypted-data-key',
124+
};
125+
126+
// Mock KMS responses
127+
const kmsNock = nock(kmsUrl)
128+
.get(`/key/${input.pub}`)
129+
.query({ source: 'user' })
130+
.reply(200, mockKmsResponse);
131+
132+
const dataKeyNock = nock(kmsUrl).post('/generateDataKey').reply(200, mockDataKeyResponse);
133+
134+
const response = await agent
135+
.post(`/api/${coin}/mpc/sign/commitment`)
136+
.set('Authorization', `Bearer ${accessToken}`)
137+
.send(input);
138+
139+
response.status.should.equal(200);
140+
response.body.should.have.property('userToBitgoCommitment');
141+
response.body.should.have.property('encryptedSignerShare');
142+
response.body.should.have.property('encryptedUserToBitgoRShare');
143+
response.body.should.have.property('encryptedDataKey');
144+
145+
kmsNock.done();
146+
dataKeyNock.done();
147+
148+
// Continue with R share test using the returned encryptedUserToBitgoRShare
149+
const encryptedUserToBitgoRShare = response.body.encryptedUserToBitgoRShare;
150+
const encryptedDataKey = response.body.encryptedDataKey;
151+
152+
const rInput = {
153+
source: 'user',
154+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
155+
txRequest: mockTxRequest,
156+
encryptedUserToBitgoRShare,
157+
encryptedDataKey,
158+
};
159+
160+
const mockDecryptedDataKeyResponse = {
161+
plaintextKey: 'mock-plaintext-data-key',
162+
};
163+
164+
// Mock KMS responses for R share
165+
const rKmsNock = nock(kmsUrl)
166+
.get(`/key/${rInput.pub}`)
167+
.query({ source: 'user' })
168+
.reply(200, mockKmsResponse);
169+
170+
const decryptDataKeyNock = nock(kmsUrl)
171+
.post('/decryptDataKey')
172+
.reply(200, mockDecryptedDataKeyResponse);
173+
174+
const rResponse = await agent
175+
.post(`/api/${coin}/mpc/sign/r`)
176+
.set('Authorization', `Bearer ${accessToken}`)
177+
.send(rInput);
178+
179+
rResponse.status.should.equal(200);
180+
rResponse.body.should.have.property('rShare');
181+
182+
rKmsNock.done();
183+
decryptDataKeyNock.done();
184+
185+
// Continue with G share test using the returned rShare
186+
const rShare = rResponse.body.rShare;
187+
const derivationPath = 'm/0';
188+
const tMessage = 'testMessage';
189+
190+
// Derive signing key and create bitgo sign share
191+
const signingKey = MPC.keyDerive(
192+
userSigningMaterial.uShare,
193+
[userSigningMaterial.bitgoYShare, userSigningMaterial.backupYShare],
194+
derivationPath,
195+
);
196+
197+
const bitgoCombine = MPC.keyCombine(bitgo.uShare, [signingKey.yShares[3], backup.yShares[3]]);
198+
const bitgoSignShare = await MPC.signShare(
199+
Buffer.from(tMessage, 'hex'),
200+
bitgoCombine.pShare,
201+
[bitgoCombine.jShares[1]],
202+
);
203+
204+
const signatureShareRec = {
205+
from: SignatureShareType.BITGO,
206+
to: SignatureShareType.USER,
207+
share: bitgoSignShare.rShares[1].r + bitgoSignShare.rShares[1].R,
208+
};
209+
210+
const bitgoToUserCommitmentShare = {
211+
from: SignatureShareType.BITGO,
212+
to: SignatureShareType.USER,
213+
share: bitgoSignShare.rShares[1].commitment,
214+
type: 'commitment',
215+
};
216+
217+
const gInput = {
218+
source: 'user',
219+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
220+
txRequest: mockTxRequest,
221+
userToBitgoRShare: rShare,
222+
bitgoToUserRShare: signatureShareRec,
223+
bitgoToUserCommitment: bitgoToUserCommitmentShare,
224+
};
225+
226+
// Mock KMS response for G share
227+
const gKmsNock = nock(kmsUrl)
228+
.get(`/key/${gInput.pub}`)
229+
.query({ source: 'user' })
230+
.reply(200, mockKmsResponse);
231+
232+
const gResponse = await agent
233+
.post(`/api/${coin}/mpc/sign/g`)
234+
.set('Authorization', `Bearer ${accessToken}`)
235+
.send(gInput);
236+
237+
gResponse.status.should.equal(200);
238+
gResponse.body.should.have.property('gShare');
239+
gResponse.body.gShare.should.have.property('i');
240+
gResponse.body.gShare.should.have.property('y');
241+
gResponse.body.gShare.should.have.property('gamma');
242+
gResponse.body.gShare.should.have.property('R');
243+
244+
gKmsNock.done();
245+
});
246+
247+
it('should fail when KMS returns no private key', async () => {
248+
const input = {
249+
source: 'user',
250+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
251+
txRequest: mockTxRequest,
252+
bitgoGpgPubKey: bitgoGpgPubKey,
253+
};
254+
255+
const kmsNock = nock(kmsUrl)
256+
.get(`/key/${input.pub}`)
257+
.query({ source: 'user' })
258+
.reply(404, { error: 'Key not found' });
259+
260+
const response = await agent
261+
.post(`/api/${coin}/mpc/sign/commitment`)
262+
.set('Authorization', `Bearer ${accessToken}`)
263+
.send(input);
264+
265+
response.status.should.equal(500);
266+
response.body.should.have.property('error');
267+
kmsNock.done();
268+
});
269+
270+
it('should fail for unsupported share type', async () => {
271+
const user = MPC.keyShare(1, 2, 3);
272+
const backup = MPC.keyShare(2, 2, 3);
273+
const bitgo = MPC.keyShare(3, 2, 3);
274+
275+
const userSigningMaterial = {
276+
uShare: user.uShare,
277+
bitgoYShare: bitgo.yShares[1],
278+
backupYShare: backup.yShares[1],
279+
};
280+
281+
const mockKmsResponse = {
282+
prv: JSON.stringify(userSigningMaterial),
283+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
284+
source: 'user',
285+
type: 'independent',
286+
};
287+
288+
const input = {
289+
source: 'user',
290+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
291+
txRequest: mockTxRequest,
292+
};
293+
294+
const kmsNock = nock(kmsUrl)
295+
.get(`/key/${input.pub}`)
296+
.query({ source: 'user' })
297+
.reply(200, mockKmsResponse);
298+
299+
const response = await agent
300+
.post(`/api/${coin}/mpc/sign/invalid`)
301+
.set('Authorization', `Bearer ${accessToken}`)
302+
.send(input);
303+
304+
response.status.should.equal(500);
305+
response.body.should.have.property('error');
306+
response.body.details.should.equal(
307+
'Share type invalid not supported for EDDSA, only commitment, G and R share generation is supported.',
308+
);
309+
310+
kmsNock.done();
311+
});
312+
313+
it('should fail when required fields are missing', async () => {
314+
const input = {
315+
source: 'user',
316+
pub: 'DSqMPMsMAbEJVNuPKv1ZFdzt6YvJaDPDddfeW7ajtqds',
317+
// Missing txRequest and bitgoGpgPubKey for commitment
318+
};
319+
320+
const response = await agent
321+
.post(`/api/${coin}/mpc/sign/commitment`)
322+
.set('Authorization', `Bearer ${accessToken}`)
323+
.send(input);
324+
325+
response.status.should.equal(500);
326+
response.body.should.have.property('error');
327+
});
328+
});
329+
});

src/__tests__/signMultisigTransaction.test.ts renamed to src/__tests__/api/enclaved/signMultisigTransaction.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import 'should';
22

33
import * as request from 'supertest';
44
import nock from 'nock';
5-
import { app as enclavedApp } from '../enclavedApp';
6-
import { AppMode, EnclavedConfig, TlsMode } from '../types';
5+
import { app as enclavedApp } from '../../../enclavedApp';
6+
import { AppMode, EnclavedConfig, TlsMode } from '../../../shared/types';
77
import express from 'express';
88

99
import * as sinon from 'sinon';
10-
import * as configModule from '../initConfig';
10+
import * as configModule from '../../../initConfig';
1111

1212
describe('signMultisigTransaction', () => {
1313
let cfg: EnclavedConfig;

0 commit comments

Comments
 (0)