mirror of
https://github.com/luxfi/pulsar.git
synced 2026-07-28 16:39:37 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user