From 73c11773e53686f26ba3872b7b31337bee479311 Mon Sep 17 00:00:00 2001 From: Ranjna Ganesh Ram Date: Tue, 11 Aug 2026 16:13:45 +0530 Subject: [PATCH] feat(sdk-lib-mpc): add RedPallas DKG wrapper for FROST keygen Expose a TypeScript RedPallas (Pallas/Orchard FROST) DKG wrapper on top of @bitgo/wasm-mps so OVC and tests can run the user/backup sides of a 2-of-3 keygen ceremony. Bumps wasm-mps to 1.11.0. Ticket: CSHLD-1444 --- modules/sdk-lib-mpc/package.json | 2 +- modules/sdk-lib-mpc/src/tss/index.ts | 1 + .../sdk-lib-mpc/src/tss/redpallas-mps/dkg.ts | 280 +++++++++++++ .../src/tss/redpallas-mps/index.ts | 3 + .../src/tss/redpallas-mps/types.ts | 68 ++++ .../sdk-lib-mpc/src/tss/redpallas-mps/util.ts | 73 ++++ .../test/unit/tss/redpallas/dkg.ts | 379 ++++++++++++++++++ .../unit/tss/redpallas/redpallas-utils.ts | 70 ++++ .../test/unit/tss/redpallas/util.ts | 3 + yarn.lock | 8 +- 10 files changed, 882 insertions(+), 5 deletions(-) create mode 100644 modules/sdk-lib-mpc/src/tss/redpallas-mps/dkg.ts create mode 100644 modules/sdk-lib-mpc/src/tss/redpallas-mps/index.ts create mode 100644 modules/sdk-lib-mpc/src/tss/redpallas-mps/types.ts create mode 100644 modules/sdk-lib-mpc/src/tss/redpallas-mps/util.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/redpallas/dkg.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/redpallas/redpallas-utils.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/redpallas/util.ts diff --git a/modules/sdk-lib-mpc/package.json b/modules/sdk-lib-mpc/package.json index 209f56ab0b..ee55368071 100644 --- a/modules/sdk-lib-mpc/package.json +++ b/modules/sdk-lib-mpc/package.json @@ -36,7 +36,7 @@ ] }, "dependencies": { - "@bitgo/wasm-mps": "1.10.0", + "@bitgo/wasm-mps": "1.11.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/index.ts b/modules/sdk-lib-mpc/src/tss/index.ts index 504d0eb8bf..0665eabc6c 100644 --- a/modules/sdk-lib-mpc/src/tss/index.ts +++ b/modules/sdk-lib-mpc/src/tss/index.ts @@ -1,3 +1,4 @@ export * from './ecdsa'; export * from './ecdsa-dkls'; export * from './eddsa-mps'; +export * from './redpallas-mps'; diff --git a/modules/sdk-lib-mpc/src/tss/redpallas-mps/dkg.ts b/modules/sdk-lib-mpc/src/tss/redpallas-mps/dkg.ts new file mode 100644 index 0000000000..f54a0f0cb0 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/redpallas-mps/dkg.ts @@ -0,0 +1,280 @@ +import type { MsgDerivationInit, MsgState } from '@bitgo/wasm-mps'; +import { encode } from 'cbor-x'; +import crypto from 'crypto'; +import { DeserializedMessage, DeserializedMessages, RedPallasDkgState, RedPallasReducedKeyShare } from './types'; + +type NodeWasmer = typeof import('@bitgo/wasm-mps'); +type WebWasmer = typeof import('@bitgo/wasm-mps/web'); +type WasmMps = NodeWasmer | WebWasmer; + +/** + * RedPallas (Zcash Ironwood) Distributed Key Generation (DKG) implementation using @bitgo/wasm-mps. + * + * Mirrors the structure of the EdDSA MPS `DKG` class (see `../eddsa-mps/dkg.ts`), but wraps the + * `redpallas_dkg_*` WASM bindings. Round2 additionally requires a `derivationSeed` and returns a + * `MsgDerivationInit` (msg/pk/share/state) rather than a final `Share`, because RedPallas key + * derivation (ask/nk/rivk/ivks, via `redpallas_derivation_process`) is a separate, subsequent + * process that is platform-side only and intentionally not exposed here. + * + * 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, or in OVC's case, a microSD card). + * + * @example + * ```typescript + * const dkg = new RedPallasDKG(3, 2, 0); + * // X25519 keys come from GPG encryption subkeys (extracted by the orchestrator) + * await dkg.initDkg(myX25519PrivKey, [otherParty1X25519PubKey, otherParty2X25519PubKey]); + * const msg1 = dkg.getFirstMessage(); + * const msg2s = dkg.handleIncomingMessages(allThreeMsg1s); + * dkg.handleIncomingMessages(allThreeMsg2s, derivationSeed); // completes DKG + * const keyShare = dkg.getKeyShare(); + * ``` + */ +export class RedPallasDKG { + 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; + /** RedPallas public key (verification key) from round2 */ + private sharePk: Buffer | null = null; + /** Lazily loaded WASM module */ + private wasmMps: WasmMps | null = null; + + protected dkgState: RedPallasDkgState = RedPallasDkgState.Uninitialized; + + constructor(n: number, t: number, partyIdx: number) { + this.n = n; + this.t = t; + this.partyIdx = partyIdx; + } + + private async loadWasmMps(): Promise { + if (!this.wasmMps) { + if ( + typeof window !== 'undefined' && + /* checks for electron processes */ + !window.process && + !window.process?.['type'] + ) { + // 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; + } + + getState(): RedPallasDkgState { + 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]. + */ + async initDkg(decryptionKey: Buffer, otherEncPublicKeys: Buffer[]): Promise { + await this.loadWasmMps(); + 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 = RedPallasDkgState.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 !== RedPallasDkgState.Init) { + throw Error('DKG session not initialized'); + } + + const seed = dkgSeed ?? crypto.randomBytes(32); + const wasm = this.getWasmMps(); + let result: MsgState; + try { + result = wasm.redpallas_dkg_round0_process(this.partyIdx, this.decryptionKey!, this.otherPubKeys!, seed); + } catch (err) { + throw new Error(`Error while creating the first message from party ${this.partyIdx}: ${err}`); + } + + this.dkgStateBytes = Buffer.from(result.state); + this.dkgState = RedPallasDkgState.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). + * @param derivationSeed - Required only when advancing WaitMsg2 -> Complete (round2): a + * 32-byte seed consumed by the subsequent, platform-side-only derivation process. + */ + handleIncomingMessages(messagesForIthRound: DeserializedMessages, derivationSeed?: Buffer): DeserializedMessages { + if (this.dkgState === RedPallasDkgState.Complete) { + throw Error('DKG session already completed'); + } + if (this.dkgState === RedPallasDkgState.Uninitialized) { + throw Error('DKG session not initialized'); + } + if (this.dkgState === RedPallasDkgState.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); + + const wasm = this.getWasmMps(); + + if (this.dkgState === RedPallasDkgState.WaitMsg1) { + let result: MsgState; + try { + result = wasm.redpallas_dkg_round1_process(otherMsgs, this.dkgStateBytes!); + } catch (err) { + throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dkgState}: ${err}`); + } + // Store new state; this is what would be persisted between API rounds / microSD exchanges + this.dkgStateBytes = Buffer.from(result.state); + this.dkgState = RedPallasDkgState.WaitMsg2; + return [{ payload: new Uint8Array(result.msg), from: this.partyIdx }]; + } + + if (this.dkgState === RedPallasDkgState.WaitMsg2) { + if (!derivationSeed || derivationSeed.length !== 32) { + throw Error('Missing or invalid derivationSeed: must be 32 bytes (required for round2)'); + } + let result: MsgDerivationInit; + try { + result = wasm.redpallas_dkg_round2_process(otherMsgs, this.dkgStateBytes!, derivationSeed); + } catch (err) { + throw new Error(`Error while creating messages from party ${this.partyIdx}, round ${this.dkgState}: ${err}`); + } + this.keyShare = Buffer.from(result.share); + this.sharePk = Buffer.from(result.pk); + this.dkgStateBytes = null; + this.dkgState = RedPallasDkgState.Complete; + return []; + } + + throw Error('Unexpected DKG state'); + } + + /** + * Returns the opaque bincode-serialised keyshare produced by round2. + * This is used as input to the (platform-side) signing and derivation protocols. + */ + getKeyShare(): Buffer { + if (!this.keyShare) { + throw Error('DKG session not initialized'); + } + return this.keyShare; + } + + /** + * Returns the RedPallas public key (verification 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 ReducedKeyShare buffer containing the party's opaque + * signing key share in the `keyShare` field. This buffer is private key material. + * The caller encrypts it and stores it as `reducedEncryptedPrv` on the key card QR code. + */ + getReducedKeyShare(): Buffer { + if (!this.keyShare || !this.sharePk) { + throw Error('DKG session not initialized'); + } + const reducedKeyShare: RedPallasReducedKeyShare = { + keyShare: Array.from(this.keyShare), + 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 === RedPallasDkgState.Complete) { + throw Error('DKG session is complete. Exporting the session is not allowed.'); + } + if (this.dkgState === RedPallasDkgState.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/redpallas-mps/index.ts b/modules/sdk-lib-mpc/src/tss/redpallas-mps/index.ts new file mode 100644 index 0000000000..b5955a7ce3 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/redpallas-mps/index.ts @@ -0,0 +1,3 @@ +export * as RedPallasMPSDkg from './dkg'; +export * as RedPallasMPSUtil from './util'; +export * as RedPallasMPSTypes from './types'; diff --git a/modules/sdk-lib-mpc/src/tss/redpallas-mps/types.ts b/modules/sdk-lib-mpc/src/tss/redpallas-mps/types.ts new file mode 100644 index 0000000000..c38bc4d754 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/redpallas-mps/types.ts @@ -0,0 +1,68 @@ +import { decode } from 'cbor-x'; +import { isLeft } from 'fp-ts/Either'; +import * as t from 'io-ts'; + +export const RedPallasReducedKeyShareType = t.type({ + keyShare: t.array(t.number), + pub: t.array(t.number), +}); + +export type RedPallasReducedKeyShare = t.TypeOf; + +/** + * Represents the state of a RedPallas DKG (Distributed Key Generation) session. + * + * Unlike the EdDSA MPS `DkgState`, there is no `Share` state: `redpallas_dkg_round2_process` + * returns a `MsgDerivationInit` (msg/pk/share/state) directly, completing the DKG session in + * one step. Key derivation (ask/nk/rivk/ivks) is a separate, subsequent, platform-side-only + * process (`redpallas_derivation_process`) not modelled here. + */ +export enum RedPallasDkgState { + /** 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 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: 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): RedPallasReducedKeyShare { + const decoded = RedPallasReducedKeyShareType.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/redpallas-mps/util.ts b/modules/sdk-lib-mpc/src/tss/redpallas-mps/util.ts new file mode 100644 index 0000000000..73cc8c9c49 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/redpallas-mps/util.ts @@ -0,0 +1,73 @@ +import crypto from 'crypto'; +import assert from 'assert'; +import { x25519 } from '@noble/curves/ed25519'; +import { RedPallasDKG } from './dkg'; + +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 }; +} + +/** + * Per-party deterministic seed material. To use the same seed for both, pass it as both fields. + * `encKey` seeds the X25519 encryption key; `dkgSeed` seeds DKG round 0. + */ +export interface RedPallasDKGPartySeed { + encKey?: Buffer; + dkgSeed?: Buffer; +} + +function validateSeed(seed?: RedPallasDKGPartySeed): RedPallasDKGPartySeed { + assert(!seed?.encKey || seed.encKey.length >= 32, 'encKey must be at least 32 bytes'); + assert(!seed?.dkgSeed || seed.dkgSeed.length >= 32, 'dkgSeed must be at least 32 bytes'); + return seed ?? {}; +} + +/** + * Runs a full 3-party (2-of-3) RedPallas DKG in-process. See `RedPallasDKGPartySeed`. + * Mirrors `generateEdDsaDKGKeyShares` in `../eddsa-mps/util.ts`. + * + * @param derivationSeed - 32-byte seed consumed by round2 for the (platform-side-only) + * subsequent derivation process. Must be the same value across all three parties. + */ +export async function generateRedPallasDKGKeyShares( + derivationSeed: Buffer, + seedUser?: RedPallasDKGPartySeed, + seedBackup?: RedPallasDKGPartySeed, + seedBitgo?: RedPallasDKGPartySeed +): Promise<[RedPallasDKG, RedPallasDKG, RedPallasDKG]> { + const { encKey: userEncKey, dkgSeed: userDkgSeed } = validateSeed(seedUser); + const { encKey: backupEncKey, dkgSeed: backupDkgSeed } = validateSeed(seedBackup); + const { encKey: bitgoEncKey, dkgSeed: bitgoDkgSeed } = validateSeed(seedBitgo); + + const user = new RedPallasDKG(3, 2, 0); + const backup = new RedPallasDKG(3, 2, 1); + const bitgo = new RedPallasDKG(3, 2, 2); + + const userKP = generateX25519Keypair(userEncKey); + const backupKP = generateX25519Keypair(backupEncKey); + const bitgoKP = generateX25519Keypair(bitgoEncKey); + + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const r1Messages = [ + user.getFirstMessage(userDkgSeed), + backup.getFirstMessage(backupDkgSeed), + bitgo.getFirstMessage(bitgoDkgSeed), + ]; + + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + user.handleIncomingMessages(r2Messages, derivationSeed); + backup.handleIncomingMessages(r2Messages, derivationSeed); + bitgo.handleIncomingMessages(r2Messages, derivationSeed); + + return [user, backup, bitgo]; +} diff --git a/modules/sdk-lib-mpc/test/unit/tss/redpallas/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/redpallas/dkg.ts new file mode 100644 index 0000000000..cc350dd226 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/redpallas/dkg.ts @@ -0,0 +1,379 @@ +import assert from 'assert'; +import crypto from 'crypto'; +import { x25519 } from '@noble/curves/ed25519'; +import { RedPallasMPSDkg, RedPallasMPSTypes } from '../../../../src/tss/redpallas-mps'; +import { RedPallasDkgState } from '../../../../src/tss/redpallas-mps/types'; +import { generateRedPallasDKGKeyShares } 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 }; +} + +function makeDerivationSeed(): Buffer { + return crypto.randomBytes(32); +} + +describe('RedPallas MPS DKG', function () { + let user: RedPallasMPSDkg.RedPallasDKG; + let backup: RedPallasMPSDkg.RedPallasDKG; + let bitgo: RedPallasMPSDkg.RedPallasDKG; + let userKP: { privKey: Buffer; pubKey: Buffer }; + let backupKP: { privKey: Buffer; pubKey: Buffer }; + let bitgoKP: { privKey: Buffer; pubKey: Buffer }; + let derivationSeed: Buffer; + + beforeEach(function () { + user = new RedPallasMPSDkg.RedPallasDKG(3, 2, 0); + backup = new RedPallasMPSDkg.RedPallasDKG(3, 2, 1); + bitgo = new RedPallasMPSDkg.RedPallasDKG(3, 2, 2); + + userKP = makeKeypair(); + backupKP = makeKeypair(); + bitgoKP = makeKeypair(); + derivationSeed = makeDerivationSeed(); + }); + + describe('DKG Initialization', function () { + it('should initialize DKG sessions for all parties', async function () { + assert.strictEqual(user.getState(), RedPallasDkgState.Uninitialized); + assert.strictEqual(backup.getState(), RedPallasDkgState.Uninitialized); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.Uninitialized); + + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + assert.strictEqual(user.getState(), RedPallasDkgState.Init); + assert.strictEqual(backup.getState(), RedPallasDkgState.Init); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.Init); + + const userMessage = user.getFirstMessage(); + const backupMessage = backup.getFirstMessage(); + const bitgoMessage = bitgo.getFirstMessage(); + + assert.strictEqual(user.getState(), RedPallasDkgState.WaitMsg1); + assert.strictEqual(backup.getState(), RedPallasDkgState.WaitMsg1); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.WaitMsg1); + + 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.strictEqual(user.getState(), RedPallasDkgState.Uninitialized); + + assert.throws(() => { + user.getFirstMessage(); + }, /DKG session not initialized/); + + assert.throws(() => { + user.handleIncomingMessages([]); + }, /DKG session not initialized/); + + assert.throws(() => { + user.getKeyShare(); + }, /DKG session not initialized/); + }); + + it('should reject invalid decryption keys and party pubkey counts', async function () { + await assert.rejects( + user.initDkg(Buffer.alloc(31), [backupKP.pubKey, bitgoKP.pubKey]), + /Missing or invalid decryption key/ + ); + await assert.rejects(user.initDkg(userKP.privKey, [backupKP.pubKey]), /Expected 2 other parties' public keys/); + }); + }); + + describe('DKG Protocol Execution', function () { + beforeEach(async function () { + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + }); + + it('should complete full DKG protocol and generate key shares', async function () { + assert.strictEqual(user.getState(), RedPallasDkgState.Init); + assert.strictEqual(backup.getState(), RedPallasDkgState.Init); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.Init); + + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + + assert.strictEqual(user.getState(), RedPallasDkgState.WaitMsg1); + assert.strictEqual(backup.getState(), RedPallasDkgState.WaitMsg1); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.WaitMsg1); + + 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(user.getState(), RedPallasDkgState.WaitMsg2); + assert.strictEqual(backup.getState(), RedPallasDkgState.WaitMsg2); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.WaitMsg2); + + 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, derivationSeed), + ...backup.handleIncomingMessages(r2Messages, derivationSeed), + ...bitgo.handleIncomingMessages(r2Messages, derivationSeed), + ]; + + assert.strictEqual(user.getState(), RedPallasDkgState.Complete); + assert.strictEqual(backup.getState(), RedPallasDkgState.Complete); + assert.strictEqual(bitgo.getState(), RedPallasDkgState.Complete); + + 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 require a 32-byte derivationSeed for round 2', async function () { + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + assert.strictEqual(user.getState(), RedPallasDkgState.WaitMsg2); + + assert.throws(() => { + user.handleIncomingMessages(r2Messages); + }, /Missing or invalid derivationSeed/); + + assert.throws(() => { + user.handleIncomingMessages(r2Messages, Buffer.alloc(31)); + }, /Missing or invalid derivationSeed/); + + // Failed round2 must leave the session in WaitMsg2 + assert.strictEqual(user.getState(), RedPallasDkgState.WaitMsg2); + }); + + it('should generate consistent public keys across all parties', async function () { + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + user.handleIncomingMessages(r2Messages, derivationSeed); + backup.handleIncomingMessages(r2Messages, derivationSeed); + bitgo.handleIncomingMessages(r2Messages, derivationSeed); + + 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 () { + const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const fixedDerivationSeed = Buffer.from('c526955e37be0a0c8b77a831eb615948772b38df9f04d8c5a2e0e1f1d0c9b8a7', 'hex'); + + it('should create key shares with deterministic seeds', async function () { + const userParty = { encKey: seedUser, dkgSeed: seedUser }; + const backupParty = { encKey: seedBackup, dkgSeed: seedBackup }; + const bitgoParty = { encKey: seedBitgo, dkgSeed: seedBitgo }; + + const [user1, backup1, bitgo1] = await generateRedPallasDKGKeyShares( + fixedDerivationSeed, + userParty, + backupParty, + bitgoParty + ); + + 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 generateRedPallasDKGKeyShares(fixedDerivationSeed, userParty, backupParty, bitgoParty); + 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 seedAUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedABackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedABitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const seedBUser = Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'); + const seedBBackup = Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'); + const seedBBitgo = Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex'); + + const [user1] = await generateRedPallasDKGKeyShares( + fixedDerivationSeed, + { encKey: seedAUser, dkgSeed: seedAUser }, + { encKey: seedABackup, dkgSeed: seedABackup }, + { encKey: seedABitgo, dkgSeed: seedABitgo } + ); + const [user2] = await generateRedPallasDKGKeyShares( + fixedDerivationSeed, + { encKey: seedBUser, dkgSeed: seedBUser }, + { encKey: seedBBackup, dkgSeed: seedBBackup }, + { encKey: seedBBitgo, dkgSeed: seedBBitgo } + ); + + assert.notStrictEqual( + user1.getSharePublicKey().toString('hex'), + user2.getSharePublicKey().toString('hex'), + 'Different seeds should produce different public keys' + ); + }); + + it('should create key shares without party seeds (random)', async function () { + const [userDkg, backupDkg, bitgoDkg] = await generateRedPallasDKGKeyShares(derivationSeed); + + const userPk = userDkg.getSharePublicKey().toString('hex'); + const backupPk = backupDkg.getSharePublicKey().toString('hex'); + const bitgoPk = bitgoDkg.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 [userDkg, backupDkg, bitgoDkg] = await generateRedPallasDKGKeyShares(derivationSeed); + + const userReduced = userDkg.getReducedKeyShare(); + const backupReduced = backupDkg.getReducedKeyShare(); + const bitgoReduced = bitgoDkg.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 userDecoded = RedPallasMPSTypes.getDecodedReducedKeyShare(userReduced); + const backupDecoded = RedPallasMPSTypes.getDecodedReducedKeyShare(backupReduced); + const bitgoDecoded = RedPallasMPSTypes.getDecodedReducedKeyShare(bitgoReduced); + + const userPub = Buffer.from(userDecoded.pub).toString('hex'); + const backupPub = Buffer.from(backupDecoded.pub).toString('hex'); + const bitgoPub = Buffer.from(bitgoDecoded.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'); + + assert.strictEqual( + userPub, + userDkg.getSharePublicKey().toString('hex'), + 'Reduced pub should match getSharePublicKey' + ); + + // keyShare must be present and non-empty (opaque WASM bincode) + assert(userDecoded.keyShare.length > 0, 'User reduced share must include keyShare'); + assert(backupDecoded.keyShare.length > 0, 'Backup reduced share must include keyShare'); + assert(bitgoDecoded.keyShare.length > 0, 'BitGo reduced share must include keyShare'); + + // RedPallas reduced shares do not carry a BIP32 rootChainCode + assert.strictEqual('rootChainCode' in userDecoded, false, 'RedPallas reduced share has no rootChainCode'); + }); + }); + + describe('Message Serialization', function () { + it('should serialize and deserialize messages round-trip', async function () { + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const r1Messages = [user.getFirstMessage(), backup.getFirstMessage(), bitgo.getFirstMessage()]; + + const serialized = RedPallasMPSTypes.serializeMessages(r1Messages); + assert( + serialized.every((m) => typeof m.payload === 'string'), + 'Serialized payloads should be strings' + ); + + const deserialized = RedPallasMPSTypes.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 state', async function () { + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await 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 RedPallasMPSDkg.RedPallasDKG(3, 2, 0); + const restoredBackup = new RedPallasMPSDkg.RedPallasDKG(3, 2, 1); + const restoredBitgo = new RedPallasMPSDkg.RedPallasDKG(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 [userDkg, backupDkg, bitgoDkg] = await generateRedPallasDKGKeyShares(derivationSeed); + + assert.throws(() => { + userDkg.getSession(); + }, /DKG session is complete. Exporting the session is not allowed./); + + assert.throws(() => { + backupDkg.getSession(); + }, /DKG session is complete. Exporting the session is not allowed./); + + assert.throws(() => { + bitgoDkg.getSession(); + }, /DKG session is complete. Exporting the session is not allowed./); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/test/unit/tss/redpallas/redpallas-utils.ts b/modules/sdk-lib-mpc/test/unit/tss/redpallas/redpallas-utils.ts new file mode 100644 index 0000000000..9331c06002 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/redpallas/redpallas-utils.ts @@ -0,0 +1,70 @@ +import assert from 'assert'; +import { generateRedPallasDKGKeyShares } from '../../../../src/tss/redpallas-mps/util'; + +describe('RedPallas Utility Functions', function () { + describe('generateRedPallasDKGKeyShares', function () { + const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const dkgSeedUser = Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'); + const dkgSeedBackup = Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'); + const dkgSeedBitgo = Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex'); + const derivationSeed = Buffer.from('c526955e37be0a0c8b77a831eb615948772b38df9f04d8c5a2e0e1f1d0c9b8a7', 'hex'); + + it('should be deterministic with split encKey and dkgSeed', async function () { + const split = { + user: { encKey: seedUser, dkgSeed: dkgSeedUser }, + backup: { encKey: seedBackup, dkgSeed: dkgSeedBackup }, + bitgo: { encKey: seedBitgo, dkgSeed: dkgSeedBitgo }, + }; + const [user, backup, bitgo] = await generateRedPallasDKGKeyShares( + derivationSeed, + split.user, + split.backup, + split.bitgo + ); + const [repeatUser] = await generateRedPallasDKGKeyShares(derivationSeed, split.user, split.backup, split.bitgo); + + const userPublicKey = user.getSharePublicKey().toString('hex'); + assert.strictEqual(userPublicKey, backup.getSharePublicKey().toString('hex')); + assert.strictEqual(userPublicKey, bitgo.getSharePublicKey().toString('hex')); + assert.strictEqual(userPublicKey, repeatUser.getSharePublicKey().toString('hex')); + }); + + it('should reject seeds shorter than 32 bytes', async function () { + const okBackup = { encKey: seedBackup, dkgSeed: dkgSeedBackup }; + const okBitgo = { encKey: seedBitgo, dkgSeed: dkgSeedBitgo }; + await assert.rejects( + generateRedPallasDKGKeyShares( + derivationSeed, + { encKey: Buffer.alloc(31), dkgSeed: dkgSeedUser }, + okBackup, + okBitgo + ), + /encKey must be at least 32 bytes/ + ); + await assert.rejects( + generateRedPallasDKGKeyShares( + derivationSeed, + { encKey: seedUser, dkgSeed: Buffer.alloc(31) }, + okBackup, + okBitgo + ), + /dkgSeed must be at least 32 bytes/ + ); + }); + + it('should produce distinct key shares per party with a shared public key', async function () { + const [user, backup, bitgo] = await generateRedPallasDKGKeyShares(derivationSeed); + + const userShare = user.getKeyShare(); + const backupShare = backup.getKeyShare(); + const bitgoShare = bitgo.getKeyShare(); + + assert.notStrictEqual(userShare.toString('hex'), backupShare.toString('hex')); + assert.notStrictEqual(backupShare.toString('hex'), bitgoShare.toString('hex')); + assert.strictEqual(user.getSharePublicKey().toString('hex'), backup.getSharePublicKey().toString('hex')); + assert.strictEqual(backup.getSharePublicKey().toString('hex'), bitgo.getSharePublicKey().toString('hex')); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/test/unit/tss/redpallas/util.ts b/modules/sdk-lib-mpc/test/unit/tss/redpallas/util.ts new file mode 100644 index 0000000000..dbf43e12ca --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/redpallas/util.ts @@ -0,0 +1,3 @@ +// Re-export the production helper so tests can resolve via './util' +// without a separate, drifting copy. +export { generateRedPallasDKGKeyShares } from '../../../../src/tss/redpallas-mps/util'; diff --git a/yarn.lock b/yarn.lock index 82001ae13c..fbe1255c3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1059,10 +1059,10 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz" integrity sha512-KoXavJvyDHlEN+sWcigbgxYJtdFaU7gS0EkYQbNH4npVjNlzo6rL6gwjyWbyOy7oEs65DhpJ9vY5kRbE/bKiTQ== -"@bitgo/wasm-mps@1.10.0": - version "1.10.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.10.0.tgz#df6a056247ce04c7d92369d257b659876e03261d" - integrity sha512-f42sMCyqqlaId3AtcvdpOfR+mOjAyVopCxCCAqW7wcTAQ8ZBS9rMGQIzTMiqFmZDBMWsAe+QHWA6XseIuzTVdQ== +"@bitgo/wasm-mps@1.11.0": + version "1.11.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-mps/-/wasm-mps-1.11.0.tgz#642f0a970f3545e6e4fa4b7df1920a7309952923" + integrity sha512-+RnpCdBpF41//duuvdeoreEzDMUANSB14H/wTRKOxLLOOPCA6WiXVKV4/20mGMvI1Gcx39xDdQM62M9a2kUwtA== "@bitgo/wasm-solana@^2.6.0": version "2.6.0"