pulsar: no-reconstruct committee threshold sign; quarantine reconstruct+dealer

Make the committee threshold-SIGN combine genuinely no-reconstruct and remove
the centralized-trust paths from the production build.

- Quarantine the GF(q) SEED-share reconstruct family behind
  //go:build legacy_trusted_dealer: large_threshold.go (LargeCombine, the
  KeyFromSeed-at-sign H-1 footgun), large_dkg.go, large_reshare.go,
  large_types.go, largeshamir.go (+ their tests). The production committee
  sign path is now ONLY the no-reconstruct AlgShare/AggregateBCC RoundSigner
  (distributed_bcc.go): each party emits z_i = λ_i·y_i + c·λ_i·s1_i from its
  own share+nonce; the combiner Lagrange-sums to z and recovers the hint from
  public w' = A·z − c·t1·2^d — never forming s1, the seed, or sk.

- Rip out the trusted s1-share dealer DealAlgShares (+ zeroizeKeyMaterial)
  from distributed_bcc.go into bootstrap_dealer_test.go (a _test.go file,
  uncompilable into any production binary; available to tests/bootstrap only).

- Add the two hard gates (no_reconstruct_committee_test.go):
  GATE 1 (standard-verifier): a (t,n)=(4,7) committee no-reconstruct signature
  verifies under independent cloudflare/circl mldsa65.Verify; tamper rejected.
  GATE 2 (no-reconstruct invariant): structural — the production build has no
  KeyFromSeed call in any sign-combine file and no LargeCombine; behavioural —
  Partial and AggregateBCC carry no share/sk/seed, and t-1 partials cannot sign.

