threshold: make trusted-dealer keygen explicit, drop dead ReconstructSecret, pin sub-quorum soundness

Part of the Lux threshold-crypto security rip (killing trusted-dealer /
full-key-reconstruction footguns).

- Rename threshold.GenerateKeys -> threshold.GenerateKeysTrustedDealer.
  The function samples the full secret and sets sign.K / sign.Threshold in
  one process: it IS the trusted dealer. The new name mirrors
  keyera.BootstrapTrustedDealer so the trust model is explicit and
  greppable. Doc comment now states it is for test/KAT/CT/CLI/oracle use
  only, and that production chain keygen uses keyera.Bootstrap (dealerless
  Pedersen DKG). Stays exported; every caller updated (cli, ct/dudect,
  same-package tests) plus stale comment refs in cmd, keyera, wire,
  reshare.

- Delete utils.ReconstructSecret. It Lagrange-interpolates the full secret
  from shares and had zero callers anywhere in the repo: a
  reconstruction-shaped footgun left dead. CompareSecrets and the section
  retained.

- Include threshold/minority_soundness_test.go: an adversarial negative
  pinning that a strict sub-quorum cannot assemble a signature that
  verifies under the group key (uncancelled PRF mask fails the L2-norm
  gate). A sub-quorum forgery would be a catastrophic finality break.
