diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts new file mode 100644 index 0000000000..0a1f96de4e --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts @@ -0,0 +1,239 @@ +import { + ed25519_dsg_round0_process, + ed25519_dsg_round1_process, + ed25519_dsg_round2_process, + ed25519_dsg_round3_process, +} from '@bitgo/wasm-mps'; +import { DeserializedMessage, DeserializedMessages, DsgState } from './types'; + +/** + * EdDSA Distributed Sign Generation (DSG) implementation using @bitgo/wasm-mps. + * + * State is explicit: each WASM round function returns + * `{ msg, state }` bytes; the state bytes are stored between rounds and passed to the + * next round function (this is what a server would persist to a database between API + * rounds). + * + * The protocol is hard-coded 2-of-3: each signing party communicates with exactly one + * counterpart. `handleIncomingMessages` accepts both messages (own + counterpart), and + * filters own out internally. + * + * @example + * ```typescript + * const dsg = new DSG(0); // partyIdx 0 + * dsg.initDsg(keyShare, message, 'm', 2); // counterpart is party 2 + * const msg1 = dsg.getFirstMessage(); + * const msg2 = dsg.handleIncomingMessages([msg1, peerMsg1]); // emits SignMsg2 + * const msg3 = dsg.handleIncomingMessages([msg2[0], peerMsg2]); // emits SignMsg3 + * dsg.handleIncomingMessages([msg3[0], peerMsg3]); // completes DSG + * const signature = dsg.getSignature(); // 64-byte Ed25519 signature + * ``` + */ +export class DSG { + protected partyIdx: number; + protected otherPartyIdx: number | null = null; + + /** Opaque bincode-serialised Keyshare from a prior DKG */ + private keyShare: Buffer | null = null; + /** Raw message bytes to sign (Ed25519 hashes internally; no prehashing required) */ + private message: Buffer | null = null; + /** BIP-32-style derivation path, e.g. "m" or "m/0/1". Folded in via Keyshare::derive_with_offset */ + private derivationPath: string | null = null; + + /** Serialised round state bytes returned by the previous round function */ + private dsgStateBytes: Buffer | null = null; + /** Final 64-byte Ed25519 signature, available after WaitMsg3 -> Complete */ + private signature: Buffer | null = null; + + protected dsgState: DsgState = DsgState.Uninitialized; + + constructor(partyIdx: number) { + this.partyIdx = partyIdx; + } + + getState(): DsgState { + return this.dsgState; + } + + /** + * Initialises the DSG session. The keyshare must come from a prior DKG run, and + * `otherPartyIdx` must be the single counterpart who will co-sign with this party. + * + * @param keyShare - Opaque bincode-serialised Keyshare bytes from `DKG.getKeyShare()`. + * @param message - Raw message bytes to sign (no prehashing). + * @param derivationPath - BIP-32-style derivation path. Use `"m"` for the root key. + * @param otherPartyIdx - Party index of the single counterpart in this signing session. + * Must differ from this party's own `partyIdx` and be in `[0, 2]`. + */ + initDsg(keyShare: Buffer, message: Buffer, derivationPath: string, otherPartyIdx: number): void { + if (!keyShare || keyShare.length === 0) { + throw Error('Missing or invalid keyShare'); + } + if (!message || message.length === 0) { + throw Error('Missing or invalid message'); + } + if (this.partyIdx < 0 || this.partyIdx > 2) { + throw Error(`Invalid partyIdx ${this.partyIdx}: must be in [0, 2]`); + } + if (otherPartyIdx < 0 || otherPartyIdx > 2 || otherPartyIdx === this.partyIdx) { + throw Error(`Invalid otherPartyIdx ${otherPartyIdx}: must be in [0, 2] and != partyIdx`); + } + + this.keyShare = keyShare; + this.message = message; + this.derivationPath = derivationPath; + this.otherPartyIdx = otherPartyIdx; + this.dsgState = DsgState.Init; + } + + /** + * Runs round 0 of the DSG protocol. Returns this party's broadcast message + * (a `SignMsg1` containing the commitment to `R_i`). Stores the round state + * bytes internally for the next round. + */ + getFirstMessage(): DeserializedMessage { + if (this.dsgState !== DsgState.Init) { + throw Error('DSG session not initialized'); + } + + let result; + try { + result = ed25519_dsg_round0_process(this.keyShare!, this.derivationPath!, this.message!); + } catch (err) { + throw new Error(`Error while creating the first message from party ${this.partyIdx}: ${err}`); + } + + this.dsgStateBytes = Buffer.from(result.state); + this.dsgState = DsgState.WaitMsg1; + return { payload: new Uint8Array(result.msg), from: this.partyIdx }; + } + + /** + * Handles incoming messages for the current round and advances the protocol. + * + * - In `WaitMsg1`: runs round 1, returns this party's `SignMsg2` broadcast. + * - In `WaitMsg2`: runs round 2 (which internally fuses two Silence Labs transitions), + * returns this party's `SignMsg3` broadcast (partial signature). + * - In `WaitMsg3`: runs round 3, completes DSG, returns `[]`. + * + * The caller passes both messages (own + counterpart) for symmetry with + * `DKG.handleIncomingMessages`. Own message is filtered out internally; only the + * counterpart's payload is forwarded to the WASM round function. + * + * @param messagesForIthRound - Both messages for this round (own + counterpart). + */ + handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages { + if (this.dsgState === DsgState.Complete) { + throw Error('DSG session already completed'); + } + if (this.dsgState === DsgState.Uninitialized) { + throw Error('DSG session not initialized'); + } + if (this.dsgState === DsgState.Init) { + throw Error( + 'DSG session must call getFirstMessage() before handling incoming messages. Call getFirstMessage() first.' + ); + } + if (messagesForIthRound.length !== 2) { + throw Error('Invalid number of messages for the round. Expected 2 messages (own + counterpart) for 2-of-3 DSG'); + } + + const peerMessages = messagesForIthRound.filter((m) => m.from !== this.partyIdx); + if (peerMessages.length !== 1) { + throw Error(`Expected exactly 1 counterpart message; got ${peerMessages.length}`); + } + const peerMsg = peerMessages[0]; + if (peerMsg.from !== this.otherPartyIdx) { + throw Error(`Unexpected counterpart party index: got ${peerMsg.from}, expected ${this.otherPartyIdx}`); + } + const peerPayload = Buffer.from(peerMsg.payload); + + if (this.dsgState === DsgState.WaitMsg1) { + let result; + try { + result = ed25519_dsg_round1_process(peerPayload, this.dsgStateBytes!); + } catch (err) { + throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`); + } + this.dsgStateBytes = Buffer.from(result.state); + this.dsgState = DsgState.WaitMsg2; + return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }]; + } + + if (this.dsgState === DsgState.WaitMsg2) { + let result; + try { + result = ed25519_dsg_round2_process(peerPayload, this.dsgStateBytes!); + } catch (err) { + throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`); + } + this.dsgStateBytes = Buffer.from(result.state); + this.dsgState = DsgState.WaitMsg3; + return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }]; + } + + if (this.dsgState === DsgState.WaitMsg3) { + let sigBytes; + try { + sigBytes = ed25519_dsg_round3_process(peerPayload, this.dsgStateBytes!); + } catch (err) { + throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dsgState}: ${err}`); + } + this.signature = Buffer.from(sigBytes); + this.dsgStateBytes = null; + this.dsgState = DsgState.Complete; + return []; + } + + throw Error('Unexpected DSG state'); + } + + /** + * Returns the final 64-byte Ed25519 signature produced by round 3. + * Only available once the protocol reaches `Complete`. + */ + getSignature(): Buffer { + if (!this.signature) { + throw Error('DSG session has not produced a signature yet'); + } + return this.signature; + } + + /** + * Exports the current session state as a JSON string for persistence. + * Includes the opaque round state bytes plus everything needed to re-enter the + * protocol after a restart (keyshare, message, derivation path, counterpart). + */ + getSession(): string { + if (this.dsgState === DsgState.Complete) { + throw Error('DSG session is complete. Exporting the session is not allowed.'); + } + if (this.dsgState === DsgState.Uninitialized) { + throw Error('DSG session not initialized'); + } + return JSON.stringify({ + dsgStateBytes: this.dsgStateBytes?.toString('base64') ?? null, + dsgRound: this.dsgState, + keyShare: this.keyShare?.toString('base64') ?? null, + message: this.message?.toString('base64') ?? null, + derivationPath: this.derivationPath, + partyIdx: this.partyIdx, + otherPartyIdx: this.otherPartyIdx, + }); + } + + /** + * Restores a previously exported session. Allows the protocol to continue from + * where it left off, as if the round state was loaded from a database. + */ + restoreSession(session: string): void { + const data = JSON.parse(session); + this.dsgStateBytes = data.dsgStateBytes ? Buffer.from(data.dsgStateBytes, 'base64') : null; + this.dsgState = data.dsgRound; + this.keyShare = data.keyShare ? Buffer.from(data.keyShare, 'base64') : null; + this.message = data.message ? Buffer.from(data.message, 'base64') : null; + this.derivationPath = data.derivationPath ?? null; + this.partyIdx = data.partyIdx; + this.otherPartyIdx = data.otherPartyIdx ?? null; + } +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts index eadfa9e80f..cc355458a4 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts @@ -1,4 +1,5 @@ export * as EddsaMPSDkg from './dkg'; +export * as EddsaMPSDsg from './dsg'; export * as MPSUtil from './util'; export * as MPSTypes from './types'; export * as MPSComms from './commsLayer'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts index 3392793f76..f026f322f1 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts @@ -26,6 +26,24 @@ export enum DkgState { Complete = 'Complete', } +/** + * Represents the state of a DSG (Distributed Sign Generation) session. + */ +export enum DsgState { + /** DSG session has not been initialized */ + Uninitialized = 'Uninitialized', + /** initDsg() has been called; ready for getFirstMessage() */ + Init = 'Init', + /** R0 broadcast emitted; waiting for counterpart's R0 broadcast (SignMsg1) */ + WaitMsg1 = 'WaitMsg1', + /** R1 broadcast emitted; waiting for counterpart's R1 broadcast (SignMsg2) */ + WaitMsg2 = 'WaitMsg2', + /** R2 broadcast emitted; waiting for counterpart's R2 broadcast (SignMsg3, the partial sig) */ + WaitMsg3 = 'WaitMsg3', + /** Final 64-byte Ed25519 signature is available via getSignature() */ + Complete = 'Complete', +} + export interface Message { payload: T; from: number; diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts new file mode 100644 index 0000000000..4ce0321849 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts @@ -0,0 +1,251 @@ +import assert from 'assert'; +import { ed25519 } from '@noble/curves/ed25519'; +import { EddsaMPSDkg, EddsaMPSDsg, MPSTypes } from '../../../../src/tss/eddsa-mps'; +import { generateEdDsaDKGKeyShares, runEdDsaDSG } from './util'; + +const MESSAGE = Buffer.from('The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'); + +describe('EdDSA MPS DSG', function () { + // DKG is expensive; generate keyshares once and reuse across tests. + let userDkg: EddsaMPSDkg.DKG; + let backupDkg: EddsaMPSDkg.DKG; + let bitgoDkg: EddsaMPSDkg.DKG; + + let userKeyShare: Buffer; + let backupKeyShare: Buffer; + let bitgoKeyShare: Buffer; + let dkgPubKey: Buffer; + + before(async function () { + [userDkg, backupDkg, bitgoDkg] = await generateEdDsaDKGKeyShares(); + userKeyShare = userDkg.getKeyShare(); + backupKeyShare = backupDkg.getKeyShare(); + bitgoKeyShare = bitgoDkg.getKeyShare(); + dkgPubKey = userDkg.getSharePublicKey(); + }); + + describe('DSG Initialization', function () { + it('should accept valid inputs and produce a first message', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + + const msg = dsg.getFirstMessage(); + assert.strictEqual(msg.from, 0, 'First message should be from party 0'); + assert(msg.payload.length > 0, 'First message should have non-empty payload'); + }); + + it('should throw when getFirstMessage is called before initDsg', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.getFirstMessage(), /DSG session not initialized/); + }); + + it('should throw when handleIncomingMessages is called before initDsg', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.handleIncomingMessages([]), /DSG session not initialized/); + }); + + it('should throw when getSignature is called before completion', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + assert.throws(() => dsg.getSignature(), /has not produced a signature yet/); + }); + + it('should throw on empty keyShare', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.initDsg(Buffer.alloc(0), MESSAGE, 'm', 2), /Missing or invalid keyShare/); + }); + + it('should throw on empty message', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.initDsg(userKeyShare, Buffer.alloc(0), 'm', 2), /Missing or invalid message/); + }); + + it('should throw when otherPartyIdx equals own partyIdx', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.initDsg(userKeyShare, MESSAGE, 'm', 0), /Invalid otherPartyIdx/); + }); + + it('should throw when otherPartyIdx is out of range', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.initDsg(userKeyShare, MESSAGE, 'm', 5), /Invalid otherPartyIdx/); + }); + + it('should throw when partyIdx is out of range', function () { + const dsg = new EddsaMPSDsg.DSG(7); + assert.throws(() => dsg.initDsg(userKeyShare, MESSAGE, 'm', 0), /Invalid partyIdx/); + }); + + it('should throw when handleIncomingMessages is called before getFirstMessage', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + assert.throws(() => dsg.handleIncomingMessages([]), /must call getFirstMessage/); + }); + }); + + describe('DSG Protocol Execution (2-of-3)', function () { + it('should complete full DSG between user (0) and bitgo (2) and produce identical signatures', function () { + const { dsgA, dsgB } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE); + + assert.strictEqual(dsgA.getState(), 'Complete'); + assert.strictEqual(dsgB.getState(), 'Complete'); + + const sigA = dsgA.getSignature(); + const sigB = dsgB.getSignature(); + + assert.strictEqual(sigA.length, 64, 'Signature must be 64 bytes'); + assert.strictEqual(sigA.toString('hex'), sigB.toString('hex'), 'Both parties must produce identical signatures'); + }); + + it('should produce a signature that verifies under the DKG public key', function () { + const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE); + const sig = dsgA.getSignature(); + + const isValid = ed25519.verify(sig, MESSAGE, dkgPubKey); + assert(isValid, 'Signature should verify under DKG public key'); + }); + + it('should sign the same message identically across all 2-of-3 party combinations', function () { + const userBackupSig = runEdDsaDSG(userKeyShare, backupKeyShare, 0, 1, MESSAGE).dsgA.getSignature(); + const backupBitgoSig = runEdDsaDSG(backupKeyShare, bitgoKeyShare, 1, 2, MESSAGE).dsgA.getSignature(); + const userBitgoSig = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE).dsgA.getSignature(); + + // Per-session nonce randomisation means signatures across DIFFERENT signing + // sessions WILL differ. The invariant we test is that every 2-of-3 subset + // produces a signature that verifies under the SAME DKG public key. + assert(ed25519.verify(userBackupSig, MESSAGE, dkgPubKey), 'user+backup signature should verify'); + assert(ed25519.verify(backupBitgoSig, MESSAGE, dkgPubKey), 'backup+bitgo signature should verify'); + assert(ed25519.verify(userBitgoSig, MESSAGE, dkgPubKey), 'user+bitgo signature should verify'); + }); + + it('should sign arbitrary message lengths', function () { + const shortMsg = Buffer.from([0x01]); + const longMsg = Buffer.alloc(4096, 0xab); + + const { dsgA: short } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, shortMsg); + const { dsgA: long } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, longMsg); + + assert(ed25519.verify(short.getSignature(), shortMsg, dkgPubKey), '1-byte message signature should verify'); + assert(ed25519.verify(long.getSignature(), longMsg, dkgPubKey), '4096-byte message signature should verify'); + }); + + it('should throw when handleIncomingMessages is called after completion', function () { + const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE); + assert.throws(() => dsgA.handleIncomingMessages([]), /already completed/); + }); + }); + + describe('Derivation Paths', function () { + it('should produce different signatures for different derivation paths', function () { + const { dsgA: rootSig } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm'); + const { dsgA: derivedSig } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm/0/1'); + + assert.notStrictEqual( + rootSig.getSignature().toString('hex'), + derivedSig.getSignature().toString('hex'), + 'Different derivation paths should produce different signatures' + ); + }); + }); + + describe('Error Handling', function () { + it('should throw when handleIncomingMessages receives the wrong number of messages', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + const own = dsg.getFirstMessage(); + + assert.throws(() => dsg.handleIncomingMessages([own]), /Expected 2 messages/); + assert.throws(() => dsg.handleIncomingMessages([own, own, own]), /Expected 2 messages/); + }); + + it('should throw when counterpart message comes from an unexpected party', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + + const own = dsg.getFirstMessage(); + // Forge a "counterpart" message from party 1 instead of expected party 2 + const wrongPeer = { from: 1, payload: own.payload }; + + assert.throws(() => dsg.handleIncomingMessages([own, wrongPeer]), /Unexpected counterpart party index/); + }); + + it('should throw when both messages claim to come from this party', function () { + const dsg = new EddsaMPSDsg.DSG(0); + dsg.initDsg(userKeyShare, MESSAGE, 'm', 2); + const own = dsg.getFirstMessage(); + + assert.throws(() => dsg.handleIncomingMessages([own, own]), /Expected exactly 1 counterpart message/); + }); + }); + + describe('Message Serialization', function () { + it('should serialize and deserialize DSG messages round-trip', function () { + const dsgA = new EddsaMPSDsg.DSG(0); + const dsgB = new EddsaMPSDsg.DSG(2); + dsgA.initDsg(userKeyShare, MESSAGE, 'm', 2); + dsgB.initDsg(bitgoKeyShare, MESSAGE, 'm', 0); + + const a0 = dsgA.getFirstMessage(); + const b0 = dsgB.getFirstMessage(); + + const serialized = MPSTypes.serializeMessages([a0, b0]); + assert( + serialized.every((m) => typeof m.payload === 'string'), + 'Serialized payloads should be strings' + ); + + const deserialized = MPSTypes.deserializeMessages(serialized); + assert.strictEqual(deserialized.length, 2); + deserialized.forEach((msg, i) => { + const original = i === 0 ? a0 : b0; + assert.strictEqual(msg.from, original.from); + assert.deepStrictEqual(Buffer.from(msg.payload), Buffer.from(original.payload)); + }); + }); + }); + + describe('Session Management', function () { + it('should export and restore DSG session and continue protocol to a valid signature', function () { + const dsgA = new EddsaMPSDsg.DSG(0); + const dsgB = new EddsaMPSDsg.DSG(2); + dsgA.initDsg(userKeyShare, MESSAGE, 'm', 2); + dsgB.initDsg(bitgoKeyShare, MESSAGE, 'm', 0); + + const a0 = dsgA.getFirstMessage(); + const b0 = dsgB.getFirstMessage(); + + const sessionA = dsgA.getSession(); + assert(typeof sessionA === 'string' && sessionA.length > 0); + + // Restore A in a fresh instance and finish the protocol from there. + const restoredA = new EddsaMPSDsg.DSG(0); + restoredA.restoreSession(sessionA); + assert.strictEqual(restoredA.getState(), dsgA.getState(), 'Restored state should match original'); + + const [a1] = restoredA.handleIncomingMessages([a0, b0]); + const [b1] = dsgB.handleIncomingMessages([a0, b0]); + + const [a2] = restoredA.handleIncomingMessages([a1, b1]); + const [b2] = dsgB.handleIncomingMessages([a1, b1]); + + restoredA.handleIncomingMessages([a2, b2]); + dsgB.handleIncomingMessages([a2, b2]); + + const sigA = restoredA.getSignature(); + const sigB = dsgB.getSignature(); + + assert.strictEqual(sigA.toString('hex'), sigB.toString('hex'), 'Restored signer must agree with counterpart'); + assert(ed25519.verify(sigA, MESSAGE, dkgPubKey), 'Restored-session signature should verify under DKG pubkey'); + }); + + it('should throw when exporting session after completion', function () { + const { dsgA, dsgB } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE); + assert.throws(() => dsgA.getSession(), /DSG session is complete\. Exporting the session is not allowed\./); + assert.throws(() => dsgB.getSession(), /DSG session is complete\. Exporting the session is not allowed\./); + }); + + it('should throw when exporting session before initialization', function () { + const dsg = new EddsaMPSDsg.DSG(0); + assert.throws(() => dsg.getSession(), /DSG session not initialized/); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts index 237f2b12a3..4e00505197 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import { x25519 } from '@noble/curves/ed25519'; -import { EddsaMPSDkg } from '../../../../src/tss/eddsa-mps'; +import { EddsaMPSDkg, EddsaMPSDsg } from '../../../../src/tss/eddsa-mps'; +import { DeserializedMessage } from '../../../../src/tss/eddsa-mps/types'; /** * Generates an X25519 keypair. If a seed is provided (32 bytes), it is used as the @@ -54,3 +55,44 @@ export async function generateEdDsaDKGKeyShares( return [user, backup, bitgo]; } + +/** + * Runs a full 2-of-3 EdDSA DSG protocol between two parties holding `keyShareA` + * and `keyShareB`, signing `message` under `derivationPath`. + * + * Returns both parties' resulting `DSG` instances so callers can compare signatures + * (`dsgA.getSignature()` and `dsgB.getSignature()` should be byte-identical) or + * verify against a public key. + */ +export function runEdDsaDSG( + keyShareA: Buffer, + keyShareB: Buffer, + partyAIdx: number, + partyBIdx: number, + message: Buffer, + derivationPath = 'm' +): { dsgA: EddsaMPSDsg.DSG; dsgB: EddsaMPSDsg.DSG } { + const dsgA = new EddsaMPSDsg.DSG(partyAIdx); + const dsgB = new EddsaMPSDsg.DSG(partyBIdx); + + dsgA.initDsg(keyShareA, message, derivationPath, partyBIdx); + dsgB.initDsg(keyShareB, message, derivationPath, partyAIdx); + + // Round 0 -> SignMsg1 + const a0: DeserializedMessage = dsgA.getFirstMessage(); + const b0: DeserializedMessage = dsgB.getFirstMessage(); + + // Round 1 -> SignMsg2 + const [a1] = dsgA.handleIncomingMessages([a0, b0]); + const [b1] = dsgB.handleIncomingMessages([a0, b0]); + + // Round 2 -> SignMsg3 (partial sig) + const [a2] = dsgA.handleIncomingMessages([a1, b1]); + const [b2] = dsgB.handleIncomingMessages([a1, b1]); + + // Round 3 -> Complete (no output messages) + dsgA.handleIncomingMessages([a2, b2]); + dsgB.handleIncomingMessages([a2, b2]); + + return { dsgA, dsgB }; +}