Residual (flagged, NOT claimed fixed): a dealerless s1-share KEYGEN is the
research-blocked Part-2 problem (naive_additive_seta_obstruction.go computes the
obstruction; Mithril short-replicated shares is the adoption target). Keygen-side
KeyFromSeed in dkg.go (group-pubkey derivation) is a separate keygen residual.
Honest claim: no-reconstruct committee threshold signing whose output verifies
under the standard ML-DSA verifier — NOT FIPS-204 KeyGen distribution-equivalence,
NOT FIPS-validated.
This commit is contained in:
zeekay
2026-06-27 19:30:56 -07:00
parent 158658fcca
commit a3306ffc35
11 changed files with 504 additions and 116 deletions
+148
View File
@@ -0,0 +1,148 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// bootstrap_dealer_test.go — the TRUSTED-DEALER s1-share keygen, RIPPED OUT
// of production and confined to test/bootstrap scope.
//
// WHY THIS IS A _test.go FILE. The no-reconstruct committee SIGN path
// (DistributedBCCSigner / AggregateBCC in distributed_bcc.go) needs each
// validator to hold one poly-vector Shamir share of the EXPANDED signing
// component s1 (AlgShare). A genuinely DEALERLESS keygen that emits such
// s1-shares does NOT exist in this package (it is the research-blocked
// Part-2 problem — naive_additive_seta_obstruction.go computes the
// obstruction; Mithril short-replicated shares, ia.cr/2026/013, is the
// adoption target). The only currently-available s1-share source is a
// TRUSTED DEALER that forms the master key for one call and wipes it.
//
// Per the no-reconstruct mandate, that dealer MUST NOT be reachable from
// production dispatch. Living in a `_test.go` file is the strongest
// guarantee: the Go toolchain never compiles `_test.go` files into a
// production binary, so DealAlgShares cannot be linked into luxd/consensus.
// It remains available to the package's own tests (distributed_bcc_test.go,
// talus_test.go, no_reconstruct_committee_test.go) to seed the SIGNING
// proofs — exactly the "tests/bootstrap" carve-out.
//
// CLAIM DISCIPLINE. This makes SIGNING no-reconstruct (the combiner never
// forms s1/seed/sk), NOT keygen dealerless. The dealer reconstructs the key
// once at genesis; that residual is intentional and quarantined here.
import "io"
// DealAlgShares is the Part-1 TRUSTED-DEALER keygen: it expands the seed
// into the FIPS 204 key material ONCE, Shamir-shares the signing component
// s1 over GF(q) across the committee, wipes every secret, and returns the
// public setup plus one AlgShare per committee member.
//
// TRUST MODEL — TRUSTED DEALER (explicit, test/bootstrap scope). The dealer
// holds the master key for the duration of this one call. This makes
// SIGNING no-reconstruct (no party ever reconstructs s1), but KEYGEN is not
// dealerless. Dealerless ML-DSA DKG is the research-blocked Part-2 problem
// (naive_additive_seta_obstruction.go documents the precise, COMPUTED
// obstruction). For production permissionless safety, the consensus cert is
// AND-mode dual-PQ: the Corona leg is genuinely dealerless; this Pulsar
// leg's genesis is dealer/TEE-gated.
//
// rng supplies the Shamir polynomial coefficients; pass a deterministic
// reader for KAT-reproducible shares. committee must be distinct NodeIDs;
// threshold is the reconstruction threshold t (1 ≤ t ≤ n).
func DealAlgShares(params *Params, committee []NodeID, threshold int, seed [SeedSize]byte, rng io.Reader) (*AlgSetup, []*AlgShare, error) {
if err := params.Validate(); err != nil {
return nil, nil, err
}
if _, _, _, ok := bccParams(params.Mode); !ok {
// BCC/CEF (and therefore this no-reconstruct signer) is proven only
// for ML-DSA-65/87 (the ‖c·t0‖∞ < γ2 scope). Refuse other sets.
return nil, nil, ErrBCCParamSet
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, nil, ErrInvalidThreshold
}
K, L, _ := modeShape(params.Mode)
km, err := deriveKeyMaterial(params.Mode, &seed)
if err != nil {
return nil, nil, err
}
// Public setup: copy the public matrix A, t1, tr, rho, and packed pk.
setup := &AlgSetup{
Mode: params.Mode,
Pub: &PublicKey{Mode: params.Mode, Bytes: append([]byte(nil), km.pub...)},
rho: km.rho,
tr: km.tr,
t1: make(polyVec, K),
a: make([]polyVec, K),
}
copy(setup.t1, km.t1)
for i := 0; i < K; i++ {
setup.a[i] = append(polyVec(nil), km.a[i]...)
}
// Eval points: deterministic, non-zero, distinct GF(q) points per party.
evalPoints := make([]uint32, n)
seen := make(map[uint32]struct{}, n)
for i, id := range committee {
x := EvalPointFromIDQ(id)
if _, dup := seen[x]; dup {
return nil, nil, ErrDuplicateEvalPoint
}
seen[x] = struct{}{}
evalPoints[i] = x
}
// Shamir-share s1 coefficient-wise over GF(q). The constant term of
// every per-coefficient sharing polynomial is the [0,q) representative
// of that s1 coefficient, so Σ_p λ_p · share_p == s1 (Lagrange at 0).
s1Norm := make(polyVec, L)
for l := 0; l < L; l++ {
s1Norm[l] = km.s1[l]
s1Norm[l].normalize() // [q-η, q+η] → [0, q)
}
perParty, err := shamirSharePolyVecGFq(s1Norm, evalPoints, threshold, rng)
if err != nil {
zeroizeKeyMaterial(km)
return nil, nil, err
}
shares := make([]*AlgShare, n)
for i := range committee {
shares[i] = &AlgShare{
NodeID: committee[i],
EvalPoint: evalPoints[i],
S1Share: perParty[i],
Mode: params.Mode,
}
}
// Wipe every secret the dealer touched. After this point the master key
// exists nowhere; only the n single shares and the public setup remain.
for l := 0; l < L; l++ {
s1Norm[l] = poly{}
}
zeroizeKeyMaterial(km)
for i := range seed {
seed[i] = 0
}
return setup, shares, nil
}
// zeroizeKeyMaterial scrubs the secret polynomials and packed private key
// of a dealer's expanded key material.
func zeroizeKeyMaterial(km *mldsaKeyMaterial) {
for i := range km.s1 {
km.s1[i] = poly{}
}
for i := range km.s2 {
km.s2[i] = poly{}
}
for i := range km.t0 {
km.t0[i] = poly{}
}
for i := range km.prv {
km.prv[i] = 0
}
}
+11 -116
View File
@@ -128,122 +128,17 @@ type AlgSetup struct {
t1 polyVec // K, high bits in [0, 2^10)
}
// DealAlgShares is the Part-1 TRUSTED-DEALER keygen: it expands the seed
// into the FIPS 204 key material ONCE, Shamir-shares the signing component
// s1 over GF(q) across the committee, wipes every secret, and returns the
// public setup plus one AlgShare per committee member.
//
// TRUST MODEL — TRUSTED DEALER (explicit, Part-1 scope). The dealer holds
// the master key for the duration of this one call. This makes SIGNING
// no-reconstruct (no party ever reconstructs s1), but KEYGEN is not
// dealerless. Dealerless ML-DSA DKG is the research-blocked Part-2 problem
// (distributed_bcc_dkg.go documents the precise obstruction). For
// production permissionless safety, the consensus cert is AND-mode dual-PQ:
// the Corona leg is genuinely dealerless; this Pulsar leg's genesis is
// dealer/TEE-gated.
//
// rng supplies the Shamir polynomial coefficients; pass a deterministic
// reader for KAT-reproducible shares. committee must be distinct NodeIDs;
// threshold is the reconstruction threshold t (1 ≤ t ≤ n).
func DealAlgShares(params *Params, committee []NodeID, threshold int, seed [SeedSize]byte, rng io.Reader) (*AlgSetup, []*AlgShare, error) {
if err := params.Validate(); err != nil {
return nil, nil, err
}
if _, _, _, ok := bccParams(params.Mode); !ok {
// BCC/CEF (and therefore this no-reconstruct signer) is proven only
// for ML-DSA-65/87 (the ‖c·t0‖∞ < γ2 scope). Refuse other sets.
return nil, nil, ErrBCCParamSet
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, nil, ErrInvalidThreshold
}
K, L, _ := modeShape(params.Mode)
km, err := deriveKeyMaterial(params.Mode, &seed)
if err != nil {
return nil, nil, err
}
// Public setup: copy the public matrix A, t1, tr, rho, and packed pk.
setup := &AlgSetup{
Mode: params.Mode,
Pub: &PublicKey{Mode: params.Mode, Bytes: append([]byte(nil), km.pub...)},
rho: km.rho,
tr: km.tr,
t1: make(polyVec, K),
a: make([]polyVec, K),
}
copy(setup.t1, km.t1)
for i := 0; i < K; i++ {
setup.a[i] = append(polyVec(nil), km.a[i]...)
}
// Eval points: deterministic, non-zero, distinct GF(q) points per party.
evalPoints := make([]uint32, n)
seen := make(map[uint32]struct{}, n)
for i, id := range committee {
x := EvalPointFromIDQ(id)
if _, dup := seen[x]; dup {
return nil, nil, ErrDuplicateEvalPoint
}
seen[x] = struct{}{}
evalPoints[i] = x
}
// Shamir-share s1 coefficient-wise over GF(q). The constant term of
// every per-coefficient sharing polynomial is the [0,q) representative
// of that s1 coefficient, so Σ_p λ_p · share_p == s1 (Lagrange at 0).
s1Norm := make(polyVec, L)
for l := 0; l < L; l++ {
s1Norm[l] = km.s1[l]
s1Norm[l].normalize() // [q-η, q+η] → [0, q)
}
perParty, err := shamirSharePolyVecGFq(s1Norm, evalPoints, threshold, rng)
if err != nil {
zeroizeKeyMaterial(km)
return nil, nil, err
}
shares := make([]*AlgShare, n)
for i := range committee {
shares[i] = &AlgShare{
NodeID: committee[i],
EvalPoint: evalPoints[i],
S1Share: perParty[i],
Mode: params.Mode,
}
}
// Wipe every secret the dealer touched. After this point the master key
// exists nowhere; only the n single shares and the public setup remain.
for l := 0; l < L; l++ {
s1Norm[l] = poly{}
}
zeroizeKeyMaterial(km)
for i := range seed {
seed[i] = 0
}
return setup, shares, nil
}
// zeroizeKeyMaterial scrubs the secret polynomials and packed private key
// of a dealer's expanded key material.
func zeroizeKeyMaterial(km *mldsaKeyMaterial) {
for i := range km.s1 {
km.s1[i] = poly{}
}
for i := range km.s2 {
km.s2[i] = poly{}
}
for i := range km.t0 {
km.t0[i] = poly{}
}
for i := range km.prv {
km.prv[i] = 0
}
}
// DealAlgShares (the Part-1 TRUSTED-DEALER s1-share keygen) has been RIPPED
// OUT of the production surface. It expanded the seed and formed the master
// key for the duration of one call — a centralized-trust genesis. It now
// lives ONLY in the test/bootstrap file bootstrap_dealer_test.go (a
// `_test.go` file, so it is uncompilable into any production binary) where
// it seeds the no-reconstruct SIGNING tests. The production no-reconstruct
// SIGN path (DistributedBCCSigner / AggregateBCC, below) never forms s1,
// the seed, or sk. A genuinely DEALERLESS s1-share keygen is the
// research-blocked Part-2 problem — see naive_additive_seta_obstruction.go
// (DealerlessMLDSADKG, COMPUTED obstruction; Mithril short-replicated
// shares ia.cr/2026/013 is the adoption target).
// shamirSharePolyVecGFq shares each coefficient of secret (a length-L
// poly-vector, coefficients already normalized to [0,q)) with a fresh
+9
View File
@@ -1,5 +1,14 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): GF(q) SEED-share committee path. It is dealerless
// at keygen but its output (LargeKeyShare = a Shamir share of the 32-byte
// ML-DSA seed) can ONLY be consumed by the reconstruct-at-sign combiner
// (large_threshold.go). NOT in the default production build. The production
// committee path is the no-reconstruct AlgShare/AggregateBCC signer
// (distributed_bcc.go). Build with `-tags legacy_trusted_dealer` only.
package pulsar
+8
View File
@@ -1,5 +1,13 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined) tests for the GF(q) SEED-share reconstruct family
// (LargeDKGSession / LargeThresholdSigner / LargeCombine / reshare). Run
// with `-tags legacy_trusted_dealer`. The default build covers the
// no-reconstruct committee path (distributed_bcc_test.go, talus_test.go,
// no_reconstruct_committee_test.go).
package pulsar
+6
View File
@@ -1,5 +1,11 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): reshare for the GF(q) SEED-share committee path
// (LargeKeyShare). NOT in the default production build. Build with
// `-tags legacy_trusted_dealer` only.
package pulsar
+11
View File
@@ -1,5 +1,16 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): GF(q) SEED-share committee path that RECONSTRUCTS
// the master key at sign time (KeyFromSeed in LargeCombine — the H-1
// footgun). NOT in the default production build. The production committee
// threshold-sign path is the no-reconstruct AlgShare/AggregateBCC
// RoundSigner (distributed_bcc.go), which never forms s1/seed/sk at the
// combiner. Build with `-tags legacy_trusted_dealer` only for the
// explicitly opt-in audit-attestation / bootstrap deployment that accepts
// centralized reconstruction.
package pulsar
+6
View File
@@ -1,5 +1,11 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): wire types for the GF(q) SEED-share committee path
// (LargeKeyShare, LargeDKG*, LargeRound*). NOT in the default production
// build. Build with `-tags legacy_trusted_dealer` only.
package pulsar
+8
View File
@@ -1,5 +1,13 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): high-level GF(q) Shamir wrapper (LargeShamir) for
// the SEED-share committee path. NOT in the default production build. The
// shared low-level GF(q) Shamir primitives used by the production
// no-reconstruct/TALUS path live in shamir_gfq.go (always built). Build
// with `-tags legacy_trusted_dealer` only.
package pulsar
+6
View File
@@ -1,5 +1,11 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined) tests for the LargeShamir wrapper. Run with
// `-tags legacy_trusted_dealer`. Shared GF(q) Shamir primitives are
// covered by shamir_gfq_test.go (default build).
package pulsar
@@ -0,0 +1,283 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// no_reconstruct_committee_test.go — the two HARD GATES for the
// no-reconstruct committee threshold-sign fix (fix/no-reconstruct-committee).
//
// GATE 1 (standard-verifier): a committee threshold signature produced by the
// no-reconstruct path (DistributedBCCSigner / AggregateBCC) verifies under the
// INDEPENDENT, unmodified cloudflare/circl mldsa65.Verify — the group public
// key is re-derived and checked by circl alone. Bytes/acceptance preserved.
//
// GATE 2 (no-reconstruct INVARIANT): the committee sign-combine NEVER
// materialises the secret. Proven STRUCTURALLY (the production build contains
// no KeyFromSeed call in any sign-combine file, and the reconstruct combiner
// LargeCombine / large_threshold.go is not in the production build at all) AND
// BEHAVIOURALLY (the combiner's inputs — Partial, AggregateBCC's parameters —
// carry no share, sk, seed, or nonce; a sub-threshold coalition cannot sign).
//
// CLAIM DISCIPLINE. What these gates establish: "no-reconstruct committee
// threshold signing whose output verifies under the standard ML-DSA verifier."
// They do NOT establish FIPS-204 KeyGen-distribution equivalence (a separate
// hiding/simulation argument) and NOT FIPS validation.
import (
"crypto/rand"
"go/build"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
)
// ─────────────────────────────────────────────────────────────────────────
// GATE 1 — the committee no-reconstruct signature verifies under circl alone.
// ─────────────────────────────────────────────────────────────────────────
// TestCommittee_NoReconstruct_VerifiesUnderCircl runs a real (t,n)=(4,7)
// committee no-reconstruct ceremony (each validator a SEPARATE signer holding
// ONE s1-share; combiner sees only z-partials) and checks the aggregated
// signature with cloudflare/circl's stock mldsa65.Verify — the independent
// reference verifier — by re-deriving the public key into a circl PublicKey
// and calling Verify directly. A tampered message must be rejected.
func TestCommittee_NoReconstruct_VerifiesUnderCircl(t *testing.T) {
const n, threshold = 7, 4
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("no-reconstruct-committee-gate-1"))
msg := []byte("committee finality: no-reconstruct threshold ML-DSA-65")
sig, nodes, err := runBCCCeremony(t, f, threshold, sid, nil, msg)
if err != nil {
t.Fatalf("committee no-reconstruct ceremony: %v", err)
}
// Custody: every node held exactly one share; the combiner is one node.
seen := make(map[NodeID]bool, len(nodes))
for i, nd := range nodes {
if got := nd.ShareCount(); got != 1 {
t.Fatalf("node %d ShareCount=%d, want 1 (single-share custody violated)", i, got)
}
if seen[nd.NodeID()] {
t.Fatalf("node %d co-locates a NodeID with another node", i)
}
seen[nd.NodeID()] = true
}
// INDEPENDENT verifier: re-derive the group pk into a circl PublicKey and
// verify with stock circl mldsa65.Verify (ctx = empty, matching the bare
// ceremony). This is the same call path the EVM precompile's reference
// verifier uses — no pulsar code in the loop.
if len(sig.Bytes) != f.params.SignatureSize {
t.Fatalf("signature size %d, want %d", len(sig.Bytes), f.params.SignatureSize)
}
var pk mldsa65.PublicKey
var pkBuf [mldsa65.PublicKeySize]byte
if len(f.setup.Pub.Bytes) != mldsa65.PublicKeySize {
t.Fatalf("group pk size %d, want %d", len(f.setup.Pub.Bytes), mldsa65.PublicKeySize)
}
copy(pkBuf[:], f.setup.Pub.Bytes)
pk.Unpack(&pkBuf)
if !mldsa65.Verify(&pk, msg, nil, sig.Bytes) {
t.Fatalf("GATE 1 FAILED: circl mldsa65.Verify REJECTED the no-reconstruct committee signature")
}
// Negative control: a one-bit message tamper must be rejected by circl.
bad := append([]byte(nil), msg...)
bad[0] ^= 0x01
if mldsa65.Verify(&pk, bad, nil, sig.Bytes) {
t.Fatalf("GATE 1 FAILED: circl accepted a tampered message — binding broken")
}
t.Logf("GATE 1 PASS: circl mldsa65.Verify accepts the (t,n)=(%d,%d) no-reconstruct committee signature; tamper rejected", threshold, n)
}
// ─────────────────────────────────────────────────────────────────────────
// GATE 2 (structural) — no KeyFromSeed in the production sign-combine path,
// and the reconstruct combiner is not in the production build at all.
// ─────────────────────────────────────────────────────────────────────────
// keyFromSeedCall matches a CALL to KeyFromSeed (the seed→sk reconstruct
// primitive) while NOT matching circl's NewKeyFromSeed or the bare word in
// prose: it requires a non-identifier byte immediately before "KeyFromSeed(".
var keyFromSeedCall = regexp.MustCompile(`(^|[^A-Za-z0-9_])KeyFromSeed\(`)
// keyFromSeedCallAllowlist is the ONLY set of production files permitted to
// call KeyFromSeed. Both are KEYGEN, never sign-combine:
// - keygen.go: defines KeyFromSeed and the single-key GenerateKey wrapper.
// - dkg.go: the small-committee DKG derives the GROUP PUBLIC KEY once at
// keygen by forming sk (a flagged keygen-side residual — NOT signing).
//
// large_threshold.go and large_dkg.go also call KeyFromSeed, but they are
// quarantined behind //go:build legacy_trusted_dealer and so are absent from
// the default build this test inspects.
var keyFromSeedCallAllowlist = map[string]bool{
"keygen.go": true,
"dkg.go": true,
}
func TestCommittee_NoReconstruct_Invariant_NoKeyFromSeedInProductionBuild(t *testing.T) {
// Enumerate the package's PRODUCTION (default-build, non-test) Go files,
// honouring build tags: legacy_trusted_dealer files are excluded exactly as
// they are from a production binary.
pkg, err := build.Default.ImportDir(".", 0)
if err != nil {
t.Fatalf("enumerate default-build package files: %v", err)
}
// (a) The reconstruct combiner MUST NOT be in the production build.
for _, f := range pkg.GoFiles {
if f == "large_threshold.go" {
t.Fatalf("GATE 2 FAILED: large_threshold.go (LargeCombine, reconstruct-at-sign) is in the PRODUCTION build")
}
}
// (b) No production file may CALL KeyFromSeed except the keygen allowlist;
// and no production file may DECLARE LargeCombine.
offenders := 0
for _, f := range pkg.GoFiles {
src, err := os.ReadFile(filepath.Join(pkg.Dir, f))
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
text := string(src)
if strings.Contains(text, "func LargeCombine(") {
t.Errorf("GATE 2 FAILED: %s declares LargeCombine (reconstruct combiner) in the production build", f)
offenders++
}
if keyFromSeedCall.MatchString(text) && !keyFromSeedCallAllowlist[f] {
t.Errorf("GATE 2 FAILED: %s calls KeyFromSeed (seed→sk reconstruct) but is not an allowlisted keygen file", f)
offenders++
}
}
if offenders > 0 {
t.Fatalf("GATE 2 FAILED: %d production file(s) reconstruct the secret key", offenders)
}
// (c) The load-bearing combiner file itself must be free of every
// reconstruct vector: no KeyFromSeed, no key-material expansion, no
// master-seed assembly, no GF(q) SEED reconstruction.
combine, err := os.ReadFile(filepath.Join(pkg.Dir, "distributed_bcc.go"))
if err != nil {
t.Fatalf("read distributed_bcc.go: %v", err)
}
for _, banned := range []string{"KeyFromSeed(", "deriveKeyMaterial(", "masterSeed", "shamirReconstructGFQ"} {
if strings.Contains(string(combine), banned) {
t.Fatalf("GATE 2 FAILED: distributed_bcc.go (AggregateBCC sign-combine) contains reconstruct vector %q", banned)
}
}
t.Logf("GATE 2 (structural) PASS: %d production files scanned; KeyFromSeed only in %v; LargeCombine absent; AggregateBCC reconstruct-free",
len(pkg.GoFiles), keysOf(keyFromSeedCallAllowlist))
}
// ─────────────────────────────────────────────────────────────────────────
// GATE 2 (behavioural) — the combiner is handed only z-partials and public
// material; nothing it receives can reconstruct the secret.
// ─────────────────────────────────────────────────────────────────────────
func TestCommittee_NoReconstruct_Invariant_CombinerSeesNoSecret(t *testing.T) {
// (a) Partial is the ONLY per-signer artifact the combiner ingests. It must
// carry no secret: no s1-share, no nonce-share, no seed, no sk. ZShare
// is the MASKED response z_i = λ_i·y_i + c·λ_i·s1_i (the same quantity a
// normal ML-DSA signature reveals), not a secret.
bannedFieldName := []string{"S1", "S1Share", "Seed", "Sk", "Secret", "YShare", "NonceShare", "Master", "PrivateKey", "Lambda"}
bannedTypeSubstr := []string{"AlgShare", "PrivateKey", "mldsaKeyMaterial", "polyVec", "poly"}
pt := reflect.TypeOf(Partial{})
for i := 0; i < pt.NumField(); i++ {
fld := pt.Field(i)
for _, b := range bannedFieldName {
if fld.Name == b {
t.Fatalf("GATE 2 FAILED: Partial.%s — the combiner's input carries a secret-bearing field", fld.Name)
}
}
ts := fld.Type.String()
for _, b := range bannedTypeSubstr {
if strings.Contains(ts, b) {
t.Fatalf("GATE 2 FAILED: Partial.%s has secret-bearing type %s", fld.Name, ts)
}
}
}
// (b) AggregateBCC is the free-function no-reconstruct boundary. Its
// parameter list must carry NO share / sk / key-material type — only
// public setup, public challenge/commitment, ids, and []Partial.
at := reflect.TypeOf(AggregateBCC)
for i := 0; i < at.NumIn(); i++ {
ts := at.In(i).String()
for _, b := range []string{"AlgShare", "PrivateKey", "mldsaKeyMaterial"} {
if strings.Contains(ts, b) {
t.Fatalf("GATE 2 FAILED: AggregateBCC parameter %d is secret-bearing type %s", i, ts)
}
}
}
// (c) Behavioural threshold bound: a combiner handed t-1 valid partials
// cannot produce a signature (it never has enough to reconstruct, and
// by design it could not reconstruct even with t). End-to-end.
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate2-subquorum"))
msg := []byte("a sub-threshold coalition must not sign")
quorum, evalPoints, qshares := f.quorum(threshold)
var nonceID [32]byte
nonceID[0] = 0x9c
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("nonce deal: %v", err)
}
nodes := make([]*DistributedBCCSigner, threshold)
partials := make([]Partial, 0, threshold)
var aggR1 SignRound1
for i := 0; i < threshold; i++ {
nd, err := NewDistributedBCCSigner(f.params, f.setup, qshares[i], quorum, evalPoints, sid, nil, msg, rand.Reader)
if err != nil {
t.Fatalf("signer %d: %v", i, err)
}
nodes[i] = nd
if err := nd.SetNonceShare(nonceID, deal.YShares[quorum[i]]); err != nil {
t.Fatalf("set nonce share %d: %v", i, err)
}
r1, err := nd.Round1(sid, nonceID, deal.Cert)
if err != nil {
t.Fatalf("round1 %d: %v", i, err)
}
if nd.IsAggregator() {
aggR1 = r1
}
p, err := nd.Round2(r1, PartialInput{})
if err != nil {
t.Fatalf("round2 %d: %v", i, err)
}
partials = append(partials, p)
}
var agg *DistributedBCCSigner
for _, nd := range nodes {
if nd.IsAggregator() {
agg = nd
}
}
if _, _, err := agg.Finalize(aggR1, partials[:threshold-1]); err == nil {
t.Fatalf("GATE 2 FAILED: combiner produced a signature from t-1 partials")
}
t.Logf("GATE 2 (behavioural) PASS: Partial + AggregateBCC carry no secret; t-1 partials cannot sign")
}
// keysOf returns the sorted-ish key list of a string-set (for log output).
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+8
View File
@@ -1,5 +1,13 @@
//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): EVM-precompile ctx closure for the GF(q)
// SEED-share reconstruct path (LargeCombine). Run with
// `-tags legacy_trusted_dealer`. The default build's no-reconstruct
// committee ctx coverage is TestDistributedBCC_Ctx (distributed_bcc_test.go)
// and no_reconstruct_committee_test.go.
package pulsar