From 66c651bf5b0cbd7d83bb128401046d84145f729d Mon Sep 17 00:00:00 2001 From: Renaud Date: Tue, 14 Jul 2026 13:43:26 +0000 Subject: [PATCH] fix: use modular inverse in deriveInterpolatingValue BigInt integer division truncates toward zero, so the Lagrange coefficients were only correct when the divisor happened to divide the numerator exactly. This holds for any consecutive identifier set {1, ..., t} (which is all the existing tests exercised) but fails for every other quorum: with 2-of-3 and quorum {1, 3} the coefficients came out as lambda_1 = 1 and lambda_3 = 0, producing signature shares that aggregate into an invalid signature. Compute the coefficient in the scalar field instead, reusing the existing invModOrder, and reduce the partial products mod order along the way. Adds a regression test signing with all three 2-of-3 quorums from a trusted dealer keygen; {1, 3} fails without this fix. --- src/frost/babyfrost.ts | 10 ++++--- test/babyfrost.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/frost/babyfrost.ts b/src/frost/babyfrost.ts index bc90a62..75de41b 100644 --- a/src/frost/babyfrost.ts +++ b/src/frost/babyfrost.ts @@ -96,10 +96,14 @@ class BabyFROST extends RailJubCurvePoint { let dom = 1n for (const x_j of L) { if (x_j === x_i) continue - num *= x_j - dom *= x_j - x_i + num = this.modOrder(num * x_j) + dom = this.modOrder(dom * (x_j - x_i)) } - const value = num / dom + // Field division: BigInt `num / dom` truncates toward zero and yields + // wrong coefficients whenever dom does not divide num exactly (e.g. the + // quorum {1, 3} gives lambda_1 = 1 and lambda_3 = 0, so any quorum other + // than the full consecutive set produces invalid signatures). + const value = this.modOrder(num * this.invModOrder(dom)) return value } diff --git a/test/babyfrost.test.ts b/test/babyfrost.test.ts index 10c169e..34ae3e0 100644 --- a/test/babyfrost.test.ts +++ b/test/babyfrost.test.ts @@ -4,6 +4,7 @@ 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 { TrustedDKG } from '../src/frost' import { subOrder } from '@zk-kit/baby-jubjub' // can skip the mod purely for tests, it passes without. @@ -137,3 +138,61 @@ describe("BabyFrost RFC9591 spec implementation", () => { assert(ok, 'validation failed') }) }) + +describe('BabyFrost Lagrange coefficients (regression)', () => { + // The Lagrange coefficients at zero are integers for any consecutive + // identifier set {1, ..., t}, so signing with such quorums cannot detect a + // deriveInterpolatingValue that uses BigInt integer division instead of a + // modular inverse. A non-consecutive quorum such as {1, 3} did produce + // invalid signatures before the fix (lambda_1 = 1, lambda_3 = 0). + const dkg = new TrustedDKG() + const threshold = 2 + const n = 3 + const secret = 0x43583e33fb2f47faa243b5cdf8cb251f7e9482f0386064901ae0c5e2134b78fn + const { participantPrivateKeys: shares, vssCommitment } = dkg.trustedDealerKeygen(secret, n, threshold) + const group = dkg.deriveGroupInfo(n, threshold, vssCommitment) + const finalized = shares.map(({ x_i, y_i }) => { + const res = dkg.finalizeParticipant(x_i, [{ dealerId: 1, s_ki: y_i }], [vssCommitment]) + return { id: x_i, skShare: res.share.skShare } + }) + + for (const ids of [[1, 2], [1, 3], [2, 3]]) { + it(`should properly compute signature with quorum {${ids.join(', ')}}`, () => { + const frost = new BabyFROST_RFC9591() + const subset = ids.map(id => finalized[id - 1]!) + const groupPublicKey = group.PK! + const msgHash = BigInt('0x' + poseidonHex(['0x' + 12345n.toString(16)], true)) + + // round 1 commitments + const rounds = subset.map(s => frost.commit(s.skShare, BigInt(s.id))) + const commitmentList: Commitment[] = rounds.map((a) => ({ ...a.commitments })) + + // round 2 signature shares + const sigShares = rounds.map((r, idx) => frost.sign( + r.commitments.identifier, + subset[idx]!.skShare, + groupPublicKey, + r.nonces, + msgHash, + commitmentList + )) + + rounds.forEach((r, idx) => { + const verified = frost.verifySignatureShare( + r.commitments.identifier, + subset[idx]!.skShare, + r.commitments, + sigShares[idx]!, + commitmentList, + groupPublicKey, + msgHash + ) + assert(verified, `participant ${r.commitments.identifier} provided a share that failed verification`) + }) + + const sig = frost.aggregate(commitmentList, msgHash, groupPublicKey, sigShares) + const ok = eddsaBuild.verifyPoseidon(frost.toBytes(msgHash).toReversed(), sig, groupPublicKey) + assert(ok, `validation failed for quorum {${ids.join(', ')}}`) + }) + } +})