From 37ab5d034bcc15ad1881b054a07b42c570dc2155 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Tue, 8 Sep 2026 00:17:21 -0400 Subject: [PATCH] feat(sdk-lib-mpc): add VrfDkg wrapper for MPS VRF keygen Ticket: WCN-2583 --- .../sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts | 233 +++++++++++++++ .../src/tss/eddsa-mps-vrf/index.ts | 3 + .../src/tss/eddsa-mps-vrf/types.ts | 85 ++++++ .../sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts | 46 +++ modules/sdk-lib-mpc/src/tss/index.ts | 1 + .../test/unit/tss/eddsa-mps-vrf/dkg.ts | 267 ++++++++++++++++++ 6 files changed, 635 insertions(+) create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts create mode 100644 modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts new file mode 100644 index 0000000000..cd67e2c6cd --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts @@ -0,0 +1,233 @@ +import type { MsgState, MsgStateMap, VrfShare } from '@bitgo/wasm-mps'; +import { Buffer } from 'buffer'; +import crypto from 'crypto'; +import { DeserializedMessages } from '../ecdsa-dkls/types'; +import { decodePartyId, decodeVrfDkgSessionData, decodeVrfRound1MsgMap, VrfDkgSessionData, VrfDkgState } from './types'; + +type NodeWasmer = typeof import('@bitgo/wasm-mps'); +type WebWasmer = typeof import('@bitgo/wasm-mps/web'); +type WasmMps = NodeWasmer | WebWasmer; + +/** + * Round driver for the EdDSA MPS VRF DKG, which produces a Ristretto VRF keyshare. + * + * Two message exchanges: round 0 broadcasts VrfKeygenMsg1, round 1 emits per-recipient + * VrfKeygenMsg2 as p2p messages, round 2 returns the VrfShare. There is no chain-code + * commitment step, unlike the signing DKG. + * + * Callers pass every message they hold; this class routes them internally. Round 1 + * must exclude the party's own commitment (the wasm rejects a sender set containing + * it) and round 2 consumes the openings addressed to this party. + * + * Party indices follow the MPCv2 convention: 0 = user, 1 = backup, 2 = bitgo. + * `VrfShare` exposes only share bytes — no public key, key id, or root chain code. + */ +export class VrfDkg { + protected n: number; + protected t: number; + protected partyIdx: number; + protected seed: Buffer | undefined; + /** Opaque wasm round-state bytes. Secret key material. */ + protected vrfStateBytes: Buffer | undefined; + protected keyShareBuff: Buffer | undefined; + protected vrfState: VrfDkgState = VrfDkgState.Uninitialized; + private wasmMps: WasmMps | null = null; + + constructor(n: number, t: number, partyIdx: number, seed?: Buffer) { + this.n = n; + this.t = t; + this.partyIdx = partyIdx; + this.seed = seed; + } + + private async loadWasmMps(): Promise { + if (!this.wasmMps) { + // Electron renderer sets process.type and must use the node wasm build. + if (typeof window !== 'undefined' && window.process?.['type'] !== 'renderer') { + // Browser: web build has explicit init() — guaranteed ready after await + // eslint-disable-next-line import/no-internal-modules -- @bitgo/wasm-mps exposes environment-specific subpath exports. + const webWasm = await import('@bitgo/wasm-mps/web'); + await webWasm.default(); + this.wasmMps = webWasm; + } else { + // Node.js: dynamic import() rewritten to require() by tsc → CJS build → readFileSync + this.wasmMps = await import('@bitgo/wasm-mps'); + } + } + } + + private getWasmMps(): WasmMps { + if (!this.wasmMps) { + throw Error('WASM module not loaded'); + } + return this.wasmMps; + } + + private getVrfStateBytes(): Buffer { + if (!this.vrfStateBytes) { + throw Error(`VRF DKG state bytes missing in state ${this.vrfState}`); + } + return this.vrfStateBytes; + } + + getState(): VrfDkgState { + return this.vrfState; + } + + /** + * Create this party's VRF DKG commitment (VrfKeygenMsg1, broadcast). + */ + async initDkg(): Promise { + await this.loadWasmMps(); + if (this.t > this.n || this.partyIdx >= this.n) { + throw Error('Invalid parameters for VRF DKG'); + } + if (this.seed && this.seed.length !== 32) { + throw Error(`Seed should be 32 bytes, got ${this.seed.length}.`); + } + if (this.vrfState !== VrfDkgState.Uninitialized) { + throw Error('VRF DKG session already initialized'); + } + + const wasm = this.getWasmMps(); + let result: MsgState; + try { + result = wasm.ed25519_vrf_dkg_round0_process(this.partyIdx, this.seed ?? crypto.randomBytes(32)); + } catch (err) { + throw new Error(`Error while creating the first VRF message from party ${this.partyIdx}: ${err}`); + } + const payload = new Uint8Array(result.msg); + this.vrfStateBytes = Buffer.from(result.state); + result.free(); + this.vrfState = VrfDkgState.Round1; + return { broadcastMessages: [{ payload, from: this.partyIdx }], p2pMessages: [] }; + } + + /** + * Process the messages this party holds for the current round and return this + * party's messages for the next round. Callers pass everything they hold; the + * round routing happens here: + * + * - Round 1: consumes the other parties' commitments (own excluded) and emits + * per-recipient openings (VrfKeygenMsg2) as p2p messages. + * - Round 2: consumes the openings addressed to this party and finalizes the DKG. + */ + async handleIncomingMessages(messagesForIthRound: DeserializedMessages): Promise { + await this.loadWasmMps(); + if (this.vrfState === VrfDkgState.Complete) { + throw Error('VRF DKG session already completed'); + } + if (this.vrfState === VrfDkgState.Uninitialized) { + throw Error('VRF DKG session not initialized'); + } + const wasm = this.getWasmMps(); + + switch (this.vrfState) { + case VrfDkgState.Round1: { + const othersCommitments = messagesForIthRound.broadcastMessages + .filter((m) => m.from !== this.partyIdx) + .sort((a, b) => a.from - b.from) + .map((m) => m.payload); + let result: MsgStateMap; + try { + result = wasm.ed25519_vrf_dkg_round1_process(othersCommitments, this.getVrfStateBytes()); + } catch (err) { + throw new Error( + `Error while creating VRF messages from party ${this.partyIdx}, state ${this.vrfState}: ${err}` + ); + } + const openings = Object.entries(decodeVrfRound1MsgMap(result.msg)).map(([recipient, payload]) => ({ + payload: new Uint8Array(payload), + from: this.partyIdx, + to: decodePartyId(recipient), + })); + this.vrfStateBytes = Buffer.from(result.state); + result.free(); + this.vrfState = VrfDkgState.Round2; + return { broadcastMessages: [], p2pMessages: openings }; + } + + case VrfDkgState.Round2: { + const openingsForMe = messagesForIthRound.p2pMessages + .filter((m) => m.to === this.partyIdx) + .sort((a, b) => a.from - b.from) + .map((m) => m.payload); + let share: VrfShare; + try { + share = wasm.ed25519_vrf_dkg_round2_process(openingsForMe, this.getVrfStateBytes()); + } catch (err) { + throw new Error( + `Error while creating VRF messages from party ${this.partyIdx}, state ${this.vrfState}: ${err}` + ); + } + this.keyShareBuff = Buffer.from(share.share); + share.free(); + this.vrfStateBytes = undefined; + this.vrfState = VrfDkgState.Complete; + return { broadcastMessages: [], p2pMessages: [] }; + } + + default: + throw Error(`Invalid VRF DKG state: ${this.vrfState}`); + } + } + + /** + * Get the VRF keyshare bytes once the DKG is complete. + * This buffer is private key material. + */ + getKeyShare(): Buffer { + if (!this.keyShareBuff) { + throw Error('Can not get key share, VRF DKG is not complete yet.'); + } + return this.keyShareBuff; + } + + /** + * Get the current session data that can be used to restore the session later. + * + * The returned state bytes are secret key material — they carry this party's + * secret VRF share. They must never be logged or persisted in the clear. + */ + getSessionData(): VrfDkgSessionData { + if (this.vrfState === VrfDkgState.Uninitialized) { + throw Error('VRF DKG session not initialized'); + } + const sessionData: VrfDkgSessionData = { vrfState: this.vrfState }; + if (this.vrfStateBytes) { + sessionData.vrfStateBytes = this.vrfStateBytes; + } + if (this.keyShareBuff) { + sessionData.keyShareBuff = this.keyShareBuff; + } + return sessionData; + } + + /** + * Restore a VRF DKG session from previous session data. + * MPS wasm state bytes have no round tag, so the persisted `vrfState` is used. + */ + static async restoreSession(n: number, t: number, partyIdx: number, sessionData: unknown): Promise { + const data = decodeVrfDkgSessionData(sessionData); + const vrfDkg = new VrfDkg(n, t, partyIdx); + switch (data.vrfState) { + case VrfDkgState.Round1: + case VrfDkgState.Round2: + if (!data.vrfStateBytes) { + throw Error(`Cannot restore VRF DKG session in state ${data.vrfState} without state bytes`); + } + vrfDkg.vrfStateBytes = Buffer.from(data.vrfStateBytes); + break; + case VrfDkgState.Complete: + if (!data.keyShareBuff) { + throw Error('Cannot restore a completed VRF DKG session without a key share'); + } + vrfDkg.keyShareBuff = data.keyShareBuff; + break; + default: + throw Error(`Invalid VRF DKG state: ${data.vrfState}`); + } + vrfDkg.vrfState = data.vrfState; + return vrfDkg; + } +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts new file mode 100644 index 0000000000..90b53daf53 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts @@ -0,0 +1,3 @@ +export * as MpsVrf from './dkg'; +export * as MpsVrfTypes from './types'; +export * as MpsVrfUtils from './util'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts new file mode 100644 index 0000000000..97a6a589ad --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts @@ -0,0 +1,85 @@ +import { Buffer } from 'buffer'; +import { isLeft } from 'fp-ts/Either'; +import * as t from 'io-ts'; + +/** + * States of the VRF DKG state machine. Kept separate from `eddsa-mps`'s signing + * `DkgState` because the round counts differ. MPS wasm state bytes have no round + * tag, so the round is tracked here and carried in `VrfDkgSessionData`. + */ +export enum VrfDkgState { + Uninitialized = 0, + /** Commitment created and broadcast; waiting for the other parties' VrfKeygenMsg1. */ + Round1, + /** Openings created; waiting for the VrfKeygenMsg2 entries addressed to this party. */ + Round2, + Complete, + InvalidState, +} + +export interface VrfDkgSessionData { + /** + * Serialized wasm round state. Secret key material — it carries this party's + * secret VRF share. Never log it or persist it in the clear. + */ + vrfStateBytes?: Uint8Array; + vrfState: VrfDkgState; + keyShareBuff?: Buffer; +} + +const Uint8ArrayCodec = new t.Type( + 'Uint8Array', + (u): u is Uint8Array => u instanceof Uint8Array, + (u, c) => (u instanceof Uint8Array ? t.success(u) : t.failure(u, c)), + t.identity +); + +const BufferCodec = new t.Type( + 'Buffer', + (u): u is Buffer => Buffer.isBuffer(u), + (u, c) => (Buffer.isBuffer(u) ? t.success(u) : t.failure(u, c)), + t.identity +); + +const VrfDkgRound1MsgMap = t.record(t.string, Uint8ArrayCodec); + +const RestorableVrfDkgState = t.union([ + t.literal(VrfDkgState.Round1), + t.literal(VrfDkgState.Round2), + t.literal(VrfDkgState.Complete), +]); + +const VrfDkgSessionDataCodec = t.intersection([ + t.type({ vrfState: RestorableVrfDkgState }), + t.partial({ + vrfStateBytes: Uint8ArrayCodec, + keyShareBuff: BufferCodec, + }), +]); + +/** Decode a wasm round-1 map key as a party index. */ +export function decodePartyId(recipient: string): number { + const to = Number.parseInt(recipient, 10); + if (!Number.isInteger(to) || to < 0 || String(to) !== recipient) { + throw new Error(`VRF DKG round-1 recipient is not a party id: ${recipient}`); + } + return to; +} + +/** Decode the wasm round-1 recipient → bytes map. */ +export function decodeVrfRound1MsgMap(msg: unknown): Record { + const decoded = VrfDkgRound1MsgMap.decode(msg); + if (isLeft(decoded)) { + throw new Error('VRF DKG round-1 message is not a party-id map of byte arrays'); + } + return decoded.right; +} + +/** Decode persisted VRF DKG session data. */ +export function decodeVrfDkgSessionData(sessionData: unknown): VrfDkgSessionData { + const decoded = VrfDkgSessionDataCodec.decode(sessionData); + if (isLeft(decoded)) { + throw new Error('Invalid VRF DKG session data'); + } + return decoded.right; +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts new file mode 100644 index 0000000000..3ba2877885 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts @@ -0,0 +1,46 @@ +import { Buffer } from 'buffer'; +import { VrfDkg } from './dkg'; + +/** + * Runs a local 2-of-3 VRF DKG across user (0), backup (1) and bitgo (2) parties and + * returns the three completed VrfDkg sessions, mirroring `generateVrfDKGKeyShares` from + * `dkls-vrf/util.ts`. + */ +export async function generateVrfDKGKeyShares( + seedUser?: Buffer, + seedBackup?: Buffer, + seedBitgo?: Buffer +): Promise<[VrfDkg, VrfDkg, VrfDkg]> { + const user = new VrfDkg(3, 2, 0, seedUser); + const backup = new VrfDkg(3, 2, 1, seedBackup); + const bitgo = new VrfDkg(3, 2, 2, seedBitgo); + + // #region round 1 + const userRound1Messages = await user.initDkg(); + const backupRound1Messages = await backup.initDkg(); + const bitgoRound1Messages = await bitgo.initDkg(); + const round1Messages = [userRound1Messages, backupRound1Messages, bitgoRound1Messages]; + // #endregion + + // #region round 2 + const round2Outputs = await Promise.all( + [user, backup, bitgo].map((party, i) => + party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: round1Messages.flatMap((m) => m.broadcastMessages).filter((m) => m.from !== i), + }) + ) + ); + // #endregion + + // #region finalize + for (const [i, party] of [user, backup, bitgo].entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + // #endregion + + return [user, backup, bitgo]; +} diff --git a/modules/sdk-lib-mpc/src/tss/index.ts b/modules/sdk-lib-mpc/src/tss/index.ts index 6233ce24d2..6e124e0a98 100644 --- a/modules/sdk-lib-mpc/src/tss/index.ts +++ b/modules/sdk-lib-mpc/src/tss/index.ts @@ -2,4 +2,5 @@ export * from './ecdsa'; export * from './ecdsa-dkls'; export * from './dkls-vrf'; export * from './eddsa-mps'; +export * from './eddsa-mps-vrf'; export * from './redpallas-mps'; diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts new file mode 100644 index 0000000000..dcd65be4d6 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts @@ -0,0 +1,267 @@ +import assert from 'assert'; +import crypto from 'crypto'; +import * as openpgp from 'openpgp'; +import { DklsTypes, MPSComms, MPSTypes, MPSUtil, MpsVrf, MpsVrfTypes, MpsVrfUtils } from '../../../../src/tss'; +import { serializeMessages, type DeserializedMessages } from '../../../../src/tss/ecdsa-dkls/types'; + +// Measured on @bitgo/wasm-mps 1.14.0; keycard sizing depends on this. +const VRF_KEYSHARE_SIZE_BYTES = 229; + +describe('MPS VRF DKG 2x3', function () { + it('should create VRF key shares of the measured size for all three parties', async function () { + const [user, backup, bitgo] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const userKeyShare = user.getKeyShare(); + const backupKeyShare = backup.getKeyShare(); + const bitgoKeyShare = bitgo.getKeyShare(); + assert.equal(userKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.equal(backupKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.equal(bitgoKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.notDeepStrictEqual(userKeyShare, backupKeyShare); + assert.notDeepStrictEqual(userKeyShare, bitgoKeyShare); + for (const party of [user, backup, bitgo]) { + assert.equal(party.getState(), MpsVrfTypes.VrfDkgState.Complete); + } + }); + + it('should produce key shares that agree on one VRF key, proven by hard derivation', async function () { + const mps = await import('@bitgo/wasm-mps'); + const [rootUser, rootBackup, rootBitgo] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const rootShares = [rootUser.getKeyShare(), rootBackup.getKeyShare(), rootBitgo.getKeyShare()]; + const vrfShares = [vrfUser.getKeyShare(), vrfBackup.getKeyShare(), vrfBitgo.getKeyShare()]; + const path = "m/0'"; + + const pairs: [number, number][] = [ + [0, 2], + [0, 1], + ]; + const derived = pairs.map(([a, b]) => { + const round0 = [a, b].map((i) => mps.ed25519_hard_derive_round0_process(vrfShares[i], rootShares[i], path)); + const round1 = [0, 1].map((i) => mps.ed25519_hard_derive_round1_process(round0[1 - i].msg, round0[i].state)); + return [0, 1].map((i) => mps.ed25519_hard_derive_round2_process(round1[1 - i].msg, round1[i].state)); + }); + + assert.deepStrictEqual(Buffer.from(derived[0][0].pk), Buffer.from(derived[1][0].pk)); + assert.deepStrictEqual(Buffer.from(derived[0][0].chaincode), Buffer.from(derived[1][0].chaincode)); + assert.deepStrictEqual(Buffer.from(derived[0][1].pk), Buffer.from(derived[0][0].pk)); + assert.deepStrictEqual(Buffer.from(derived[0][1].chaincode), Buffer.from(derived[0][0].chaincode)); + }); + + it('should carry VRF messages through the existing MPS sign/verify comms unchanged', async function () { + const [userGpg, backupGpg, bitgoGpg] = await Promise.all([ + openpgp.generateKey({ userIDs: [{ name: 'user', email: 'u@test.com' }], curve: 'ed25519', format: 'object' }), + openpgp.generateKey({ userIDs: [{ name: 'backup', email: 'b@test.com' }], curve: 'ed25519', format: 'object' }), + openpgp.generateKey({ userIDs: [{ name: 'bitgo', email: 'bg@test.com' }], curve: 'ed25519', format: 'object' }), + ]); + const prvKeys = [userGpg.privateKey, backupGpg.privateKey, bitgoGpg.privateKey]; + const pubKeys = [userGpg.publicKey, backupGpg.publicKey, bitgoGpg.publicKey]; + const parties = [new MpsVrf.VrfDkg(3, 2, 0), new MpsVrf.VrfDkg(3, 2, 1), new MpsVrf.VrfDkg(3, 2, 2)]; + + const round1 = await Promise.all(parties.map((p) => p.initDkg())); + const round1Signed = await Promise.all( + round1.map((m, i) => MPSComms.detachSignMpsMessage(Buffer.from(m.broadcastMessages[0].payload), prvKeys[i])) + ); + const round1Outputs: DeserializedMessages[] = []; + for (const [i, party] of parties.entries()) { + const signerIds = [0, 1, 2].filter((j) => j !== i); + const signed = round1Signed.filter((_, j) => j !== i); + const verified = await Promise.all(signed.map((s, k) => MPSComms.verifyMpsMessage(s, pubKeys[signerIds[k]]))); + round1Outputs.push( + await party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: verified.map((payload, k) => ({ payload: new Uint8Array(payload), from: signerIds[k] })), + }) + ); + } + + const openingsForParty: { signed: MPSTypes.MPSSignedMessage; from: number }[][] = [[], [], []]; + for (const [i, msgs] of round1Outputs.entries()) { + for (const p2p of msgs.p2pMessages) { + openingsForParty[p2p.to].push({ + signed: await MPSComms.detachSignMpsMessage(Buffer.from(p2p.payload), prvKeys[i]), + from: i, + }); + } + } + for (const [i, party] of parties.entries()) { + const verified = await Promise.all( + openingsForParty[i].map(async (o) => ({ + payload: new Uint8Array(await MPSComms.verifyMpsMessage(o.signed, pubKeys[o.from])), + from: o.from, + to: i, + })) + ); + await party.handleIncomingMessages({ p2pMessages: verified, broadcastMessages: [] }); + } + + for (const party of parties) { + assert.equal(party.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + } + }); + + it('should round-trip VRF messages through serializeMessages/deserializeMessages', async function () { + const parties = [new MpsVrf.VrfDkg(3, 2, 0), new MpsVrf.VrfDkg(3, 2, 1), new MpsVrf.VrfDkg(3, 2, 2)]; + const round1 = await Promise.all(parties.map((p) => p.initDkg())); + + const deserialized = DklsTypes.deserializeMessages(serializeMessages(round1[0])); + assert.equal(deserialized.broadcastMessages.length, round1[0].broadcastMessages.length); + assert.equal(deserialized.broadcastMessages[0].from, round1[0].broadcastMessages[0].from); + assert.deepEqual(deserialized.broadcastMessages[0].payload, round1[0].broadcastMessages[0].payload); + assert.equal(deserialized.p2pMessages.length, 0); + + const round2Outputs = await Promise.all( + parties.map((party, i) => + party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: round1.flatMap((m) => m.broadcastMessages).filter((m) => m.from !== i), + }) + ) + ); + + const deserializedOpenings = DklsTypes.deserializeMessages(serializeMessages(round2Outputs[0])); + assert.equal(deserializedOpenings.p2pMessages.length, round2Outputs[0].p2pMessages.length); + assert.deepEqual(deserializedOpenings.p2pMessages[0].payload, round2Outputs[0].p2pMessages[0].payload); + assert.equal(deserializedOpenings.p2pMessages[0].to, round2Outputs[0].p2pMessages[0].to); + assert.equal(deserializedOpenings.p2pMessages[0].from, 0); + + for (const [i, party] of parties.entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + assert.equal(parties[0].getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a session serialized after initialization', async function () { + const restored = await runWithRestore('afterInit'); + assert.equal(restored.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a session serialized after round 1', async function () { + const restored = await runWithRestore('afterRound1'); + assert.equal(restored.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a completed session from its key share', async function () { + const [user] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const restored = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, user.getSessionData()); + assert.deepEqual(restored.getKeyShare(), user.getKeyShare()); + }); + + it('should reject a wrong message count in round 1', async function () { + const [user, backupRound1] = await startThreeParties(); + await assert.rejects( + user.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [...backupRound1.broadcastMessages] }), + /Invalid Input/ + ); + }); + + it('should reject duplicate senders in round 1', async function () { + const [user, backupRound1] = await startThreeParties(); + await assert.rejects( + user.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [...backupRound1.broadcastMessages, ...backupRound1.broadcastMessages], + }), + /Protocol Error/ + ); + }); + + it('should reject getKeyShare before the DKG completes', async function () { + const [user] = await startThreeParties(); + assert.throws(() => user.getKeyShare(), /Can not get key share/); + }); + + it('should reject invalid constructor parameters and double initialization', async function () { + await assert.rejects(new MpsVrf.VrfDkg(2, 3, 0).initDkg(), /Invalid parameters for VRF DKG/); + await assert.rejects(new MpsVrf.VrfDkg(3, 2, 5).initDkg(), /Invalid parameters for VRF DKG/); + await assert.rejects(new MpsVrf.VrfDkg(3, 2, 0, Buffer.alloc(16)).initDkg(), /Seed should be 32 bytes, got 16/); + const [user] = await startThreeParties(); + await assert.rejects(user.initDkg(), /VRF DKG session already initialized/); + }); + + it('should reject handling messages before initialization and after completion', async function () { + const user = new MpsVrf.VrfDkg(3, 2, 0); + await assert.rejects( + user.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [] }), + /VRF DKG session not initialized/ + ); + const [completed] = await MpsVrfUtils.generateVrfDKGKeyShares(); + await assert.rejects( + completed.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [] }), + /VRF DKG session already completed/ + ); + }); + + it('should reject restoring a session without the required material', async function () { + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Round1 }), + /without state bytes/ + ); + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Complete }), + /without a key share/ + ); + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Uninitialized }), + /Invalid VRF DKG session data/ + ); + await assert.rejects(MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: 'Round1' }), /Invalid VRF DKG session data/); + }); + + it('should reject non-integer round-1 recipient ids', function () { + assert.throws(() => MpsVrfTypes.decodePartyId('1.5'), /not a party id/); + assert.throws(() => MpsVrfTypes.decodePartyId('01'), /not a party id/); + assert.throws(() => MpsVrfTypes.decodePartyId('user'), /not a party id/); + assert.equal(MpsVrfTypes.decodePartyId('2'), 2); + }); + + async function startThreeParties(): Promise<[MpsVrf.VrfDkg, DeserializedMessages]> { + const user = new MpsVrf.VrfDkg(3, 2, 0); + const backup = new MpsVrf.VrfDkg(3, 2, 1); + const bitgo = new MpsVrf.VrfDkg(3, 2, 2); + const round1 = await Promise.all([user, backup, bitgo].map((p) => p.initDkg())); + return [user, round1[1]]; + } + + /** + * Runs a full ceremony where party 0's session is serialized and restored at + * the requested point, returning party 0's completed session. + */ + async function runWithRestore(restoreAfter: 'afterInit' | 'afterRound1'): Promise { + const user = new MpsVrf.VrfDkg(3, 2, 0, crypto.randomBytes(32)); + const backup = new MpsVrf.VrfDkg(3, 2, 1, crypto.randomBytes(32)); + const bitgo = new MpsVrf.VrfDkg(3, 2, 2, crypto.randomBytes(32)); + const round1 = await Promise.all([user, backup, bitgo].map((p) => p.initDkg())); + const [userRound1, backupRound1, bitgoRound1] = round1; + + let userSession: MpsVrf.VrfDkg = user; + if (restoreAfter === 'afterInit') { + userSession = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, user.getSessionData()); + } + const userRound2 = await userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [backupRound1, bitgoRound1].flatMap((m) => m.broadcastMessages), + }); + if (restoreAfter === 'afterRound1') { + userSession = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, userSession.getSessionData()); + } + const backupRound2 = await backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1, bitgoRound1].flatMap((m) => m.broadcastMessages), + }); + const bitgoRound2 = await bitgo.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1, backupRound1].flatMap((m) => m.broadcastMessages), + }); + const round2Outputs = [userRound2, backupRound2, bitgoRound2]; + for (const [i, party] of [userSession, backup, bitgo].entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + return userSession; + } +});