From bf6ac4566fc9a2c9a001aa8650e9201b2ec76778 Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Wed, 18 Mar 2026 22:25:30 +0530 Subject: [PATCH] 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 --- modules/sdk-lib-mpc/package.json | 1 + modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts | 216 ++++++++++++++ .../sdk-lib-mpc/src/tss/eddsa-mps/index.ts | 3 + .../sdk-lib-mpc/src/tss/eddsa-mps/types.ts | 64 ++++ modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts | 9 + modules/sdk-lib-mpc/src/tss/index.ts | 1 + .../sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts | 278 ++++++++++++++++++ .../test/unit/tss/eddsa/eddsa-utils.ts | 17 ++ .../sdk-lib-mpc/test/unit/tss/eddsa/util.ts | 56 ++++ webpack/bitgojs.config.js | 1 + yarn.lock | 54 +++- 11 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts diff --git a/modules/sdk-lib-mpc/package.json b/modules/sdk-lib-mpc/package.json index 6e87cacde1..aee4977910 100644 --- a/modules/sdk-lib-mpc/package.json +++ b/modules/sdk-lib-mpc/package.json @@ -36,6 +36,7 @@ ] }, "dependencies": { + "@bitgo/wasm-mps": "1.6.0", "@noble/curves": "1.8.1", "@silencelaboratories/dkls-wasm-ll-node": "1.2.0-pre.4", "@silencelaboratories/dkls-wasm-ll-web": "1.2.0-pre.4", diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts new file mode 100644 index 0000000000..7477884df4 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/dkg.ts @@ -0,0 +1,216 @@ +import { ed25519_dkg_round0_process, ed25519_dkg_round1_process, ed25519_dkg_round2_process } from '@bitgo/wasm-mps'; +import { encode } from 'cbor-x'; +import crypto from 'crypto'; +import { DeserializedMessage, DeserializedMessages, DkgState, EddsaReducedKeyShare } from './types'; + +/** + * EdDSA Distributed Key Generation (DKG) implementation using @bitgo/wasm-mps. + * + * State is explicit: each round function returns `{ msg, state }` bytes. + * The state bytes are stored between rounds and passed to the next round function, + * mirroring the server-side persistence pattern (state would be serialised to DB). + * + * @example + * ```typescript + * const dkg = new DKG(3, 2, 0); + * // X25519 keys come from GPG encryption subkeys (extracted by the orchestrator) + * dkg.initDkg(myX25519PrivKey, [otherParty1X25519PubKey, otherParty2X25519PubKey]); + * const msg1 = dkg.getFirstMessage(); + * const msg2s = dkg.handleIncomingMessages(allThreeMsg1s); + * dkg.handleIncomingMessages(allThreeMsg2s); // completes DKG + * const keyShare = dkg.getKeyShare(); + * ``` + */ +export class DKG { + protected n: number; + protected t: number; + protected partyIdx: number; + + /** Private X25519 key (from GPG encryption subkey) */ + private decryptionKey: Buffer | null = null; + /** Other parties' X25519 public keys (from their GPG encryption subkeys), sorted by party index */ + private otherPubKeys: Buffer[] | null = null; + /** Serialised round state bytes returned by the previous round function */ + private dkgStateBytes: Buffer | null = null; + /** Opaque bincode-serialised keyshare from round2 */ + private keyShare: Buffer | null = null; + /** 32-byte Ed25519 public key from round2 */ + private sharePk: Buffer | null = null; + + protected dkgState: DkgState = DkgState.Uninitialized; + + constructor(n: number, t: number, partyIdx: number) { + this.n = n; + this.t = t; + this.partyIdx = partyIdx; + } + + getState(): DkgState { + return this.dkgState; + } + + /** + * Initialises the DKG session with this party's X25519 private key and the other parties' + * X25519 public keys. Keys are extracted from GPG encryption subkeys by the orchestrator. + * + * @param decryptionKey - This party's 32-byte X25519 private key (GPG enc subkey private part). + * @param otherEncPublicKeys - Other parties' 32-byte X25519 public keys, sorted by ascending + * party index (excluding own). For a 3-party setup, this is [party_A_pub, party_B_pub]. + */ + initDkg(decryptionKey: Buffer, otherEncPublicKeys: Buffer[]): void { + if (!decryptionKey || decryptionKey.length !== 32) { + throw Error('Missing or invalid decryption key: must be 32 bytes'); + } + if (!otherEncPublicKeys || otherEncPublicKeys.length !== this.n - 1) { + throw Error(`Expected ${this.n - 1} other parties' public keys`); + } + if (this.t > this.n || this.partyIdx >= this.n) { + throw Error('Invalid parameters for DKG'); + } + + this.decryptionKey = decryptionKey; + this.otherPubKeys = otherEncPublicKeys; + this.dkgState = DkgState.Init; + } + + /** + * Runs round0 of the DKG protocol. Returns this party's broadcast message. + * Stores the round state bytes internally for the next round. + * + * @param dkgSeed - Optional 32-byte seed for deterministic DKG output (testing only). + */ + getFirstMessage(dkgSeed?: Buffer): DeserializedMessage { + if (this.dkgState !== DkgState.Init) { + throw Error('DKG session not initialized'); + } + + const seed = dkgSeed ?? crypto.randomBytes(32); + const result = ed25519_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed); + + this.dkgStateBytes = Buffer.from(result.state); + this.dkgState = DkgState.WaitMsg1; + return { payload: new Uint8Array(result.msg), from: this.partyIdx }; + } + + /** + * Handles incoming messages from all parties and advances the protocol. + * + * - In WaitMsg1: runs round1, returns this party's round1 broadcast message. + * - In WaitMsg2: runs round2, completes DKG, returns []. + * + * The caller passes all n messages (including own); own message is filtered + * out internally. Other parties' messages are sorted by ascending party index, + * matching the ordering expected by @bitgo/wasm-mps. + * + * @param messagesForIthRound - All n messages for this round (including own). + */ + handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages { + if (this.dkgState === DkgState.Complete) { + throw Error('DKG session already completed'); + } + if (this.dkgState === DkgState.Uninitialized) { + throw Error('DKG session not initialized'); + } + if (this.dkgState === DkgState.Init) { + throw Error( + 'DKG session must call getFirstMessage() before handling incoming messages. Call getFirstMessage() first.' + ); + } + if (messagesForIthRound.length !== this.n) { + throw Error('Invalid number of messages for the round. Number of messages should be equal to N'); + } + + // Extract other parties' messages, sorted by party index (ascending) + const otherMsgs = messagesForIthRound + .filter((m) => m.from !== this.partyIdx) + .sort((a, b) => a.from - b.from) + .map((m) => m.payload); + + if (this.dkgState === DkgState.WaitMsg1) { + const result = ed25519_dkg_round1_process(otherMsgs, this.dkgStateBytes!); + // Store new state; this is what would be persisted to DB between API rounds + this.dkgStateBytes = Buffer.from(result.state); + this.dkgState = DkgState.WaitMsg2; + return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }]; + } + + if (this.dkgState === DkgState.WaitMsg2) { + const share = ed25519_dkg_round2_process(otherMsgs, this.dkgStateBytes!); + this.keyShare = Buffer.from(share.share); + this.sharePk = Buffer.from(share.pk); + this.dkgStateBytes = null; + this.dkgState = DkgState.Complete; + return []; + } + + throw Error('Unexpected DKG state'); + } + + /** + * Returns the opaque bincode-serialised keyshare produced by round2. + * This is used as input to the signing protocol. + */ + getKeyShare(): Buffer { + if (!this.keyShare) { + throw Error('DKG session not initialized'); + } + return this.keyShare; + } + + /** + * Returns the 32-byte Ed25519 public key agreed by all parties during DKG. + */ + getSharePublicKey(): Buffer { + if (!this.sharePk) { + throw Error('DKG session not initialized'); + } + return this.sharePk; + } + + /** + * Returns a CBOR-encoded reduced representation containing the public key. + * Note: private key material and chain code are not separately accessible + * from @bitgo/wasm-mps; the full keyshare is available via getKeyShare(). + */ + getReducedKeyShare(): Buffer { + if (!this.sharePk) { + throw Error('DKG session not initialized'); + } + const reducedKeyShare: EddsaReducedKeyShare = { + pub: Array.from(this.sharePk), + }; + return Buffer.from(encode(reducedKeyShare)); + } + + /** + * Exports the current session state as a JSON string for persistence. + * Includes: round state bytes, current DKG round, decryption key, other parties' pub keys. + * This mirrors what a server would store in a database between API rounds. + */ + getSession(): string { + if (this.dkgState === DkgState.Complete) { + throw Error('DKG session is complete. Exporting the session is not allowed.'); + } + if (this.dkgState === DkgState.Uninitialized) { + throw Error('DKG session not initialized'); + } + return JSON.stringify({ + dkgStateBytes: this.dkgStateBytes?.toString('base64') ?? null, + dkgRound: this.dkgState, + decryptionKey: this.decryptionKey?.toString('base64') ?? null, + otherPubKeys: this.otherPubKeys?.map((k) => k.toString('base64')) ?? null, + }); + } + + /** + * 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.dkgStateBytes = data.dkgStateBytes ? Buffer.from(data.dkgStateBytes, 'base64') : null; + this.dkgState = data.dkgRound; + this.decryptionKey = data.decryptionKey ? Buffer.from(data.decryptionKey, 'base64') : null; + this.otherPubKeys = data.otherPubKeys ? (data.otherPubKeys as string[]).map((k) => Buffer.from(k, 'base64')) : 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 new file mode 100644 index 0000000000..c3f103fc2d --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts @@ -0,0 +1,3 @@ +export * as EddsaMPSDkg from './dkg'; +export * as MPSUtil from './util'; +export * as MPSTypes from './types'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts new file mode 100644 index 0000000000..e90b55503b --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/types.ts @@ -0,0 +1,64 @@ +import { decode } from 'cbor-x'; +import { isLeft } from 'fp-ts/Either'; +import * as t from 'io-ts'; + +export const ReducedKeyShareType = t.type({ + pub: t.array(t.number), +}); + +export type EddsaReducedKeyShare = t.TypeOf; + +/** + * Represents the state of a DKG (Distributed Key Generation) session + */ +export enum DkgState { + /** DKG session has not been initialized */ + Uninitialized = 'Uninitialized', + /** DKG session has been initialized (Init state in WASM) */ + Init = 'Init', + /** DKG session is waiting for first message (WaitMsg1 state in WASM) */ + WaitMsg1 = 'WaitMsg1', + /** DKG session is waiting for second message (WaitMsg2 state in WASM) */ + WaitMsg2 = 'WaitMsg2', + /** DKG session has generated key shares (Share state in WASM) */ + Share = 'Share', + /** DKG session has completed successfully and key shares are available */ + Complete = 'Complete', +} + +export interface Message { + payload: T; + from: number; +} + +export type SerializedMessage = Message; + +export type SerializedMessages = Message[]; + +export type DeserializedMessage = Message; + +export type DeserializedMessages = Message[]; + +export function serializeMessage(msg: DeserializedMessage): SerializedMessage { + return { from: msg.from, payload: Buffer.from(msg.payload).toString('base64') }; +} + +export function deserializeMessage(msg: SerializedMessage): DeserializedMessage { + return { from: msg.from, payload: new Uint8Array(Buffer.from(msg.payload, 'base64')) }; +} + +export function serializeMessages(msgs: DeserializedMessages): SerializedMessages { + return msgs.map(serializeMessage); +} + +export function deserializeMessages(msgs: SerializedMessages): DeserializedMessages { + return msgs.map(deserializeMessage); +} + +export function getDecodedReducedKeyShare(reducedKeyShare: Buffer | Uint8Array): EddsaReducedKeyShare { + const decoded = ReducedKeyShareType.decode(decode(reducedKeyShare)); + if (isLeft(decoded)) { + throw new Error(`Unable to parse reducedKeyShare: ${decoded.left}`); + } + return decoded.right; +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts new file mode 100644 index 0000000000..05b68a979c --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -0,0 +1,9 @@ +/** + * Concatenates multiple Uint8Array instances into a single Uint8Array + * @param chunks - Array of Uint8Array instances to concatenate + * @returns Concatenated Uint8Array + */ +export function concatBytes(chunks: Uint8Array[]): Uint8Array { + const buffers = chunks.map((chunk) => Buffer.from(chunk)); + return new Uint8Array(Buffer.concat(buffers)); +} diff --git a/modules/sdk-lib-mpc/src/tss/index.ts b/modules/sdk-lib-mpc/src/tss/index.ts index b8d5aecfbe..504d0eb8bf 100644 --- a/modules/sdk-lib-mpc/src/tss/index.ts +++ b/modules/sdk-lib-mpc/src/tss/index.ts @@ -1,2 +1,3 @@ export * from './ecdsa'; export * from './ecdsa-dkls'; +export * from './eddsa-mps'; diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts new file mode 100644 index 0000000000..4d2787b6e0 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts @@ -0,0 +1,278 @@ +import assert from 'assert'; +import crypto from 'crypto'; +import { x25519 } from '@noble/curves/ed25519'; +import { EddsaMPSDkg, MPSTypes } from '../../../../src/tss/eddsa-mps'; +import { generateEdDsaDKGKeyShares } from './util'; + +function makeKeypair(seed?: Buffer) { + const privKey = seed ? Buffer.from(seed.subarray(0, 32)) : crypto.randomBytes(32); + const pubKey = Buffer.from(x25519.getPublicKey(privKey)); + return { privKey, pubKey }; +} + +describe('EdDSA MPS DKG', function () { + let user: EddsaMPSDkg.DKG; + let backup: EddsaMPSDkg.DKG; + let bitgo: EddsaMPSDkg.DKG; + let userKP: { privKey: Buffer; pubKey: Buffer }; + let backupKP: { privKey: Buffer; pubKey: Buffer }; + let bitgoKP: { privKey: Buffer; pubKey: Buffer }; + + beforeEach(function () { + user = new EddsaMPSDkg.DKG(3, 2, 0); + backup = new EddsaMPSDkg.DKG(3, 2, 1); + bitgo = new EddsaMPSDkg.DKG(3, 2, 2); + + userKP = makeKeypair(); + backupKP = makeKeypair(); + bitgoKP = makeKeypair(); + }); + + describe('DKG Initialization', function () { + it('should initialize DKG sessions for all parties', function () { + user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const userMessage = user.getFirstMessage(); + const backupMessage = backup.getFirstMessage(); + const bitgoMessage = bitgo.getFirstMessage(); + + assert(userMessage.payload.length > 0, 'User first message should have payload'); + assert(backupMessage.payload.length > 0, 'Backup first message should have payload'); + assert(bitgoMessage.payload.length > 0, 'BitGo first message should have payload'); + + assert.strictEqual(userMessage.from, 0, 'User message should be from party 0'); + assert.strictEqual(backupMessage.from, 1, 'Backup message should be from party 1'); + assert.strictEqual(bitgoMessage.from, 2, 'BitGo message should be from party 2'); + }); + + it('should throw error when DKG session is not initialized', function () { + assert.throws(() => { + user.getFirstMessage(); + }, /DKG session not initialized/); + + assert.throws(() => { + user.handleIncomingMessages([]); + }, /DKG session not initialized/); + + assert.throws(() => { + user.getKeyShare(); + }, /DKG session not initialized/); + }); + }); + + describe('DKG Protocol Execution', function () { + beforeEach(function () { + user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + }); + + it('should complete full DKG protocol and generate key shares', function () { + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + + assert.strictEqual(r1Messages.length, 3, 'Should have 3 round 1 messages'); + r1Messages.forEach((msg, index) => { + assert.strictEqual(msg.from, index, `Message ${index} should be from party ${index}`); + assert(msg.payload.length > 0, `Message ${index} should have payload`); + }); + + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + assert.strictEqual(r2Messages.length, 3, 'Should have 3 round 2 messages'); + r2Messages.forEach((msg) => { + assert(msg.payload.length > 0, 'Round 2 message should have payload'); + }); + + const r3Messages = [ + ...user.handleIncomingMessages(r2Messages), + ...backup.handleIncomingMessages(r2Messages), + ...bitgo.handleIncomingMessages(r2Messages), + ]; + + assert.strictEqual(r3Messages.length, 0, 'Round 3 should produce no output messages'); + + const userKeyShare = user.getKeyShare(); + const backupKeyShare = backup.getKeyShare(); + const bitgoKeyShare = bitgo.getKeyShare(); + + assert(Buffer.isBuffer(userKeyShare) && userKeyShare.length > 0, 'User key share should be non-empty Buffer'); + assert( + Buffer.isBuffer(backupKeyShare) && backupKeyShare.length > 0, + 'Backup key share should be non-empty Buffer' + ); + assert(Buffer.isBuffer(bitgoKeyShare) && bitgoKeyShare.length > 0, 'BitGo key share should be non-empty Buffer'); + }); + + it('should generate consistent public keys across all parties', function () { + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + user.handleIncomingMessages(r2Messages); + backup.handleIncomingMessages(r2Messages); + bitgo.handleIncomingMessages(r2Messages); + + const userPk = user.getSharePublicKey().toString('hex'); + const backupPk = backup.getSharePublicKey().toString('hex'); + const bitgoPk = bitgo.getSharePublicKey().toString('hex'); + + assert.strictEqual(userPk, backupPk, 'User and backup should agree on public key'); + assert.strictEqual(backupPk, bitgoPk, 'Backup and BitGo should agree on public key'); + }); + }); + + describe('Seed-based Key Generation', function () { + it('should create key shares with deterministic seeds', async function () { + const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + + const [user1, backup1, bitgo1] = await generateEdDsaDKGKeyShares(seedUser, seedBackup, seedBitgo); + + const pk0 = user1.getSharePublicKey().toString('hex'); + const pk1 = backup1.getSharePublicKey().toString('hex'); + const pk2 = bitgo1.getSharePublicKey().toString('hex'); + assert.strictEqual(pk0, pk1, 'User and backup should have same public key'); + assert.strictEqual(pk1, pk2, 'Backup and BitGo should have same public key'); + + const [user2] = await generateEdDsaDKGKeyShares(seedUser, seedBackup, seedBitgo); + assert.strictEqual( + user1.getSharePublicKey().toString('hex'), + user2.getSharePublicKey().toString('hex'), + 'Same seeds should produce same public key' + ); + }); + + it('should create different key shares with different seeds', async function () { + const [user1] = await generateEdDsaDKGKeyShares( + Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'), + Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'), + Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex') + ); + const [user2] = await generateEdDsaDKGKeyShares( + Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'), + Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'), + Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex') + ); + + assert.notStrictEqual( + user1.getSharePublicKey().toString('hex'), + user2.getSharePublicKey().toString('hex'), + 'Different seeds should produce different public keys' + ); + }); + + it('should create key shares without seeds (random)', async function () { + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(); + + const userPk = user.getSharePublicKey().toString('hex'); + const backupPk = backup.getSharePublicKey().toString('hex'); + const bitgoPk = bitgo.getSharePublicKey().toString('hex'); + + assert.strictEqual(userPk, backupPk, 'User and backup should agree on public key'); + assert.strictEqual(backupPk, bitgoPk, 'Backup and BitGo should agree on public key'); + }); + + it('should generate valid reduced key shares', async function () { + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(); + + const userReduced = user.getReducedKeyShare(); + const backupReduced = backup.getReducedKeyShare(); + const bitgoReduced = bitgo.getReducedKeyShare(); + + assert(Buffer.isBuffer(userReduced) && userReduced.length > 0, 'User reduced key share should be non-empty'); + assert( + Buffer.isBuffer(backupReduced) && backupReduced.length > 0, + 'Backup reduced key share should be non-empty' + ); + assert(Buffer.isBuffer(bitgoReduced) && bitgoReduced.length > 0, 'BitGo reduced key share should be non-empty'); + + const userPub = Buffer.from(MPSTypes.getDecodedReducedKeyShare(userReduced).pub).toString('hex'); + const backupPub = Buffer.from(MPSTypes.getDecodedReducedKeyShare(backupReduced).pub).toString('hex'); + const bitgoPub = Buffer.from(MPSTypes.getDecodedReducedKeyShare(bitgoReduced).pub).toString('hex'); + + assert.strictEqual(userPub, backupPub, 'User and backup should have same public key in reduced share'); + assert.strictEqual(backupPub, bitgoPub, 'Backup and BitGo should have same public key in reduced share'); + }); + }); + + describe('Message Serialization', function () { + it('should serialize and deserialize messages round-trip', function () { + userKP = makeKeypair(); + backupKP = makeKeypair(); + bitgoKP = makeKeypair(); + user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + + const serialized = MPSTypes.serializeMessages(r1Messages); + assert( + serialized.every((m) => typeof m.payload === 'string'), + 'Serialized payloads should be strings' + ); + + const deserialized = MPSTypes.deserializeMessages(serialized); + assert.strictEqual(deserialized.length, r1Messages.length); + deserialized.forEach((msg, i) => { + assert.strictEqual(msg.from, r1Messages[i].from); + assert.deepStrictEqual(Buffer.from(msg.payload), Buffer.from(r1Messages[i].payload)); + }); + }); + }); + + describe('Session Management', function () { + it('should export and restore DKG session and continue protocol correctly', function () { + user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + user.getFirstMessage(); + backup.getFirstMessage(); + bitgo.getFirstMessage(); + + const userSession = user.getSession(); + const backupSession = backup.getSession(); + const bitgoSession = bitgo.getSession(); + + assert(typeof userSession === 'string' && userSession.length > 0, 'Session should be non-empty string'); + + const restoredUser = new EddsaMPSDkg.DKG(3, 2, 0); + const restoredBackup = new EddsaMPSDkg.DKG(3, 2, 1); + const restoredBitgo = new EddsaMPSDkg.DKG(3, 2, 2); + + restoredUser.restoreSession(userSession); + restoredBackup.restoreSession(backupSession); + restoredBitgo.restoreSession(bitgoSession); + + assert.strictEqual(restoredUser.getState(), user.getState(), 'Restored state should match original'); + assert.strictEqual(restoredBackup.getState(), backup.getState(), 'Restored backup state should match original'); + assert.strictEqual(restoredBitgo.getState(), bitgo.getState(), 'Restored BitGo state should match original'); + }); + + it('should throw error when trying to export session after completion', async function () { + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(); + + assert.throws(() => { + user.getSession(); + }, /DKG session is complete. Exporting the session is not allowed./); + + assert.throws(() => { + backup.getSession(); + }, /DKG session is complete. Exporting the session is not allowed./); + + assert.throws(() => { + bitgo.getSession(); + }, /DKG 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 new file mode 100644 index 0000000000..38fa856290 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts @@ -0,0 +1,17 @@ +import assert from 'assert'; +import { concatBytes } from '../../../../src/tss/eddsa-mps/util'; + +describe('EdDSA Utility Functions', function () { + describe('concatBytes', function () { + it('should concatenate Uint8Array arrays correctly', function () { + const arr1 = new Uint8Array([1, 2, 3]); + const arr2 = new Uint8Array([4, 5, 6]); + const arr3 = new Uint8Array([7, 8, 9]); + + const result = concatBytes([arr1, arr2, arr3]); + const expected = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); + + assert.deepStrictEqual(result, expected, 'concatBytes should concatenate arrays correctly'); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts new file mode 100644 index 0000000000..237f2b12a3 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts @@ -0,0 +1,56 @@ +import crypto from 'crypto'; +import { x25519 } from '@noble/curves/ed25519'; +import { EddsaMPSDkg } from '../../../../src/tss/eddsa-mps'; + +/** + * Generates an X25519 keypair. If a seed is provided (32 bytes), it is used as the + * private key directly, giving deterministic output. This mirrors how the orchestrator + * extracts X25519 keys from GPG encryption subkeys. + */ +function generateX25519Keypair(seed?: Buffer): { privKey: Buffer; pubKey: Buffer } { + const privKey = seed ? seed.subarray(0, 32) : crypto.randomBytes(32); + const pubKey = Buffer.from(x25519.getPublicKey(privKey)); + return { privKey: Buffer.from(privKey), pubKey }; +} + +/** + * Generates EdDSA DKG key shares for 3 parties with optional seeds. + * Seeds are used as X25519 private keys AND as DKG round0 seeds for full determinism. + */ +export async function generateEdDsaDKGKeyShares( + seedUser?: Buffer, + seedBackup?: Buffer, + seedBitgo?: Buffer +): Promise<[EddsaMPSDkg.DKG, EddsaMPSDkg.DKG, EddsaMPSDkg.DKG]> { + const user = new EddsaMPSDkg.DKG(3, 2, 0); + const backup = new EddsaMPSDkg.DKG(3, 2, 1); + const bitgo = new EddsaMPSDkg.DKG(3, 2, 2); + + const userKP = generateX25519Keypair(seedUser); + const backupKP = generateX25519Keypair(seedBackup); + const bitgoKP = generateX25519Keypair(seedBitgo); + + // Each party gets own privKey + other parties' pubKeys sorted by ascending party index + user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + // Use seed as DKG round0 seed for determinism when seed is provided + const r1Messages = [ + user.getFirstMessage(seedUser), + backup.getFirstMessage(seedBackup), + bitgo.getFirstMessage(seedBitgo), + ]; + + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + user.handleIncomingMessages(r2Messages); + backup.handleIncomingMessages(r2Messages); + bitgo.handleIncomingMessages(r2Messages); + + return [user, backup, bitgo]; +} diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index 1839c798b5..c863a50e1d 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -21,6 +21,7 @@ module.exports = { '@bitgo/wasm-dot': path.resolve('../../node_modules/@bitgo/wasm-dot/dist/esm/js/index.js'), '@bitgo/wasm-utxo': path.resolve('../../node_modules/@bitgo/wasm-utxo/dist/esm/js/index.js'), '@bitgo/wasm-solana': path.resolve('../../node_modules/@bitgo/wasm-solana/dist/esm/js/index.js'), + '@bitgo/wasm-mps': path.resolve('../../node_modules/@bitgo/wasm-mps/dist/esm/js/wasm/wasm_mps.js'), '@bitgo/utxo-ord': path.resolve('../utxo-ord/dist/esm/index.js'), }, fallback: { diff --git a/yarn.lock b/yarn.lock index 9f55fffeab..adb9365290 100644 --- a/yarn.lock +++ b/yarn.lock @@ -124,6 +124,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.28.6": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.27.2", "@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0": version "7.28.0" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz" @@ -310,12 +319,12 @@ "@babel/types" "^7.28.2" "@babel/helpers@^7.28.2", "@babel/helpers@^7.28.3": - version "7.28.4" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" - integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz#9cfbccb02b8e229892c0b07038052cc1a8709c49" + integrity sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" "@babel/highlight@^7.10.4": version "7.25.9" @@ -334,6 +343,13 @@ dependencies: "@babel/types" "^7.28.2" +"@babel/parser@^7.28.6": + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + dependencies: + "@babel/types" "^7.29.0" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz" @@ -890,9 +906,9 @@ esutils "^2.0.2" "@babel/runtime@7.6.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.0", "@babel/runtime@^7.28.2", "@babel/runtime@^7.28.6", "@babel/runtime@^7.7.6": - version "7.28.4" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" - integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e" + integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== "@babel/template@^7.27.1", "@babel/template@^7.27.2": version "7.27.2" @@ -903,6 +919,15 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/traverse@^7.23.2", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.4.5": version "7.28.3" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz" @@ -924,10 +949,10 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.4": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== +"@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -990,6 +1015,11 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.6.0.tgz#4a0a3e1447e1ee112d11f01399645c8e2b5d573c" integrity sha512-YYvvmMz4OQRLq0OYdGIkNn9L32Uzi7cHaGBfuxtvVEf14w8VlA/gU/UdC6dat+oitVB4jHEyM54rltqNoBkKsA== +"@bitgo/wasm-mps@1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.6.0.tgz#3e1f0618c1efac35ccd56301f8198f19d934e5ed" + integrity sha512-4Mzs124Wj3QbqaZqTYX4t2vSVNKblL/53SQFddoPgggfCnZpuV4tYovpD2sIwhbWe8hVWJXZR2/1CP+zUHKMaw== + "@bitgo/wasm-solana@^2.6.0": version "2.6.0" resolved "https://registry.npmjs.org/@bitgo/wasm-solana/-/wasm-solana-2.6.0.tgz#c8b57ab010f22f1a1c90681cd180814c4ec2867b"