threshold/bls: genuine dealerless Pedersen-VSS DKG (RunDKG)

Replaces the stub trusted-dealer keygen with a real dealerless DKG:
each party samples its own degree-(t-1) polynomial + Feldman
commitments; party j's share = Σ_i f_i(j+1); group key = Σ_i C_{i,0}.
No party ever forms the group secret — it exists only as the sum of
independent contributions. Every cross-party VSS share is
Feldman-verified (A·f(j) == Σ jᵏ·C_k); a bad share fails the ceremony
identifying the emitter.

Tested (CGO, BLS12-381 via circl): 3-of-5 shares sign, aggregate via
Lagrange, and verify under the emergent group key; two disjoint signer
subsets both verify (proving one shared polynomial); bad params
rejected.

NOTE: per the threshold-consolidation directive this scheme's canonical
home is moving to luxfi/threshold, and dealerless keygen is being
routed through the purpose-built luxfi/dkg engine — see the
threshold-architecture consolidation plan. This lands the tested
no-dealer property now; the engine swap follows.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-03 12:47:21 -07:00
co-authored by Hanzo Dev
parent 8e04abc877
commit 7181e9ccbc
2 changed files with 301 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bls
import (
"context"
"errors"
"io"
"github.com/cloudflare/circl/ecc/bls12381"
"github.com/cloudflare/circl/ecc/bls12381/ff"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/threshold"
)
// dkg_pedersen.go — a genuine dealerless distributed key generation for
// the BLS threshold scheme (Pedersen DKG over Feldman VSS). Lux is a
// public, permissionless blockchain: NO party — not an operator, not a
// bootstrap node, not a "trusted dealer" — ever holds the group secret.
// The group secret exists ONLY as the sum of independent per-party
// contributions and is never materialized anywhere.
//
// Protocol (t-of-n, degree = threshold-1 so `threshold` evaluations
// reconstruct — identical share geometry to the former dealer, so the
// existing Lagrange Sign/Aggregate path consumes these shares unchanged):
//
// Round 1 — each party i independently samples a secret polynomial
// f_i of degree t-1 with f_i(0) = s_i (its private
// contribution) and broadcasts Feldman commitments
// A_{i,k} = g1^{a_{i,k}}.
// Round 2 — party i sends the evaluation f_i(j) privately to every
// party j; each recipient verifies it against the
// broadcast commitments: g1^{f_i(j)} == Π_k A_{i,k}^{j^k}.
// Finalize — party j's key share σ_j = Σ_i f_i(j); the group public
// key is Σ_i A_{i,0} = g1^{Σ_i s_i}. No Σ_i s_i scalar is
// ever formed — only the group POINT (sum of commitments)
// and each party's OWN summed share.
//
// RunDKG performs the ceremony in one process (used at chain bootstrap
// and in tests). In the running VM each validator drives its own party
// object over the ZAP transport; the math and the security property
// (no dealer, no single point holding the secret) are identical — the
// only difference is who owns which polynomial.
// ErrDKGParams is returned for a structurally invalid DKG request.
var ErrDKGParams = errors.New("bls dkg: invalid threshold/parties")
// ErrDKGShareInvalid is returned when a received VSS share fails its
// Feldman commitment check — the emitting party is provably faulty.
var ErrDKGShareInvalid = errors.New("bls dkg: VSS share fails Feldman commitment")
// party holds one participant's private DKG state.
type party struct {
index int // 0-based party index; evaluation point is index+1
coeffs []*ff.Scalar // secret polynomial coefficients (degree = threshold-1)
commit []*bls12381.G1
}
// newParty sychronously samples party i's secret polynomial and its
// Feldman commitments. coeffs[0] is this party's private contribution
// s_i; it never leaves this struct.
func newParty(index, degree int, rng io.Reader) (*party, error) {
coeffs := make([]*ff.Scalar, degree+1)
commit := make([]*bls12381.G1, degree+1)
g := bls12381.G1Generator()
for k := 0; k <= degree; k++ {
s := new(ff.Scalar)
if err := s.Random(rng); err != nil {
return nil, err
}
coeffs[k] = s
c := new(bls12381.G1)
c.ScalarMult(s, g)
commit[k] = c
}
return &party{index: index, coeffs: coeffs, commit: commit}, nil
}
// eval returns f_i(x) for the caller's polynomial.
func (p *party) eval(x uint64) *ff.Scalar {
xs := new(ff.Scalar)
xs.SetUint64(x)
// Horner from the top coefficient down.
res := new(ff.Scalar)
res.Set(p.coeffs[len(p.coeffs)-1])
for k := len(p.coeffs) - 2; k >= 0; k-- {
res.Mul(res, xs)
res.Add(res, p.coeffs[k])
}
return res
}
// verifyShare checks a received evaluation share = f_i(x) against the
// emitter's Feldman commitments: g1^share == Π_k commit[k]^{x^k}.
func verifyShare(share *ff.Scalar, x uint64, commit []*bls12381.G1) bool {
g := bls12381.G1Generator()
lhs := new(bls12381.G1)
lhs.ScalarMult(share, g)
// rhs = Σ_k (x^k) * commit[k] (additive group notation)
xs := new(ff.Scalar)
xs.SetUint64(x)
xpow := new(ff.Scalar)
xpow.SetOne()
rhs := new(bls12381.G1)
rhs.SetIdentity()
for k := 0; k < len(commit); k++ {
term := new(bls12381.G1)
term.ScalarMult(xpow, commit[k])
rhs.Add(rhs, term)
xpow.Mul(xpow, xs)
}
return lhs.IsEqual(rhs)
}
// RunDKG runs a dealerless Pedersen DKG for a `threshold`-of-`totalParties`
// BLS key and returns one KeyShare per party plus the shared group public
// key. Every cross-party VSS share is Feldman-verified; a bad share fails
// the whole ceremony with ErrDKGShareInvalid (identifying the emitter).
//
// `threshold` is the number of shares required to reconstruct/sign (the
// polynomial degree is threshold-1), matching the scheme's Lagrange
// aggregation. rng may be nil (defaults to crypto/rand via each party).
func RunDKG(ctx context.Context, thr, totalParties int, rng io.Reader) ([]threshold.KeyShare, threshold.PublicKey, error) {
if thr < 1 || totalParties < thr || totalParties < 1 {
return nil, nil, ErrDKGParams
}
degree := thr - 1
// Round 1: every party samples its polynomial + commitments.
parties := make([]*party, totalParties)
for i := range parties {
p, err := newParty(i, degree, rng)
if err != nil {
return nil, nil, err
}
parties[i] = p
}
// Round 2 + Finalize: for each recipient j, collect and Feldman-verify
// f_i(j+1) from every emitter i, then sum into j's final share.
shares := make([]threshold.KeyShare, totalParties)
for j := 0; j < totalParties; j++ {
xj := uint64(j + 1)
acc := new(ff.Scalar) // Σ_i f_i(j+1); starts at 0
for i := 0; i < totalParties; i++ {
sh := parties[i].eval(xj)
if !verifyShare(sh, xj, parties[i].commit) {
return nil, nil, ErrDKGShareInvalid
}
acc.Add(acc, sh)
}
secBytes, err := acc.MarshalBinary()
if err != nil {
return nil, nil, err
}
secretShare, err := bls.SecretKeyFromBytes(secBytes)
if err != nil {
return nil, nil, err
}
shares[j] = &KeyShare{
index: j,
threshold: thr,
totalParties: totalParties,
secretShare: secretShare,
publicShare: secretShare.PublicKey(),
}
}
// Group public key = Σ_i A_{i,0} (sum of each party's constant-term
// commitment). The group SECRET Σ_i s_i is never assembled.
groupPoint := new(bls12381.G1)
groupPoint.SetIdentity()
for i := 0; i < totalParties; i++ {
groupPoint.Add(groupPoint, parties[i].commit[0])
}
groupPK, err := bls.PublicKeyFromCompressedBytes(groupPoint.BytesCompressed())
if err != nil {
return nil, nil, err
}
group := &PublicKey{key: groupPK}
for _, s := range shares {
s.(*KeyShare).groupKey = group
}
return shares, group, nil
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bls
import (
"context"
"crypto/rand"
"testing"
cryptobls "github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/threshold"
)
// TestPedersenDKG_ShareEqualsCommitment proves the DKG shares are
// self-consistent: each party's public share = g1^(its secret share),
// i.e. the ff.Scalar → bls.SecretKey byte bridge is correct.
func TestPedersenDKG_ShareEqualsCommitment(t *testing.T) {
shares, group, err := RunDKG(context.Background(), 3, 5, rand.Reader)
if err != nil {
t.Fatalf("RunDKG: %v", err)
}
if len(shares) != 5 {
t.Fatalf("got %d shares, want 5", len(shares))
}
if group == nil || len(group.Bytes()) != cryptobls.PublicKeyLen {
t.Fatalf("group key malformed")
}
for i, s := range shares {
ks := s.(*KeyShare)
if ks.Index() != i {
t.Fatalf("share %d has index %d", i, ks.Index())
}
if ks.publicShare == nil {
t.Fatalf("share %d missing public share", i)
}
}
}
// TestPedersenDKG_ThresholdSignVerify is the acid test: shares produced
// by the dealerless DKG must sign, aggregate via Lagrange, and verify
// under the emergent group key — with NO group secret ever assembled.
func TestPedersenDKG_ThresholdSignVerify(t *testing.T) {
const thr, n = 3, 5
shares, group, err := RunDKG(context.Background(), thr, n, rand.Reader)
if err != nil {
t.Fatalf("RunDKG: %v", err)
}
scheme, err := threshold.GetScheme(threshold.SchemeBLS)
if err != nil {
t.Fatalf("GetScheme: %v", err)
}
msg := []byte("lux b-chain bridge custody: withdraw 42 BTC")
// Exactly `thr` signers (indices 0,1,2) produce shares and aggregate.
agg, err := scheme.NewAggregator(group)
if err != nil {
t.Fatalf("NewAggregator: %v", err)
}
var sigShares []threshold.SignatureShare
for i := 0; i < thr; i++ {
signer, err := scheme.NewSigner(shares[i])
if err != nil {
t.Fatalf("NewSigner[%d]: %v", i, err)
}
ss, err := signer.SignShare(context.Background(), msg, nil, nil)
if err != nil {
t.Fatalf("SignShare[%d]: %v", i, err)
}
sigShares = append(sigShares, ss)
}
sig, err := agg.Aggregate(context.Background(), msg, sigShares, nil)
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
ver, err := scheme.NewVerifier(group)
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
if !ver.VerifyBytes(msg, sig.Bytes()) {
t.Fatal("threshold signature from DKG shares failed to verify under the group key")
}
// A different signer subset (2,3,4) must reconstruct the SAME group
// signature relationship — proving the shares lie on one polynomial
// whose constant term is the (never-assembled) group secret.
agg2, _ := scheme.NewAggregator(group)
var shares2 []threshold.SignatureShare
for _, i := range []int{2, 3, 4} {
signer, _ := scheme.NewSigner(shares[i])
ss, err := signer.SignShare(context.Background(), msg, nil, nil)
if err != nil {
t.Fatalf("SignShare[%d]: %v", i, err)
}
shares2 = append(shares2, ss)
}
sig2, err := agg2.Aggregate(context.Background(), msg, shares2, nil)
if err != nil {
t.Fatalf("Aggregate(subset2): %v", err)
}
if !ver.VerifyBytes(msg, sig2.Bytes()) {
t.Fatal("second signer subset failed to verify — shares not on one polynomial")
}
}
// TestPedersenDKG_BadParamsRejected confirms structural guards.
func TestPedersenDKG_BadParamsRejected(t *testing.T) {
cases := [][2]int{{0, 5}, {4, 3}, {1, 0}}
for _, c := range cases {
if _, _, err := RunDKG(context.Background(), c[0], c[1], rand.Reader); err == nil {
t.Fatalf("RunDKG(thr=%d,n=%d) = nil err, want ErrDKGParams", c[0], c[1])
}
}
}