Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<DeserializedMessages> {
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<DeserializedMessages> {
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<VrfDkg> {
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;
}
}
3 changes: 3 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * as MpsVrf from './dkg';
export * as MpsVrfTypes from './types';
export * as MpsVrfUtils from './util';
85 changes: 85 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts
Original file line number Diff line number Diff line change
@@ -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, Uint8Array, unknown>(
'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, Buffer, unknown>(
'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<string, Uint8Array> {
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;
}
46 changes: 46 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts
Original file line number Diff line number Diff line change
@@ -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];
}
1 change: 1 addition & 0 deletions modules/sdk-lib-mpc/src/tss/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading