Skip to content

Commit c8889ca

Browse files
committed
fix(sdk-core): address PR review comments on EdDSA MPCv2 key gen
- Re-export EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise, and MPCv2 types from eddsa index to match ECDSA export pattern - Add reducedEncryptedPrv round-trip assertions to createParticipantKeychain tests to catch regressions in btoa browser-safe encoding path - Seed bitgoMPCv2PublicGpgKey in fallback test to fix fire-and-forget constructor race with beforeEach nock setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> TICKET: WCI-5
1 parent 9485b23 commit c8889ca

6 files changed

Lines changed: 230 additions & 83 deletions

File tree

modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,26 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
159159
({ ...userKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedUserKeychain);
160160
({ ...backupKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedBackupKeychain);
161161
({ ...bitgoKeychain, reducedEncryptedPrv: '' }).should.deepEqual(nockedBitGoKeychain);
162+
163+
// reducedEncryptedPrv must round-trip: decrypting with the passphrase should recover
164+
// the browser-safe btoa encoding of the original reduced private material.
165+
const encodeReduced = (buf: Buffer) => btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(buf))));
166+
167+
assert.ok(userKeychain.reducedEncryptedPrv);
168+
assert.equal(
169+
bitgo.decrypt({ input: userKeychain.reducedEncryptedPrv, password: 'passphrase' }),
170+
encodeReduced(Buffer.from('userReduced'))
171+
);
172+
assert.ok(backupKeychain.reducedEncryptedPrv);
173+
assert.equal(
174+
bitgo.decrypt({ input: backupKeychain.reducedEncryptedPrv, password: 'passphrase' }),
175+
encodeReduced(Buffer.from('backupReduced'))
176+
);
162177
});
163178

