Skip to content

Commit 9ff9752

Browse files
committed
feat: implement EdDSA MPS DKG key gen orchestration
TICKET: WCI-5 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 902654c commit 9ff9752

9 files changed

Lines changed: 847 additions & 0 deletions

File tree

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

Lines changed: 425 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { IBaseCoin } from '../../../baseCoin';
2+
import baseTSSUtils from '../baseTSSUtils';
3+
import { KeyShare } from './types';
4+
import { BitGoBase } from '../../../bitgoBase';
5+
import { IWallet } from '../../../wallet';
6+
7+
export class BaseEddsaUtils extends baseTSSUtils<KeyShare> {
8+
constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, wallet?: IWallet) {
9+
super(bitgo, baseCoin, wallet);
10+
this.setBitgoGpgPubKey(bitgo);
11+
}
12+
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import assert from 'assert';
2+
import * as pgp from 'openpgp';
3+
import { NonEmptyString } from 'io-ts-types';
4+
import {
5+
EddsaMPCv2KeyGenRound1Request,
6+
EddsaMPCv2KeyGenRound1Response,
7+
EddsaMPCv2KeyGenRound2Request,
8+
EddsaMPCv2KeyGenRound2Response,
9+
MPCv2KeyGenStateEnum,
10+
MPCv2PartyFromStringOrNumber,
11+
} from '@bitgo/public-types';
12+
import { EddsaMPSDkg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc';
13+
import { KeychainsTriplet } from '../../../baseCoin';
14+
import { AddKeychainOptions, Keychain, KeyType } from '../../../keychain';
15+
import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys';
16+
import { generateGPGKeyPair } from '../../opengpgUtils';
17+
import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2';
18+
import { BaseEddsaUtils } from './base';
19+
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
20+
21+
export class EddsaMPCv2Utils extends BaseEddsaUtils {
22+
/** @inheritdoc */
23+
async createKeychains(params: {
24+
passphrase: string;
25+
enterprise: string;
26+
originalPasscodeEncryptionCode?: string;
27+
}): Promise<KeychainsTriplet> {
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();
43+
44+
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
45+
assert(isBitgoMpcPubKey(bitgoPublicGpgKeyArmored, 'mpcv2'), 'Invalid BitGo GPG public key');
46+
}
47+
48+
const bitgoKeyObj = await pgp.readKey({ armoredKey: bitgoPublicGpgKeyArmored });
49+
const bitgoPk = await MPSComms.extractEd25519PublicKey(bitgoKeyObj);
50+
51+
// Create DKG sessions for user (party 0) and backup (party 1)
52+
const userDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.USER);
53+
const backupDkg = new EddsaMPSDkg.DKG(3, 2, MPCv2PartiesEnum.BACKUP);
54+
55+
// #region round 1
56+
userDkg.initDkg(userSk, [backupPk, bitgoPk]);
57+
backupDkg.initDkg(backupSk, [userPk, bitgoPk]);
58+
59+
const userMsg1 = userDkg.getFirstMessage();
60+
const backupMsg1 = backupDkg.getFirstMessage();
61+
62+
const userSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgKey);
63+
const backupSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg1.payload), backupGpgKey);
64+
65+
assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required');
66+
assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required');
67+
68+
const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(params.enterprise, {
69+
userGpgPublicKey,
70+
backupGpgPublicKey,
71+
userMsg1: userSignedMsg1,
72+
backupMsg1: backupSignedMsg1,
73+
});
74+
// #endregion
75+
76+
// #region round 2
77+
const bitgoRawMsg1Bytes = await MPSComms.verifyMpsMessage(bitgoMsg1, bitgoKeyObj);
78+
const bitgoDeserializedMsg1: MPSTypes.DeserializedMessage = {
79+
from: MPCv2PartiesEnum.BITGO,
80+
payload: new Uint8Array(bitgoRawMsg1Bytes),
81+
};
82+
83+
const round1Messages: MPSTypes.DeserializedMessages = [userMsg1, backupMsg1, bitgoDeserializedMsg1];
84+
85+
const userRound2Msgs = userDkg.handleIncomingMessages(round1Messages);
86+
const backupRound2Msgs = backupDkg.handleIncomingMessages(round1Messages);
87+
88+
assert(userRound2Msgs.length === 1, 'User round 1 should produce exactly one round 2 message');
89+
assert(backupRound2Msgs.length === 1, 'Backup round 1 should produce exactly one round 2 message');
90+
91+
const userMsg2 = userRound2Msgs[0];
92+
const backupMsg2 = backupRound2Msgs[0];
93+
94+
const userSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgKey);
95+
const backupSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(backupMsg2.payload), backupGpgKey);
96+
97+
const {
98+
sessionId: sessionIdRound2,
99+
commonPublicKey,
100+
bitgoMsg2,
101+
} = await this.sendKeyGenerationRound2(params.enterprise, {
102+
sessionId,
103+
userMsg2: userSignedMsg2,
104+
backupMsg2: backupSignedMsg2,
105+
});
106+
// #endregion
107+
108+
// #region keychain creation
109+
assert.equal(sessionId, sessionIdRound2, 'Round 1 and round 2 session IDs do not match');
110+
111+
const bitgoRawMsg2Bytes = await MPSComms.verifyMpsMessage(bitgoMsg2, bitgoKeyObj);
112+
const bitgoDeserializedMsg2: MPSTypes.DeserializedMessage = {
113+
from: MPCv2PartiesEnum.BITGO,
114+
payload: new Uint8Array(bitgoRawMsg2Bytes),
115+
};
116+
117+
const round2Messages: MPSTypes.DeserializedMessages = [userMsg2, backupMsg2, bitgoDeserializedMsg2];
118+
119+
const userFinalMsgs = userDkg.handleIncomingMessages(round2Messages);
120+
const backupFinalMsgs = backupDkg.handleIncomingMessages(round2Messages);
121+
122+
assert(userFinalMsgs.length === 0, 'WASM round 2 should produce no output messages for user');
123+
assert(backupFinalMsgs.length === 0, 'WASM round 2 should produce no output messages for backup');
124+
125+
const userCommonKey = userDkg.getSharePublicKey().toString('hex');
126+
const backupCommonKey = backupDkg.getSharePublicKey().toString('hex');
127+
128+
assert.equal(userCommonKey, commonPublicKey, 'User computed public key does not match BitGo common public key');
129+
assert.equal(backupCommonKey, commonPublicKey, 'Backup computed public key does not match BitGo common public key');
130+
131+
const userPrivateMaterial = userDkg.getKeyShare();
132+
const backupPrivateMaterial = backupDkg.getKeyShare();
133+
const userReducedPrivateMaterial = userDkg.getReducedKeyShare();
134+
const backupReducedPrivateMaterial = backupDkg.getReducedKeyShare();
135+
136+
const userKeychainPromise = this.addUserKeychain(
137+
commonPublicKey,
138+
userPrivateMaterial,
139+
userReducedPrivateMaterial,
140+
params.passphrase,
141+
params.originalPasscodeEncryptionCode
142+
);
143+
const backupKeychainPromise = this.addBackupKeychain(
144+
commonPublicKey,
145+
backupPrivateMaterial,
146+
backupReducedPrivateMaterial,
147+
params.passphrase,
148+
params.originalPasscodeEncryptionCode
149+
);
150+
const bitgoKeychainPromise = this.addBitgoKeychain(commonPublicKey);
151+
152+
const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([
153+
userKeychainPromise,
154+
backupKeychainPromise,
155+
bitgoKeychainPromise,
156+
]);
157+
// #endregion
158+
159+
return {
160+
userKeychain,
161+
backupKeychain,
162+
bitgoKeychain,
163+
};
164+
}
165+
166+
// #region keychain utils
167+
async createParticipantKeychain(
168+
participantIndex: MPCv2PartyFromStringOrNumber,
169+
commonKeychain: string,
170+
privateMaterial?: Buffer,
171+
reducedPrivateMaterial?: Buffer,
172+
passphrase?: string,
173+
originalPasscodeEncryptionCode?: string
174+
): Promise<Keychain> {
175+
let source: string;
176+
let encryptedPrv: string | undefined = undefined;
177+
let reducedEncryptedPrv: string | undefined = undefined;
178+
179+
switch (participantIndex) {
180+
case MPCv2PartiesEnum.USER:
181+
case MPCv2PartiesEnum.BACKUP:
182+
source = participantIndex === MPCv2PartiesEnum.USER ? 'user' : 'backup';
183+
assert(privateMaterial, `Private material is required for ${source} keychain`);
184+
assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`);
185+
assert(passphrase, `Passphrase is required for ${source} keychain`);
186+
encryptedPrv = this.bitgo.encrypt({
187+
input: privateMaterial.toString('base64'),
188+
password: passphrase,
189+
});
190+
// Encrypts the CBOR-encoded ReducedKeyShare (which contains the party's public
191+
// key) with the wallet passphrase. The result is stored as reducedEncryptedPrv
192+
// on the key card QR code and represents a second copy of key material
193+
// beyond the server-stored encryptedPrv.
194+
reducedEncryptedPrv = this.bitgo.encrypt({
195+
// Buffer.toString('base64') can not be used here as it does not work on the browser.
196+
// The browser deals with a Buffer as Uint8Array, therefore in the browser .toString('base64') just creates a comma separated string of the array values.
197+
input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))),
198+
password: passphrase,
199+
});
200+
break;
201+
case MPCv2PartiesEnum.BITGO:
202+
source = 'bitgo';
203+
break;
204+
default:
205+
throw new Error('Invalid participant index');
206+
}
207+
208+
const keychainParams: AddKeychainOptions = {
209+
source,
210+
keyType: 'tss' as KeyType,
211+
commonKeychain,
212+
encryptedPrv,
213+
originalPasscodeEncryptionCode,
214+
isMPCv2: true,
215+
};
216+
217+
const keychains = this.baseCoin.keychains();
218+
return { ...(await keychains.add(keychainParams)), reducedEncryptedPrv };
219+
}
220+
221+
private async addUserKeychain(
222+
commonKeychain: string,
223+
privateMaterial: Buffer,
224+
reducedPrivateMaterial: Buffer,
225+
passphrase: string,
226+
originalPasscodeEncryptionCode?: string
227+
): Promise<Keychain> {
228+
return this.createParticipantKeychain(
229+
MPCv2PartiesEnum.USER,
230+
commonKeychain,
231+
privateMaterial,
232+
reducedPrivateMaterial,
233+
passphrase,
234+
originalPasscodeEncryptionCode
235+
);
236+
}
237+
238+
private async addBackupKeychain(
239+
commonKeychain: string,
240+
privateMaterial: Buffer,
241+
reducedPrivateMaterial: Buffer,
242+
passphrase: string,
243+
originalPasscodeEncryptionCode?: string
244+
): Promise<Keychain> {
245+
return this.createParticipantKeychain(
246+
MPCv2PartiesEnum.BACKUP,
247+
commonKeychain,
248+
privateMaterial,
249+
reducedPrivateMaterial,
250+
passphrase,
251+
originalPasscodeEncryptionCode
252+
);
253+
}
254+
255+
private async addBitgoKeychain(commonKeychain: string): Promise<Keychain> {
256+
return this.createParticipantKeychain(MPCv2PartiesEnum.BITGO, commonKeychain);
257+
}
258+
// #endregion
259+
260+
async sendKeyGenerationRound1(
261+
enterprise: string,
262+
payload: EddsaMPCv2KeyGenRound1Request
263+
): Promise<EddsaMPCv2KeyGenRound1Response> {
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);
272+
}
273+
274+
async sendKeyGenerationRound2(
275+
enterprise: string,
276+
payload: EddsaMPCv2KeyGenRound2Request
277+
): Promise<EddsaMPCv2KeyGenRound2Response> {
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);
286+
}
287+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { KeyGenTypeEnum, MPCv2KeyGenState } from '@bitgo/public-types';
2+
import { BitGoBase } from '../../../bitgoBase';
3+
import { GenerateEddsaMPCv2KeyRequestBody, GenerateEddsaMPCv2KeyRequestResponse } from './typesEddsaMPCv2';
4+
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>;
14+
15+
export function KeyGenSenderForEnterprise<T extends GenerateEddsaMPCv2KeyRequestResponse>(
16+
bitgo: BitGoBase,
17+
enterprise: string
18+
): EddsaMPCv2KeyGenSendFn<T> {
19+
return (round, payload) => {
20+
return bitgo
21+
.post(bitgo.url('/mpc/generatekey', 2))
22+
.send({ enterprise, type: KeyGenTypeEnum.MPCv2, keyCurve: KeyCurveEnum.EdDSA, round, payload })
23+
.result();
24+
};
25+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ export {
1313
SignatureShareType,
1414
TxRequest,
1515
} from '../baseTypes';
16+
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)