Skip to content

Commit 7e545c5

Browse files
committed
feat(sdk-lib-mpc): implement EdDSA DKG MPS functionality
- Add EdDSA DKG MPS implementation with core types and utilities - Implement distributed key generation for EdDSA signatures - Add type definitions for DKG session Ticket: WP-8197
1 parent f47b962 commit 7e545c5

File tree

10 files changed

+687
-12
lines changed

10 files changed

+687
-12
lines changed

modules/sdk-lib-mpc/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
]
3737
},
3838
"dependencies": {
39+
"@bitgo/wasm-mps": "1.6.0",
3940
"@noble/curves": "1.8.1",
4041
"@silencelaboratories/dkls-wasm-ll-node": "1.2.0-pre.4",
4142
"@silencelaboratories/dkls-wasm-ll-web": "1.2.0-pre.4",
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { ed25519_dkg_round0_process, ed25519_dkg_round1_process, ed25519_dkg_round2_process } from '@bitgo/wasm-mps';
2+
import { encode } from 'cbor-x';
3+
import crypto from 'crypto';
4+
import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare } from './types';
5+
6+
/**
7+
* EdDSA Distributed Key Generation (DKG) implementation using @bitgo/wasm-mps.
8+
*
9+
* State is explicit: each round function returns `{ msg, state }` bytes.
10+
* The state bytes are stored between rounds and passed to the next round function,
11+
* mirroring the server-side persistence pattern (state would be serialised to DB).
12+
*
13+
* @example
14+
* ```typescript
15+
* const dkg = new DKG(3, 2, 0);
16+
* // X25519 keys come from GPG encryption subkeys (extracted by the orchestrator)
17+
* dkg.initDkg(myX25519PrivKey, [otherParty1X25519PubKey, otherParty2X25519PubKey]);
18+
* const msg1 = dkg.getFirstMessage();
19+
* const msg2s = dkg.handleIncomingMessages(allThreeMsg1s);
20+
* dkg.handleIncomingMessages(allThreeMsg2s); // completes DKG
21+
* const keyShare = dkg.getKeyShare();
22+
* ```
23+
*/
24+
export class DKG {
25+
protected n: number;
26+
protected t: number;
27+
protected partyIdx: number;
28+
29+
/** Private X25519 key (from GPG encryption subkey) */
30+
private decryptionKey: Buffer | null = null;
31+
/** Other parties' X25519 public keys (from their GPG encryption subkeys), sorted by party index */
32+
private otherPubKeys: Buffer[] | null = null;
33+
/** Serialised round state bytes returned by the previous round function */
34+
private dkgStateBytes: Buffer | null = null;
35+
/** Opaque bincode-serialised keyshare from round2 */
36+
private keyShare: Buffer | null = null;
37+
/** 32-byte Ed25519 public key from round2 */
38+
private sharePk: Buffer | null = null;
39+
40+
protected dkgState: DkgState = DkgState.Uninitialized;
41+
42+
constructor(n: number, t: number, partyIdx: number) {
43+
this.n = n;
44+
this.t = t;
45+
this.partyIdx = partyIdx;
46+
}
47+
48+
getState(): DkgState {
49+
return this.dkgState;
50+
}
51+
52+
/**
53+
* Initialises the DKG session with this party's X25519 private key and the other parties'
54+
* X25519 public keys. Keys are extracted from GPG encryption subkeys by the orchestrator.
55+
*
56+
* @param decryptionKey - This party's 32-byte X25519 private key (GPG enc subkey private part).
57+
* @param otherEncPublicKeys - Other parties' 32-byte X25519 public keys, sorted by ascending
58+
* party index (excluding own). For a 3-party setup, this is [party_A_pub, party_B_pub].
59+
*/
60+
initDkg(decryptionKey: Buffer, otherEncPublicKeys: Buffer[]): void {
61+
if (!decryptionKey || decryptionKey.length !== 32) {
62+
throw Error('Missing or invalid decryption key: must be 32 bytes');
63+
}
64+
if (!otherEncPublicKeys || otherEncPublicKeys.length !== this.n - 1) {
65+
throw Error(`Expected ${this.n - 1} other parties' public keys`);
66+
}
67+
if (this.t > this.n || this.partyIdx >= this.n) {
68+
throw Error('Invalid parameters for DKG');
69+
}
70+
71+
this.decryptionKey = decryptionKey;
72+
this.otherPubKeys = otherEncPublicKeys;
73+
this.dkgState = DkgState.Init;
74+
}
75+
76+
/**
77+
* Runs round0 of the DKG protocol. Returns this party's broadcast message.
78+
* Stores the round state bytes internally for the next round.
79+
*
80+
* @param dkgSeed - Optional 32-byte seed for deterministic DKG output (testing only).
81+
*/
82+
getFirstMessage(dkgSeed?: Buffer): DeserializedMessage {
83+
if (this.dkgState !== DkgState.Init) {
84+
throw Error('DKG session not initialized');
85+
}
86+
87+
const seed = dkgSeed ?? crypto.randomBytes(32);
88+
const result = ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed);
89+
90+
this.dkgStateBytes = Buffer.from(result.state);
91+
this.dkgState = DkgState.WaitMsg1;
92+
return { payload: new Uint8Array(result.msg), from: this.partyIdx };
93+
}
94+
95+
/**
96+
* Handles incoming messages from all parties and advances the protocol.
97+
*
98+
* - In WaitMsg1: runs round1, returns this party's round1 broadcast message.
99+
* - In WaitMsg2: runs round2, completes DKG, returns [].
100+
*
101+
* The caller passes all n messages (including own); own message is filtered
102+
* out internally. Other parties' messages are sorted by ascending party index,
103+
* matching the ordering expected by @bitgo/wasm-mps.
104+
*
105+
* @param messagesForIthRound - All n messages for this round (including own).
106+
*/
107+
handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages {
108+
if (this.dkgState === DkgState.Complete) {
109+
throw Error('DKG session already completed');
110+
}
111+
if (this.dkgState === DkgState.Uninitialized) {
112+
throw Error('DKG session not initialized');
113+
}
114+
if (this.dkgState === DkgState.Init) {
115+
throw Error(
116+
'DKG session must call getFirstMessage() before handling incoming messages. Call getFirstMessage() first.'
117+
);
118+
}
119+
if (messagesForIthRound.length !== this.n) {
120+
throw Error('Invalid number of messages for the round. Number of messages should be equal to N');
121+
}
122+
123+
// Extract other parties' messages, sorted by party index (ascending)
124+
const otherMsgs = messagesForIthRound
125+
.filter((m) => m.from !== this.partyIdx)
126+
.sort((a, b) => a.from - b.from)
127+
.map((m) => m.payload);
128+
129+
if (this.dkgState === DkgState.WaitMsg1) {
130+
const result = ed25519_dkg_round1_process(otherMsgs, this.dkgStateBytes!);
131+
// Store new state; this is what would be persisted to DB between API rounds
132+
this.dkgStateBytes = Buffer.from(result.state);
133+
this.dkgState = DkgState.WaitMsg2;
134+
return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }];
135+
}
136+
137+
if (this.dkgState === DkgState.WaitMsg2) {
138+
const share = ed25519_dkg_round2_process(otherMsgs, this.dkgStateBytes!);
139+
this.keyShare = Buffer.from(share.share);
140+
this.sharePk = Buffer.from(share.pk);
141+
this.dkgStateBytes = null;
142+
this.dkgState = DkgState.Complete;
143+
return [];
144+
}
145+
146+
throw Error('Unexpected DKG state');
147+
}
148+
149+
/**
150+
* Returns the opaque bincode-serialised keyshare produced by round2.
151+
* This is used as input to the signing protocol.
152+
*/
153+
getKeyShare(): Buffer {
154+
if (!this.keyShare) {
155+
throw Error('DKG session not initialized');
156+
}
157+
return this.keyShare;
158+
}
159+
160+
/**
161+
* Returns the 32-byte Ed25519 public key agreed by all parties during DKG.
162+
*/
163+
getSharePublicKey(): Buffer {
164+
if (!this.sharePk) {
165+
throw Error('DKG session not initialized');
166+
}
167+
return this.sharePk;
168+
}
169+
170+
/**
171+
* Returns a CBOR-encoded reduced representation containing the public key.
172+
* Note: private key material and chain code are not separately accessible
173+
* from @bitgo/wasm-mps; the full keyshare is available via getKeyShare().
174+
*/
175+
getReducedKeyShare(): Buffer {
176+
if (!this.sharePk) {
177+
throw Error('DKG session not initialized');
178+
}
179+
const reducedKeyShare: EddsaReducedKeyShare = {
180+
pub: Array.from(this.sharePk),
181+
};
182+
return Buffer.from(encode(reducedKeyShare));
183+
}
184+
185+
/**
186+
* Exports the current session state as a JSON string for persistence.
187+
* Includes: round state bytes, current DKG round, decryption key, other parties' pub keys.
188+
* This mirrors what a server would store in a database between API rounds.
189+
*/
190+
getSession(): string {
191+
if (this.dkgState === DkgState.Complete) {
192+
throw Error('DKG session is complete. Exporting the session is not allowed.');
193+
}
194+
if (this.dkgState === DkgState.Uninitialized) {
195+
throw Error('DKG session not initialized');
196+
}
197+
return JSON.stringify({
198+
dkgStateBytes: this.dkgStateBytes?.toString('base64') ?? null,
199+
dkgRound: this.dkgState,
200+
decryptionKey: this.decryptionKey?.toString('base64') ?? null,
201+
otherPubKeys: this.otherPubKeys?.map((k) => k.toString('base64')) ?? null,
202+
});
203+
}
204+
205+
/**
206+
* Restores a previously exported session. Allows the protocol to continue
207+
* from where it left off, as if the round state was loaded from a database.
208+
*/
209+
restoreSession(session: string): void {
210+
const data = JSON.parse(session);
211+
this.dkgStateBytes = data.dkgStateBytes ? Buffer.from(data.dkgStateBytes, 'base64') : null;
212+
this.dkgState = data.dkgRound;
213+
this.decryptionKey = data.decryptionKey ? Buffer.from(data.decryptionKey, 'base64') : null;
214+
this.otherPubKeys = data.otherPubKeys ? (data.otherPubKeys as string[]).map((k) => Buffer.from(k, 'base64')) : null;
215+
}
216+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * as EddsaMPSDkg from './dkg';
2+
export * as MPSUtil from './util';
3+
export * as MPSTypes from './types';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { decode } from 'cbor-x';
2+
import { isLeft } from 'fp-ts/Either';
3+
import * as t from 'io-ts';
4+
5+
export const ReducedKeyShareType = t.type({
6+
pub: t.array(t.number),
7+
});
8+
9+
export type EddsaReducedKeyShare = t.TypeOf<typeof ReducedKeyShareType>;
10+
11+
/**
12+
* Represents the state of a DKG (Distributed Key Generation) session
13+
*/
14+
export enum DkgState {
15+
/** DKG session has not been initialized */
16+
Uninitialized = 'Uninitialized',
17+
/** DKG session has been initialized (Init state in WASM) */
18+
Init = 'Init',
19+
/** DKG session is waiting for first message (WaitMsg1 state in WASM) */
20+
WaitMsg1 = 'WaitMsg1',
21+
/** DKG session is waiting for second message (WaitMsg2 state in WASM) */
22+
WaitMsg2 = 'WaitMsg2',
23+
/** DKG session has generated key shares (Share state in WASM) */
24+
Share = 'Share',
25+
/** DKG session has completed successfully and key shares are available */
26+
Complete = 'Complete',
27+
}
28+
29+
export interface Message<T> {
30+
payload: T;
31+
from: number;
32+
}
33+
34+
export type SerializedMessage = Message<string>;
35+
36+
export type SerializedMessages = Message<string>[];
37+
38+
export type DeserializedMessage = Message<Uint8Array>;
39+
40+
export type DeserializedMessages = Message<Uint8Array>[];
41+
42+
export function serializeMessage(msg: DeserializedMessage): SerializedMessage {
43+
return { from: msg.from, payload: Buffer.from(msg.payload).toString('base64') };
44+
}
45+
46+
export function deserializeMessage(msg: SerializedMessage): DeserializedMessage {
47+
return { from: msg.from, payload: new Uint8Array(Buffer.from(msg.payload, 'base64')) };
48+
}
49+
50+
export function serializeMessages(msgs: DeserializedMessages): SerializedMessages {
51+
return msgs.map(serializeMessage);
52+
}
53+
54+
export function deserializeMessages(msgs: SerializedMessages): DeserializedMessages {
55+
return msgs.map(deserializeMessage);
56+
}
57+
58+
export function getDecodedReducedKeyShare(reducedKeyShare: Buffer | Uint8Array): EddsaReducedKeyShare {
59+
const decoded = ReducedKeyShareType.decode(decode(reducedKeyShare));
60+
if (isLeft(decoded)) {
61+
throw new Error(`Unable to parse reducedKeyShare: ${decoded.left}`);
62+
}
63+
return decoded.right;
64+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Concatenates multiple Uint8Array instances into a single Uint8Array
3+
* @param chunks - Array of Uint8Array instances to concatenate
4+
* @returns Concatenated Uint8Array
5+
*/
6+
export function concatBytes(chunks: Uint8Array[]): Uint8Array {
7+
const buffers = chunks.map((chunk) => Buffer.from(chunk));
8+
return new Uint8Array(Buffer.concat(buffers));
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './ecdsa';
22
export * from './ecdsa-dkls';
3+
export * from './eddsa-mps';

0 commit comments

Comments
 (0)