Skip to content

Commit a7fd39f

Browse files
test: add eddsa MPCv2 SMC util test cases
TICKET: WCI-242
1 parent c74ea84 commit a7fd39f

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

  • modules/sdk-core/test/unit/bitgo/utils/tss/eddsa
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { NonEmptyString } from 'io-ts-types';
4+
import {
5+
EddsaBitgoToOVC1Round1Response,
6+
EddsaBitgoToOVC1Round2Response,
7+
EddsaKeyCreationMPCv2StateEnum,
8+
EddsaMPCv2KeyGenRound1Response,
9+
EddsaMPCv2KeyGenRound2Response,
10+
EddsaOVC1ToBitgoRound1Payload,
11+
EddsaOVC2ToBitgoRound2Payload,
12+
OVCIndexEnum,
13+
WalletTypeEnum,
14+
} from '@bitgo/public-types';
15+
import { BitGoBase, IBaseCoin } from '../../../../../../src';
16+
import { MPCv2SMCUtils } from '../../../../../../src/bitgo/utils/tss/eddsa/SMC/utils';
17+
18+
describe('EdDSA MPCv2 SMC Utils:', function () {
19+
const enterpriseId = '6449153a6f6bc20006d66771cdbe15d3';
20+
const coinName = 'sol';
21+
22+
let smcUtils: MPCv2SMCUtils;
23+
let mockBitgo: BitGoBase;
24+
let mockBaseCoin: IBaseCoin;
25+
let keychainsStub: { get: sinon.SinonStub; add: sinon.SinonStub };
26+
let postChain: { send: sinon.SinonStub; result: sinon.SinonStub };
27+
28+
const fakeSignedMessage = (suffix: string) => ({
29+
message: Buffer.from(`message-${suffix}`).toString('base64'),
30+
signature: `signature-${suffix}`,
31+
});
32+
33+
const buildRound1Payload = (
34+
state: EddsaKeyCreationMPCv2StateEnum = EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data
35+
): EddsaOVC1ToBitgoRound1Payload =>
36+
({
37+
state,
38+
tssVersion: '0.0.1' as NonEmptyString,
39+
walletType: WalletTypeEnum.tss,
40+
coin: coinName as NonEmptyString,
41+
ovc: {
42+
[OVCIndexEnum.ONE]: {
43+
gpgPubKey: 'user-gpg-pubkey' as NonEmptyString,
44+
ovcMsg1: fakeSignedMessage('user-1'),
45+
},
46+
[OVCIndexEnum.TWO]: {
47+
gpgPubKey: 'backup-gpg-pubkey' as NonEmptyString,
48+
ovcMsg1: fakeSignedMessage('backup-1'),
49+
},
50+
},
51+
} as EddsaOVC1ToBitgoRound1Payload);
52+
53+
const buildRound2Payload = (
54+
sessionId = 'test-session-id',
55+
state: EddsaKeyCreationMPCv2StateEnum = EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data
56+
): EddsaOVC2ToBitgoRound2Payload =>
57+
({
58+
state,
59+
tssVersion: '0.0.1' as NonEmptyString,
60+
walletType: WalletTypeEnum.tss,
61+
coin: coinName as NonEmptyString,
62+
ovc: {
63+
[OVCIndexEnum.ONE]: {
64+
gpgPubKey: 'user-gpg-pubkey' as NonEmptyString,
65+
ovcMsg1: fakeSignedMessage('user-1'),
66+
ovcMsg2: fakeSignedMessage('user-2'),
67+
},
68+
[OVCIndexEnum.TWO]: {
69+
gpgPubKey: 'backup-gpg-pubkey' as NonEmptyString,
70+
ovcMsg1: fakeSignedMessage('backup-1'),
71+
ovcMsg2: fakeSignedMessage('backup-2'),
72+
},
73+
},
74+
platform: {
75+
sessionId: sessionId as NonEmptyString,
76+
bitgoMsg1: fakeSignedMessage('bitgo-1'),
77+
},
78+
} as EddsaOVC2ToBitgoRound2Payload);
79+
80+
beforeEach(function () {
81+
postChain = {
82+
send: sinon.stub().returnsThis(),
83+
result: sinon.stub(),
84+
};
85+
mockBitgo = {
86+
post: sinon.stub().returns(postChain),
87+
url: sinon.stub().callsFake((path: string) => `/api/v2${path}`),
88+
} as unknown as BitGoBase;
89+
90+
keychainsStub = {
91+
get: sinon.stub(),
92+
add: sinon.stub(),
93+
};
94+
mockBaseCoin = {
95+
keychains: sinon.stub().returns(keychainsStub),
96+
} as unknown as IBaseCoin;
97+
98+
smcUtils = new MPCv2SMCUtils(mockBitgo, mockBaseCoin);
99+
});
100+
101+
afterEach(function () {
102+
sinon.restore();
103+
});
104+
105+
describe('keyGenRound1BySender', function () {
106+
it('returns a well-formed BitGo→OVC1 round 1 response on success', async function () {
107+
const payload = buildRound1Payload();
108+
const senderFn = sinon.stub().resolves({
109+
sessionId: 'session-abc' as NonEmptyString,
110+
bitgoMsg1: fakeSignedMessage('bitgo-r1'),
111+
} as EddsaMPCv2KeyGenRound1Response);
112+
113+
const response = (await smcUtils.keyGenRound1BySender(
114+
senderFn as never,
115+
payload
116+
)) as EddsaBitgoToOVC1Round1Response;
117+
118+
assert.strictEqual(response.state, EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data);
119+
assert.strictEqual(response.tssVersion, payload.tssVersion);
120+
assert.strictEqual(response.walletType, payload.walletType);
121+
assert.strictEqual(response.coin, payload.coin);
122+
assert.deepStrictEqual(response.ovc, payload.ovc);
123+
assert.strictEqual(response.platform.sessionId, 'session-abc');
124+
assert.deepStrictEqual(response.platform.bitgoMsg1, fakeSignedMessage('bitgo-r1'));
125+
assert.ok(senderFn.calledOnce);
126+
127+
const [, senderPayload] = senderFn.firstCall.args as [unknown, Record<string, unknown>];
128+
assert.strictEqual(senderPayload.userGpgPublicKey, payload.ovc[OVCIndexEnum.ONE].gpgPubKey);
129+
assert.strictEqual(senderPayload.backupGpgPublicKey, payload.ovc[OVCIndexEnum.TWO].gpgPubKey);
130+
assert.deepStrictEqual(senderPayload.userMsg1, payload.ovc[OVCIndexEnum.ONE].ovcMsg1);
131+
assert.deepStrictEqual(senderPayload.backupMsg1, payload.ovc[OVCIndexEnum.TWO].ovcMsg1);
132+
});
133+
134+
it('rejects when the payload state is not WaitingForBitgoRound1Data', async function () {
135+
const payload = buildRound1Payload(EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data);
136+
const senderFn = sinon.stub().rejects(new Error('sender should not be invoked'));
137+
138+
await assert.rejects(smcUtils.keyGenRound1BySender(senderFn as never, payload), {
139+
message: `Invalid state for round 1, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`,
140+
});
141+
assert.ok(senderFn.notCalled);
142+
});
143+
144+
it('rejects when the response is malformed (sessionId empty)', async function () {
145+
const payload = buildRound1Payload();
146+
const senderFn = sinon.stub().resolves({
147+
sessionId: '' as unknown as NonEmptyString,
148+
bitgoMsg1: fakeSignedMessage('bitgo-r1'),
149+
});
150+
151+
await assert.rejects(smcUtils.keyGenRound1BySender(senderFn as never, payload), /error\(s\) parsing response/);
152+
});
153+
});
154+
155+
describe('keyGenRound2BySender', function () {
156+
beforeEach(function () {
157+
const fakeKeychain = {
158+
id: 'bitgo-keychain-id',
159+
source: 'bitgo',
160+
type: 'tss' as const,
161+
commonKeychain: 'a'.repeat(64),
162+
};
163+
keychainsStub.add.resolves(fakeKeychain);
164+
});
165+
166+
it('returns a well-formed BitGo→OVC1 round 2 response and adds the BitGo keychain', async function () {
167+
const payload = buildRound2Payload('session-xyz');
168+
const senderFn = sinon.stub().resolves({
169+
sessionId: 'session-xyz' as NonEmptyString,
170+
commonPublicKeychain: 'a'.repeat(64) as NonEmptyString,
171+
bitgoMsg2: fakeSignedMessage('bitgo-r2'),
172+
} as EddsaMPCv2KeyGenRound2Response);
173+
174+
const response = (await smcUtils.keyGenRound2BySender(
175+
senderFn as never,
176+
payload
177+
)) as EddsaBitgoToOVC1Round2Response;
178+
179+
assert.strictEqual(response.state, EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1GenerateKey);
180+
assert.strictEqual(response.bitGoKeyId, 'bitgo-keychain-id');
181+
assert.strictEqual(response.tssVersion, payload.tssVersion);
182+
assert.strictEqual(response.walletType, payload.walletType);
183+
assert.strictEqual(response.coin, payload.coin);
184+
assert.deepStrictEqual(response.ovc, payload.ovc);
185+
assert.strictEqual(response.platform.sessionId, 'session-xyz');
186+
assert.strictEqual(response.platform.commonPublicKeychain, 'a'.repeat(64));
187+
assert.deepStrictEqual(response.platform.bitgoMsg1, payload.platform.bitgoMsg1);
188+
assert.deepStrictEqual(response.platform.bitgoMsg2, fakeSignedMessage('bitgo-r2'));
189+
190+
const [, senderPayload] = senderFn.firstCall.args as [unknown, Record<string, unknown>];
191+
assert.strictEqual(senderPayload.sessionId, 'session-xyz');
192+
assert.deepStrictEqual(senderPayload.userMsg2, payload.ovc[OVCIndexEnum.ONE].ovcMsg2);
193+
assert.deepStrictEqual(senderPayload.backupMsg2, payload.ovc[OVCIndexEnum.TWO].ovcMsg2);
194+
195+
// BitGo keychain is added with the keychain from the round-2 response
196+
assert.ok(keychainsStub.add.calledOnce);
197+
assert.deepStrictEqual(keychainsStub.add.firstCall.args[0], {
198+
source: 'bitgo',
199+
keyType: 'tss',
200+
commonKeychain: 'a'.repeat(64),
201+
isMPCv2: true,
202+
});
203+
});
204+
205+
it('rejects when the payload state is not WaitingForBitgoRound2Data', async function () {
206+
const payload = buildRound2Payload('session-xyz', EddsaKeyCreationMPCv2StateEnum.WaitingForOVC2Round2Data);
207+
const senderFn = sinon.stub().rejects(new Error('sender should not be invoked'));
208+
209+
await assert.rejects(smcUtils.keyGenRound2BySender(senderFn as never, payload), {
210+
message: `Invalid state for round 2, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${EddsaKeyCreationMPCv2StateEnum.WaitingForOVC2Round2Data}`,
211+
});
212+
assert.ok(senderFn.notCalled);
213+
assert.ok(keychainsStub.add.notCalled);
214+
});
215+
216+
it('rejects when session IDs returned by BitGo do not match the payload', async function () {
217+
const payload = buildRound2Payload('session-xyz');
218+
const senderFn = sinon.stub().resolves({
219+
sessionId: 'different-session-id' as NonEmptyString,
220+
commonPublicKeychain: 'a'.repeat(64) as NonEmptyString,
221+
bitgoMsg2: fakeSignedMessage('bitgo-r2'),
222+
} as EddsaMPCv2KeyGenRound2Response);
223+
224+
await assert.rejects(
225+
smcUtils.keyGenRound2BySender(senderFn as never, payload),
226+
/Round 1 and round 2 session IDs do not match/
227+
);
228+
assert.ok(keychainsStub.add.notCalled);
229+
});
230+
});
231+
232+
describe('keyGenRound1 (enterprise)', function () {
233+
it('rejects for an invalid payload state without calling the API', async function () {
234+
const invalidPayload = {
235+
state: EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data,
236+
} as unknown as EddsaOVC1ToBitgoRound1Payload;
237+
238+
await assert.rejects(smcUtils.keyGenRound1(enterpriseId, invalidPayload), {
239+
message: `Invalid state for round 1, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound1Data}, got: ${EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`,
240+
});
241+
assert.ok((mockBitgo.post as sinon.SinonStub).notCalled);
242+
});
243+
244+
it('POSTs to the MPCv2 generatekey endpoint and returns a parsed round 1 response', async function () {
245+
const payload = buildRound1Payload();
246+
postChain.result.resolves({
247+
sessionId: 'enterprise-session',
248+
bitgoMsg1: fakeSignedMessage('bitgo-r1'),
249+
});
250+
251+
const response = await smcUtils.keyGenRound1(enterpriseId, payload);
252+
253+
assert.ok((mockBitgo.post as sinon.SinonStub).calledOnce);
254+
assert.strictEqual((mockBitgo.post as sinon.SinonStub).firstCall.args[0], '/api/v2/mpc/generatekey');
255+
const sentBody = postChain.send.firstCall.args[0] as {
256+
enterprise: string;
257+
round: string;
258+
payload: { userGpgPublicKey: string; backupGpgPublicKey: string };
259+
};
260+
assert.strictEqual(sentBody.enterprise, enterpriseId);
261+
assert.strictEqual(sentBody.round, 'MPCv2-R1');
262+
assert.strictEqual(sentBody.payload.userGpgPublicKey, payload.ovc[OVCIndexEnum.ONE].gpgPubKey);
263+
assert.strictEqual(sentBody.payload.backupGpgPublicKey, payload.ovc[OVCIndexEnum.TWO].gpgPubKey);
264+
265+
assert.strictEqual(response.state, EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data);
266+
assert.strictEqual(response.platform.sessionId, 'enterprise-session');
267+
});
268+
});
269+
270+
describe('keyGenRound2 (enterprise)', function () {
271+
it('rejects for an invalid payload state without calling the API', async function () {
272+
const invalidPayload = {
273+
state: EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data,
274+
} as unknown as EddsaOVC2ToBitgoRound2Payload;
275+
276+
await assert.rejects(smcUtils.keyGenRound2(enterpriseId, invalidPayload), {
277+
message: `Invalid state for round 2, expected: ${EddsaKeyCreationMPCv2StateEnum.WaitingForBitgoRound2Data}, got: ${EddsaKeyCreationMPCv2StateEnum.WaitingForOVC1Round2Data}`,
278+
});
279+
assert.ok((mockBitgo.post as sinon.SinonStub).notCalled);
280+
});
281+
});
282+
283+
describe('uploadClientKeys', function () {
284+
const bitgoKeyId = 'bitgo-key-id';
285+
const commonKeychain = 'a'.repeat(64);
286+
287+
it('uploads user/backup keychains and returns the triplet on the happy path', async function () {
288+
const bitgoKeychain = { id: bitgoKeyId, type: 'tss', source: 'bitgo', commonKeychain };
289+
const userKeychain = { id: 'user-id', type: 'tss', source: 'user', commonKeychain };
290+
const backupKeychain = { id: 'backup-id', type: 'tss', source: 'backup', commonKeychain };
291+
292+
keychainsStub.get.resolves(bitgoKeychain);
293+
keychainsStub.add.withArgs(sinon.match({ source: 'user' })).resolves(userKeychain);
294+
keychainsStub.add.withArgs(sinon.match({ source: 'backup' })).resolves(backupKeychain);
295+
296+
const result = await smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain);
297+
298+
assert.deepStrictEqual(result.userKeychain, userKeychain);
299+
assert.deepStrictEqual(result.backupKeychain, backupKeychain);
300+
assert.deepStrictEqual(result.bitgoKeychain, bitgoKeychain);
301+
302+
assert.ok(keychainsStub.get.calledOnceWithExactly({ id: bitgoKeyId }));
303+
assert.strictEqual(keychainsStub.add.callCount, 2);
304+
assert.deepStrictEqual(keychainsStub.add.firstCall.args[0], {
305+
source: 'user',
306+
keyType: 'tss',
307+
commonKeychain,
308+
isMPCv2: true,
309+
});
310+
assert.deepStrictEqual(keychainsStub.add.secondCall.args[0], {
311+
source: 'backup',
312+
keyType: 'tss',
313+
commonKeychain,
314+
isMPCv2: true,
315+
});
316+
});
317+
318+
it('rejects when user and backup common keychains differ', async function () {
319+
await assert.rejects(
320+
smcUtils.uploadClientKeys(bitgoKeyId, 'a'.repeat(64), 'b'.repeat(64)),
321+
/Common keychain mismatch between the user and backup keychains/
322+
);
323+
assert.ok(keychainsStub.get.notCalled);
324+
assert.ok(keychainsStub.add.notCalled);
325+
});
326+
327+
it('rejects when the BitGo keychain cannot be found', async function () {
328+
keychainsStub.get.resolves(undefined);
329+
330+
await assert.rejects(smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain), /Keychain not found/);
331+
assert.ok(keychainsStub.add.notCalled);
332+
});
333+
334+
it('rejects when the fetched keychain is not a BitGo-source keychain', async function () {
335+
keychainsStub.get.resolves({ id: bitgoKeyId, type: 'tss', source: 'user', commonKeychain });
336+
337+
await assert.rejects(
338+
smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain),
339+
/The keychain is not a BitGo keychain/
340+
);
341+
assert.ok(keychainsStub.add.notCalled);
342+
});
343+
344+
it('rejects when the fetched keychain is not a TSS keychain', async function () {
345+
keychainsStub.get.resolves({ id: bitgoKeyId, type: 'independent', source: 'bitgo', commonKeychain });
346+
347+
await assert.rejects(
348+
smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain),
349+
/BitGo keychain is not a TSS keychain/
350+
);
351+
assert.ok(keychainsStub.add.notCalled);
352+
});
353+
354+
it('rejects when the BitGo keychain has no commonKeychain', async function () {
355+
keychainsStub.get.resolves({ id: bitgoKeyId, type: 'tss', source: 'bitgo' });
356+
357+
await assert.rejects(
358+
smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain),
359+
/BitGo keychain does not have a common keychain/
360+
);
361+
assert.ok(keychainsStub.add.notCalled);
362+
});
363+
364+
it('rejects when OVC-supplied common keychain does not match BitGo', async function () {
365+
keychainsStub.get.resolves({ id: bitgoKeyId, type: 'tss', source: 'bitgo', commonKeychain: 'c'.repeat(64) });
366+
367+
await assert.rejects(
368+
smcUtils.uploadClientKeys(bitgoKeyId, commonKeychain, commonKeychain),
369+
/Common keychain mismatch between the OVCs and BitGo/
370+
);
371+
assert.ok(keychainsStub.add.notCalled);
372+
});
373+
});
374+
});

0 commit comments

Comments
 (0)