164179
it('should reject when BitGo PGP signature on round 1 response is invalid', async function () {
165180
nock(bgUrl)
166-
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPS-R1')
181+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R1')
167182
.once()
168183
.reply(200, {
169184
sessionId: 'bad-session',
@@ -175,6 +190,111 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
175190

176191
await assert.rejects(tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }));
177192
});
193+
194+
it('should reject when BitGo PGP signature on round 2 response is invalid', async function () {
195+
const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2);
196+
const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {};
197+
await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1);
198+
199+
nock(bgUrl)
200+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2')
201+
.once()
202+
.reply(200, {
203+
sessionId: 'test-session-id',
204+
commonPublicKey: 'a'.repeat(64),
205+
bitgoMsg2: {
206+
message: Buffer.from('garbage').toString('base64'),
207+
signature: '-----BEGIN PGP SIGNATURE-----\nFAKE\n-----END PGP SIGNATURE-----',
208+
},
209+
});
210+
211+
await assert.rejects(tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }));
212+
});
213+
214+
it('should reject when session IDs from round 1 and round 2 do not match', async function () {
215+
const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2);
216+
const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {};
217+
await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1);
218+
219+
nock(bgUrl)
220+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2')
221+
.once()
222+
.reply(200, {
223+
sessionId: 'different-session-id',
224+
commonPublicKey: 'a'.repeat(64),
225+
bitgoMsg2: { message: '', signature: '' },
226+
});
227+
228+
await assert.rejects(
229+
tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }),
230+
/Round 1 and round 2 session IDs do not match/
231+
);
232+
});
233+
234+
it('should reject when commonPublicKey from BitGo does not match the locally computed key', async function () {
235+
const bitgoSession = new EddsaMPSDkg.DKG(3, 2, 2);
236+
const bitgoState: { msg2?: MPSTypes.DeserializedMessage } = {};
237+
await nockMPSKeyGenRound1(bitgoSession, bitgoState, 1);
238+
239+
nock(bgUrl)
240+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2')
241+
.once()
242+
.reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound2Request }) => {
243+
const { userMsg2, backupMsg2 } = payload;
244+
assert.ok(bitgoState.msg2, 'BitGo round-2 message missing — round-1 nock must run first');
245+
246+
const userDeserMsg2: MPSTypes.DeserializedMessage = {
247+
from: 0,
248+
payload: new Uint8Array(Buffer.from(userMsg2.message, 'base64')),
249+
};
250+
const backupDeserMsg2: MPSTypes.DeserializedMessage = {
251+
from: 1,
252+
payload: new Uint8Array(Buffer.from(backupMsg2.message, 'base64')),
253+
};
254+
bitgoSession.handleIncomingMessages([userDeserMsg2, backupDeserMsg2, bitgoState.msg2]);
255+
256+
return {
257+
sessionId: 'test-session-id',
258+
commonPublicKey: 'fakefakeee'.repeat(8), // mutated — will not match user/backup computed key
259+
bitgoMsg2: await MPSComms.detachSignMpsMessage(Buffer.from(bitgoState.msg2.payload), bitgoPrvKeyObj),
260+
};
261+
});
262+
263+
await assert.rejects(
264+
tssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }),
265+
/does not match BitGo common public key/
266+
);
267+
});
268+
269+
it('should reject when BitGo GPG public key does not match known keys in prod/test envs', async function () {
270+
// Use a staging env so envRequiresBitgoPubGpgKeyConfig returns true
271+
const stagingBitgo = TestBitGo.decorate(BitGo, { env: 'staging' });
272+
stagingBitgo.initializeTestVars();
273+
const stagingBaseCoin = stagingBitgo.coin(coinName);
274+
const stagingBgUrl = common.Environments[stagingBitgo.getEnv()].uri;
275+
const stagingWallet = new Wallet(stagingBitgo, stagingBaseCoin, {
276+
id: walletId,
277+
enterprise: enterpriseId,
278+
coin: coinName,
279+
coinSpecific: {},
280+
multisigType: 'tss',
281+
});
282+
const stagingTssUtils = new EDDSAUtils.EddsaMPCv2Utils(stagingBitgo, stagingBaseCoin, stagingWallet);
283+
284+
// Return a key that is NOT in the hardcoded BitGo MPC v2 key list
285+
nock(stagingBgUrl).get(`/api/v2/${coinName}/tss/pubkey`).query({ enterpriseId }).reply(200, {
286+
name: 'irrelevant',
287+
publicKey: bitgoGpgKeyPair.publicKey,
288+
mpcv2PublicKey: bitgoGpgKeyPair.publicKey,
289+
enterpriseId,
290+
});
291+
nock(stagingBgUrl).get('/api/v1/client/constants').reply(200, { ttl: 3600, constants });
292+
293+
await assert.rejects(
294+
stagingTssUtils.createKeychains({ passphrase: 'test', enterprise: enterpriseId }),
295+
/Invalid BitGo GPG public key/
296+
);
297+
});
178298
});
179299