This commit is contained in:
zeekay
2026-06-26 21:13:45 -07:00
parent 7c102d2d14
commit f08e2b57aa
18 changed files with 156 additions and 62 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ Examples:
fmt.Fprintf(os.Stderr, "Generating %d-of-%d threshold key shares...\n", t, n)
shares, groupKey, err := threshold.GenerateKeys(t, n, rand.Reader)
shares, groupKey, err := threshold.GenerateKeysTrustedDealer(t, n, rand.Reader)
if err != nil {
return fmt.Errorf("generate keys: %w", err)
}
+1 -1
View File
@@ -945,7 +945,7 @@ func emitSignVerify(outDir string) error {
Description: "Full Corona Sign+Verify round-trip. For each (t,n,msg,seed): " +
"Gen → SignRound1 (all parties) → SignRound2Preprocess+SignRound2 (all parties) → " +
"SignFinalize → Verify. Note: corona/sign currently signs with K=Threshold=n " +
"(see threshold.GenerateKeys). The n in this KAT is the total signer count; " +
"(see threshold.GenerateKeysTrustedDealer). The n in this KAT is the total signer count; " +
"the t is documented for the C++ port to validate threshold-aware sharing logic " +
"once it is implemented downstream. SHA-256 hashes are used for large fields " +
"(A, BTilde, sk shares, partial sigs) to keep file size finite while still " +
+1 -1
View File
@@ -60,7 +60,7 @@ var (
// populated R2 data map ready for Finalize.
func makeCombineFixture(msg string) (*combineFixture, error) {
const t, n = 2, 3
shares, gk, err := threshold.GenerateKeys(t, n, rand.Reader)
shares, gk, err := threshold.GenerateKeysTrustedDealer(t, n, rand.Reader)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -93,7 +93,7 @@ func signFresh() (*threshold.Signature, error) {
// quorum for the dudect harness; the property under test is
// content-dependent timing in Verify, independent of party count).
const t, n = 2, 3
shares, gk, err := threshold.GenerateKeys(t, n, rand.Reader)
shares, gk, err := threshold.GenerateKeysTrustedDealer(t, n, rand.Reader)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -366,7 +366,7 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
}
for j, v := range validators {
// sShares[j] is in standard form; convert to NTT-Mont for the
// signing-time multiplication path (matches threshold.GenerateKeys
// signing-time multiplication path (matches threshold.GenerateKeysTrustedDealer
// convention).
skNTT := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
+4 -4
View File
@@ -22,10 +22,10 @@ package reshare
// RotateEpochKeys ReshareEpoch (or RotateEpochShares)
// EpochKeys (struct) EpochShareState
// RotateEpoch(newVals) RotateEpoch(newVals) calls Reshare(...)
// → calls GenerateKeys instead of GenerateKeys
// → calls GenerateKeysTrustedDealer instead of GenerateKeysTrustedDealer
//
// Concretely the existing `EpochManager.RotateEpoch` re-runs
// `coronaThreshold.GenerateKeys(t, n, nil)` which generates a FRESH
// `coronaThreshold.GenerateKeysTrustedDealer(t, n, nil)` which generates a FRESH
// secret and a NEW group public key per epoch. With Reshare, the
// master secret and group public key are PERSISTENT across epochs;
// only the share distribution rotates. So:
@@ -177,7 +177,7 @@ func (i *RefreshEpochInputs) BuildTranscript() TranscriptInputs {
// name but change the body). Caller code in quasar.go and the
// `validator-rotation` consensus path need updates.
//
// [2] Replace `coronaThreshold.GenerateKeys` call with a Reshare
// [2] Replace `coronaThreshold.GenerateKeysTrustedDealer` call with a Reshare
// invocation: extract OldShares from current EpochShareState,
// feed into Reshare(r, oldShares, tOld, newSet, tNew, randSource).
//
@@ -220,7 +220,7 @@ func (i *RefreshEpochInputs) BuildTranscript() TranscriptInputs {
// equivocation) is forwarded to the slashing module. The
// slasher reverifies on-chain and emits the slashing tx.
//
// [12] Backwards compatibility: `EpochManager.GenerateKeys` is
// [12] Backwards compatibility: `EpochManager.GenerateKeysTrustedDealer` is
// KEPT as the genesis path (one-time bootstrap). Subsequent
// epoch rotations go through ReshareEpoch only.
//
+2 -2
View File
@@ -13,7 +13,7 @@ import (
// BenchmarkPulsarSign measures the wall-clock cost of the 2-round
// Pulsar threshold protocol's *online* phase (Round1 + Round2 +
// Finalize, given a fresh GenerateKeys epoch). The IEEE S&P 2025
// Finalize, given a fresh GenerateKeysTrustedDealer epoch). The IEEE S&P 2025
// Pulsar evaluation calls out a 0.6 s online phase across 5
// continents at the production shape; this bench gives the local
// upper bound (network RTT is excluded; the cost here is pure CPU /
@@ -62,7 +62,7 @@ func benchPulsarSign(b *testing.B, n, thr int, gpuOn bool) {
}
b.Cleanup(cgpu.DisableAccelerator)
shares, _, err := GenerateKeys(thr, n, rand.Reader)
shares, _, err := GenerateKeysTrustedDealer(thr, n, rand.Reader)
if err != nil {
b.Fatal(err)
}
+2 -2
View File
@@ -29,7 +29,7 @@ func TestE2EThresholdVariants(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run("", func(t *testing.T) {
shares, gk, err := GenerateKeys(tc.t, tc.n, rand.Reader)
shares, gk, err := GenerateKeysTrustedDealer(tc.t, tc.n, rand.Reader)
if err != nil {
t.Fatalf("(%d,%d) GenerateKeys: %v", tc.t, tc.n, err)
}
@@ -153,7 +153,7 @@ func TestE2EKATReplayDeterminism(t *testing.T) {
return b
}
shares, _, err := GenerateKeys(2, 3, rand.Reader)
shares, _, err := GenerateKeysTrustedDealer(2, 3, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
}
+2 -2
View File
@@ -182,9 +182,9 @@ var (
func mustKernelCeremony(tb testing.TB) {
kernelOnce.Do(func() {
shares, gk, err := GenerateKeys(2, 3, rand.Reader)
shares, gk, err := GenerateKeysTrustedDealer(2, 3, rand.Reader)
if err != nil {
tb.Fatalf("GenerateKeys: %v", err)
tb.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
kShares = shares
kGroupKey = gk
+4 -4
View File
@@ -20,9 +20,9 @@ import (
// not Verify(...) = true.
func FuzzVerifyParseSignature(f *testing.F) {
// Seed corpus: one fresh valid signature.
shares, gk, err := GenerateKeys(2, 3, rand.Reader)
shares, gk, err := GenerateKeysTrustedDealer(2, 3, rand.Reader)
if err != nil {
f.Fatalf("GenerateKeys: %v", err)
f.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
signers := make([]*Signer, 3)
for i, share := range shares {
@@ -88,9 +88,9 @@ func FuzzVerifyParseSignature(f *testing.F) {
// Together with FuzzVerifyParseSignature, this exercises BOTH the
// structural (gob) and the byte-level interpretation paths.
func FuzzVerifyRandomBytes(f *testing.F) {
_, gk, err := GenerateKeys(2, 3, rand.Reader)
_, gk, err := GenerateKeysTrustedDealer(2, 3, rand.Reader)
if err != nil {
f.Fatalf("GenerateKeys: %v", err)
f.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
f.Add([]byte{0, 0, 0, 0})
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package threshold
import (
"crypto/rand"
"testing"
)
// TestThresholdSubQuorumCannotForge pins the permissionless threshold-
// soundness invariant: a strict sub-quorum of the committee (a minority
// that omits at least one signer) CANNOT assemble a signature that
// verifies under the group public key.
//
// Why this holds at runtime: each party's Round-2 partial carries an
// additive PRF mask term (mask / maskPrime in sign.SignRound2). Those
// masks only telescope to zero when the partials are summed over the
// FULL signing set T. Drop any signer and the aggregate z retains an
// uncancelled, high-norm mask, so the L2-norm gate in sign.Verify
// (CheckL2Norm against Bsquare) rejects it. This is the runtime
// counterpart to the (t,n)-Shamir secrecy argument: fewer than the full
// quorum can neither interpolate s nor emit a low-norm signature.
//
// If a sub-quorum signature EVER verified, that would be a catastrophic
// forgery break (a minority producing valid finality certs), so this
// test is an adversarial negative, not a smoke test.
func TestThresholdSubQuorumCannotForge(t *testing.T) {
const tt, n = 3, 5
shares, gk, err := GenerateKeysTrustedDealer(tt, n, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeysTrustedDealer(%d,%d): %v", tt, n, err)
}
signers := make([]*Signer, n)
for i, share := range shares {
signers[i] = NewSigner(share)
}
signerIDs := make([]int, n)
for i := range signerIDs {
signerIDs[i] = i
}
const sid = 1
prfKey := make([]byte, 32)
if _, err := rand.Read(prfKey); err != nil {
t.Fatal(err)
}
message := "corona minority-soundness test"
// Round 1 — every committee member participates so masks are set up
// over the full set T (the honest protocol).
r1 := make(map[int]*Round1Data, n)
for _, s := range signers {
d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1 party %d: %v", s.share.Index, err)
}
r1[d.PartyID] = d
}
// Round 2 — every committee member emits its z partial.
r2 := make(map[int]*Round2Data, n)
for _, s := range signers {
d, err := s.Round2(sid, message, prfKey, signerIDs, r1)
if err != nil {
t.Fatalf("Round2 party %d: %v", s.share.Index, err)
}
r2[d.PartyID] = d
}
// Positive control: the FULL quorum produces a verifying signature.
fullSig, err := signers[0].Finalize(r2)
if err != nil {
t.Fatalf("Finalize (full quorum): %v", err)
}
if !Verify(gk, message, fullSig) {
t.Fatal("full-quorum honest signature was rejected — test setup is wrong")
}
// Adversarial negative: a minority of size t-1 = 2 (parties {0,1})
// attempts to finalize using only its own partials. The aggregate z
// is missing n-(t-1)=3 mask-cancelling terms.
minority := map[int]*Round2Data{
0: r2[0],
1: r2[1],
}
if len(minority) >= n {
t.Fatalf("minority set not a strict subset: |minority|=%d n=%d", len(minority), n)
}
forged, err := signers[0].Finalize(minority)
if err != nil {
// A hard error here is also an acceptable rejection of the minority
// attempt; the invariant (no valid sub-quorum signature) still holds.
return
}
if Verify(gk, message, forged) {
t.Fatalf("CRITICAL: a %d-of-%d sub-quorum produced a signature that "+
"VERIFIED under the group key — threshold soundness is broken "+
"(minority forgery)", len(minority), n)
}
}
+11 -3
View File
@@ -111,9 +111,17 @@ type Signature struct {
Delta structs.Vector[ring.Poly]
}
// GenerateKeys generates threshold key shares for n parties with threshold t.
// This runs once per epoch when the validator set changes.
func GenerateKeys(t, n int, randSource io.Reader) ([]*KeyShare, *GroupKey, error) {
// GenerateKeysTrustedDealer is the trusted-dealer, in-process threshold keygen.
// It sets the global sign.K / sign.Threshold and samples the full secret in a
// single process, then derives the n KeyShares for threshold t. Because one
// party materializes the whole secret, this is a FOOTGUN for production: use it
// ONLY for tests / KAT / constant-time harnesses / CLI / the reference oracle.
//
// Production chain keygen MUST use keyera.Bootstrap (the dealerless Pedersen
// DKG), where no single party ever holds the secret. The TrustedDealer suffix
// mirrors keyera.BootstrapTrustedDealer to keep the trust model explicit and
// greppable.
func GenerateKeysTrustedDealer(t, n int, randSource io.Reader) ([]*KeyShare, *GroupKey, error) {
if n < 2 {
return nil, nil, ErrInvalidPartyCount
}
+3 -3
View File
@@ -14,7 +14,7 @@ import (
)
// deterministicReader is a SHA-256 keystream over a 32-byte seed,
// suitable as an io.Reader for GenerateKeys. Two calls with the same
// suitable as an io.Reader for GenerateKeysTrustedDealer. Two calls with the same
// seed produce the same bytes, which fixes the trustedDealerKey across
// the CPU and GPU legs of this test.
type deterministicReader struct {
@@ -88,9 +88,9 @@ func TestThresholdSign_CPU_vs_GPU_ByteIdentical(t *testing.T) {
} else {
cgpu.DisableAccelerator()
}
shares, groupKey, err := GenerateKeys(thr, k, newDeterministicReader(seed))
shares, groupKey, err := GenerateKeysTrustedDealer(thr, k, newDeterministicReader(seed))
if err != nil {
t.Fatalf("GenerateKeys (gpu=%v): %v", gpuOn, err)
t.Fatalf("GenerateKeysTrustedDealer (gpu=%v): %v", gpuOn, err)
}
signers := make([]*Signer, k)
+12 -12
View File
@@ -7,10 +7,10 @@ import (
"testing"
)
func TestGenerateKeys(t *testing.T) {
shares, groupKey, err := GenerateKeys(2, 3, nil)
func TestGenerateKeysTrustedDealer(t *testing.T) {
shares, groupKey, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys failed: %v", err)
t.Fatalf("GenerateKeysTrustedDealer failed: %v", err)
}
if len(shares) != 3 {
@@ -38,9 +38,9 @@ func TestGenerateKeys(t *testing.T) {
func TestThresholdSigningFlow(t *testing.T) {
// Generate 2-of-3 threshold keys
shares, groupKey, err := GenerateKeys(2, 3, nil)
shares, groupKey, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys failed: %v", err)
t.Fatalf("GenerateKeysTrustedDealer failed: %v", err)
}
// Create signers for all parties
@@ -93,9 +93,9 @@ func TestThresholdSigningFlow(t *testing.T) {
}
func TestThresholdWrongMessage(t *testing.T) {
shares, groupKey, err := GenerateKeys(2, 3, nil)
shares, groupKey, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys failed: %v", err)
t.Fatalf("GenerateKeysTrustedDealer failed: %v", err)
}
signers := make([]*Signer, 3)
@@ -140,7 +140,7 @@ func TestThresholdWrongMessage(t *testing.T) {
// rejects them with ErrDuplicateSigner instead of silently overwriting the
// duplicated z share (which would double-count one signer).
func TestFinalize_DuplicatePartyID_Rejected(t *testing.T) {
shares, _, err := GenerateKeys(2, 3, nil)
shares, _, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys failed: %v", err)
}
@@ -190,7 +190,7 @@ func TestFinalize_DuplicatePartyID_Rejected(t *testing.T) {
// for the Round 2 combine, where a repeated PartyID would overwrite the D
// matrix / MAC entry collected for that party.
func TestRound2_DuplicatePartyID_Rejected(t *testing.T) {
shares, _, err := GenerateKeys(2, 3, nil)
shares, _, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys failed: %v", err)
}
@@ -231,19 +231,19 @@ func TestRound2_DuplicatePartyID_Rejected(t *testing.T) {
func TestInvalidThreshold(t *testing.T) {
// Threshold >= total
_, _, err := GenerateKeys(3, 3, nil)
_, _, err := GenerateKeysTrustedDealer(3, 3, nil)
if err != ErrInvalidThreshold {
t.Errorf("expected ErrInvalidThreshold, got %v", err)
}
// Threshold = 0
_, _, err = GenerateKeys(0, 3, nil)
_, _, err = GenerateKeysTrustedDealer(0, 3, nil)
if err != ErrInvalidThreshold {
t.Errorf("expected ErrInvalidThreshold, got %v", err)
}
// Too few parties
_, _, err = GenerateKeys(1, 1, nil)
_, _, err = GenerateKeysTrustedDealer(1, 1, nil)
if err != ErrInvalidPartyCount {
t.Errorf("expected ErrInvalidPartyCount, got %v", err)
}
+2 -2
View File
@@ -15,9 +15,9 @@ import (
// (Sign1/Sign2/Combine are the only public entry points).
func freshSig(t testing.TB, message string) (*GroupKey, *Signature) {
t.Helper()
shares, groupKey, err := GenerateKeys(2, 3, nil)
shares, groupKey, err := GenerateKeysTrustedDealer(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
t.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
signers := make([]*Signer, 3)
for i, share := range shares {
+1 -1
View File
@@ -153,7 +153,7 @@ func (s *Signature) UnmarshalBinary(b []byte) error {
// MarshalBinary serializes the public GroupKey to a canonical wire
// form. Only the public matrix A and rounded public key BTilde are
// emitted; Params is reconstructed on the verifier side from a fresh
// NewParams() call (matches the GenerateKeys / Bootstrap conventions).
// NewParams() call (matches the GenerateKeysTrustedDealer / Bootstrap conventions).
//
// Layout:
//
+6 -6
View File
@@ -15,15 +15,15 @@ import (
// parsed group key over the same message. Byte-equal serialization is
// enforced.
//
// NOTE: cannot t.Parallel — GenerateKeys mutates sign.K / sign.Threshold
// package globals (see threshold.go:123-124). The wire codec itself is
// NOTE: cannot t.Parallel — GenerateKeysTrustedDealer mutates sign.K / sign.Threshold
// package globals (see threshold.go:131-132). The wire codec itself is
// pure; this is a kernel-side limitation we accept rather than refactor
// out from under sibling agents who own pulsar/.
func TestSignatureWireRoundtrip(t *testing.T) {
const tThr, n = 1, 2
shares, gk, err := GenerateKeys(tThr, n, rand.Reader)
shares, gk, err := GenerateKeysTrustedDealer(tThr, n, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
t.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
// Two-party signing (smallest committee that exercises the protocol).
@@ -224,9 +224,9 @@ func TestGroupKeyWireRejectMalformed(t *testing.T) {
// GroupKey-magic frame in the Signature slot and vice versa — domain
// separation must be effective.
func TestVerifyBytesRejectsCrossWire(t *testing.T) {
shares, gk, err := GenerateKeys(1, 2, rand.Reader)
shares, gk, err := GenerateKeysTrustedDealer(1, 2, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
t.Fatalf("GenerateKeysTrustedDealer: %v", err)
}
_ = shares
gkBytes, err := gk.MarshalBinary()
-16
View File
@@ -352,22 +352,6 @@ func FormatBigIntSlice(slice []*big.Int) string {
// UTIL FUNCTIONS FOR LAGRANGE INTERPOLATION TESTING
// ReconstructSecret reconstructs the secret using the shares and Lagrange coefficients.
func ReconstructSecret(r *ring.Ring, shares map[int]structs.Vector[ring.Poly], lagrangeCoeffs map[int]ring.Poly) structs.Vector[ring.Poly] {
reconstructed := make(structs.Vector[ring.Poly], len(shares[0]))
for i := range reconstructed {
reconstructed[i] = r.NewPoly()
for partyID, share := range shares {
temp := r.NewPoly()
MulPolyNTT(r, share[i], lagrangeCoeffs[partyID], temp)
r.Add(reconstructed[i], temp, reconstructed[i])
}
}
return reconstructed
}
// CompareSecrets compares the reconstructed secret with the original.
func CompareSecrets(r *ring.Ring, reconstructed, original structs.Vector[ring.Poly]) bool {
for i := range reconstructed {