From 02a232a8431ea8e633bc62190ffa5fa9b192d528 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Wed, 13 May 2026 14:47:51 +0530 Subject: [PATCH] feat(sdk-lib-mpc): add executeTillRound handler util Adds an executeTillRound orchestration utility for the EdDSA MPSv2 DSG protocol, mirroring DklsUtils.executeTillRound for ECDSA DKLS. Also exposes getPartyIdx and getOtherPartyIdx accessors on the DSG class to support index-free wiring inside the utility. - Add executeTillRound to modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts; accepts two un-initialized DSG instances plus key shares, message, and derivation path, calls initDsg internally using getPartyIdx(), and drives all 3 interactive rounds (WaitMsg1 -> WaitMsg2 -> WaitMsg3 -> Complete) - Return intermediate DeserializedMessages[][] for rounds 1-2 and the final 64-byte Ed25519 signature Buffer for round 3 - Add getPartyIdx() and getOtherPartyIdx() accessors to DSG class - Add unit tests in modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts for executeTillRound: per-pair signatures across all three 2-of-3 party combinations (0+1, 0+2, 1+2), root-key verification via getCommonKeychain(), derived-path (m/0/0) key isolation check, intermediate round return-type assertions, and out-of-range round error handling - Migrate existing dsg.ts callers from the local runEdDsaDSG test helper to MPSUtil.executeTillRound and remove runEdDsaDSG from test/unit/tss/eddsa/util.ts - Add dsg.ts protocol test asserting wasm-mps rejects cross-message signing with the wrapped "round WaitMsg2: Protocol Error" failure Ticket: WCI-386 --- modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts | 8 ++ modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts | 46 +++++++ .../sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts | 124 +++++++++++++++--- .../test/unit/tss/eddsa/eddsa-utils.ts | 113 ++++++++++++++++ .../sdk-lib-mpc/test/unit/tss/eddsa/util.ts | 44 ------- 5 files changed, 272 insertions(+), 63 deletions(-) diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts index 717a8b3386..a59ba857b1 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dsg.ts @@ -56,6 +56,14 @@ export class DSG { return this.dsgState; } + getPartyIdx(): number { + return this.partyIdx; + } + + getOtherPartyIdx(): number | null { + return this.otherPartyIdx; + } + /** * 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. diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts index f97d06a152..d19602424e 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -2,6 +2,8 @@ import crypto from 'crypto'; import assert from 'assert'; import { x25519 } from '@noble/curves/ed25519'; import { DKG } from './dkg'; +import { DSG } from './dsg'; +import { DeserializedMessages } from './types'; /** * Concatenates multiple Uint8Array instances into a single Uint8Array @@ -74,3 +76,47 @@ export async function generateEdDsaDKGKeyShares( return [user, backup, bitgo]; } + +/** + * Initializes two DSG parties and drives them through the protocol until the specified round. + * + * @param round - Round to execute until (1–3). Returns intermediate message arrays for 1–2, + * or the 64-byte Ed25519 signature Buffer for 3. + * @param party1Dsg - First DSG party (`new DSG(partyIdx)`), not yet initialized. + * @param party2Dsg - Second DSG party (`new DSG(partyIdx)`), not yet initialized. + * @param keyShare1 - Key share for the first party. + * @param keyShare2 - Key share for the second party. + * @param message - Raw message bytes to sign. + * @param derivationPath - BIP-32-style derivation path, e.g. `"m"` or `"m/0/0"`. + */ +export function executeTillRound( + round: number, + party1Dsg: DSG, + party2Dsg: DSG, + keyShare1: Buffer, + keyShare2: Buffer, + message: Buffer, + derivationPath: string +): DeserializedMessages[] | Buffer { + if (round < 1 || round > 3) { + throw Error('Invalid round number'); + } + party1Dsg.initDsg(keyShare1, message, derivationPath, party2Dsg.getPartyIdx()); + party2Dsg.initDsg(keyShare2, message, derivationPath, party1Dsg.getPartyIdx()); + const party1Round0Message = party1Dsg.getFirstMessage(); + const party2Round0Message = party2Dsg.getFirstMessage(); + + const [party2Round1Messages] = party2Dsg.handleIncomingMessages([party1Round0Message, party2Round0Message]); + const [party1Round1Messages] = party1Dsg.handleIncomingMessages([party1Round0Message, party2Round0Message]); + if (round === 1) return [[party1Round1Messages], [party2Round1Messages]]; + + const [party1Round2Messages] = party1Dsg.handleIncomingMessages([party1Round1Messages, party2Round1Messages]); + const [party2Round2Messages] = party2Dsg.handleIncomingMessages([party1Round1Messages, party2Round1Messages]); + if (round === 2) return [[party1Round2Messages], [party2Round2Messages]]; + + party1Dsg.handleIncomingMessages([party1Round2Messages, party2Round2Messages]); + party2Dsg.handleIncomingMessages([party1Round2Messages, party2Round2Messages]); + + assert(party1Dsg.getSignature().toString('hex') === party2Dsg.getSignature().toString('hex')); + return party1Dsg.getSignature(); +} diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts index 49fbe64e6f..edf89c6a95 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dsg.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { ed25519 } from '@noble/curves/ed25519'; -import { EddsaMPSDkg, EddsaMPSDsg, MPSTypes } from '../../../../src/tss/eddsa-mps'; -import { generateEdDsaDKGKeyShares, runEdDsaDSG } from './util'; +import { EddsaMPSDkg, EddsaMPSDsg, MPSTypes, MPSUtil } from '../../../../src/tss/eddsa-mps'; +import { generateEdDsaDKGKeyShares } from './util'; const MESSAGE = Buffer.from('The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'); @@ -84,7 +84,9 @@ describe('EdDSA MPS DSG', function () { 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); + const dsgA = new EddsaMPSDsg.DSG(0); + const dsgB = new EddsaMPSDsg.DSG(2); + MPSUtil.executeTillRound(3, dsgA, dsgB, userKeyShare, bitgoKeyShare, MESSAGE, 'm'); assert.strictEqual(dsgA.getState(), 'Complete'); assert.strictEqual(dsgB.getState(), 'Complete'); @@ -97,17 +99,48 @@ describe('EdDSA MPS DSG', function () { }); 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 sig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + MESSAGE, + 'm' + ) as Buffer; 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(); + const userBackupSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(1), + userKeyShare, + backupKeyShare, + MESSAGE, + 'm' + ) as Buffer; + const backupBitgoSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(1), + new EddsaMPSDsg.DSG(2), + backupKeyShare, + bitgoKeyShare, + MESSAGE, + 'm' + ) as Buffer; + const userBitgoSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + MESSAGE, + 'm' + ) as Buffer; // Per-session nonce randomisation means signatures across DIFFERENT signing // sessions WILL differ. The invariant we test is that every 2-of-3 subset @@ -121,27 +154,78 @@ describe('EdDSA MPS DSG', 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'); + const shortSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + shortMsg, + 'm' + ) as Buffer; + const longSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + longMsg, + 'm' + ) as Buffer; + + assert(ed25519.verify(shortSig, shortMsg, dkgPubKey), '1-byte message signature should verify'); + assert(ed25519.verify(longSig, 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); + const dsgA = new EddsaMPSDsg.DSG(0); + MPSUtil.executeTillRound(3, dsgA, new EddsaMPSDsg.DSG(2), userKeyShare, bitgoKeyShare, MESSAGE, 'm'); assert.throws(() => dsgA.handleIncomingMessages([]), /already completed/); }); + + it('should fail when parties sign different messages', function () { + const dsg1 = new EddsaMPSDsg.DSG(0); + const dsg2 = new EddsaMPSDsg.DSG(2); + dsg1.initDsg(userKeyShare, Buffer.from('MESSAGE'), 'm', 2); + dsg2.initDsg(bitgoKeyShare, Buffer.from('DIFFERENT_MESSAGE'), 'm', 0); + + const r0_1 = dsg1.getFirstMessage(); + const r0_2 = dsg2.getFirstMessage(); + + const [r1_1] = dsg1.handleIncomingMessages([r0_1, r0_2]); + const [r1_2] = dsg2.handleIncomingMessages([r0_1, r0_2]); + + assert.throws( + () => dsg1.handleIncomingMessages([r1_1, r1_2]), + /Error while creating messages from party 0, round WaitMsg2: Protocol Error/ + ); + }); }); 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'); + const rootSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + MESSAGE, + 'm' + ) as Buffer; + const derivedSig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + userKeyShare, + bitgoKeyShare, + MESSAGE, + 'm/0/1' + ) as Buffer; assert.notStrictEqual( - rootSig.getSignature().toString('hex'), - derivedSig.getSignature().toString('hex'), + rootSig.toString('hex'), + derivedSig.toString('hex'), 'Different derivation paths should produce different signatures' ); }); @@ -238,7 +322,9 @@ describe('EdDSA MPS DSG', function () { }); it('should throw when exporting session after completion', function () { - const { dsgA, dsgB } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE); + const dsgA = new EddsaMPSDsg.DSG(0); + const dsgB = new EddsaMPSDsg.DSG(2); + MPSUtil.executeTillRound(3, dsgA, dsgB, userKeyShare, bitgoKeyShare, MESSAGE, 'm'); 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\./); }); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts index 32689be628..1102468c8f 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts @@ -1,4 +1,6 @@ import assert from 'assert'; +import { ed25519 } from '@noble/curves/ed25519'; +import { EddsaMPSDkg, EddsaMPSDsg, MPSUtil } from '../../../../src/tss/eddsa-mps'; import { concatBytes, generateEdDsaDKGKeyShares } from '../../../../src/tss/eddsa-mps/util'; describe('EdDSA Utility Functions', function () { @@ -51,4 +53,115 @@ describe('EdDSA Utility Functions', function () { ); }); }); + + describe('executeTillRound', function () { + const MESSAGE = Buffer.from('The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'); + + let userDkg: EddsaMPSDkg.DKG; + let keySharesByIdx: [Buffer, Buffer, Buffer]; + let dkgPubKey: Buffer; + + before(async function () { + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(); + userDkg = user; + keySharesByIdx = [user.getKeyShare(), backup.getKeyShare(), bitgo.getKeyShare()]; + dkgPubKey = user.getSharePublicKey(); + }); + + // All three 2-of-3 signing combinations: user+backup, user+BitGo, backup+BitGo. + const PARTY_PAIRS: Array<[number, number]> = [ + [0, 1], + [0, 2], + [1, 2], + ]; + + PARTY_PAIRS.forEach(([p1, p2]) => { + it(`should produce a valid signature verifying under the DKG public key for parties ${p1}+${p2}`, function () { + const sig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(p1), + new EddsaMPSDsg.DSG(p2), + keySharesByIdx[p1], + keySharesByIdx[p2], + MESSAGE, + 'm' + ) as Buffer; + assert.strictEqual(sig.length, 64); + assert(ed25519.verify(sig, MESSAGE, dkgPubKey)); + }); + }); + + it('should verify round-3 signature against root public key from getCommonKeychain()', function () { + const sig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + keySharesByIdx[0], + keySharesByIdx[2], + MESSAGE, + 'm' + ) as Buffer; + const rootPubKey = Buffer.from(userDkg.getCommonKeychain().slice(0, 64), 'hex'); + assert(ed25519.verify(sig, MESSAGE, rootPubKey), 'should verify under root public key from getCommonKeychain()'); + }); + + it('should not verify under the root public key when signing at a derived path (m/0/0)', function () { + const sig = MPSUtil.executeTillRound( + 3, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + keySharesByIdx[0], + keySharesByIdx[2], + MESSAGE, + 'm/0/0' + ) as Buffer; + assert.strictEqual(sig.length, 64, 'Derived path signature must be 64 bytes'); + const rootPubKey = Buffer.from(userDkg.getCommonKeychain().slice(0, 64), 'hex'); + assert( + !ed25519.verify(sig, MESSAGE, rootPubKey), + 'derived-path signature should not verify under root public key' + ); + }); + + it('should return message arrays (not a Buffer) for intermediate round 1', function () { + const result = MPSUtil.executeTillRound( + 1, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + keySharesByIdx[0], + keySharesByIdx[2], + MESSAGE, + 'm' + ); + assert(!Buffer.isBuffer(result), 'round 1 should return message arrays, not a Buffer'); + assert.strictEqual(result.length, 2, 'should contain message arrays for both parties'); + }); + + it('should return message arrays (not a Buffer) for intermediate round 2', function () { + const result = MPSUtil.executeTillRound( + 2, + new EddsaMPSDsg.DSG(0), + new EddsaMPSDsg.DSG(2), + keySharesByIdx[0], + keySharesByIdx[2], + MESSAGE, + 'm' + ); + assert(!Buffer.isBuffer(result), 'round 2 should return message arrays, not a Buffer'); + assert.strictEqual(result.length, 2, 'should contain message arrays for both parties'); + }); + + it('should throw for round out of range', function () { + const dsg1 = new EddsaMPSDsg.DSG(0); + const dsg2 = new EddsaMPSDsg.DSG(2); + assert.throws( + () => MPSUtil.executeTillRound(0, dsg1, dsg2, keySharesByIdx[0], keySharesByIdx[2], MESSAGE, 'm'), + /Invalid round number/ + ); + assert.throws( + () => MPSUtil.executeTillRound(4, dsg1, dsg2, keySharesByIdx[0], keySharesByIdx[2], MESSAGE, 'm'), + /Invalid round number/ + ); + }); + }); }); 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 0005208e85..211ab0dfce 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts @@ -1,47 +1,3 @@ -import { EddsaMPSDsg } from '../../../../src/tss/eddsa-mps'; -import { DeserializedMessage } from '../../../../src/tss/eddsa-mps/types'; - // Re-export the production helper so existing tests can resolve via './util' // without a separate, drifting copy. export { generateEdDsaDKGKeyShares } from '../../../../src/tss/eddsa-mps/util'; - -/** - * 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 }; -}