180300
// ---------------------------------------------------------------------------
@@ -202,7 +322,7 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
202322
times = 1
203323
) {
204324
return nock(bgUrl)
205-
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPS-R1')
325+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R1')
206326
.times(times)
207327
.reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound1Request }) => {
208328
const { userGpgPublicKey, backupGpgPublicKey, userMsg1, backupMsg1 } = payload;
@@ -211,15 +331,9 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
211331
const userPubKeyObj = await openpgp.readKey({ armoredKey: userGpgPublicKey });
212332
const backupPubKeyObj = await openpgp.readKey({ armoredKey: backupGpgPublicKey });
213333

214-
const userPk = Buffer.from(
215-
((await userPubKeyObj.getEncryptionKey()).keyPacket.publicParams as { Q: Uint8Array }).Q
216-
).subarray(1);
217-
const backupPk = Buffer.from(
218-
((await backupPubKeyObj.getEncryptionKey()).keyPacket.publicParams as { Q: Uint8Array }).Q
219-
).subarray(1);
220-
const bitgoSk = Buffer.from(
221-
((await bitgoPrvKeyObj.getDecryptionKeys())[0].keyPacket.privateParams as { d: Uint8Array }).d
222-
).reverse();
334+
const userPk = await MPSComms.extractEd25519PublicKey(userPubKeyObj);
335+
const backupPk = await MPSComms.extractEd25519PublicKey(backupPubKeyObj);
336+
const [, bitgoSk] = await MPSComms.extractEd25519KeyPair(bitgoPrvKeyObj);
223337

224338
bitgoSession.initDkg(bitgoSk, [userPk, backupPk]);
225339
const bitgoRawMsg1 = bitgoSession.getFirstMessage();
@@ -252,7 +366,7 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
252366
times = 1
253367
) {
254368
return nock(bgUrl)
255-
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPS-R2')
369+
.post('/api/v2/mpc/generatekey', (body) => body.round === 'MPCv2-R2')
256370
.times(times)
257371
.reply(200, async (_uri, { payload }: { payload: EddsaMPCv2KeyGenRound2Request }) => {
258372
const { sessionId, userMsg2, backupMsg2 } = payload;

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 39 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@ import {
66
EddsaMPCv2KeyGenRound1Response,
77
EddsaMPCv2KeyGenRound2Request,
88
EddsaMPCv2KeyGenRound2Response,
9+
MPCv2KeyGenStateEnum,
10+
MPCv2PartyFromStringOrNumber,
911
} from '@bitgo/public-types';
1012
import { EddsaMPSDkg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc';
1113
import { KeychainsTriplet } from '../../../baseCoin';
1214
import { AddKeychainOptions, Keychain, KeyType } from '../../../keychain';
13-
import { envRequiresBitgoPubGpgKeyConfig } from '../../../tss/bitgoPubKeys';
15+
import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys';
16+
import { generateGPGKeyPair } from '../../opengpgUtils';
1417
import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2';
1518
import { BaseEddsaUtils } from './base';
16-
import { MPSKeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
17-
18-
/** Round identifiers sent in the `round` field of each API request */
19-
const MPS_ROUND_1 = 'MPS-R1';
20-
const MPS_ROUND_2 = 'MPS-R2';
19+
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
2120

2221
export class EddsaMPCv2Utils extends BaseEddsaUtils {
2322
/** @inheritdoc */
@@ -26,52 +25,28 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
2625
enterprise: string;
2726
originalPasscodeEncryptionCode?: string;
2827
}): Promise<KeychainsTriplet> {
29-
const userKey = await pgp.generateKey({
30-
curve: 'ed25519',
31-
userIDs: [{ name: 'user <user@localhost>' }],
32-
subkeys: [{ sign: true }, { sign: false }],
33-
format: 'object',
34-
});
35-
const userGpgKey = userKey.privateKey;
36-
const userGpgPublicKey = await userKey.publicKey.armor();
37-
const userPk = Buffer.from(
38-
((await userGpgKey.getEncryptionKey()).keyPacket.publicParams as { Q: Uint8Array }).Q
39-
).subarray(1);
40-
const userSk = Buffer.from(
41-
((await userGpgKey.getEncryptionKey()).keyPacket as unknown as { privateParams: { d: Uint8Array } }).privateParams
42-
.d
43-
).reverse();
44-
45-
const backupKey = await pgp.generateKey({
46-
curve: 'ed25519',
47-
userIDs: [{ name: 'backup <backup@localhost>' }],
48-
subkeys: [{ sign: true }, { sign: false }],
49-
format: 'object',
50-
});
51-
const backupGpgKey = backupKey.privateKey;
52-
const backupGpgPublicKey = await backupKey.publicKey.armor();
53-
const backupPk = Buffer.from(
54-
((await backupGpgKey.getEncryptionKey()).keyPacket.publicParams as { Q: Uint8Array }).Q
55-
).subarray(1);
56-
const backupSk = Buffer.from(
57-
((await backupGpgKey.getEncryptionKey()).keyPacket as unknown as { privateParams: { d: Uint8Array } })
58-
.privateParams.d
59-
).reverse();
60-
61-
// Get the BitGo public key based on user/enterprise feature flags
62-
// If it doesn't work, use the default public key from the constants
63-
const bitgoPublicGpgKeyArmored = (
64-
(await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true)) ?? this.bitgoMPCv2PublicGpgKey
65-
).armor();
28+
const userKeyPair = await generateGPGKeyPair('ed25519');
29+
const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey });
30+
const userGpgPublicKey = userKeyPair.publicKey;
31+
const [userPk, userSk] = await MPSComms.extractEd25519KeyPair(userGpgKey);
32+
33+
const backupKeyPair = await generateGPGKeyPair('ed25519');
34+
const backupGpgKey = await pgp.readPrivateKey({ armoredKey: backupKeyPair.privateKey });
35+
const backupGpgPublicKey = backupKeyPair.publicKey;
36+
const [backupPk, backupSk] = await MPSComms.extractEd25519KeyPair(backupGpgKey);
37+
38+
// Get the BitGo public key based on user/enterprise feature flags;
39+
// fall back to the hardcoded MPCv2 public key from constants.
40+
const bitgoPublicGpgKey =
41+
(await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true)) ?? this.bitgoMPCv2PublicGpgKey;
42+
const bitgoPublicGpgKeyArmored = bitgoPublicGpgKey.armor();
6643

6744
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
68-
assert(bitgoPublicGpgKeyArmored, 'BitGo GPG public key is required');
45+
assert(isBitgoMpcPubKey(bitgoPublicGpgKeyArmored, 'mpcv2'), 'Invalid BitGo GPG public key');
6946
}
7047

