diff --git a/.gitignore b/.gitignore index 34e0f97..f3fe558 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ protected-main.json # misc dist .vscode -coverage \ No newline at end of file +coverage + +local-distribution diff --git a/docs/dkg-manager.md b/docs/dkg-manager.md new file mode 100644 index 0000000..5bf033e --- /dev/null +++ b/docs/dkg-manager.md @@ -0,0 +1,113 @@ +# DKGManager + +A small orchestration helper around the FROST DKG primitives that supports two flows: + +- Trusted-dealer keygen: one dealer produces all shares and the group public key. +- Coordinator-less multi-dealer: many dealers each contribute a polynomial; participants encrypt/decrypt shares and finalize their own share. + +This README covers the public API, step-by-step usage, and common errors. The code lives in `src/manager/dkg.ts`. + +## Public API (stable) + +- `new DKGManager()` +- `getAnnouncement(): { pubKey: Uint8Array }` — publish your X25519 public key for share encryption. +- `assignRoster(roster: Record): void` — map of participantId → X25519 public key. Your own key must be present. +- `runTrustedKeygen(secret: bigint, n: number, t: number)` → `{ shares, groupPublicKey, viewingPrivateKey }` +- `commitmentRound(secret: bigint, n: number, t: number)` → `{ shares: Record, commitments: Point[] }` +- `addParticipantCommitments(dealerId: number, commitments: Point[]): void` +- `getEncryptedShares()` → `Record` +- `addEncryptedShares(dealerId: number, shares: Record): void` +- `finalize()` → `{ share: { id, skShare, skShareDiv8 }, PKGroup, viewingPrivateKey }` + +Notes +- Participant IDs are 1..N positive integers. +- Shares and commitments must be provided for every dealer in the roster before finalizing. + +## Flow 1: Trusted-dealer keygen + +When a single dealer generates all shares and the group key. + +```ts +import DKGManager from '../src/manager/dkg' + +const dkg = new DKGManager() +const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn +const n = 5 +const t = 3 + +const { groupPublicKey, shares, viewingPrivateKey } = dkg.runTrustedKeygen(secret, n, t) +// shares: [{ identifier, skShare, skShareDiv8 }] +// groupPublicKey: [xHex, yHex] +``` + +Validation +- `n > 0`, `t > 0`, `t <= n`. + +## Flow 2: Coordinator-less multi-dealer + +Every dealer contributes a polynomial. Each participant obtains an encrypted share from every dealer, decrypts locally, and finalizes their own share. + +High level steps per dealer: + +1. Announce and roster +```ts +const dealer = new DKGManager() +const announce = dealer.getAnnouncement() // { pubKey } +// Build a roster across all dealers +const roster: Record = { 1: pub1, 2: pub2, 3: pub3, 4: pub4, 5: pub5 } +dealer.assignRoster(roster) // assigns dealer.participantID implicitly based on its own pubKey +``` + +2. Commitment round (per dealer) +```ts +const { shares, commitments } = dealer.commitmentRound(secret_i, n, t) +// Broadcast `commitments` to everyone; keep `shares` to encrypt next +``` + +3. Collect commitments +```ts +for (const [dealerId, comms] of Object.entries(allCommitments)) { + dealer.addParticipantCommitments(Number(dealerId), comms) +} +``` + +4. Encrypt and distribute shares (per dealer) +```ts +const encryptedByRecipient = dealer.getEncryptedShares() +// Send encryptedByRecipient[participantId] to each participant +``` + +5. Collect encrypted shares (per participant) +```ts +for (const [dealerId, encBundle] of Object.entries(collectedEncrypted)) { + dealer.addEncryptedShares(Number(dealerId), encBundle) +} +``` + +6. Finalize local share (per participant) +```ts +const { share, PKGroup, viewingPrivateKey } = dealer.finalize() +// share.id === dealer.participantID +``` + +Validation and ordering +- `assignRoster` must run before `commitmentRound`. +- Each `addParticipantCommitments` must be called for all dealers listed in roster (same ID set). +- `getEncryptedShares` requires a complete commitment set and local shares from `commitmentRound`. +- `addEncryptedShares` must be called for all dealers in roster. +- `finalize` throws if any dealers are missing. + +## Error messages and causes + +- `roster not assigned` — call `assignRoster` first. +- `our announcement pubKey not present in roster` — include the current manager's pubKey in roster. +- `roster size does not match desiredShares` — ensure `n` equals roster size. +- `missing commitments for one or more dealers` — collect all dealer commitments before encrypting or finalizing. +- `missing encrypted shares or keys from dealers: ...` — not all encrypted shares were collected. +- `threshold cannot exceed desiredShares` — fix `t` vs `n`. + +## Tips + +- Participant IDs are 1..N consecutively. +- The `viewingPrivateKey` is derived deterministically from the set of dealers (C0 commitments) and is identical across participants. +- The returned `PKGroup` is the sum of the first commitments across dealers. diff --git a/docs/signing-manager.md b/docs/signing-manager.md new file mode 100644 index 0000000..9e90b30 --- /dev/null +++ b/docs/signing-manager.md @@ -0,0 +1,73 @@ +# FROSTSigningManager + +Orchestrates the 2-round FROST signing flow on top of `BabyFROST` primitives, given a finalized group public key and per-participant shares. + +This README documents the public API, a minimal E2E example, and helper methods for readiness and diagnostics. The code lives in `src/manager/signing.ts`. + +## Public API (stable) + +- `new FROSTSigningManager(groupPublicKey: Point, threshold: number)` +- `addSigner({ id: number, skShare: bigint }): void` — add a local signer/share owned by this manager instance. +- `round1(): void` — generate commitments for local signers and reset round state. +- `exportRound1(): Commitment[]` — commitments for local signers to share with others. +- `addRemoteSigner(commitment: Commitment): void` — add a commitment from another participant (id is unique). +- `sign(msgHash: bigint): { identifier: number, partial: bigint }[]` — produce signature shares for all local signers, bound to the combined commitment list. +- `recievePartials(partials: { identifier, partial }[]): void` — collect partials from others. +- `finalize(msgHash: bigint)` → aggregated signature (tuple `[R8x, R8y, s]` from `BabyFROST`). + +Helper methods +- `expectedParticipantIds(): number[]` — identifiers implied by the current commitment list. +- `getMissingPartials(): number[]` — identifiers without collected partials. +- `readyToFinalize(): boolean` — true when at least `threshold` partials are available among expected ids. +- `resetRoundState(): void` — clears commitments and partials. + +## Minimal E2E usage (t-of-n) + +```ts +import FROSTSigningManager from '../src/manager/signing' +import { eddsaBuild } from '../src' // provides verifyPoseidon +import { bigIntToBuffer } from '@zk-kit/utils' + +const t = 3 +const groupPublicKey = /* Point from DKG */ + +// choose any t participants that have finalized DKG shares +const signers: FROSTSigningManager[] = [] +for (const { id, skShare } of subsetShares) { + const sm = new FROSTSigningManager(groupPublicKey, t) + sm.addSigner({ id, skShare }) + signers.push(sm) +} + +// round 1: produce + exchange commitments +for (const sm of signers) { + sm.round1() + const local = sm.exportRound1() + for (const c of local) { + for (const peer of signers) if (!peer.hasId(c.identifier)) peer.addRemoteSigner(c) + } +} + +// round 2: sign and exchange partials +const msg = 42069n +const partialsFromAll: { identifier: number, partial: bigint }[][] = [] +for (const sm of signers) partialsFromAll.push(sm.sign(msg)) +for (const sm of signers) for (const batch of partialsFromAll) sm.recievePartials(batch) + +// finalize and verify +const sig = signers[0].finalize(msg) +const ok = eddsaBuild.verifyPoseidon(bigIntToBuffer(msg), sig, groupPublicKey) +``` + +## Validation and errors + +- `getCommitmentList` sorts commitments by identifier and enforces `list.length >= threshold`. +- `sign` and `finalize` throw when commitments are missing or inconsistent. +- `finalize` verifies local signature shares before aggregation and throws if any fail verification. +- Use `readyToFinalize` and `getMissingPartials` to monitor progress. + +## Tips + +- Keep one manager instance per participating device/process. Each instance can manage one or more local shares if needed. +- Always call `round1()` before `exportRound1()` and `sign()`. +- Identifiers must be positive integers and must match identifiers assigned during DKG. diff --git a/pack.sh b/pack.sh new file mode 100755 index 0000000..ebcb017 --- /dev/null +++ b/pack.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# default to patch version bump +VERSION_TYPE="${1:-patch}" +OUT_DIR="${2:-./local-distribution}" + +# bump version in package.json +npm version $VERSION_TYPE --no-git-tag-version + +# pack the package +yarn pack + +# get package name and version +PACKAGE_NAME=$(node -p "require('./package.json').name.replace('@', '').replace('/', '-')") +PACKAGE_VERSION=$(node -p "require('./package.json').version") +OUT_NAME="${PACKAGE_NAME}-v${PACKAGE_VERSION}.tgz" + +# move the tarball to specified directory +if [ "$OUT_DIR" != "./" ]; then + mkdir -p "$OUT_DIR" + mv "$OUT_NAME" "$OUT_DIR/" + echo "Package moved to: $OUT_DIR/$OUT_NAME" +else + echo "Package created: $OUT_NAME" +fi diff --git a/package.json b/package.json index 2b70880..204ed37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@railgun-reloaded/curves-lite", - "version": "0.0.1", + "version": "0.2.9", "description": "Dependency light version of railgun curves functionality.", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -11,20 +11,20 @@ "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }, - "./frost/rfc9591": { - "types": "./dist/types/frost/rfc9591.d.ts", - "import": "./dist/esm/frost/rfc9591.js", - "require": "./dist/cjs/frost/rfc9591.js" + "./babyfrost": { + "types": "./dist/types/frost/babyfrost.d.ts", + "import": "./dist/esm/frost/babyfrost.js", + "require": "./dist/cjs/frost/babyfrost.js" }, - "./frost/vss-dkg": { - "types": "./dist/types/frost/vss-dkg.d.ts", - "import": "./dist/esm/frost/vss-dkg.js", - "require": "./dist/cjs/frost/vss-dkg.js" + "./trusted-dkg": { + "types": "./dist/types/frost/trusted-dkg.d.ts", + "import": "./dist/esm/frost/trusted-dkg.js", + "require": "./dist/cjs/frost/trusted-dkg.js" }, - "./vss": { - "types": "./dist/types/frost/vss-dkg.d.ts", - "import": "./dist/esm/frost/vss-dkg.js", - "require": "./dist/cjs/frost/vss-dkg.js" + "./manager": { + "types": "./dist/types/manager/index.d.ts", + "import": "./dist/esm/manager/index.js", + "require": "./dist/cjs/manager/index.js" } }, "scripts": { @@ -38,6 +38,7 @@ "pretest": "npm run build", "test": "node --import tsx --test --test-reporter=spec \"./test/**/*.test.ts\"", "coverage": "c8 --clean --reporter=text --reporter=lcov --include='src/**/*.ts' --exclude='src/**/*.test.ts' --exclude='**/dist/**' node --import tsx --test --test-reporter=spec \"./test/**/*.test.ts\"", + "pack:dev": "./pack.sh", "prepack": "npm run build" }, "repository": { @@ -63,6 +64,7 @@ }, "dependencies": { "@noble/ciphers": "^2.0.1", + "@noble/curves": "^2.0.1", "@noble/ed25519": "^3.0.0", "@noble/hashes": "^2.0.1", "@zk-kit/baby-jubjub": "^1.0.3", diff --git a/src/curve.ts b/src/curve.ts index abf42bf..bceb979 100644 --- a/src/curve.ts +++ b/src/curve.ts @@ -1,9 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { blake512 } from '@noble/hashes/blake1.js' -import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js' +import { randomBytes } from '@noble/hashes/utils.js' import type { Point } from '@zk-kit/baby-jubjub' import { Base8, Fr as FrValue, mulPointEscalar, order, r, subOrder } from '@zk-kit/baby-jubjub' -import { packPublicKey, unpackPublicKey } from '@zk-kit/eddsa-poseidon' import { leBigIntToBuffer, leBufferToBigInt } from '@zk-kit/utils' type FrType = typeof FrValue @@ -23,6 +22,172 @@ class RailJubCurvePoint { public readonly fieldPrime = r blake512 = blake512 + // RFC 8032 compliant point compression + // Encodes point as y-coordinate with sign bit for x in bit 255 + private pointCompress (P: Point): bigint { + const x = P[0] + const y = P[1] + + // Set bit 255 to the sign (least significant bit) of x + const sign = x & 1n + const encoded = y | (sign << 255n) + + return encoded + } + + // RFC 8032 compliant point decompression + // Recovers point from y-coordinate and x sign bit + private pointDecompress (s: bigint): Point | null { + // Extract sign bit from bit 255 + const sign = (s >> 255n) & 1n + // Mask out the sign bit to get y + const y = s & ((1n << 255n) - 1n) + + // Recover x from y and sign + const x = this.recoverX(y, sign) + if (x === null) return null + + return [x, y] + } + + // Recover x-coordinate from y-coordinate and sign bit + // Baby Jubjub curve equation: ax² + y² = 1 + dx²y² + // where a = 168700, d = 168696 + private recoverX (y: bigint, sign: bigint): bigint | null { + const a = 168700n + const d = 168696n + + // const p = this.fieldPrime + // Compute x² from curve equation + // x² = (y² - 1) / (dy² - a) + const y2 = this.modP(y * y) + const u = this.modP(y2 - 1n) + const v = this.modP(d * y2 - a) + + const vInv = this.modPInv(v) + if (vInv === null) return null + + const x2 = this.modP(u * vInv) + + // Compute square root + let x = this.modPSqrt(x2) + if (x === null) return null + + // Choose the square root with the correct sign + if ((x & 1n) !== sign) { + x = this.modP(-x) + } + + return x + } + + // Modular arithmetic in the field + private modP (x: bigint): bigint { + const r = x % this.fieldPrime + return r < 0n ? r + this.fieldPrime : r + } + + // Modular inverse in the field + private modPInv (a: bigint): bigint | null { + let t = 0n; let newT = 1n + let r = this.fieldPrime + let newR = this.modP(a) + + while (newR !== 0n) { + const q = r / newR + ;[t, newT] = [newT, t - q * newT] + ;[r, newR] = [newR, r - q * newR] + } + + if (r !== 1n) return null + if (t < 0n) t += this.fieldPrime + return t + } + + // Modular square root using Tonelli-Shanks or direct formula + // For Baby Jubjub, p ≡ 1 (mod 4), so we use Tonelli-Shanks + private modPSqrt (n: bigint): bigint | null { + const p = this.fieldPrime + + // Check if n is a quadratic residue + const ls = this.legendreSymbol(n, p) + if (ls !== 1n) return null + + // For p ≡ 5 (mod 8), we can use a direct formula + // Check if p ≡ 3 (mod 4) for simpler case + if (p % 4n === 3n) { + const exp = (p + 1n) / 4n + return this.modPPow(n, exp) + } + + // Otherwise use Tonelli-Shanks algorithm + return this.tonelliShanks(n, p) + } + + // Legendre symbol computation + private legendreSymbol (a: bigint, p: bigint): bigint { + const ls = this.modPPow(a, (p - 1n) / 2n) + return ls === p - 1n ? -1n : ls + } + + // Modular exponentiation + private modPPow (base: bigint, exp: bigint): bigint { + let result = 1n + base = this.modP(base) + + while (exp > 0n) { + if (exp % 2n === 1n) { + result = this.modP(result * base) + } + exp = exp / 2n + base = this.modP(base * base) + } + + return result + } + + // Tonelli-Shanks algorithm for computing square roots mod p + private tonelliShanks (n: bigint, p: bigint): bigint | null { + // Find Q and S such that p - 1 = Q * 2^S with Q odd + let Q = p - 1n + let S = 0n + while (Q % 2n === 0n) { + Q = Q / 2n + S += 1n + } + + // Find a quadratic non-residue z + let z = 2n + while (this.legendreSymbol(z, p) !== -1n) { + z += 1n + } + + let M = S + let c = this.modPPow(z, Q) + let t = this.modPPow(n, Q) + let R = this.modPPow(n, (Q + 1n) / 2n) + + while (true) { + if (t === 0n) return 0n + if (t === 1n) return R + + // Find the least i such that t^(2^i) = 1 + let i = 1n + let temp = this.modP(t * t) + while (temp !== 1n && i < M) { + temp = this.modP(temp * temp) + i += 1n + } + + const exp = M - i - 1n + const b = this.modPPow(c, 1n << exp) + M = i + c = this.modP(b * b) + t = this.modP(t * c) + R = this.modP(R * b) + } + } + // appendix D.(1, 2) rejectionSampling () { while (true) { @@ -38,9 +203,14 @@ class RailJubCurvePoint { } wideBytes () { - const wideBytes = randomBytes(48) - const wideBigInt = leBufferToBigInt(wideBytes) - return this.modOrder(wideBigInt) + const nBytes = 48 + const R = 1n << BigInt(nBytes * 8) + const limit = R - (R % this.order) + while (true) { + const bytes = randomBytes(nBytes) + const x = leBufferToBigInt(bytes) + if (x < limit) return x % this.order + } } Order () { @@ -51,7 +221,9 @@ class RailJubCurvePoint { return this.identity } - RandomScalar (rejectionSampling = false): bigint { + RandomScalar (rejectionSampling = true): bigint { + // RFC 9591: Scalars must be uniformly sampled in [0, order-1] + // Use rejection sampling by default to avoid modulo reduction bias. return rejectionSampling ? this.rejectionSampling() : this.wideBytes() } @@ -65,20 +237,27 @@ class RailJubCurvePoint { SerializeElement (A: Point): Uint8Array { if (this.pointsEqual(A, this.identity)) throw new Error('SerializeElement: input is group identity') - const packed = packPublicKey(A) - const hex = packed.toString(16).padStart(64, '0') - const be = hexToBytes(hex) - return be + + // Use RFC 8032 compliant compression + const packed = this.pointCompress(A) + const bytes = leBigIntToBuffer(packed, 32) + + return bytes } DeserializeElement (buf: Uint8Array): Point { if (buf.length !== 32) throw new Error('DeserializeElement: invalid length') - const formatted = BigInt('0x' + bytesToHex(buf)) - const P = unpackPublicKey(formatted) + + // Use RFC 8032 compliant decompression + const encoded = leBufferToBigInt(buf) + const P = this.pointDecompress(encoded) + if (P === null) throw new Error('DeserializeElement: invalid point encoding') if (this.pointsEqual(P, this.identity)) throw new Error('DeserializeElement: point is identity') + const check = mulPointEscalar(P, this.order) if (!this.pointsEqual(check, this.identity)) throw new Error('DeserializeElement: not in prime-order subgroup') + return P } @@ -103,12 +282,6 @@ class RailJubCurvePoint { return a[0] === b[0] && a[1] === b[1] } - // used in vss-dkg test for now. - multiplyUnsafe = (r: bigint) => { - const result = mulPointEscalar(this.generator, this.modCurveOrder(r)) - return result - } - toBytes (a: bigint) { return leBigIntToBuffer(a, 32) } @@ -127,6 +300,19 @@ class RailJubCurvePoint { return r < 0n ? r + this.order : r } + invModOrder (a: bigint): bigint { + let t = 0n; let newT = 1n + let r = this.order; let newR = this.modOrder(a) + while (newR !== 0n) { + const q = r / newR + ;[t, newT] = [newT, t - q * newT] + ;[r, newR] = [newR, r - q * newR] + } + if (r !== 1n) throw new Error('inverse does not exist') + if (t < 0n) t += this.order + return t + } + pruneBuffer (buff: Uint8Array) { const out = new Uint8Array(buff) out[0]! &= 0xf8 @@ -141,6 +327,18 @@ class RailJubCurvePoint { const s = leBufferToBigInt(pr.subarray(0, 32)) return s >> 3n } + + // utility functions + toMontgomery (ed: { x: bigint; y: bigint }) { + const { x, y } = ed + if (x === 0n) throw new Error('toMontgomery: x = 0 maps to v undefined') + const denU = this.modP(1n - y) + if (denU === 0n) throw new Error('toMontgomery: y = 1 (identity) not mappable') + + const u = this.modP(this.modP(1n + y) * this.modPInv(denU)!) + const v = this.modP(u * this.modP(x)) + return { u, v } + } } export type { AffinePoint } export { RailJubCurvePoint } diff --git a/src/eddsa/babyposeidon.ts b/src/eddsa/babyposeidon.ts index e4edd15..e0ab3e2 100644 --- a/src/eddsa/babyposeidon.ts +++ b/src/eddsa/babyposeidon.ts @@ -9,9 +9,14 @@ import { verifySignature, } from '@zk-kit/eddsa-poseidon' -import { RailJubCurvePoint } from '../curve' +import { RailJubCurvePoint } from '../curve.js' -type Signature = { +type SignatureHex = { + R8: [string, string], + S: string +} + +type Signature = SignatureHex | { R8: [bigint, bigint]; S: bigint; } @@ -26,7 +31,7 @@ class EddsaPoseidon extends RailJubCurvePoint { return result } - verifyPoseidon (msg: Uint8Array, sig: Signature, A: Point) { + verifyPoseidon (msg: Uint8Array, sig: Signature, A: Point | Point) { return verifySignature(msg, sig, A) as any } diff --git a/src/eddsa/index.ts b/src/eddsa/index.ts index 0f0213c..3200811 100644 --- a/src/eddsa/index.ts +++ b/src/eddsa/index.ts @@ -1,4 +1,4 @@ -import eddsaBuild from './babyposeidon' +import eddsaBuild from './babyposeidon.js' -export { EddsaPoseidon } from './babyposeidon' +export { EddsaPoseidon } from './babyposeidon.js' export { eddsaBuild } diff --git a/src/frost/babyfrost.ts b/src/frost/babyfrost.ts index 80046b6..63c92bf 100644 --- a/src/frost/babyfrost.ts +++ b/src/frost/babyfrost.ts @@ -1,57 +1,41 @@ /* eslint-disable camelcase, jsdoc/require-jsdoc */ -import { blake2b } from '@noble/hashes/blake2.js' import type { Point } from '@zk-kit/baby-jubjub' import { addPoint } from '@zk-kit/baby-jubjub' -import { leBigIntToBuffer, leBufferToBigInt } from '@zk-kit/utils' -import { poseidon5 } from 'poseidon-lite' import { RailJubCurvePoint } from '../curve.js' +import RFC9591Hasher from '../hashing.js' import type { BindingFactor, Bindings, Commitment, NoncePair } from './types.js' class BabyFROST extends RailJubCurvePoint { public readonly contextString = 'FROST-EDBABYJUJUB-BLAKE512-v1' + hasher: RFC9591Hasher - Hash (m: Uint8Array): Uint8Array { - return blake2b(m, { dkLen: 64 }) - } - - taggedBlake512 (tag: string, input: any) { - // Convert strings to buffers if needed - const prefixBuf = Buffer.from(this.contextString, 'utf8') - const tagBuf = Buffer.from(tag, 'utf8') - const inputBuf = Buffer.isBuffer(input) ? input : Buffer.from(input) - // Concatenate: prefix + tag + input - const combined = Buffer.concat([prefixBuf, tagBuf, inputBuf]) - return this.Hash(combined) - } - - deriveHashed (tag: string, input: Uint8Array): bigint { - const digest = this.taggedBlake512(tag, input) // 64 bytes - const kLE = leBigIntToBuffer(leBufferToBigInt(digest), 64) // interpret as LE bigint - return this.modOrder(leBufferToBigInt(kLE)) // reduce to scalar field + constructor () { + super() + this.hasher = new RFC9591Hasher(this.contextString, this.order) } H1 (m: Uint8Array): bigint { - return this.deriveHashed('rho', m) + return this.hasher.H1(m) } // H2: challenge computation compatible with eddsaBuild.verifyPoseidon H2 (R8: Point, A: Point, msgHash: bigint): bigint { - return this.modOrder(poseidon5([...R8, ...A, msgHash])) + return this.hasher.H2(R8, A, msgHash) } H3 (m: Uint8Array): bigint { - return this.deriveHashed('nonce', m) + return this.hasher.H3(m) } H4 (m: Uint8Array): bigint { - return this.deriveHashed('msg', m) + return this.hasher.H4(m) } H5 (m: Uint8Array): bigint { - return this.deriveHashed('com', m) + return this.hasher.H5(m) } // section 4.1 helper functions start diff --git a/src/frost/index.ts b/src/frost/index.ts index 202bebe..d4053d3 100644 --- a/src/frost/index.ts +++ b/src/frost/index.ts @@ -1,4 +1,4 @@ import BabyFROST, { frost } from './babyfrost.js' -import BabyFrostVSSDKG, { vss } from './vss-dkg.js' +import TrustedDKG from './trusted-dkg.js' -export { BabyFROST, frost, BabyFrostVSSDKG, vss } +export { BabyFROST, frost, TrustedDKG } diff --git a/src/frost/rfc-hashes.ts b/src/frost/rfc-hashes.ts deleted file mode 100644 index c4aaea5..0000000 --- a/src/frost/rfc-hashes.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -// Tagged Blake512 hash for FROST EdDSA Baby Jubjub - -import type { WithImplicitCoercion } from 'buffer' - -import { blake2b } from '@noble/hashes/blake2.js' -import { subOrder } from '@zk-kit/baby-jubjub' -import { leBigIntToBuffer, leBufferToBigInt } from '@zk-kit/utils' - -const mod = (a: bigint, n: bigint) => { - const r = a % n - return r >= 0n ? r : r + n -} - -/** - * Creates a tagged Blake512 hash for FROST EdDSA Baby Jubjub operations. - * @param tag - The tag string to include in the hash - * @param input - The input data to hash (string or Buffer) - * @returns The Blake512 hash digest as a Buffer (64 bytes) - */ -function taggedBlake512 (tag: WithImplicitCoercion, input: any) { - const prefix = 'FROST-EDBABYJUJUB-BLAKE512-v1' - - // Convert strings to buffers if needed - const prefixBuf = Buffer.from(prefix, 'utf8') - const tagBuf = Buffer.from(tag, 'utf8') - const inputBuf = Buffer.isBuffer(input) ? input : Buffer.from(input) - - // Concatenate: prefix + tag + input - const combined = Buffer.concat([prefixBuf, tagBuf, inputBuf]) - - return blake2b(combined, { dkLen: 64 }) -} - -function deriveHashed (tag: string, input: Uint8Array): bigint { - const digest = taggedBlake512(tag, input) // 64 bytes - const kLE = leBigIntToBuffer(leBufferToBigInt(digest), 64) // interpret as LE bigint - return mod(leBufferToBigInt(kLE), subOrder) // reduce to scalar field -} - -/** - * Computes H1 hash function for FROST using tagged Blake512 - * @param input - The input data to hash - * @returns The hash result modulo baby jubjub order - */ -function H1 (input: any) { - return deriveHashed('rho', input) - // const babyJubOrder = BigInt('2736030358979909402780800718157159386076813972158567259200215660948447373041') -} - -/** - * Computes H3 hash function for FROST nonce generation using tagged Blake512 - * @param input - The input data to hash - * @returns The hash result modulo baby jubjub order - */ -function H3 (input: any) { - return deriveHashed('nonce', input) - - // const hashedNonce = Scalar.fromRprLE(taggedBlake512('nonce', input), 0, 64) - // // const babyJubOrder = BigInt('2736030358979909402780800718157159386076813972158567259200215660948447373041') - // return Scalar.mod(hashedNonce, subOrder) -} - -/** - * Computes H4 hash function for FROST message hashing using tagged Blake512 - * @param input - The input data to hash - * @returns The hash result modulo baby jubjub order - */ -function H4 (input: any) { - return deriveHashed('msg', input) - // const hashedNonce = Scalar.fromRprLE(taggedBlake512('msg', input), 0, 64) - // // const babyJubOrder = BigInt('2736030358979909402780800718157159386076813972158567259200215660948447373041') - // return Scalar.mod(hashedNonce, subOrder) -} - -/** - * Computes H5 hash function for FROST commitment hashing using tagged Blake512 - * @param input - The input data to hash - * @returns The hash result modulo baby jubjub order - */ -function H5 (input: any) { - return deriveHashed('com', input) - // const hashedNonce = Scalar.fromRprLE(taggedBlake512('com', input), 0, 64) - // // const babyJubOrder = BigInt('2736030358979909402780800718157159386076813972158567259200215660948447373041') - // return Scalar.mod(hashedNonce, subOrder) -} - -export { deriveHashed, taggedBlake512, H1, H3, H4, H5 } diff --git a/src/frost/trusted-dkg.ts b/src/frost/trusted-dkg.ts index 00f416c..d84782c 100644 --- a/src/frost/trusted-dkg.ts +++ b/src/frost/trusted-dkg.ts @@ -1,11 +1,25 @@ /* eslint-disable camelcase */ /* eslint-disable jsdoc/require-jsdoc */ + +import { gcm } from '@noble/ciphers/aes.js' +import { randomBytes } from '@noble/hashes/utils.js' import type { Point } from '@zk-kit/baby-jubjub' import { addPoint, mulPointEscalar } from '@zk-kit/baby-jubjub' -import { RailJubCurvePoint } from '../curve' +import { RailJubCurvePoint } from '../curve.js' +import RFC9591Hasher from '../hashing.js' + +import type { EncryptedShare } from './types.js' class TrustedDKG extends RailJubCurvePoint { + public readonly contextString = 'FROST-EDBABYJUJUB-BLAKE512-v1' + hasher: RFC9591Hasher + + constructor () { + super() + this.hasher = new RFC9591Hasher(this.contextString, this.order) + } + // Appendix C.1.1 polynomialEvaluate (x: bigint, coefficients: bigint[]) { let value = 0n @@ -17,7 +31,7 @@ class TrustedDKG extends RailJubCurvePoint { } trustedDealerKeygen (secretKey: bigint, MAX_PARTICIPANTS: number, MIN_PARTICIPANTS: number) { - const coefficients = [] + const coefficients: bigint[] = [] for (let i = 0; i < MIN_PARTICIPANTS - 1; i++) { coefficients.push(this.RandomScalar()) } @@ -80,7 +94,233 @@ class TrustedDKG extends RailJubCurvePoint { } participantPublicKeys.push(PK_i) } - return { PK, participantPublicKeys } + const viewingPrivateKey = new Uint8Array(this.deriveViewKeyFromPK([vssCommitment])) + return { PK, participantPublicKeys, viewingPrivateKey } + } + + // coordinator-less style helpers (similar to vss-dkg) + static assertSortedConsecutiveIds (ids: number[]) { + if (!ids.length) throw new Error('empty recipient id list') + if (ids.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new Error('recipient ids must be strictly increasing positive integers (self may be excluded)') + } + for (let i = 1; i < ids.length; i++) { + if (ids[i]! <= ids[i - 1]!) { + throw new Error('recipient ids must be strictly increasing positive integers (self may be excluded)') + } + } + } + + private verifyCommitmentsShape (allDealerCommitments: Point[][]): number { + const lens = allDealerCommitments.map((c) => (c?.length ?? 0)) + if (lens.length === 0) throw new Error('no dealer commitments') + if (lens.some((l) => l <= 0)) throw new Error('dealer missing commitments') + const t = lens[0]! + for (const l of lens) { + if (l !== t) throw new Error('commitment degree mismatch across dealers') + } + return t + } + + computeSharesForIds (coefficients: bigint[], recipientIds: number[]): Record { + TrustedDKG.assertSortedConsecutiveIds(recipientIds) + const out: Record = {} + for (const id of recipientIds) { + if (!Number.isInteger(id) || id <= 0) throw new Error(`bad recipient id: ${id}`) + out[id] = this.polynomialEvaluate(BigInt(id), coefficients) + } + return out + } + + combineGroupPubkeyFromCommitments (allDealerCommitments: Point[][]): Point { + if (!allDealerCommitments.length) throw new Error('no dealer commitments') + this.verifyCommitmentsShape(allDealerCommitments) + let acc: Point = this.Identity() + for (const dealerCom of allDealerCommitments) { + if (!dealerCom?.length) throw new Error('dealer missing commitments') + const P0 = dealerCom[0]! + if (!this.pointsEqual(this.ScalarMult(P0, this.order), this.Identity())) { + throw new Error('C0 not in subgroup') + } + acc = addPoint(acc, P0) + } + return acc + } + + verifyAllCommitmentsSubgroup (allDealerCommitments: Point[][]): boolean { + if (!allDealerCommitments.length) return false + for (const dealerCom of allDealerCommitments) { + if (!dealerCom?.length) return false + for (const Cj of dealerCom) { + if (!this.pointsEqual(this.ScalarMult(Cj, this.order), this.Identity())) return false + } + } + return true + } + + verifyFeldmanShare ( + id: number, + sk_i: bigint, + commitments: Array> + ): boolean { + try { + if (!Number.isInteger(id) || id <= 0) return false + if (!commitments?.length) return false + + const LHS = this.ScalarBaseMult(this.modOrder(sk_i)) + + let RHS: Point = this.Identity() + let pow = 1n + const idL = this.modOrder(BigInt(id)) + for (const Cj of commitments) { + if (!this.pointsEqual(this.ScalarMult(Cj, this.order), this.Identity())) return false + RHS = addPoint(RHS, this.ScalarMult(Cj, pow)) + pow = this.modOrder(pow * idL) + } + return this.pointsEqual(LHS, RHS) + } catch { + return false + } + } + + deriveInterpolatingValue (ids: bigint[], x_i: bigint): bigint { + const found = ids.find(a => { return a === x_i }) + if (!found) throw new Error('invalid parameters') + let num = 1n + let dom = 1n + for (const x_j of ids) { + if (x_j === x_i) continue + num = this.modOrder(num * this.modOrder(x_j)) + dom = this.modOrder(dom * this.modOrder(x_j - x_i)) + } + const invDom = this.invModOrder(dom) + return this.modOrder(num * invDom) + } + + reconstructConstantFromShares (subset: { id: number; s_i: bigint }[]): bigint { + if (!subset.length) throw new Error('no shares') + const ids = subset.map((s) => this.modOrder(BigInt(s.id))) + let a0 = 0n + for (const { id, s_i } of subset) { + const lambda = this.deriveInterpolatingValue(ids, this.modOrder(BigInt(id))) + a0 = this.modOrder(a0 + this.modOrder(s_i) * lambda) + } + return a0 + } + + private sortKey = (P: Point) => + `${P[0].toString(16).padStart(64, '0')}:${P[1].toString(16).padStart(64, '0')}` + + deriveViewKeyFromPK (allDealerCommitments: Point[][]): Uint8Array { + const C0s: Point[] = [] + for (const dealerComms of allDealerCommitments) { + if (!dealerComms?.length) continue + C0s.push(dealerComms[0]!) + } + C0s.sort((a, b) => (this.sortKey(a) < this.sortKey(b) ? -1 : this.sortKey(a) > this.sortKey(b) ? 1 : 0)) + let acc = 0n + for (const C0 of C0s) { + // hash the affine coordinates + domain; interpret as scalar then accumulate in modL + const v = this.hasher.H7(this.SerializeElement(C0)) + const v_k = this.modOrder(v) + acc = this.modOrder(acc + v_k) + } + return this.toBytes(acc === 0n ? 1n : acc) // avoid zero key + } + + finalizeParticipant ( + participantId: number, + s_ki_byDealer: Array<{ dealerId: number; s_ki: bigint }>, + allDealerCommitments: Point[][] + ): { share: { id: number; skShare: bigint; skShareDiv8: bigint }; PKGroup: Point, viewingPrivateKey: Uint8Array } { + if (!Number.isInteger(participantId) || participantId <= 0) { + throw new Error('bad participantId') + } + if (!s_ki_byDealer.length || s_ki_byDealer.length !== allDealerCommitments.length) { + throw new Error('dealer/share count mismatch') + } + const dealerIds = s_ki_byDealer.map((d) => d.dealerId).slice().sort((a, b) => a - b) + TrustedDKG.assertSortedConsecutiveIds(dealerIds) + let s_i = 0n + for (const { s_ki } of s_ki_byDealer) s_i = this.modOrder(s_i + this.modOrder(s_ki)) + const skShareDiv8 = s_i + const skShare = this.modOrder(8n * skShareDiv8) + const share = { id: participantId, skShare, skShareDiv8 } + const viewingPrivateKey = this.deriveViewKeyFromPK(allDealerCommitments) + const PKGroup = this.combineGroupPubkeyFromCommitments(allDealerCommitments) + return { share, PKGroup, viewingPrivateKey } + } + + commitmentsDigest (allDealerCommitments: Point[][]): bigint { + const enc = this.encodeCommitmentsBytes(allDealerCommitments) + return this.hasher.H5(enc) + } + + private encodeCommitmentsBytes (allDealerCommitments: Point[][]): Uint8Array { + const dealers = allDealerCommitments.slice().filter(a => a?.length) + dealers.sort((a, b) => (this.sortKey(a[0]!) < this.sortKey(b[0]!) ? -1 : 1)) + let out = new Uint8Array() + for (const dealer of dealers) { + for (const Cj of dealer) { + const enc = this.SerializeElement(Cj) + out = Buffer.concat([out, enc]) + } + } + return out + } + + encryptSharesAESGCMWithAAD ( + shares: Record, + keyById: Record, + allDealerCommitments: Point[][] + ): Record { + const ids = Object.keys(shares).map(Number).sort((a, b) => a - b) + TrustedDKG.assertSortedConsecutiveIds(ids) + const out: Record = {} + const digest = this.commitmentsDigest(allDealerCommitments) + const digestBytes = this.toBytes(digest) + for (const [idStr, s] of Object.entries(shares)) { + const id = Number(idStr) + const key = keyById[id] + if (!key || key.length !== 32) throw new Error(`bad AES key for id ${id}`) + const nonce = randomBytes(12) + const pt = this.toBytes(this.modOrder(s)) + const aad = Buffer.concat([this.SerializeScalar(BigInt(id)), digestBytes]) + const aead = gcm(key, nonce, aad) + const ciphertext = aead.encrypt(pt) + out[id] = { nonce, ciphertext } + } + return out + } + + decryptShareAESGCMWithAAD ( + enc: EncryptedShare, + keyBytes: Uint8Array, + participantId: number, + allDealerCommitments: Point[][] + ): bigint { + if (keyBytes.length !== 32) throw new Error('bad AES key length') + if (!Number.isInteger(participantId) || participantId <= 0) throw new Error('bad participant id') + let nonce = new Uint8Array(Object.values(enc.nonce)) + const ct = new Uint8Array(Object.values(enc.ciphertext)) + if (nonce.length !== 12) { + const out = new Uint8Array(12) + if (nonce.length > 12) { + // trim from the end + out.set(nonce.subarray(0, 12)) + } else { + // pad with zeros at the end + out.set(nonce, 0) + } + nonce = out + } + if (nonce.length !== 12) throw new Error('bad nonce length (expected 12)') + if (ct.length < 16) throw new Error('bad ciphertext (must include 16B tag)') + const digest = this.commitmentsDigest(allDealerCommitments) + const aad = Buffer.concat([this.SerializeScalar(BigInt(participantId)), this.toBytes(digest)]) + const aead = gcm(keyBytes, nonce, aad) + const pt = aead.decrypt(ct) + return this.fromBytes(pt) } } diff --git a/src/frost/vss-dkg.ts b/src/frost/vss-dkg.ts deleted file mode 100644 index bacac75..0000000 --- a/src/frost/vss-dkg.ts +++ /dev/null @@ -1,242 +0,0 @@ -/* eslint-disable jsdoc/require-returns */ -/* eslint-disable jsdoc/require-param */ -/* eslint-disable camelcase */ -/* eslint-disable jsdoc/require-jsdoc */ - -import { gcm } from '@noble/ciphers/aes.js' -import { randomBytes } from '@noble/hashes/utils.js' -import type { Point } from '@zk-kit/baby-jubjub' -import { addPoint, mulPointEscalar } from '@zk-kit/baby-jubjub' -import { poseidon3, poseidon4 } from 'poseidon-lite' - -import { RailJubCurvePoint } from '../curve.js' - -import type { EncryptedShare, ParticipantInput, Share } from './types.js' - -function objToU8 (o: Record | Uint8Array): Uint8Array { - if (o instanceof Uint8Array) return o - const out = new Uint8Array(Object.keys(o).length) - for (const [k, v] of Object.entries(o)) out[Number(k)] = Number(v) - return out -} - -function asPoint (p: { x: bigint; y: bigint } | Point): Point { - return Array.isArray(p) ? p : [p.x, p.y] -} - -class BabyFrostVSSDKG extends RailJubCurvePoint { - // domain tags (as scalars) for clear separation - COEFF_DOMAIN = 0x636f6566n /* 'coef' */ - VIEW_DOMAIN = 0x76696577n /* 'view' */ - ENC_DOMAIN = 0x656e636en /* 'encn' */ - - // ---------- math ---------- - /** Evaluate polynomial (coeffs in mod L) at x=id in mod L. */ - evalPolySubgroup (coeffsL: bigint[], id: bigint): bigint { - let y = 0n - let pow = 1n - for (const aj of coeffsL) { - y = this.modOrder(y + this.modOrder(aj) * pow) - pow = this.modOrder(pow * id) - } - return y - } - - // ---------- misc ---------- - private sortKey = (P: Point) => - `${P[0].toString(16).padStart(64, '0')}:${P[1].toString(16).padStart(64, '0')}` - - /** - * Deterministically derive a "view key" from dealers' constant-term commitments, for read-only uses. - * If you will use this as an AES key, it's fine as a 32-byte secret; for scalar uses modL first. - */ - deriveViewKeyFromPK (allDealerCommitments: Point[][]): Uint8Array { - const C0s: Point[] = [] - for (const dealerComms of allDealerCommitments) { - if (!dealerComms?.length) continue - C0s.push(dealerComms[0]!) - } - C0s.sort((a, b) => (this.sortKey(a) < this.sortKey(b) ? -1 : this.sortKey(a) > this.sortKey(b) ? 1 : 0)) - let acc = 0n - for (const C0 of C0s) { - // hash the affine coordinates + domain; interpret as scalar then accumulate in modL - const v_k = this.modOrder(poseidon3([...C0, this.VIEW_DOMAIN])) - acc = this.modOrder(acc + v_k) - } - return this.toBytes(acc === 0n ? 1n : acc) // avoid zero key - } - - /** - * Deterministic dealer coefficients. If `initialState` doesn't include a0, derive a0 with j=0. - * Returns coeffs in mod L: [a0, a1, ..., a_{t-1}] - */ - makeDealerCoeffsDeterministic (p: ParticipantInput, threshold: number, initialState: bigint[] = []): bigint[] { - if (threshold <= 0) throw new Error('threshold must be > 0') - const a: bigint[] = initialState.slice() - if (a.length === 0) { - const a0 = this.modOrder(poseidon4([BigInt(p.id), 0n, this.fromBytes(p.seed), this.fromBytes(p.password)]) + this.COEFF_DOMAIN) - if (a0 === 0n) throw new Error('a0 must be non-zero') - a.push(a0) - } - for (let j = a.length; j < threshold; j++) { - const c = poseidon4([BigInt(p.id), BigInt(j), this.fromBytes(p.seed), this.fromBytes(p.password)]) - a.push(this.modOrder(c + this.COEFF_DOMAIN)) - } - if (a.length !== threshold) throw new Error('incorrect coefficient length') - return a - } - - /** Commitments Cj = Base8^{a_j} for j in [0..t-1]. */ - makeDealerCommitments (coeffsL: bigint[]): Point[] { - return coeffsL.map((aj) => { - const s = this.modOrder(aj) - // Base8 * s; stays in prime-order subgroup - return mulPointEscalar(this.generator, s) - }) - } - - /** Compute evaluations s_k(i) for recipients i. */ - computeDealerSharesForIds (coeffsL: bigint[], recipientIds: number[]): Record { - const out: Record = {} - for (const id of recipientIds) { - if (!Number.isInteger(id) || id <= 0) throw new Error(`bad recipient id: ${id}`) - out[id] = this.evalPolySubgroup(coeffsL, this.modOrder(BigInt(id))) - } - return out - } - - /** Check each dealer's C0 ∈ subgroup and aggregate the group public key A = ∑_k C_{k,0}. */ - combineGroupPubkeyFromCommitments (allDealerCommitments: Point[][]): Point { - if (!allDealerCommitments.length) throw new Error('no dealer commitments') - let acc: Point = this.identity - for (const dealerCom of allDealerCommitments) { - if (!dealerCom?.length) throw new Error('dealer missing commitments') - const P0 = dealerCom[0]! - // subgroup check: SUBORDER * P0 == 0 - if (!this.pointsEqual(mulPointEscalar(P0, this.order), this.identity)) { - throw new Error('C0 not in subgroup') - } - acc = addPoint(acc, P0) - } - return acc - } - - /** Verify Feldman share s_i against commitments. */ - verifyFeldmanShare ( - id: number, - sk_i: bigint, - commitments: Array> - ): boolean { - try { - if (!Number.isInteger(id) || id <= 0) return false - if (!commitments?.length) return false - - // LHS = Base8 * s_i - const LHS = mulPointEscalar(this.generator, this.modOrder(sk_i)) - - // RHS = Σ_j C_j * (id^j) - let RHS: Point = this.identity - let pow = 1n - const idL = this.modOrder(BigInt(id)) - for (const CjRaw of commitments) { - const Cj = asPoint(CjRaw) - // Cj must be in subgroup - if (!this.pointsEqual(mulPointEscalar(Cj, this.order), this.identity)) return false - RHS = addPoint(RHS, mulPointEscalar(Cj, pow)) - pow = this.modOrder(pow * idL) - } - return this.pointsEqual(LHS, RHS) - } catch { - return false - } - } - - /** - * Finalize participant i: aggregate all dealer evaluations s_{k}(i), - * produce (skShareDiv8, skShare = 8*skShareDiv8 mod L), group PK, and a view key. - */ - finalizeVSSForParticipant ( - participantId: number, - s_ki_byDealer: Array<{ dealerId: number; s_ki: bigint }>, - allDealerCommitments: Point[][] - ): { share: Share; PKGroup: Point; viewingPrivateKey: Uint8Array } { - if (!Number.isInteger(participantId) || participantId <= 0) { - throw new Error('bad participantId') - } - if (!s_ki_byDealer.length || s_ki_byDealer.length !== allDealerCommitments.length) { - throw new Error('dealer/share count mismatch') - } - // s_i = Σ_k s_{k}(i) (mod L) - let s_i = 0n - for (const { s_ki } of s_ki_byDealer) s_i = this.modOrder(s_i + this.modOrder(s_ki)) - - const skShareDiv8 = s_i - const skShare = this.modOrder(8n * skShareDiv8) - const share: Share = { id: participantId, skShare, skShareDiv8 } - - const PKGroup = this.combineGroupPubkeyFromCommitments(allDealerCommitments) - const viewingPrivateKey = this.deriveViewKeyFromPK(allDealerCommitments) - - return { share, PKGroup, viewingPrivateKey } - } - - /** - * Encrypt per-recipient shares using AES-GCM with strong context binding. - * keys: map of recipient id -> 32B AES key (e.g., from ECDH) - */ - encryptSharesAESGCM ( - shares: Record, - keyById: Record - ): Record { - const out: Record = {} - for (const [idStr, s] of Object.entries(shares)) { - const id = Number(idStr) - const key = keyById[id] - if (!key || key.length !== 32) throw new Error(`bad AES key for id ${id}`) - - const nonce = randomBytes(12) - const pt = this.toBytes(this.modOrder(s)) - - const aead = gcm(key, nonce) - const ciphertext = aead.encrypt(pt) - - out[id] = { nonce, ciphertext } - } - return out - } - - decryptShareAESGCM ( - enc: EncryptedShare, - keyBytes: Uint8Array - ): bigint { - if (keyBytes.length !== 32) throw new Error('bad AES key length') - - const nonce = objToU8(enc.nonce as any) - const ct = objToU8(enc.ciphertext as any) - if (nonce.length !== 12) throw new Error('bad nonce length (expected 12)') - if (ct.length < 16) throw new Error('bad ciphertext (must include 16B tag)') - const aead = gcm(keyBytes, nonce) - const pt = aead.decrypt(ct) // throws on auth failure - return this.fromBytes(pt) - } - - // ---------- Extra invariants / utilities ---------- - /** Optional: assert commitments degree matches threshold. */ - assertCommitmentDegree (commitments: Point[], threshold: number) { - if (commitments.length !== threshold) throw new Error('commitment degree mismatch') - } - - /** Optional: sanity check unique positive participant ids. */ - static assertUniqueRecipientIds (ids: number[]) { - const s = new Set(ids) - if (ids.some((i) => !Number.isInteger(i) || i <= 0) || s.size !== ids.length) { - throw new Error('bad or duplicate recipient ids') - } - } -} - -// Singleton export (like your original) -const vss = new BabyFrostVSSDKG() - -export { vss } -export default BabyFrostVSSDKG diff --git a/src/hashing.ts b/src/hashing.ts new file mode 100644 index 0000000..db3d891 --- /dev/null +++ b/src/hashing.ts @@ -0,0 +1,81 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +import { blake2b } from '@noble/hashes/blake2.js' +import type { Point } from '@zk-kit/baby-jubjub' +import { leBigIntToBuffer, leBufferToBigInt } from '@zk-kit/utils' +import { poseidon5 } from 'poseidon-lite' + +type HashFn = (m: Uint8Array) => Uint8Array +// TODO: to modularize this hasher, we can add more hash functions here +function blake2BWrapper (m: Uint8Array) { + return blake2b(m, { dkLen: 64 }) +} + +// will initialize in class used +class RFC9591Hasher { + public readonly contextString: string + private readonly hashFn: HashFn + private readonly order: bigint + constructor (contextString: string, order: bigint, hashFn?: HashFn) { + this.contextString = contextString + this.hashFn = hashFn ?? blake2BWrapper + this.order = order + } + + mod (x: bigint) { + const r = x % this.order + return r < 0n ? r + this.order : r + } + + Hash (m: Uint8Array): Uint8Array { + return this.hashFn(m) + } + + taggedHash (tag: string, input: any) { + // Convert strings to buffers if needed + const prefixBuf = Buffer.from(this.contextString, 'utf8') + const tagBuf = Buffer.from(tag, 'utf8') + const inputBuf = Buffer.isBuffer(input) ? input : Buffer.from(input) + // Concatenate: prefix + tag + input + const combined = Buffer.concat([prefixBuf, tagBuf, inputBuf]) + return this.Hash(combined) + } + + deriveHashed (tag: string, input: Uint8Array): bigint { + const digest = this.taggedHash(tag, input) // 64 bytes + const kLE = leBigIntToBuffer(leBufferToBigInt(digest), 64) // interpret as LE bigint + return this.mod(leBufferToBigInt(kLE)) // reduce to scalar field + } + + H1 (m: Uint8Array): bigint { + return this.deriveHashed('rho', m) + } + + // H2: challenge computation compatible with eddsaBuild.verifyPoseidon + H2 (R8: Point, A: Point, msgHash: bigint): bigint { + // return poseidon5([...R8, ...A, msgHash]) + return this.mod(poseidon5([...R8, ...A, msgHash])) + } + + H3 (m: Uint8Array): bigint { + return this.deriveHashed('nonce', m) + } + + H4 (m: Uint8Array): bigint { + return this.deriveHashed('msg', m) + } + + H5 (m: Uint8Array): bigint { + return this.deriveHashed('com', m) + } + + H6 (m: Uint8Array): bigint { + return this.deriveHashed('coeff', m) + } + + H7 (m: Uint8Array): bigint { + return this.deriveHashed('view', m) + } +} + +export default RFC9591Hasher diff --git a/src/index.ts b/src/index.ts index 272ef9b..6d280b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,13 @@ import { getPublicKey } from '@noble/ed25519' import type { Point } from '@zk-kit/baby-jubjub' -import { bigIntToBuffer, bufferToBigInt } from '@zk-kit/utils' +import { bigIntToBuffer, bufferToBigInt, leBigIntToBuffer, leBufferToBigInt } from '@zk-kit/utils' import msgpack from 'msgpack-lite' import type { Signature } from './eddsa/babyposeidon.js' import { EddsaPoseidon, eddsaBuild } from './eddsa/index.js' import { BabyFROST, frost } from './frost/index.js' +import { DKGManager, FROSTSigningManager } from './manager/index.js' import { poseidonFn } from './poseidon/poseidon-lite-wrapper.js' function poseidon (inputs: Uint8Array[]) { @@ -56,15 +57,15 @@ export type * from './frost/types.js' export type { Point } -// FROST -export { - BabyFrostVSSDKG, - vss, -} from './frost/index.js' - export { frost, + bufferToBigInt, + bigIntToBuffer, + leBigIntToBuffer, + leBufferToBigInt, BabyFROST, + FROSTSigningManager, + DKGManager, eddsaBuild, EddsaPoseidon, getPublicSpendingKey, @@ -75,3 +76,23 @@ export { poseidon, poseidonHex } + +export default { + bufferToBigInt, + bigIntToBuffer, + leBigIntToBuffer, + leBufferToBigInt, + frost, + BabyFROST, + FROSTSigningManager, + DKGManager, + eddsaBuild, + EddsaPoseidon, + getPublicSpendingKey, + getPublicViewingKey, + getShareableViewingKey, + signEDDSA, + verifyEDDSA, + poseidon, + poseidonHex, +} diff --git a/src/manager/dkg.ts b/src/manager/dkg.ts new file mode 100644 index 0000000..358e9fa --- /dev/null +++ b/src/manager/dkg.ts @@ -0,0 +1,278 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { + x25519 +} from '@noble/curves/ed25519.js' +import { randomBytes } from '@noble/hashes/utils.js' +import type { Point } from '@zk-kit/baby-jubjub' +import { bufferToBigInt } from '@zk-kit/utils' + +import TrustedDKG from '../frost/trusted-dkg.js' +import type { EncryptedShare } from '../frost/types.js' + +// Lightweight internal state to prevent wrong-order usage and surface clear errors +// also output state updates for the upstream clients. + +enum DKGFlowState { + Init = 'init', + RosterAssigned = 'roster-assigned', + CommitmentsCreated = 'commitments-created', + CommitmentsCollected = 'commitments-collected', + SharesEncrypted = 'shares-encrypted', + EncryptedSharesCollected = 'encrypted-shares-collected', + Finalized = 'finalized', +} + +class DKGManager { + name: string + dkg: TrustedDKG + participantID: number | undefined + private secretComKey: Uint8Array + pubComKey: Uint8Array + roster: Record + keysByID: Record = {} + private shares: Record = {} + recipientIds: number[] = [] + encryptedShares: Record> = {} + private commitmentsByDealerId: Record[]> = {} + + private state: DKGFlowState = DKGFlowState.Init + + private readonly stateOrder: Record = { + [DKGFlowState.Init]: 0, + [DKGFlowState.RosterAssigned]: 1, + [DKGFlowState.CommitmentsCreated]: 2, + [DKGFlowState.CommitmentsCollected]: 3, + [DKGFlowState.SharesEncrypted]: 4, + [DKGFlowState.EncryptedSharesCollected]: 5, + [DKGFlowState.Finalized]: 6, + } + + private ensureStateIn (where: string, allowed: DKGFlowState[]) { + if (!allowed.includes(this.state)) { + const allowedStr = allowed.join('|') + throw new Error(`${where} invalid state: ${this.state}; allowed: ${allowedStr}`) + } + } + + private ensureStateAtLeast (where: string, min: DKGFlowState) { + if (this.stateOrder[this.state] < this.stateOrder[min]) { + throw new Error(`${where} requires state >= ${min}, current=${this.state}`) + } + } + + getState () { return this.state } + + private getAllDealerCommitments (): Point[][] { + // must have collected complete commitments set + this.ensureStateAtLeast('getAllDealerCommitments', DKGFlowState.CommitmentsCollected) + const rosterIds = Object.keys(this.roster || {}).map(Number).sort((a, b) => a - b) + if (!rosterIds.length) throw new Error('roster not assigned') + + const ids = Object.keys(this.commitmentsByDealerId).map(Number).sort((a, b) => a - b) + if (ids.length === 0) throw new Error('no dealer commitments have been added') + if (ids.length !== rosterIds.length || ids.some((id, i) => id !== rosterIds[i])) { + throw new Error('missing commitments for one or more dealers') + } + const ordered: Point[][] = [] + for (const id of ids) ordered.push(this.commitmentsByDealerId[id]!) + return ordered + } + + constructor (participantName: string, secretCommKey?: Uint8Array) { + this.dkg = new TrustedDKG() + // this.secretComKey = this.dkg.RandomScalar() -- example of another way for random bytes... this key does not need to be a scalar though. + this.secretComKey = secretCommKey ?? randomBytes(32) + this.name = participantName + // this will be announced with public commitments + this.pubComKey = this.getPublicKey(this.secretComKey) + this.roster = {} + } + + runTrustedKeygen (privateKey: bigint, desiredShares: number, threshold: number) { + if (!Number.isInteger(desiredShares) || desiredShares <= 0) throw new Error('desiredShares must be a positive integer') + if (!Number.isInteger(threshold) || threshold <= 0) throw new Error('threshold must be a positive integer') + if (threshold > desiredShares) throw new Error('threshold cannot exceed desiredShares') + const result = this.dkg.trustedDealerKeygen(privateKey, desiredShares, threshold) + const groupInfo = this.dkg.deriveGroupInfo(desiredShares, threshold, result.vssCommitment) + + const output = { + shares: result.participantPrivateKeys.map(a => { + return { + identifier: a.x_i, + skShare: '0x' + this.dkg.modOrder(a.y_i * 8n).toString(16), + skShareDiv8: '0x' + a.y_i.toString(16) + } + }), + groupPublicKey: groupInfo.PK!.map(a => '0x' + a.toString(16)), + viewingPrivateKey: groupInfo.viewingPrivateKey, + } + return output + } + + getAnnouncement () { + return { pubKey: this.pubComKey, name: this.name } + } + + assignIdentifier (identifier: number) { + if (!Number.isInteger(identifier) || identifier <= 0) throw new Error('bad participant identifier') + this.participantID = identifier + } + + // id and pubkey + assignRoster (roster: Record) { + this.ensureStateIn('assignRoster', [DKGFlowState.Init]) + // Basic shape validation + if (!roster || typeof roster !== 'object') throw new Error('invalid roster') + const ids = Object.keys(roster).map(Number).sort((a, b) => a - b) + if (!ids.length) throw new Error('empty roster') + ids.forEach((id) => { if (!Number.isInteger(id) || id <= 0) throw new Error(`bad roster id: ${id}`) }) + + this.roster = {} + this.keysByID = {} + let matchedSelf = false + for (const idStr in roster) { + const id = Number(idStr) + const key = roster[id]! + if (!(key instanceof Uint8Array) || key.length !== 32) throw new Error(`bad roster pubkey for id ${id}`) + this.roster[id] = key + if (bufferToBigInt(this.pubComKey) === bufferToBigInt(key)) { + this.assignIdentifier(id) + matchedSelf = true + } + const shared = this.getSharedSecret(key) + if (!shared || shared.length !== 32) throw new Error(`failed to derive shared secret for id ${id}`) + this.keysByID[id] = shared + } + if (!matchedSelf) throw new Error('our announcement pubKey not present in roster') + this.state = DKGFlowState.RosterAssigned + } + + commitmentRound (secret: bigint, desiredShares: number, threshold: number) { + if (typeof this.participantID === 'undefined') throw new Error('missing participant identifier') + this.ensureStateIn('commitmentRound', [DKGFlowState.RosterAssigned]) + if (!Number.isInteger(desiredShares) || desiredShares <= 0) throw new Error('desiredShares must be a positive integer') + if (!Number.isInteger(threshold) || threshold <= 0) throw new Error('threshold must be a positive integer') + if (threshold > desiredShares) throw new Error('threshold cannot exceed desiredShares') + + // Ensure roster matches desiredShares + const rosterIds = Object.keys(this.roster).map(Number).sort((a, b) => a - b) + if (rosterIds.length !== desiredShares) throw new Error('roster size does not match desiredShares') + + const { coefficients, vssCommitment: commitments } = this.dkg.trustedDealerKeygen(secret, desiredShares, threshold) + const recipientIds = [] + for (let id = 1; id <= desiredShares; id++) { + recipientIds.push(id) + } + this.recipientIds = recipientIds + const shares = this.dkg.computeSharesForIds(coefficients, recipientIds) + this.shares = shares + this.state = DKGFlowState.CommitmentsCreated + this.addParticipantCommitments(this.participantID!, commitments) + return { shares, commitments } + } + + addParticipantCommitments (particpantID: number, participantCommitments: Point[]) { + this.ensureStateIn('addParticipantCommitments', [DKGFlowState.CommitmentsCreated, DKGFlowState.CommitmentsCollected]) + if (!Number.isInteger(particpantID) || particpantID <= 0) throw new Error('bad participant id for commitments') + if (!participantCommitments?.length) throw new Error('empty commitments from participant') + this.commitmentsByDealerId[particpantID] = participantCommitments + // If roster is known and we have a full set of commitments, advance state + const rosterIds = Object.keys(this.roster || {}).map(Number).sort((a, b) => a - b) + const commitIds = Object.keys(this.commitmentsByDealerId).map(Number).sort((a, b) => a - b) + if (rosterIds.length && rosterIds.length === commitIds.length && rosterIds.every((id, i) => id === commitIds[i])) { + this.state = DKGFlowState.CommitmentsCollected + } + } + + addEncryptedShares (particpantID: number, shares: Record) { + this.ensureStateAtLeast('addEncryptedShares', DKGFlowState.CommitmentsCollected) + if (!Number.isInteger(particpantID) || particpantID <= 0) throw new Error('bad dealer id for encrypted shares') + if (!shares || typeof shares !== 'object') throw new Error('invalid encrypted shares bundle') + // Basic validation for our expected entry if we know our id + if (typeof this.participantID !== 'undefined') { + const mine = shares[this.participantID] + if (!mine || !(mine.nonce instanceof Uint8Array) || !(mine.ciphertext instanceof Uint8Array)) { + // Allow storing anyway, but surface a strong error when decrypting + } + } + this.encryptedShares[particpantID] = shares + const rosterIds = Object.keys(this.roster || {}).map(Number).sort((a, b) => a - b) + const encIds = Object.keys(this.encryptedShares).map(Number).sort((a, b) => a - b) + if (rosterIds.length && rosterIds.length === encIds.length && rosterIds.every((id, i) => id === encIds[i])) { + this.state = DKGFlowState.EncryptedSharesCollected + } + } + + getEncryptedShares () { + // make sure the commitments are not empty, and same length as 'max participants' + this.ensureStateIn('getEncryptedShares', [DKGFlowState.CommitmentsCollected]) + if (!Object.keys(this.shares).length) throw new Error('no local shares computed; run commitmentRound first') + const allDealerCommitments = this.getAllDealerCommitments() + // Ensure we have derived a shared key for every intended recipient + const rosterIds = Object.keys(this.roster).map(Number).sort((a, b) => a - b) + for (const id of rosterIds) { + const k = this.keysByID[id] + if (!k || k.length !== 32) throw new Error(`missing shared key for participant ${id}`) + } + const out = this.dkg.encryptSharesAESGCMWithAAD(this.shares, this.keysByID, allDealerCommitments) + this.state = DKGFlowState.SharesEncrypted + return out + } + + getDecryptedShares () { + if (typeof this.participantID === 'undefined') throw new Error('missing participant identifier') + this.ensureStateIn('getDecryptedShares', [DKGFlowState.EncryptedSharesCollected]) + const allDealerCommitments = this.getAllDealerCommitments() + const decryptedByDealer: Record = {} + const rosterIds = Object.keys(this.roster).map(Number).sort((a, b) => a - b) + + const missing: number[] = [] + for (const dealerId of rosterIds) { + const bundle = this.encryptedShares[dealerId] + const enc = bundle?.[this.participantID] + const key = this.keysByID[dealerId] + if (!bundle || !enc || !key) { + missing.push(dealerId) + continue + } + const decrypted = this.dkg.decryptShareAESGCMWithAAD( + enc, + key, + this.participantID, + allDealerCommitments + ) + decryptedByDealer[dealerId] = decrypted + } + if (missing.length) { + throw new Error(`missing encrypted shares or keys from dealers: ${missing.join(', ')}`) + } + return decryptedByDealer + } + + finalize () { + if (typeof this.participantID === 'undefined') throw new Error('missing participant identifier') + this.ensureStateIn('finalize', [DKGFlowState.EncryptedSharesCollected]) + const dec = this.getDecryptedShares() + const shares: Array<{ dealerId: number; s_ki: bigint }> = [] + for (const d in dec) { + shares.push({ dealerId: Number(d), s_ki: dec[d]! }) + } + const allDealerCommitments = this.getAllDealerCommitments() + const res = this.dkg.finalizeParticipant(this.participantID, shares, allDealerCommitments) + this.state = DKGFlowState.Finalized + return res + } + + // used for encrypting/decrypting shares. + getPublicKey (secretKey: Uint8Array) { + const key = x25519.getPublicKey(secretKey) + return key + } + + getSharedSecret (theirPub: Uint8Array) { + const shared = x25519.getSharedSecret(this.secretComKey, theirPub) + return shared + } +} + +export default DKGManager diff --git a/src/manager/index.ts b/src/manager/index.ts new file mode 100644 index 0000000..d4396a6 --- /dev/null +++ b/src/manager/index.ts @@ -0,0 +1,3 @@ +import DKGManager from './dkg.js' +import FROSTSigningManager from './signing.js' +export { FROSTSigningManager, DKGManager } diff --git a/src/manager/signing.ts b/src/manager/signing.ts new file mode 100644 index 0000000..f25489a --- /dev/null +++ b/src/manager/signing.ts @@ -0,0 +1,195 @@ +import type { Point } from '@zk-kit/baby-jubjub' + +import { BabyFROST } from '../frost/index.js' +import type { Bindings, Commitment } from '../frost/types.js' + +type SignerShare = { id: number; skShareDiv8: bigint } +type SessionBinding = { bindings: Bindings, share: SignerShare } +type PartialSignature = { identifier: number, partial: bigint } +type SigningSession = { + msgHash: bigint, + signers: SignerShare[], + groupPublicKey: Point, + remoteSigners: Commitment[], + partials: bigint[] +} + +/* eslint-disable jsdoc/require-jsdoc */ +class FROSTSigningManager { + frost: BabyFROST + + signers: SignerShare[] = [] // map by id + // todo: make these session based... + localBindings: SessionBinding[] = [] // map by id? + + remoteSigners: Commitment[] = [] + groupPublicKey: Point + partialsById: Map = new Map() + sessions: SigningSession[] = [] + threshold: number + + constructor (publicKey: Point, threshold: number) { + this.frost = new BabyFROST() + this.threshold = threshold + this.groupPublicKey = publicKey + } + + hasId (identifier: bigint) { + for (const s of this.signers) { + if (s.id === Number(identifier.toString(10))) return true + } + return false + } + // create signing session + // msgHash, signers, remotecommitments... + + createSession (msgHash: bigint, signers: SignerShare[], publicKey: Point) { + const session = { + msgHash, + signers, + groupPublicKey: publicKey, + remoteSigners: [], + partials: [] + } + this.sessions.push(session) + return session + } + + addSigner (signer: SignerShare) { + this.signers.push(signer) + } + + addRemoteSigner (commitment: Commitment) { + // avoid duplicates by identifier + const exists = this.remoteSigners.find(c => c.identifier === commitment.identifier) + if (!exists) this.remoteSigners.push(commitment) + } + + // round 1 participants generate commitments, local will addRemoteSigners when commitments recieved + round1 () { + // reset local bindings for a fresh round + this.localBindings = [] + + for (const signer of this.signers) { + const bindings = this.frost.commit(signer.skShareDiv8, BigInt(signer.id)) + const sb = { + bindings, + share: signer + } + this.localBindings.push(sb) + } + } + + exportRound1 () { + const commitments: Commitment[] = [] + for (const sb of this.localBindings) commitments.push({ ...sb.bindings.commitments }) + return commitments + } + + getCommitmentList () { + // add local signers commitments first, then add unique remotes + const byId = new Map() + for (const c of this.exportRound1()) byId.set(String(c.identifier), c) + for (const c of this.remoteSigners) if (!byId.has(String(c.identifier))) byId.set(String(c.identifier), c) + const list = Array.from(byId.values()) + // sort by participant identifier to ensure consistent binding factors + list.sort((a, b) => (a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0)) + const required = this.threshold + if (required != null && list.length < required) { + throw new Error(`Insufficient commitments: have ${list.length}, need >= ${required}`) + } + return list + } + + sign (msgHash: bigint) { + const partials = [] + const commitmentList = this.getCommitmentList() + if (commitmentList.length === 0) throw new Error('No commitments available; run round1 and collect remotes first') + for (const signer of this.localBindings) { + const partial = this.frost.sign( + signer.bindings.commitments.identifier, + signer.share.skShareDiv8, + this.groupPublicKey, + signer.bindings.nonces, + msgHash, + commitmentList + ) + // return this based on identifier as well + const complete = { identifier: signer.share.id, partial } + // this.partials.push(complete) + partials.push(complete) + } + // todo: should pass along the commitmentlist signed against, so out of sync + // orchestration can still succeed with valid # of any threshold participants + // TODO: 11/6/25 @zy0n: re-evaluate this, ^ upon joining a signing-request we should assign an overall 'set' of participants + // and pass with these partials the participant list that these partials have been generated for. + return partials + } + + recievePartials (partials: PartialSignature[]) { + for (const p of partials) { + const id = Number(p.identifier) + if (!this.partialsById.has(id)) this.partialsById.set(id, p.partial) + } + } + + finalize (msgHash: bigint) { + const commitmentList = this.getCommitmentList() + if (commitmentList.length === 0) throw new Error('No commitments available; cannot finalize') + const expectedIds = commitmentList.map(c => Number(c.identifier)) + // require all expected shares for correctness under current flow + const missing = expectedIds.filter(id => !this.partialsById.has(id)) + if (missing.length > 0) throw new Error(`Missing partials for identifiers: ${missing.join(', ')}`) + + // verify locally known shares before aggregation + const byId = new Map() + for (const c of commitmentList) byId.set(Number(c.identifier), c) + for (const { share } of this.localBindings) { + const id = share.id + const sigShare = this.partialsById.get(id) + if (sigShare == null) continue // nothing to verify locally yet + const commitmentLocal = byId.get(id) + if (!commitmentLocal) throw new Error(`Missing commitment for local id ${id}`) + const ok = this.frost.verifySignatureShare( + BigInt(id), + share.skShareDiv8, + commitmentLocal, + sigShare, + commitmentList, + this.groupPublicKey, + msgHash + ) + if (!ok) throw new Error(`Local signature share failed verification for local signer ${id}`) + } + + // build partials in order by identifier + const sigShares: bigint[] = expectedIds.map(id => this.partialsById.get(id)!) + const sig = this.frost.aggregate(commitmentList, msgHash, this.groupPublicKey, sigShares) + return sig // check for validity after + } + + expectedParticipantIds () { + return this.getCommitmentList().map(c => Number(c.identifier)) + } + + getMissingPartials () { + const expected = this.expectedParticipantIds() + return expected.filter(id => !this.partialsById.has(id)) + } + + readyToFinalize () { + const commitmentList = this.getCommitmentList() + const expectedIds = commitmentList.map(c => Number(c.identifier)) + const available = expectedIds.filter(id => this.partialsById.has(id)).length + const required = this.threshold + return available >= required + } + + resetRoundState () { + this.localBindings = [] + this.remoteSigners = [] + this.partialsById.clear() + } +} + +export default FROSTSigningManager diff --git a/test/babyfrost.test.ts b/test/babyfrost.test.ts index e0ed8c3..10c169e 100644 --- a/test/babyfrost.test.ts +++ b/test/babyfrost.test.ts @@ -4,42 +4,49 @@ import { describe, it } from 'node:test' import type { Commitment, Point } from '../src' import { eddsaBuild, poseidonHex } from '../src' import BabyFROST_RFC9591 from '../src/frost/babyfrost' +import { subOrder } from '@zk-kit/baby-jubjub' + +// can skip the mod purely for tests, it passes without. +function mod (x: bigint) { + const r = x % subOrder + return r < 0n ? r + subOrder : r +} const multiSigVector = [ { id: 1, share: { id: 1, - skShare: 9379043151059132348915826074748660163067085078692438895653970025055239912688n, - skShareDiv8: 1172380393882391543614478259343582520383385634836554861956746253131904989086n + skShare: mod(BigInt('0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804') * 8n), + skShareDiv8: BigInt('0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804') }, PKGroup: [ - 13572882520115239477456596963790473057624212656185529673136940543510856063202n, - 12515170913156463601814218712397355783209899258093010091034332015438200954857n + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') ] }, { id: 2, share: { id: 2, - skShare: 6288919410652533324466074240073604195793794349351903202901181227799867607360n, - skShareDiv8: 786114926331566665558259280009200524474224293668987900362647653474983450920n + skShare: mod(BigInt('0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0') * 8n), + skShareDiv8: BigInt('0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0') }, PKGroup: [ - 13572882520115239477456596963790473057624212656185529673136940543510856063202n, - 12515170913156463601814218712397355783209899258093010091034332015438200954857n + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') ] }, { id: 3, share: { id: 3, - skShare: 3198795670245934300016322405398548228520503620011367510148392430544495302032n, - skShareDiv8: 399849458780741787502040300674818528565062952501420938768549053818061912754n + skShare: mod(BigInt('0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653') * 8n), + skShareDiv8: BigInt('0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653') }, PKGroup: [ - 13572882520115239477456596963790473057624212656185529673136940543510856063202n, - 12515170913156463601814218712397355783209899258093010091034332015438200954857n + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') ] } ] diff --git a/test/dkg-manager.test.ts b/test/dkg-manager.test.ts new file mode 100644 index 0000000..1b8b5eb --- /dev/null +++ b/test/dkg-manager.test.ts @@ -0,0 +1,206 @@ + +import assert from 'node:assert' + +import { describe, it } from 'node:test' +import DKGManager from '../src/manager/dkg' +import FROSTSigningManager from '../src/manager/signing' +import { eddsaBuild, type Point } from '../src' +import { bigIntToBuffer } from '@zk-kit/utils' + +describe('DKGManager e2e flow test', () => { + it('should run trusted keygen and get expected group publickey', () => { + const dkgManager = new DKGManager('test-participant-1') + const secret = BigInt(0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn) + + const keys = dkgManager.runTrustedKeygen(secret, 5, 3) + const expectedGroupPK = [ + '0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6', + '0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd' + ] + assert.deepStrictEqual(keys.groupPublicKey, expectedGroupPK, 'derived group PK does not match.') + + const secrets = [ + 0x11n, + 0x22n, + 0x33n, + 0x44n, + 0x55n, + ] + const dealers: DKGManager[] = [] + const dealerShares: Record> = {} + + + const announcements: Uint8Array[] = [] + secrets.forEach((secret, idx) => { + const dealer = new DKGManager('test-participant-1') + dealers.push(dealer) + const announce = dealer.getAnnouncement() + announcements.push(announce.pubKey) + }) + + // create roster + const roster: Record = {} + announcements.forEach((a, idx) => { + roster[idx + 1] = a + }) + + // Each dealer runs commitment round and publishes commitments and shares + const commitmentsByDealer: Record['commitments']> = {} + dealers.forEach((dealer, idx) => { + dealer.assignRoster(roster) + const { commitments: comms, shares } = dealer.commitmentRound(secrets[idx], 5, 3) + commitmentsByDealer[idx + 1] = comms + dealerShares[idx + 1] = shares + }) + + // Distribute commitments to every dealer + dealers.forEach((_dealer) => { + for (const idStr in commitmentsByDealer) { + const id = Number(idStr) + _dealer.addParticipantCommitments(id, commitmentsByDealer[id]!) + } + }) + + // Each dealer encrypts their shares using the full commitments set + const encryptedShares: Record> = {} + dealers.forEach((dealer, idx) => { + encryptedShares[idx + 1] = dealer.getEncryptedShares() + }) + + // Distribute encrypted shares to each recipient + dealers.forEach((dealer) => { + for (const idStr in encryptedShares) { + const id = Number(idStr) + dealer.addEncryptedShares(id, encryptedShares[id]!) + } + }) + + // Finalize: each dealer should be able to decrypt, combine and produce their share + const results = dealers.map((dealer) => dealer.finalize()) + results.forEach((done, idx) => { + assert.ok(done.share.skShare !== 0n) + assert.ok(done.share.skShareDiv8 !== 0n) + assert.strictEqual(done.share.id, idx + 1) + }) + // All should derive the same group public key and viewing key + const refPK = results[0].PKGroup + const refVK = Buffer.from(results[0].viewingPrivateKey) + results.forEach((r) => { + assert.deepStrictEqual(r.PKGroup, refPK) + assert.equal(Buffer.compare(Buffer.from(r.viewingPrivateKey), refVK), 0) + }) + }) + + it('trusted method: keygen -> signing end-to-end (3-of-5)', () => { + const threshold = 3 + const n = 5 + + const dkgManager = new DKGManager('test-participant-1') + const secret = BigInt(0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn) + + const { groupPublicKey, shares, } = dkgManager.runTrustedKeygen(secret, n, threshold) + + const groupPK = groupPublicKey.map(p => BigInt(p)) as Point + const expectedGroupPK = [ + '0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6', + '0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd' + ] + assert.deepStrictEqual(groupPublicKey, expectedGroupPK, 'derived group PK does not match.') + + const subset = shares.slice(0, threshold) + + // build signing managers + const signers: FROSTSigningManager[] = [] + for (const f of subset) { + const sm = new FROSTSigningManager(groupPK, threshold) + sm.addSigner({ id: f.identifier, skShareDiv8: BigInt(f.skShare)}) + // sm.addSigner({ id: f.identifier, skShare: sm.frost.modOrder(BigInt(f.skShare) * 8n) }) + signers.push(sm) + } + + // round 1 exchange + for (const sm of signers) { + sm.round1() + const c = sm.exportRound1() + for (const s of c) for (const sm2 of signers) if (!sm2.hasId(s.identifier)) sm2.addRemoteSigner(s) + } + + // round 2 + const msg = 42069n + const partials: { identifier: number, partial: bigint }[][] = [] + for (const sm of signers) partials.push(sm.sign(msg)) + for (const sm of signers) for (const p of partials) sm.recievePartials(p) + + const sig = signers[0]!.finalize(msg) + const ok = eddsaBuild.verifyPoseidon(bigIntToBuffer(msg), sig, groupPK) + assert.strictEqual(ok, true, 'coordinator-less flow signing verification failed') + + }) + + it('coordinator-less flow: finalize -> signing end-to-end (3-of-5)', () => { + const threshold = 3 + const secrets = [0x11n, 0x22n, 0x33n, 0x44n, 0x55n] + const dealers: DKGManager[] = [] + + // announcements and roster + const announcements: Uint8Array[] = [] + for (let i = 0; i < secrets.length; i++) { + const d = new DKGManager('test-participant-1') + dealers.push(d) + announcements.push(d.getAnnouncement().pubKey) + } + const roster: Record = {} + announcements.forEach((a, idx) => { roster[idx + 1] = a }) + + // commitment rounds and distribution + const commitmentsByDealer: Record['commitments']> = {} + dealers.forEach((dealer, idx) => { + dealer.assignRoster(roster) + const { commitments: comms } = dealer.commitmentRound(secrets[idx]!, secrets.length, threshold) + commitmentsByDealer[idx + 1] = comms + }) + dealers.forEach((dealer) => { + for (const idStr in commitmentsByDealer) { + const id = Number(idStr) + dealer.addParticipantCommitments(id, commitmentsByDealer[id]!) + } + }) + + // encrypt shares and distribute + const encryptedByDealerId: Record> = {} + dealers.forEach((dealer, idx) => { encryptedByDealerId[idx + 1] = dealer.getEncryptedShares() }) + dealers.forEach((dealer) => { + for (const idStr in encryptedByDealerId) dealer.addEncryptedShares(Number(idStr), encryptedByDealerId[Number(idStr)]!) + }) + + // finalize and take first 3 signers + const finalized = dealers.map((d) => d.finalize()) + const groupPublicKey = finalized[0]!.PKGroup + const subset = finalized.slice(0, threshold) + + // build signing managers + const signers: FROSTSigningManager[] = [] + for (const f of subset) { + const sm = new FROSTSigningManager(groupPublicKey, threshold) + sm.addSigner({ id: f.share.id, skShareDiv8: f.share.skShare }) + signers.push(sm) + } + + // round 1 exchange + for (const sm of signers) { + sm.round1() + const c = sm.exportRound1() + for (const s of c) for (const sm2 of signers) if (!sm2.hasId(s.identifier)) sm2.addRemoteSigner(s) + } + + // round 2 + const msg = 42069n + const partials: { identifier: number, partial: bigint }[][] = [] + for (const sm of signers) partials.push(sm.sign(msg)) + for (const sm of signers) for (const p of partials) sm.recievePartials(p) + + const sig = signers[0]!.finalize(msg) + const ok = eddsaBuild.verifyPoseidon(bigIntToBuffer(msg), sig, groupPublicKey) + assert.strictEqual(ok, true, 'coordinator-less flow signing verification failed') + }) +}) diff --git a/test/hashes.test.ts b/test/hashes.test.ts index d7bcb70..dd5761e 100644 --- a/test/hashes.test.ts +++ b/test/hashes.test.ts @@ -2,8 +2,9 @@ import assert from 'node:assert' import { describe, it } from 'node:test' -import {taggedBlake512, H1, H3, H4, H5} from '../src/frost/rfc-hashes' import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js' +import RFC9591Hasher from '../src/hashing'; +import { subOrder } from '@zk-kit/baby-jubjub'; function toHex(buffer: Uint8Array) { return Buffer.from(buffer).toString('hex'); @@ -41,7 +42,9 @@ describe('RFC9591 H1 H3 H4 H5 test vectors', () => { it('Match expectations', () => { // Generate random 32 bytes input - const funcs = [H1, H3, H4, H5] + const hasher = new RFC9591Hasher('FROST-EDBABYJUJUB-BLAKE512-v1', subOrder) + + const funcs = [hasher.H1.bind(hasher), hasher.H3.bind(hasher), hasher.H4.bind(hasher), hasher.H5.bind(hasher)] const names: ('H1'|'H3'|'H4'|'H5')[] = ['H1', 'H3', 'H4', 'H5'] const tags = ['rho', 'nonce', 'msg', 'com'] const results: bigint[] = [] @@ -59,7 +62,7 @@ describe('RFC9591 H1 H3 H4 H5 test vectors', () => { const tag = tags[idx] console.log(`${name} (tag: '${tag}'):`) const expected = VECTORS[name] - const raw = taggedBlake512(tag, randomInput) + const raw = hasher.taggedHash(tag, randomInput) assert.equal(bytesToHex(raw), expected.rawHex, 'invalid blake512 result.') console.log(" Raw hash (64 bytes):", toHex(raw)); const result = fn(randomInput) @@ -75,7 +78,7 @@ describe('RFC9591 H1 H3 H4 H5 test vectors', () => { // Verify that different tags produce different results console.log("=== Verification ==="); console.log("All hash results are different:", - results[0] !== results[2] && + results[0] !== results[2] && results[0] !== results[3] && results[0] !== results[4] && results[2] !== results[3] && @@ -84,7 +87,7 @@ describe('RFC9591 H1 H3 H4 H5 test vectors', () => { ); // Test with same tag twice to verify consistency - const h1Again = H1(randomInput); + const h1Again = hasher.H1(randomInput); console.log("H1 is deterministic:", results[0] === h1Again ? "✓ PASS" : "✗ FAIL"); }) diff --git a/test/signing-manager.test.ts b/test/signing-manager.test.ts new file mode 100644 index 0000000..49ddb9d --- /dev/null +++ b/test/signing-manager.test.ts @@ -0,0 +1,143 @@ +import { subOrder } from '@zk-kit/baby-jubjub' +import assert from 'node:assert' + +import { describe, it } from 'node:test' + +import FROSTSigningManager from '../src/manager/signing' +import { eddsaBuild } from '../src' + +function mod (x: bigint) { + const r = x % subOrder + return r < 0n ? r + subOrder : r +} + + +const multiSigVector = [ + { + id: 1, + share: { + id: 1, + skShare: mod(BigInt('0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804') * 8n), + skShareDiv8: BigInt('0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804') + }, + PKGroup: [ + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') + ] + }, + { + id: 2, + share: { + id: 2, + skShare: mod(BigInt('0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0') * 8n), + skShareDiv8: BigInt('0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0') + }, + PKGroup: [ + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') + ] + }, + { + id: 3, + share: { + id: 3, + skShare: mod(BigInt('0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653') * 8n), + skShareDiv8: BigInt('0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653') + }, + PKGroup: [ + BigInt('0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6'), + BigInt('0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefd') + ] + } +] + +describe('BabyFrost Signing Manager', () => { + it('should run e2e signing manager flow', () => { + const signers = [] + for (const v of multiSigVector) { + const signer = new FROSTSigningManager(v.PKGroup as [bigint, bigint], 3) + signer.addSigner({ id: v.id, skShareDiv8: v.share.skShare }) + signers.push(signer) + } + + const message = 12345n + const commitments = [] + for (const signer of signers) { + signer.round1() + const c = signer.exportRound1() + commitments.push(c) + for(const s of c ) { + for(const s2 of signers){ + if(!s2.hasId(s.identifier)) { + s2.addRemoteSigner(s) + } + } + } + } + const partials = [] + for (const signer of signers) { + partials.push(signer.sign(message)) + } + for (const signer of signers) { + for(const p of partials) { + signer.recievePartials(p) + } + } + const sig = signers[0].finalize(message) + const ok = eddsaBuild.verifyPoseidon(signers[0].frost.toBytes(message).toReversed(), sig, signers[0].groupPublicKey) + assert(ok, 'validation failed') + + }) + + it('exposes participant/partials helper methods', () => { + // create managers for each participant + const signers: FROSTSigningManager[] = [] + for (const v of multiSigVector) { + const signer = new FROSTSigningManager(v.PKGroup as [bigint, bigint], 3) + signer.addSigner({ id: v.id, skShareDiv8: v.share.skShare }) + signers.push(signer) + } + + const message = 12345n + + // round 1 and exchange commitments + for (const signer of signers) { + signer.round1() + const c = signer.exportRound1() + for (const s of c) { + for (const s2 of signers) { + if (!s2.hasId(s.identifier)) { + s2.addRemoteSigner(s) + } + } + } + } + + // helpers should reflect all participants and no partials yet + for (const signer of signers) { + assert.deepStrictEqual(signer.expectedParticipantIds(), [1, 2, 3]) + assert.deepStrictEqual(signer.getMissingPartials(), [1, 2, 3]) + assert.equal(signer.readyToFinalize(), false) + // cannot finalize yet + assert.throws(() => signer.finalize(message)) + } + + // produce and exchange partials + const partials: { identifier: number, partial: bigint }[][] = [] + for (const signer of signers) { + partials.push(signer.sign(message)) + } + for (const signer of signers) { + for (const p of partials) signer.recievePartials(p) + assert.deepStrictEqual(signer.getMissingPartials(), []) + assert.equal(signer.readyToFinalize(), true) + } + + // reset and ensure state is cleared + for (const signer of signers) { + signer.resetRoundState() + assert.throws(() => signer.expectedParticipantIds()) + assert.throws(() => signer.finalize(message)) + } + }) +}) diff --git a/test/trusted-dkg-e2e.test.ts b/test/trusted-dkg-e2e.test.ts new file mode 100644 index 0000000..aa6f46e --- /dev/null +++ b/test/trusted-dkg-e2e.test.ts @@ -0,0 +1,378 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import type { Point } from '@zk-kit/baby-jubjub' + +import TrustedDKG from '../src/frost/trusted-dkg' +import BabyFROST from '../src/frost/babyfrost' +import { eddsaBuild } from '../src/eddsa' +import type { Commitment } from '../src/frost/types' +import { poseidonHex } from '../src/index' + +describe('TrustedDKG', () => { + const dkg = new TrustedDKG() + + describe('Test Vector Validation', () => { + it('should match FROST test vectors for 3-of-5 threshold scheme', () => { + const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn + const a1 = 0x57ea51a2f5712861d3e3125c7b81f081b19c40b04894abf76d35429f2eef8a3n + const a2 = 0x439109f6f2946257f8c508718b4a86e0cc73e1cae22dd0b7b8a20a59331d5b4n + + const expectedShares = [ + 0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804n, // Participant 1 + 0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0n, // Participant 2 + 0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653n, // Participant 3 + 0x3050f2b896694a1de4b85af56e52fa428144c5a9eeb0f6285e68177c71cad3cn, // Participant 4 + 0x42d853c1c25b254f6324e954ba60d390776bf5e32c79d408072d46e56e4189cn // Participant 5 + ] + + const expectedPubKeys = [ + [ + 0x2ebc885681e45848f34ee1050a827eca10e8fb24cbb64a091901d96911480cc2n, + 0x482f7210ba3665fbe23bc0eab393f7266a36008186bb7c6781b1017612e49b4n + ], + [ + 0x26dd7c7370505a2496012e97bae1a23e9439661f90c0f15aac26ed1cd606535fn, + 0x2a770f7fc5a26df9427cde7456ba495a2b2eb89c6a1be3273d9d982d5417bd01n + ], + [ + 0x1136b9efcaf9dc8b3b20bf463151645d49a2b33be95a4a36c3833e7a66453e65n, + 0x28146163f1cf5182296a46ed9523b521e37f8eb312ac3d209f4d0a629b474445n + ], + [ + 0x1a78e7b5302f95764b22448764ca2ecac418116b52332882d77868f2db086c33n, + 0x178597d7aea8a7e82b223cb8a0626d776a217caf7f1988bfa775d73d49e26f0bn + ], + [ + 0x2d91b9147ee0817f26f33eb7b759b4702577e429f80097aa07c7deb69dd6f562n, + 0x71e39bb200f3b4c3e6c54134c5c1382590dbdf8500303525f3b585647cbe8edn + ], + [ + 0x190260ab0b8674b7b20c22d37d9838cd5194d348745c7d4eba69feb1a5bfc749n, + 0x1c51721613b027faee6b242b213d8f534578c115d70746347d5590e10e2ce7f8n + ] + ] + + const expectedGroupPK = { + x: 0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6n, + y: 0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefdn + } + + const coefficients = [a1, a2] + const { secretKeyShares, coefficients: coeffs } = dkg.secretShareShard(secret, coefficients, 5) + + assert.strictEqual(coeffs[0], secret, 'First coefficient should be the secret') + assert.strictEqual(coeffs[1], a1, 'Second coefficient should match a1') + assert.strictEqual(coeffs[2], a2, 'Third coefficient should match a2') + + for (let i = 0; i < 5; i++) { + const share = secretKeyShares[i]! + assert.strictEqual(share.x_i, i + 1, `Participant ${i + 1} x-coordinate should be ${i + 1}`) + assert.strictEqual(share.y_i, expectedShares[i], `Participant ${i + 1} secret share should match test vector`) + } + + const vssCommitment = dkg.vssCommit(coeffs) + + for (let i = 1; i <= 5; i++) { + const participantKey = secretKeyShares[i - 1]! + const share = { + i: BigInt(participantKey.x_i), + sk_i: participantKey.y_i + } + const isValid = dkg.vssVerify(share, vssCommitment, 3) + assert.strictEqual(isValid, true, `Participant ${i} share should be valid`) + } + + const groupInfo = dkg.deriveGroupInfo(5, 3, vssCommitment) + groupInfo.participantPublicKeys.forEach((p, idx)=>{ + console.log(`Participant ${idx + 1}:`) + console.log(` Secret share: 0x${secretKeyShares[idx].y_i.toString(16)!}`) + console.log(` pubKey.x: 0x${p[0].toString(16)!}`) + console.log(` pubKey.y: 0x${p[1].toString(16)!}`) + assert.equal(p[0], expectedPubKeys[idx][0], 'Participant public key x-coordinate should match') + assert.equal(p[1], expectedPubKeys[idx][1], 'Participant public key y-coordinate should match') + }) + assert.strictEqual(groupInfo.PK![0], expectedGroupPK.x, 'Group public key x-coordinate should match') + assert.strictEqual(groupInfo.PK![1], expectedGroupPK.y, 'Group public key y-coordinate should match') + + const directGroupPK = dkg.ScalarBaseMult(secret) + assert(dkg.pointsEqual(groupInfo.PK!, directGroupPK), 'Group PK should equal secret * Base') + }) + + it('should correctly evaluate polynomial with test vector coefficients', () => { + const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn + const a1 = 0x57ea51a2f5712861d3e3125c7b81f081b19c40b04894abf76d35429f2eef8a3n + const a2 = 0x439109f6f2946257f8c508718b4a86e0cc73e1cae22dd0b7b8a20a59331d5b4n + + const coefficients = [secret, a1, a2] + + const p1 = dkg.polynomialEvaluate(1n, coefficients) + + assert.strictEqual(p1, 0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804n) + + const p2 = dkg.polynomialEvaluate(2n, coefficients) + assert.strictEqual(p2, 0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0n) + + const p3 = dkg.polynomialEvaluate(3n, coefficients) + assert.strictEqual(p3, 0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653n) + + const p0 = dkg.polynomialEvaluate(0n, coefficients) + assert.strictEqual(p0, secret, 'Polynomial at x=0 should equal the secret') + }) + }) +}) + +describe('TrustedDKG end-to-end', () => { + const dkg = new TrustedDKG() + + it('3-of-5 flow: dealer keygen -> Feldman verify -> finalize -> secret reconstruction', () => { + const threshold = 3 + const n = 5 + + // 1) Trusted dealer keygen (deterministic secret for reproducibility) + const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn + const { participantPrivateKeys: shares, coefficients, vssCommitment } = dkg.trustedDealerKeygen(secret, n, threshold) + assert.equal(coefficients[0], secret, 'constant term must equal secret') + assert.equal(vssCommitment.length, threshold, 'commitments length = threshold') + + // 2) Feldman verification for each participant share + const finalized: Array<{ id: number; skShareDiv8: bigint; skShare: bigint }> = [] + for (let i = 0; i < n; i++) { + const { x_i, y_i } = shares[i]! + const ok = dkg.vssVerify({ i: BigInt(x_i), sk_i: y_i }, vssCommitment, threshold) + assert.strictEqual(ok, true, `vssVerify failed for id ${x_i}`) + // coordinator-less finalize for this single-dealer case + const res = dkg.finalizeParticipant(x_i, [{ dealerId: 1, s_ki: y_i }], [vssCommitment]) + finalized.push({ id: x_i, skShareDiv8: res.share.skShareDiv8, skShare: res.share.skShare }) + } + + // 3) Derive group info and cross-check with direct Base*secret + const group = dkg.deriveGroupInfo(n, threshold, vssCommitment) + const directPK = dkg.ScalarBaseMult(secret) + assert.strictEqual(dkg.pointsEqual(group.PK!, directPK), true, 'group PK mismatch') + + // 4) Coordinator-less finalize per participant using “single dealer” view + // Build input in the multi-dealer shape: one dealer with our commitments, per-participant s_ki + const allCommitments: Point[][] = [vssCommitment] + for (let i = 0; i < n; i++) { + const { x_i, y_i } = shares[i]! + const res = dkg.finalizeParticipant(x_i, [{ dealerId: 1, s_ki: y_i }], allCommitments) + // skShareDiv8 equals underlying s_i + assert.equal(res.share.skShareDiv8, y_i) + assert.strictEqual(dkg.pointsEqual(res.PKGroup, group.PK!), true, 'finalized PKGroup mismatch') + } + + // 5) Reconstruct secret from any threshold subset (ids 1,2,3) + const subsetShares = shares.slice(0, threshold).map(({ x_i, y_i }) => ({ id: x_i, s_i: y_i })) + const rec = dkg.reconstructConstantFromShares(subsetShares) + assert.equal(rec, secret, 'reconstructed secret should equal original') + + // 6) Negative: Feldman verify should fail for tampered share + const bad = dkg.modOrder(shares[0]!.y_i + 1n) + const okBad1 = dkg.vssVerify({ i: BigInt(shares[0]!.x_i), sk_i: bad }, vssCommitment, threshold) + assert.strictEqual(okBad1, false) + // 7) Perform a FROST signing round with 3 participants; verify aggregate with EDDSA-Poseidon + const frost = new BabyFROST() + const groupPublicKey = group.PK! + const subset = finalized.slice(0, threshold) + const msgHash = BigInt('0x' + poseidonHex(['0x' + 12345n.toString(16)], true)) + const p1 = frost.commit(subset[0]!.skShare, BigInt(subset[0]!.id)) + const p2 = frost.commit(subset[1]!.skShare, BigInt(subset[1]!.id)) + const p3 = frost.commit(subset[2]!.skShare, BigInt(subset[2]!.id)) + const commitmentList: Commitment[] = [p1, p2, p3].map((a) => ({ ...a.commitments })) + const s1 = frost.sign(p1.commitments.identifier, subset[0]!.skShare, groupPublicKey, p1.nonces, msgHash, commitmentList) + const s2 = frost.sign(p2.commitments.identifier, subset[1]!.skShare, groupPublicKey, p2.nonces, msgHash, commitmentList) + const s3 = frost.sign(p3.commitments.identifier, subset[2]!.skShare, groupPublicKey, p3.nonces, msgHash, commitmentList) + const sig = frost.aggregate(commitmentList, msgHash, groupPublicKey, [s1, s2, s3]) + const okAgg = eddsaBuild.verifyPoseidon(frost.toBytes(msgHash).toReversed(), sig, groupPublicKey) + assert.strictEqual(okAgg, true, 'FROST aggregate failed verification') + }) +}) + +describe('TrustedDKG coordinator-less end-to-end', () => { + const dkg = new TrustedDKG() + + it('multi-dealer 3-of-5: per-dealer shares -> finalize -> reconstruct', () => { + const threshold = 3 + const ids = [1, 2, 3, 4, 5] + + + const dealers: Array<{ id: number; coeffs: bigint[] }> = [ + { id: 1, coeffs: dkg.trustedDealerKeygen(0x11n, 5, threshold).coefficients }, + { id: 2, coeffs: dkg.trustedDealerKeygen(0x22n, 5, threshold).coefficients }, + { id: 3, coeffs: dkg.trustedDealerKeygen(0x33n, 5, threshold).coefficients }, + { id: 4, coeffs: dkg.trustedDealerKeygen(0x44n, 5, threshold).coefficients }, + { id: 5, coeffs: dkg.trustedDealerKeygen(0x55n, 5, threshold).coefficients }, + ] + + // Commitments and per-dealer shares + const commitmentsByDealer: Record[]> = {} + const sharesByDealer: Record> = {} + for (const d of dealers) { + const C = dkg.vssCommit(d.coeffs) + assert.equal(C.length, threshold, 'each dealer must produce t commitments') + commitmentsByDealer[d.id] = C + sharesByDealer[d.id] = dkg.computeSharesForIds(d.coeffs, ids) + } + + // Aggregate commitments for group PK + const allDealerCommitments = dealers.map(d => commitmentsByDealer[d.id]!) + assert.strictEqual(dkg.verifyAllCommitmentsSubgroup(allDealerCommitments), true, 'commitments not in subgroup') + const PKGroup = dkg.combineGroupPubkeyFromCommitments(allDealerCommitments) + + // Check group PK equals Base*(sum of dealers' a0) + const a0_total = dealers.reduce((acc, d) => dkg.modOrder(acc + dkg.modOrder(d.coeffs[0]!)), 0n) + const PKDirect = dkg.ScalarBaseMult(a0_total) + assert.strictEqual(dkg.pointsEqual(PKGroup, PKDirect), true, 'group PK does not match sum of a0') + + // Feldman verify each per-dealer share, then finalize per participant by summing s_{k}(i) + const finalized: Array<{ id: number; skShare: bigint }> = [] + for (const id of ids) { + const s_ki_byDealer = dealers.map(d => ({ dealerId: d.id, s_ki: sharesByDealer[d.id]![id]! })) + // verify each dealer share for this participant id + for (const d of dealers) { + const ok = dkg.vssVerify({ i: BigInt(id), sk_i: sharesByDealer[d.id]![id]! }, commitmentsByDealer[d.id]!, threshold) + assert.strictEqual(ok, true, `Feldman verify failed for dealer ${d.id}, id ${id}`) + } + const res = dkg.finalizeParticipant(id, s_ki_byDealer, allDealerCommitments) + const expectedDiv8 = s_ki_byDealer.reduce((acc, s) => dkg.modOrder(acc + dkg.modOrder(s.s_ki)), 0n) + assert.equal(res.share.skShareDiv8, expectedDiv8, 'finalized share mismatch') + assert.strictEqual(dkg.pointsEqual(res.PKGroup, PKGroup), true, 'PKGroup mismatch in finalization') + finalized.push({ id, skShare: res.share.skShare }) + } + + // Reconstruct total a0 from aggregated shares of any 3 participants (e.g., ids 1,2,5) + const recIds = [1, 2, 5] + const aggregatedShares = recIds.map((id) => ({ + id, + s_i: dealers.reduce((acc, d) => dkg.modOrder(acc + dkg.modOrder(sharesByDealer[d.id]![id]!)), 0n) + })) + const rec = dkg.reconstructConstantFromShares(aggregatedShares) + assert.equal(rec, a0_total, 'reconstructed total a0 should equal sum of dealer a0') + + // FROST signing with 3 finalized participants; verify aggregate + const frost = new BabyFROST() + const groupPublicKey = PKGroup + const signingSubset = finalized.slice(0, threshold) + const msgHash = BigInt('0x' + poseidonHex(['0x' + 99999n.toString(16)], true)) + const p1 = frost.commit(signingSubset[0]!.skShare, BigInt(signingSubset[0]!.id)) + const p2 = frost.commit(signingSubset[1]!.skShare, BigInt(signingSubset[1]!.id)) + const p3 = frost.commit(signingSubset[2]!.skShare, BigInt(signingSubset[2]!.id)) + const commitmentList: Commitment[] = [p1, p2, p3].map((a) => ({ ...a.commitments })) + const s1 = frost.sign(p1.commitments.identifier, signingSubset[0]!.skShare, groupPublicKey, p1.nonces, msgHash, commitmentList) + const s2 = frost.sign(p2.commitments.identifier, signingSubset[1]!.skShare, groupPublicKey, p2.nonces, msgHash, commitmentList) + const s3 = frost.sign(p3.commitments.identifier, signingSubset[2]!.skShare, groupPublicKey, p3.nonces, msgHash, commitmentList) + const sig = frost.aggregate(commitmentList, msgHash, groupPublicKey, [s1, s2, s3]) + const okAgg = eddsaBuild.verifyPoseidon(frost.toBytes(msgHash).toReversed(), sig, groupPublicKey) + assert.strictEqual(okAgg, true, 'FROST aggregate failed verification') + }) +}) + + +describe('TrustedDKG coordinator-less extensions', () => { + const dkg = new TrustedDKG() + + // fixed dealer coefficients for determinism (include constant term a0 first) + const dealer1Coeffs = [ + 0x11n, // a0 + 0x22n, // a1 + 0x33n, // a2 (threshold = 3) + ] + const dealer2Coeffs = [ + 0x55n, + 0x66n, + 0x77n, + ] + + it('computeSharesForIds matches polynomialEvaluate()', () => { + const ids = [1, 2, 4] + const shares = dkg.computeSharesForIds(dealer1Coeffs, ids) + for (const id of ids) { + const p = dkg.polynomialEvaluate(BigInt(id), dealer1Coeffs) + assert.equal(shares[id], p, `share mismatch for id ${id}`) + } + }) + + it('vssVerify succeeds for valid shares and fails for tampered', () => { + const commitments = dkg.vssCommit(dealer1Coeffs) + const ids = [1, 2, 3] + const shares = dkg.computeSharesForIds(dealer1Coeffs, ids) + for (const id of ids) { + const ok = dkg.vssVerify({ i: BigInt(id), sk_i: shares[id]!}, commitments, commitments.length) + assert.strictEqual(ok, true, `Feldman verify failed for id ${id}`) + const bad = dkg.vssVerify({i: BigInt(id), sk_i: dkg.modOrder(shares[id]! + 1n)}, commitments, commitments.length) + assert.strictEqual(bad, false, `Feldman verify should fail for tampered share id ${id}`) + } + }) + + it('combineGroupPubkeyFromCommitments equals Base*(sum of a0)', () => { + const c1 = dkg.vssCommit(dealer1Coeffs) + const c2 = dkg.vssCommit(dealer2Coeffs) + const group = dkg.combineGroupPubkeyFromCommitments([c1, c2]) + const sumA0 = dkg.modOrder(dealer1Coeffs[0]! + dealer2Coeffs[0]!) + const direct = dkg.ScalarBaseMult(sumA0) + assert.strictEqual(dkg.pointsEqual(group, direct), true, 'group PK mismatch with a0 sum') + }) + + it('verifyAllCommitmentsSubgroup returns true for valid commitments', () => { + const c1 = dkg.vssCommit(dealer1Coeffs) + const c2 = dkg.vssCommit(dealer2Coeffs) + const ok = dkg.verifyAllCommitmentsSubgroup([c1, c2]) + assert.strictEqual(ok, true) + }) + + it('finalizeParticipant aggregates s_ki and derives PKGroup', () => { + const c1 = dkg.vssCommit(dealer1Coeffs) + const c2 = dkg.vssCommit(dealer2Coeffs) + const allComm = [c1, c2] + const id = 2 + const s1 = dkg.polynomialEvaluate(BigInt(id), dealer1Coeffs) + const s2 = dkg.polynomialEvaluate(BigInt(id), dealer2Coeffs) + const res = dkg.finalizeParticipant(id, [ + { dealerId: 1, s_ki: s1 }, + { dealerId: 2, s_ki: s2 }, + ], allComm) + const expectedDiv8 = dkg.modOrder(s1 + s2) + assert.equal(res.share.skShareDiv8, expectedDiv8, 'skShareDiv8 should be sum of dealer shares') + assert.equal(res.share.skShare, dkg.modOrder(8n * expectedDiv8), 'skShare should be x8 mod L') + const group = dkg.combineGroupPubkeyFromCommitments(allComm) + assert.strictEqual(dkg.pointsEqual(res.PKGroup, group), true, 'PKGroup mismatch') + }) + + it('assertSortedConsecutiveIds: accepts non-consecutive, rejects invalid/unsorted', () => { + // non-consecutive is allowed + assert.doesNotThrow(() => dkg.computeSharesForIds(dealer1Coeffs, [1, 3])) + // unsorted must throw + assert.throws(() => dkg.computeSharesForIds(dealer1Coeffs, [3, 1]), /strictly increasing positive integers/i) + // invalid must throw + assert.throws(() => dkg.computeSharesForIds(dealer1Coeffs, [0, 2]), /strictly increasing positive integers/i) + }) + + it('Lagrange basis sums to 1 and reconstructs constant term', () => { + // simple linear polynomial a0 + a1*x + const a0 = 0x1234n + const a1 = 0x9n + const coeffs = [a0, a1] + const ids = [1n, 3n] + const shares = ids.map((id) => ({ id: Number(id), s_i: dkg.polynomialEvaluate(id, coeffs) })) + const lambdas = ids.map((id) => dkg.deriveInterpolatingValue(ids, id)) + const sum = lambdas.reduce((acc, l) => dkg.modOrder(acc + l), 0n) + assert.equal(sum, 1n, 'sum of λ_i(0) should be 1') + const rec = dkg.reconstructConstantFromShares(shares) + assert.equal(rec, a0, 'reconstructed a0 should equal original') + }) + + it('verifyFeldmanShare rejects tampered/non-subgroup commitment', () => { + const coeffs = [0x21n, 0x31n, 0x41n] + const commits = dkg.vssCommit(coeffs) + const id = 2 + const s = dkg.polynomialEvaluate(BigInt(id), coeffs) + // tamper C0 with a bogus point (synthetic non-subgroup/invalid) + const tampered = commits.slice() + // Intentionally use an arbitrary pair; strict verifier should return false or gracefully handle + tampered[0] = [1n, 1n] as any + const ok = dkg.verifyFeldmanShare(id, s, tampered) + assert.strictEqual(ok, false, 'tampered/non-subgroup commitment should fail Feldman verification') + }) +}) diff --git a/test/trusted-dkg.test.ts b/test/trusted-dkg.test.ts deleted file mode 100644 index ef7f70e..0000000 --- a/test/trusted-dkg.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable camelcase */ -import assert from 'node:assert' -import { describe, it } from 'node:test' - -import TrustedDKG from '../src/frost/trusted-dkg' - -describe('TrustedDKG', () => { - const dkg = new TrustedDKG() - - describe('Test Vector Validation', () => { - it('should match FROST test vectors for 3-of-5 threshold scheme', () => { - const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn - const a1 = 0x57ea51a2f5712861d3e3125c7b81f081b19c40b04894abf76d35429f2eef8a3n - const a2 = 0x439109f6f2946257f8c508718b4a86e0cc73e1cae22dd0b7b8a20a59331d5b4n - - const expectedShares = [ - 0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804n, // Participant 1 - 0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0n, // Participant 2 - 0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653n, // Participant 3 - 0x3050f2b896694a1de4b85af56e52fa428144c5a9eeb0f6285e68177c71cad3cn, // Participant 4 - 0x42d853c1c25b254f6324e954ba60d390776bf5e32c79d408072d46e56e4189cn // Participant 5 - ] - - const expectedPubKeys = [ - [ - 0x2ebc885681e45848f34ee1050a827eca10e8fb24cbb64a091901d96911480cc2n, - 0x482f7210ba3665fbe23bc0eab393f7266a36008186bb7c6781b1017612e49b4n - ], - [ - 0x26dd7c7370505a2496012e97bae1a23e9439661f90c0f15aac26ed1cd606535fn, - 0x2a770f7fc5a26df9427cde7456ba495a2b2eb89c6a1be3273d9d982d5417bd01n - ], - [ - 0x1136b9efcaf9dc8b3b20bf463151645d49a2b33be95a4a36c3833e7a66453e65n, - 0x28146163f1cf5182296a46ed9523b521e37f8eb312ac3d209f4d0a629b474445n - ], - [ - 0x1a78e7b5302f95764b22448764ca2ecac418116b52332882d77868f2db086c33n, - 0x178597d7aea8a7e82b223cb8a0626d776a217caf7f1988bfa775d73d49e26f0bn - ], - [ - 0x2d91b9147ee0817f26f33eb7b759b4702577e429f80097aa07c7deb69dd6f562n, - 0x71e39bb200f3b4c3e6c54134c5c1382590dbdf8500303525f3b585647cbe8edn - ], - [ - 0x190260ab0b8674b7b20c22d37d9838cd5194d348745c7d4eba69feb1a5bfc749n, - 0x1c51721613b027faee6b242b213d8f534578c115d70746347d5590e10e2ce7f8n - ] - ] - - const expectedGroupPK = { - x: 0x1e0762d6610a0b47f3b5e3f23f5f748fde5abb8843f33cf084c0dabd8dc813e6n, - y: 0xb82b739e78dda57e75ac680ef689df1158fe3eed8095c6d4bd1b2c7c166eefdn - } - - const coefficients = [a1, a2] - const { secretKeyShares, coefficients: coeffs } = dkg.secretShareShard(secret, coefficients, 5) - - assert.strictEqual(coeffs[0], secret, 'First coefficient should be the secret') - assert.strictEqual(coeffs[1], a1, 'Second coefficient should match a1') - assert.strictEqual(coeffs[2], a2, 'Third coefficient should match a2') - - for (let i = 0; i < 5; i++) { - const share = secretKeyShares[i]! - assert.strictEqual(share.x_i, i + 1, `Participant ${i + 1} x-coordinate should be ${i + 1}`) - assert.strictEqual(share.y_i, expectedShares[i], `Participant ${i + 1} secret share should match test vector`) - } - - const vssCommitment = dkg.vssCommit(coeffs) - - for (let i = 1; i <= 5; i++) { - const participantKey = secretKeyShares[i - 1]! - const share = { - i: BigInt(participantKey.x_i), - sk_i: participantKey.y_i - } - const isValid = dkg.vssVerify(share, vssCommitment, 3) - assert.strictEqual(isValid, true, `Participant ${i} share should be valid`) - } - - const groupInfo = dkg.deriveGroupInfo(5, 3, vssCommitment) - groupInfo.participantPublicKeys.forEach((p, idx)=>{ - console.log(`Participant ${idx + 1}:`) - console.log(` Secret share: 0x${secretKeyShares[idx].y_i.toString(16)!}`) - console.log(` pubKey.x: 0x${p[0].toString(16)!}`) - console.log(` pubKey.y: 0x${p[1].toString(16)!}`) - assert.equal(p[0], expectedPubKeys[idx][0], 'Participant public key x-coordinate should match') - assert.equal(p[1], expectedPubKeys[idx][1], 'Participant public key y-coordinate should match') - }) - assert.strictEqual(groupInfo.PK![0], expectedGroupPK.x, 'Group public key x-coordinate should match') - assert.strictEqual(groupInfo.PK![1], expectedGroupPK.y, 'Group public key y-coordinate should match') - - const directGroupPK = dkg.ScalarBaseMult(secret) - assert(dkg.pointsEqual(groupInfo.PK!, directGroupPK), 'Group PK should equal secret * Base') - }) - - it('should correctly evaluate polynomial with test vector coefficients', () => { - const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn - const a1 = 0x57ea51a2f5712861d3e3125c7b81f081b19c40b04894abf76d35429f2eef8a3n - const a2 = 0x439109f6f2946257f8c508718b4a86e0cc73e1cae22dd0b7b8a20a59331d5b4n - - const coefficients = [secret, a1, a2] - - const p1 = dkg.polynomialEvaluate(1n, coefficients) - - assert.strictEqual(p1, 0x1d4260025e6e520d8daab9c1f9923b0c94c6ee643f051ff2526517535133804n) - - const p2 = dkg.polynomialEvaluate(2n, coefficients) - assert.strictEqual(p2, 0x1d85f8d8e472e07cf9fb432c0debae008ff241ea77f69c1d8403ffb36343cf0n) - - const p3 = dkg.polynomialEvaluate(3n, coefficients) - assert.strictEqual(p3, 0x442308b78d3cf348e735520c35d77dfb70167d82e334d911afbd7f02497c653n) - - const p0 = dkg.polynomialEvaluate(0n, coefficients) - assert.strictEqual(p0, secret, 'Polynomial at x=0 should equal the secret') - }) - }) -}) \ No newline at end of file diff --git a/test/vss.test.ts b/test/vss.test.ts deleted file mode 100644 index c12aae9..0000000 --- a/test/vss.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable camelcase */ -import assert from 'node:assert' -import { describe, it } from 'node:test' - -import type { Point } from '@zk-kit/baby-jubjub' - -import { eddsaBuild } from '../src/eddsa' -import BabyFROST, { type Commitment } from '../src/frost/babyfrost' -import type { ParticipantInput, Share } from '../src/frost/types' -import BabyFrostVSSDKG from '../src/frost/vss-dkg' -import { - poseidonHex -} from '../src/index' - -function sharesById (shs: Share[]): Record { - const m: Record = {} - for (const s of shs) m[s.id] = s - return m -} - -describe('VSS DKG: aggregator learns nothing; ≥t can reconstruct, Feldman Verify', () => { - const vss = new BabyFrostVSSDKG() - const frost = new BabyFROST() - - // Reconstruct master secret a0 (T(0)) from exactly `threshold` pairs (id, s_i) - function reconstructA0FromShares (subset: { id: number; s_i: bigint }[], threshold: number): bigint { - if (subset.length < threshold) throw new Error('insufficient shares to reconstruct') - const ids = subset.map(s => s.id) - let a0 = 0n - for (const { id, s_i } of subset) { - a0 = vss.modOrder(a0 + vss.modOrder(s_i) * frost.deriveInterpolatingValue( ids.map(BigInt), BigInt(id))) - } - return a0 // subgroup scalar - } - - it('3 parties, t=2: verify shares, finalize, and demonstrate threshold property', () => { - const participants: ParticipantInput[] = [ - { id: 1, seed: Buffer.alloc(32, 0x11), password: Buffer.alloc(32, 0xaa) }, - { id: 2, seed: Buffer.alloc(32, 0x22), password: Buffer.alloc(32, 0xbb) }, - { id: 3, seed: Buffer.alloc(32, 0x33), password: Buffer.alloc(32, 0xcc) }, - ] - const ids = participants.map(p => p.id) - const threshold = 2 - - // each party is a dealer: make coeffs & commitments, then per-recipient shares - const dealerCoeffs = participants.map(p => vss.makeDealerCoeffsDeterministic(p, threshold)) - const dealerComms: Point[][] = dealerCoeffs.map(cs => vss.makeDealerCommitments(cs)) - const dealerShares: Record> = {} - dealerCoeffs.forEach((coeffs, kIdx) => { - dealerShares[participants[kIdx]!.id] = vss.computeDealerSharesForIds(coeffs, ids) - }) - - // aggregator can compute PKGroup from commitments, but not the scalar a0 - const PK_from_comm = vss.combineGroupPubkeyFromCommitments(dealerComms) - - // each participant verifies each dealer's share and finalizes their own share - const finalized: { id: number; share: Share; PKGroup: Point }[] = [] - for (const p of participants) { - // verify Feldman shares from every dealer - for (let dk = 0; dk < participants.length; dk++) { - const dealerId = participants[dk]!.id - const s_ki = dealerShares[dealerId]![p.id] // private share sent to p - const ok = vss.verifyFeldmanShare(p.id, s_ki!, dealerComms[dk]!) - assert.strictEqual(ok, true, `share from dealer ${dealerId} to ${p.id} failed Feldman check`) - } - - // collect (dealerId, s_ki) for finalize - const s_ki_byDealer = participants.map((dealer) => ({ - dealerId: dealer.id, - s_ki: dealerShares[dealer.id]![p.id], - })) - - const { share, PKGroup } = vss.finalizeVSSForParticipant(p.id, s_ki_byDealer as any, dealerComms) - finalized.push({ id: p.id, share, PKGroup }) - } - // all participants agree on PKGroup (same as from public commitments) - for (const f of finalized) { - assert.deepStrictEqual(f.PKGroup[0], PK_from_comm[0], 'PKGroup.x mismatch') - assert.deepStrictEqual(f.PKGroup[1], PK_from_comm[1], 'PKGroup.y mismatch') - } - - // aggregator cannot reconstruct with < t shares - const map = sharesById(finalized.map(f => f.share)) - assert.throws(() => reconstructA0FromShares([{ id: 1, s_i: map[1]!.skShareDiv8 }], threshold), - /insufficient shares/i, - 'should not reconstruct with fewer than threshold shares') - - // // any t shares can reconstruct the master secret a0 (threshold property) - const a0_12 = reconstructA0FromShares( - [{ id: 1, s_i: map[1]!.skShareDiv8 }, { id: 2, s_i: map[2]!.skShareDiv8 }], - threshold - ) - const A_12 = vss.multiplyUnsafe(a0_12) - - assert.deepStrictEqual(A_12[0], PK_from_comm[0], 'Reconstructed PK.x (1,2) mismatch') - assert.deepStrictEqual(A_12[1], PK_from_comm[1], 'Reconstructed PK.y (1,2) mismatch') - - const a0_23 = reconstructA0FromShares( - [{ id: 2, s_i: map[2]!.skShareDiv8 }, { id: 3, s_i: map[3]!.skShareDiv8 }], - threshold - ) - const A_23 = vss.multiplyUnsafe(a0_23) - - assert.deepStrictEqual(A_23[0], PK_from_comm[0], 'Reconstructed PK.x (2,3) mismatch') - assert.deepStrictEqual(A_23[1], PK_from_comm[1], 'Reconstructed PK.y (2,3) mismatch') - - // test signing & verify - const subset = [ - finalized[0], - finalized[1], - finalized[2] - ] - const signIds = subset.map(s => s!.id) - - const msgHash = BigInt('0x' + poseidonHex(['0x' + 12345n.toString(16)], true)) - - const groupPublicKey = finalized[0].PKGroup as Point - - // round 1 commitment - const p1 = frost.commit(subset[0].share.skShare, BigInt(subset[0].id)) - const p2 = frost.commit(subset[1].share.skShare, BigInt(subset[1].id)) - const p3 = frost.commit(subset[2].share.skShare, BigInt(subset[2].id)) - - const commitmentList: Commitment[] = [p1, p2, p3].map((a)=>{ - return { ...a.commitments } - }) - - const s1 = frost.sign( - p1.commitments.identifier, - subset[0].share.skShare, - groupPublicKey, - p1.nonces, - msgHash, - commitmentList - ) - - const s2 = frost.sign( - p2.commitments.identifier, - subset[1].share.skShare, - groupPublicKey, - p2.nonces, - msgHash, - commitmentList - ) - - const s3 = frost.sign( - p3.commitments.identifier, - subset[2].share.skShare, - groupPublicKey, - p3.nonces, - msgHash, - commitmentList - ) - - const sig = frost.aggregate(commitmentList, msgHash, groupPublicKey, [s1, s2, s3]) - const ok = eddsaBuild.verifyPoseidon(frost.toBytes(msgHash).toReversed(), sig, groupPublicKey) - assert.strictEqual(ok, true, 'FROST aggregate failed verification') - }) -}) diff --git a/yarn.lock b/yarn.lock index 849a62c..f387f35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -339,6 +339,13 @@ dependencies: "@noble/hashes" "1.3.2" +"@noble/curves@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.0.1.tgz#64ba8bd5e8564a02942655602515646df1cdb3ad" + integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== + dependencies: + "@noble/hashes" "2.0.1" + "@noble/ed25519@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-3.0.0.tgz#720d4cdb6b5f632e29164a7e9d5cdfeb82a7ac86" @@ -349,7 +356,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== -"@noble/hashes@^2.0.1": +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==