Skip to content

Commit 8fcf1da

Browse files
committed
feat(sdk-lib-mpc): add EdDSA DSG MPS class
Introduces the DSG (Distributed Sign Generation) class for EdDSA MPCv2 using the @bitgo/wasm-mps WASM bindings. The class mirrors the existing DKG pattern with explicit state management and session export/restore support for server-side persistence across rounds. - Add `DSG` class with 4-round signing protocol state machine (Init → WaitMsg1 → WaitMsg2 → WaitMsg3 → Complete) - Add `DsgState` enum to types.ts - Export `EddsaMPSDsg` namespace from index.ts - Add `runEdDsaDSG` test helper to util.ts - Add unit tests covering full 2-of-3 signing, session restore, message serialization, error handling, and derivation paths Ticket: WCI-164
1 parent 4b63462 commit 8fcf1da

5 files changed

Lines changed: 646 additions & 1 deletion

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import assert from 'assert';
2+
import {
3+
ed25519_dsg_round0_process,
4+
ed25519_dsg_round1_process,
5+
ed25519_dsg_round2_process,
6+
ed25519_dsg_round3_process,
7+
} from '@bitgo/wasm-mps';
8+
import { DeserializedMessage, DeserializedMessages, DsgState } from './types';
9+
10+
/**
11+
* EdDSA Distributed Sign Generation (DSG) implementation using @bitgo/wasm-mps.
12+
*
13+
* State is explicit: each WASM round function returns
14+
* `{ msg, state }` bytes; the state bytes are stored between rounds and passed to the
15+
* next round function (this is what a server would persist to a database between API
16+
* rounds).
17+
*
18+
* The protocol is hard-coded 2-of-3: each signing party communicates with exactly one
19+
* counterpart. `handleIncomingMessages` accepts both messages (own + counterpart), and
20+
* filters own out internally.
21+
*
22+
* @example
23+
* ```typescript
24+
* const dsg = new DSG(0); // partyIdx 0
25+
* dsg.initDsg(keyShare, message, 'm', 2); // counterpart is party 2
26+
* const msg1 = dsg.getFirstMessage();
27+
* const msg2 = dsg.handleIncomingMessages([msg1, peerMsg1]); // emits SignMsg2
28+
* const msg3 = dsg.handleIncomingMessages([msg2[0], peerMsg2]); // emits SignMsg3
29+
* dsg.handleIncomingMessages([msg3[0], peerMsg3]); // completes DSG
30+
* const signature = dsg.getSignature(); // 64-byte Ed25519 signature
31+
* ```
32+
*/
33+
export class DSG {
34+
protected partyIdx: number;
35+
protected otherPartyIdx: number | null = null;
36+
37+
/** Opaque bincode-serialised Keyshare from a prior DKG */
38+
private keyShare: Buffer | null = null;
39+
/** Raw message bytes to sign (Ed25519 hashes internally; no prehashing required) */
40+
private message: Buffer | null = null;
41+
/** BIP-32-style derivation path, e.g. "m" or "m/0/1". Folded in via Keyshare::derive_with_offset */
42+
private derivationPath: string | null = null;
43+
44+
/** Serialised round state bytes returned by the previous round function */
45+
private dsgStateBytes: Buffer | null = null;
46+
/** Final 64-byte Ed25519 signature, available after WaitMsg3 -> Complete */
47+
private signature: Buffer | null = null;
48+
49+
protected dsgState: DsgState = DsgState.Uninitialized;
50+
51+
constructor(partyIdx: number) {
52+
this.partyIdx = partyIdx;
53+
}
54+
55+
getState(): DsgState {
56+
return this.dsgState;
57+
}
58+
59+
/**
60+
* Initialises the DSG session. The keyshare must come from a prior DKG run, and
61+
* `otherPartyIdx` must be the single counterpart who will co-sign with this party.
62+
*
63+
* @param keyShare - Opaque bincode-serialised Keyshare bytes from `DKG.getKeyShare()`.
64+
* @param message - Raw message bytes to sign (no prehashing).
65+
* @param derivationPath - BIP-32-style derivation path. Use `"m"` for the root key.
66+
* @param otherPartyIdx - Party index of the single counterpart in this signing session.
67+
* Must differ from this party's own `partyIdx` and be in `[0, 2]`.
68+
*/
69+
initDsg(keyShare: Buffer, message: Buffer, derivationPath: string, otherPartyIdx: number): void {
70+
if (!keyShare || keyShare.length === 0) {
71+
throw Error('Missing or invalid keyShare');
72+
}
73+
if (!message || message.length === 0) {
74+
throw Error('Missing or invalid message');
75+
}
76+
if (this.partyIdx < 0 || this.partyIdx > 2) {
77+
throw Error(`Invalid partyIdx ${this.partyIdx}: must be in [0, 2]`);
78+
}
79+
if (otherPartyIdx < 0 || otherPartyIdx > 2 || otherPartyIdx === this.partyIdx) {
80+
throw Error(`Invalid otherPartyIdx ${otherPartyIdx}: must be in [0, 2] and != partyIdx`);
81+
}
82+
83+
this.keyShare = keyShare;
84+
this.message = message;
85+
this.derivationPath = derivationPath;
86+
this.otherPartyIdx = otherPartyIdx;
87+
this.dsgState = DsgState.Init;
88+
}
89+
90+
/**
91+
* Runs round 0 of the DSG protocol. Returns this party's broadcast message
92+
* (a `SignMsg1` containing the commitment to `R_i`). Stores the round state
93+
* bytes internally for the next round.
94+
*/
95+
getFirstMessage(): DeserializedMessage {
96+
if (this.dsgState !== DsgState.Init) {
97+
throw Error('DSG session not initialized');
98+
}
99+
assert(this.keyShare, 'keyShare must be set after initDsg');
100+
assert(this.derivationPath !== null, 'derivationPath must be set after initDsg');
101+
assert(this.message, 'message must be set after initDsg');
102+
103+
let result;
104+
try {
105+
result = ed25519_dsg_round0_process(this.keyShare, this.derivationPath, this.message);
106+
} catch (err) {
107+
throw new Error(`Error while creating the first message from party ${this.partyIdx}: ${err}`);
108+
}
109+
110+
this.dsgStateBytes = Buffer.from(result.state);
111+
this.dsgState = DsgState.WaitMsg1;
112+
return { payload: new Uint8Array(result.msg), from: this.partyIdx };
113+
}
114+
115+
/**
116+
* Handles incoming messages for the current round and advances the protocol.
117+
*
118+
* - In `WaitMsg1`: runs round 1, returns this party's `SignMsg2` broadcast.
119+
* - In `WaitMsg2`: runs round 2 (which internally fuses two Silence Labs transitions),
120+
* returns this party's `SignMsg3` broadcast (partial signature).
121+
* - In `WaitMsg3`: runs round 3, completes DSG, returns `[]`.
122+
*
123+
* The caller passes both messages (own + counterpart) for symmetry with
124+
* `DKG.handleIncomingMessages`. Own message is filtered out internally; only the
125+
* counterpart's payload is forwarded to the WASM round function.
126+
*
127+
* @param messagesForIthRound - Both messages for this round (own + counterpart).
128+
*/
129+
handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages {
130+
if (this.dsgState === DsgState.Complete) {
131+
throw Error('DSG session already completed');
132+
}
133+
if (this.dsgState === DsgState.Uninitialized) {
134+
throw Error('DSG session not initialized');
135+
}
136+
if (this.dsgState === DsgState.Init) {
137+
throw Error(
138+
'DSG session must call getFirstMessage() before handling incoming messages. Call getFirstMessage() first.'
139+
);
140+
}
141+
if (messagesForIthRound.length !== 2) {
142+
throw Error('Invalid number of messages for the round. Expected 2 messages (own + counterpart) for 2-of-3 DSG');
143+
}
144+
145+
const peerMessages = messagesForIthRound.filter((m) => m.from !== this.partyIdx);
146+
if (peerMessages.length !== 1) {
147+
throw Error(`Expected exactly 1 counterpart message; got ${peerMessages.length}`);
148+
}
149+
const peerMsg = peerMessages[0];
150+
if (peerMsg.from !== this.otherPartyIdx) {
151+
throw Error(`Unexpected counterpart party index: got ${peerMsg.from}, expected ${this.otherPartyIdx}`);
152+
}
153+
const peerPayload = Buffer.from(peerMsg.payload);
154+
155+
if (this.dsgState === DsgState.WaitMsg1) {
156+
assert(this.dsgStateBytes, 'dsgStateBytes must be set in WaitMsg1');
157+
let result;
158+
try {
159+
result = ed25519_dsg_round1_process(peerPayload, this.dsgStateBytes);
160+
} catch (err) {
161+
throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`);
162+
}
163+
this.dsgStateBytes = Buffer.from(result.state);
164+
this.dsgState = DsgState.WaitMsg2;
165+
return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }];
166+
}
167+
168+
if (this.dsgState === DsgState.WaitMsg2) {
169+
assert(this.dsgStateBytes, 'dsgStateBytes must be set in WaitMsg2');
170+
let result;
171+
try {
172+
result = ed25519_dsg_round2_process(peerPayload, this.dsgStateBytes);
173+
} catch (err) {
174+
throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`);
175+
}
176+
this.dsgStateBytes = Buffer.from(result.state);
177+
this.dsgState = DsgState.WaitMsg3;
178+
return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }];
179+
}
180+
181+
if (this.dsgState === DsgState.WaitMsg3) {
182+
assert(this.dsgStateBytes, 'dsgStateBytes must be set in WaitMsg3');
183+
let sigBytes;
184+
try {
185+
sigBytes = ed25519_dsg_round3_process(peerPayload, this.dsgStateBytes);
186+
} catch (err) {
187+
throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`);
188+
}
189+
this.signature = Buffer.from(sigBytes);
190+
this.dsgStateBytes = null;
191+
this.dsgState = DsgState.Complete;
192+
return [];
193+
}
194+
195+
throw Error('Unexpected DSG state');
196+
}
197+
198+
/**
199+
* Returns the final 64-byte Ed25519 signature produced by round 3.
200+
* Only available once the protocol reaches `Complete`.
201+
*/
202+
getSignature(): Buffer {
203+
if (!this.signature) {
204+
throw Error('DSG session has not produced a signature yet');
205+
}
206+
return this.signature;
207+
}
208+
209+
/**
210+
* Exports the current session state as a JSON string for persistence.
211+
* Includes the opaque round state bytes plus everything needed to re-enter the
212+
* protocol after a restart (keyshare, message, derivation path, counterpart).
213+
*/
214+
getSession(): string {
215+
if (this.dsgState === DsgState.Complete) {
216+
throw Error('DSG session is complete. Exporting the session is not allowed.');
217+
}
218+
if (this.dsgState === DsgState.Uninitialized) {
219+
throw Error('DSG session not initialized');
220+
}
221+
if (this.dsgState === DsgState.Init) {
222+
throw Error('DSG session must produce its first message before exporting.');
223+
}
224+
return JSON.stringify({
225+
dsgStateBytes: this.dsgStateBytes?.toString('base64') ?? null,
226+
dsgRound: this.dsgState,
227+
keyShare: this.keyShare?.toString('base64') ?? null,
228+
message: this.message?.toString('base64') ?? null,
229+
derivationPath: this.derivationPath,
230+
partyIdx: this.partyIdx,
231+
otherPartyIdx: this.otherPartyIdx,
232+
});
233+
}
234+
235+
/**
236+
* Restores a previously exported session. Allows the protocol to continue from
237+
* where it left off, as if the round state was loaded from a database.
238+
*/
239+
restoreSession(session: string): void {
240+
const data = JSON.parse(session);
241+
if (!Object.values(DsgState).includes(data.dsgRound)) {
242+
throw Error(`Invalid dsgRound in session: ${data.dsgRound}`);
243+
}
244+
if (data.dsgRound === DsgState.Uninitialized || data.dsgRound === DsgState.Init) {
245+
throw Error(`Cannot restore DSG session in state ${data.dsgRound}`);
246+
}
247+
if (data.dsgRound === DsgState.Complete) {
248+
throw Error('DSG session is complete. Restoring the session is not allowed.');
249+
}
250+
if (typeof data.partyIdx !== 'number' || data.partyIdx < 0 || data.partyIdx > 2) {
251+
throw Error(`Invalid partyIdx in session: ${data.partyIdx}`);
252+
}
253+
if (
254+
typeof data.otherPartyIdx !== 'number' ||
255+
data.otherPartyIdx < 0 ||
256+
data.otherPartyIdx > 2 ||
257+
data.otherPartyIdx === data.partyIdx
258+
) {
259+
throw Error(`Invalid otherPartyIdx in session: ${data.otherPartyIdx}`);
260+
}
261+
if (this.partyIdx !== data.partyIdx) {
262+
throw Error(`Session partyIdx ${data.partyIdx} does not match instance ${this.partyIdx}`);
263+
}
264+
if (typeof data.dsgStateBytes !== 'string' || data.dsgStateBytes.length === 0) {
265+
throw Error(`Round ${data.dsgRound} requires dsgStateBytes`);
266+
}
267+
if (typeof data.keyShare !== 'string' || data.keyShare.length === 0) {
268+
throw Error('Restored session missing keyShare');
269+
}
270+
if (typeof data.message !== 'string' || data.message.length === 0) {
271+
throw Error('Restored session missing message');
272+
}
273+
if (typeof data.derivationPath !== 'string') {
274+
throw Error('Restored session missing derivationPath');
275+
}
276+
277+
const dsgStateBytes = Buffer.from(data.dsgStateBytes, 'base64');
278+
const keyShare = Buffer.from(data.keyShare, 'base64');
279+
const message = Buffer.from(data.message, 'base64');
280+
if (dsgStateBytes.length === 0) {
281+
throw Error(`Round ${data.dsgRound} requires dsgStateBytes`);
282+
}
283+
if (keyShare.length === 0) {
284+
throw Error('Restored session missing keyShare');
285+
}
286+
if (message.length === 0) {
287+
throw Error('Restored session missing message');
288+
}
289+
290+
this.dsgStateBytes = dsgStateBytes;
291+
this.dsgState = data.dsgRound;
292+
this.keyShare = keyShare;
293+
this.message = message;
294+
this.derivationPath = data.derivationPath;
295+
this.partyIdx = data.partyIdx;
296+
this.otherPartyIdx = data.otherPartyIdx;
297+
}
298+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * as EddsaMPSDkg from './dkg';
2+
export * as EddsaMPSDsg from './dsg';
23
export * as MPSUtil from './util';
34
export * as MPSTypes from './types';
45
export * as MPSComms from './commsLayer';

modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ export enum DkgState {
2626
Complete = 'Complete',
2727
}
2828

29+
/**
30+
* Represents the state of a DSG (Distributed Sign Generation) session.
31+
*/
32+
export enum DsgState {
33+
/** DSG session has not been initialized */
34+
Uninitialized = 'Uninitialized',
35+
/** initDsg() has been called; ready for getFirstMessage() */
36+
Init = 'Init',
37+
/** R0 broadcast emitted; waiting for counterpart's R0 broadcast (SignMsg1) */
38+
WaitMsg1 = 'WaitMsg1',
39+
/** R1 broadcast emitted; waiting for counterpart's R1 broadcast (SignMsg2) */
40+
WaitMsg2 = 'WaitMsg2',
41+
/** R2 broadcast emitted; waiting for counterpart's R2 broadcast (SignMsg3, the partial sig) */
42+
WaitMsg3 = 'WaitMsg3',
43+
/** Final 64-byte Ed25519 signature is available via getSignature() */
44+
Complete = 'Complete',
45+
}
46+
2947
export interface Message<T> {
3048
payload: T;
3149
from: number;

0 commit comments

Comments
 (0)