7148
const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored });
72-
const bitgoPk = Buffer.from(
73-
((await bitgoKeyObj.getEncryptionKey()).keyPacket.publicParams as { Q: Uint8Array }).Q
74-
).subarray(1);
49+
const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj);
7550

7651
// Create DKG sessions for user (party 0) and backup (party 1)
7752
const userDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER);
@@ -190,7 +165,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
190165

191166
// #region keychain utils
192167
async createParticipantKeychain(
193-
participantIndex: MPCv2PartiesEnum,
168+
participantIndex: MPCv2PartyFromStringOrNumber,
194169
commonKeychain: string,
195170
privateMaterial?: Buffer,
196171
reducedPrivateMaterial?: Buffer,
@@ -286,13 +261,27 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
286261
enterprise: string,
287262
payload: EddsaMPCv2KeyGenRound1Request
288263
): Promise<EddsaMPCv2KeyGenRound1Response> {
289-
return MPSKeyGenSenderForEnterprise(this.bitgo, enterprise).round1(MPS_ROUND_1, payload);
264+
return this.sendKeyGenerationRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload);
265+
}
266+
267+
async sendKeyGenerationRound1BySender(
268+
senderFn: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound1Response>,
269+
payload: EddsaMPCv2KeyGenRound1Request
270+
): Promise<EddsaMPCv2KeyGenRound1Response> {
271+
return senderFn(MPCv2KeyGenStateEnum['MPCv2-R1'], payload);
290272
}
291273

292274
async sendKeyGenerationRound2(
293275
enterprise: string,
294276
payload: EddsaMPCv2KeyGenRound2Request
295277
): Promise<EddsaMPCv2KeyGenRound2Response> {
296-
return MPSKeyGenSenderForEnterprise(this.bitgo, enterprise).round2(MPS_ROUND_2, payload);
278+
return this.sendKeyGenerationRound2BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload);
279+
}
280+
281+
async sendKeyGenerationRound2BySender(
282+
senderFn: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound2Response>,
283+
payload: EddsaMPCv2KeyGenRound2Request
284+
): Promise<EddsaMPCv2KeyGenRound2Response> {
285+
return senderFn(MPCv2KeyGenStateEnum['MPCv2-R2'], payload);
297286
}
298287
}
Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,25 @@
1-
import {
2-
EddsaMPCv2KeyGenRound1Request,
3-
EddsaMPCv2KeyGenRound1Response,
4-
EddsaMPCv2KeyGenRound2Request,
5-
EddsaMPCv2KeyGenRound2Response,
6-
KeyGenTypeEnum,
7-
} from '@bitgo/public-types';
1+
import { KeyGenTypeEnum, MPCv2KeyGenState } from '@bitgo/public-types';
82
import { BitGoBase } from '../../../bitgoBase';
3+
import { GenerateEddsaMPCv2KeyRequestBody, GenerateEddsaMPCv2KeyRequestResponse } from './typesEddsaMPCv2';
94

10-
export type EddsaMPCv2KeyGenSendFn<Req, Res> = (round: string, payload: Req) => Promise<Res>;
5+
// TODO: move to @bitgo/public-types
6+
export enum KeyCurveEnum {
7+
EdDSA = 'EdDSA',
8+
}
9+
10+
export type EddsaMPCv2KeyGenSendFn<T extends GenerateEddsaMPCv2KeyRequestResponse> = (
11+
round: MPCv2KeyGenState,
12+
payload: GenerateEddsaMPCv2KeyRequestBody
13+
) => Promise<T>;
1114

12-
export function MPSKeyGenSenderForEnterprise(
15+
export function KeyGenSenderForEnterprise<T extends GenerateEddsaMPCv2KeyRequestResponse>(
1316
bitgo: BitGoBase,
1417
enterprise: string
15-
): {
16-
round1: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound1Response>;
17-
round2: EddsaMPCv2KeyGenSendFn<EddsaMPCv2KeyGenRound2Request, EddsaMPCv2KeyGenRound2Response>;
18-
} {
19-
function send<Req, Res>(round: string, payload: Req): Promise<Res> {
18+
): EddsaMPCv2KeyGenSendFn<T> {
19+
return (round, payload) => {
2020
return bitgo
2121
.post(bitgo.url('/mpc/generatekey', 2))
22-
.send({ enterprise, type: KeyGenTypeEnum.MPCv2, keyCurve: 'EdDSA', round, payload })
22+
.send({ enterprise, type: KeyGenTypeEnum.MPCv2, keyCurve: KeyCurveEnum.EdDSA, round, payload })
2323
.result();
24-
}
25-
26-
return {
27-
round1: (round, payload) => send<EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound1Response>(round, payload),
28-
round2: (round, payload) => send<EddsaMPCv2KeyGenRound2Request, EddsaMPCv2KeyGenRound2Response>(round, payload),
2924
};
3025
}

modules/sdk-core/src/bitgo/utils/tss/eddsa/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export {
1414
TxRequest,
1515
} from '../baseTypes';
1616

17-
export { EddsaMPCv2Utils } from './eddsaMPCv2';
17+
export * from './eddsaMPCv2';
18+
export * from './eddsaMPCv2KeyGenSender';
19+
export * from './typesEddsaMPCv2';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as t from 'io-ts';
2+
import {
3+
EddsaMPCv2KeyGenRound1Request,
4+
EddsaMPCv2KeyGenRound1Response,
5+
EddsaMPCv2KeyGenRound2Request,
6+
EddsaMPCv2KeyGenRound2Response,
7+
} from '@bitgo/public-types';
8+
9+
export const generateEddsaMPCv2KeyRequestBody = t.union([EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request]);
10+
11+
export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf<typeof generateEddsaMPCv2KeyRequestBody>;
12+
13+
export const generateEddsaMPCv2KeyRequestResponse = t.union([
14+
EddsaMPCv2KeyGenRound1Response,
15+
EddsaMPCv2KeyGenRound2Response,
16+
]);
17+
18+
export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf<typeof generateEddsaMPCv2KeyRequestResponse>;

0 commit comments

Comments
 (0)