mirror of
https://github.com/luxfi/pulsar.git
synced 2026-07-27 06:55:04 +00:00
pulsar: rip the legacy_trusted_dealer reconstruct path + trusted dealer
Delete the quarantined reconstruct-at-combiner stack and the trusted-dealer
keygen — dealerless Mithril RSS keygen + no-reconstruct signing are the
production path, so the quarantine is dead weight (it lives in git history).
Deleted (9 files):
production: large_types.go large_reshare.go large_dkg.go large_threshold.go
largeshamir.go (the LargeCombine reconstruct-at-sign stack)
tests: large_e2e_test.go largeshamir_test.go precompile_e2e_test.go
dealer: bootstrap_dealer_test.go (DealAlgShares trusted-dealer keygen)
Also removed the untracked scratch zz_diag16_test.go.
Repurposed/updated the gates so they are clean, not dangling:
- ci_build_invariant_test.go: the two legacy tests (assert the tag gates files
/ dealer is test-only) are obsolete — there is no legacy to exclude. Replaced
with TestCI_ReconstructAndDealerRipIsComplete: scans EVERY .go file and fails
if func LargeCombine / func DealAlgShares / the legacy_trusted_dealer tag ever
reappear, AND asserts the live MithrilRSSKeygen + AggregateBCC remain (rip
didn't overreach). Stronger + permanent. This is the sole sanctioned holder of
the forbidden strings. Kept TestCI_NoAssemblyOrCgoFiles verbatim.
- gate2_reachability_test.go: drop the deleted LargeCombine/DealAlgShares from
the banned-primitive set (the ci-invariant owns 'they don't exist'); the gate
keeps proving the LIVE reconstruct primitives are unreachable from the sign
path. Cleaned stale comments.
- no_reconstruct_committee_test.go: drop the func LargeCombine declaration scan
and the deleted-file check from GATE-2 structural (now the ci-invariant's job);
GATE-1, GATE-2 behavioural, and the KeyFromSeed allowlist scan stay green.
Default build, legacy-tag build (now a no-op), and full suite all green.
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,28 @@
|
||||
|
||||
package pulsar
|
||||
|
||||
// ci_build_invariant_test.go — item 4: the PRODUCTION build must never ship the
|
||||
// reconstruct-at-sign quarantine. RED LOW: the structural GATE-2 inspects
|
||||
// build.Default (no tags), so CI must ALSO assert the production binary is never
|
||||
// compiled with `-tags legacy_trusted_dealer` (which would re-link LargeCombine,
|
||||
// the H-1 reconstruct footgun) and that the trusted-dealer keygen is test-only.
|
||||
// ci_build_invariant_test.go — the structural proof that the reconstruct-at-sign
|
||||
// + trusted-dealer rip is COMPLETE and PERMANENT.
|
||||
//
|
||||
// The legacy_trusted_dealer quarantine tag, the reconstruct-at-sign combiner
|
||||
// LargeCombine (was large_threshold.go), and the trusted-dealer keygen
|
||||
// DealAlgShares (was bootstrap_dealer_test.go) have all been DELETED. There is
|
||||
// no longer any reconstruct path to quarantine, so the old "the production build
|
||||
// excludes the tag" invariant is obsolete. This file replaces it with the
|
||||
// stronger, permanent guard: scan EVERY .go file in the package — production,
|
||||
// test, and any build-constrained — and FAIL if any ripped declaration or the
|
||||
// quarantine tag ever reappears.
|
||||
//
|
||||
// This is the single home (one and only one way) for the "these do not exist"
|
||||
// guard. The complementary "no reconstruct PRIMITIVE is reachable from the sign
|
||||
// path" invariant lives in GATE-2 (no_reconstruct_committee_test.go) and GATE-C
|
||||
// (gate2_reachability_test.go). As the sole holder of the forbidden strings (its
|
||||
// own scan targets), this file is the documented exception to the package-wide
|
||||
// no-`DealAlgShares`/`func LargeCombine`/`legacy_trusted_dealer` rule.
|
||||
//
|
||||
// It also keeps TestCI_NoAssemblyOrCgoFiles, which seals GATE-C's blind spot by
|
||||
// asserting the package is pure Go — no .s/.c/cgo unit can hide a reconstruct
|
||||
// primitive from the AST gates.
|
||||
|
||||
import (
|
||||
"go/build"
|
||||
@@ -21,47 +38,23 @@ import (
|
||||
|
||||
const legacyTrustedDealerTag = "legacy_trusted_dealer"
|
||||
|
||||
func defaultBuildGoFiles(t *testing.T) (dir string, files map[string]bool) {
|
||||
t.Helper()
|
||||
pkg, err := build.Default.ImportDir(".", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate default-build package: %v", err)
|
||||
}
|
||||
files = make(map[string]bool, len(pkg.GoFiles))
|
||||
for _, f := range pkg.GoFiles {
|
||||
files[f] = true
|
||||
}
|
||||
return pkg.Dir, files
|
||||
// rippedDeclarations are the exact source signatures the rip removed. The
|
||||
// reconstruct-at-sign combiner (LargeCombine, the H-1 footgun that materialised
|
||||
// the master key in the aggregator) and the trusted-dealer s1-share keygen
|
||||
// (DealAlgShares) must never reappear in any .go file in the package.
|
||||
var rippedDeclarations = []string{
|
||||
"func LargeCombine(", // reconstruct-at-sign combiner (was large_threshold.go)
|
||||
"func DealAlgShares(", // trusted-dealer s1-share keygen (was bootstrap_dealer_test.go)
|
||||
}
|
||||
|
||||
func taggedBuildGoFiles(t *testing.T, tag string) map[string]bool {
|
||||
t.Helper()
|
||||
ctx := build.Default
|
||||
ctx.BuildTags = append(append([]string{}, ctx.BuildTags...), tag)
|
||||
pkg, err := ctx.ImportDir(".", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate %s-tagged package: %v", tag, err)
|
||||
}
|
||||
out := make(map[string]bool, len(pkg.GoFiles))
|
||||
for _, f := range pkg.GoFiles {
|
||||
out[f] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// goBuildLine returns the `//go:build ...` constraint line of a file (or "").
|
||||
func goBuildLine(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
for _, line := range strings.Split(string(src), "\n") {
|
||||
// goBuildConstraint returns the `//go:build ...` constraint line of a Go source
|
||||
// (or ""). Build constraints must precede the package clause.
|
||||
func goBuildConstraint(src string) string {
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(l, "//go:build ") {
|
||||
return l
|
||||
}
|
||||
// build constraints must precede the package clause.
|
||||
if strings.HasPrefix(l, "package ") {
|
||||
break
|
||||
}
|
||||
@@ -69,75 +62,65 @@ func goBuildLine(t *testing.T, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestCI_ProductionBuildExcludesLegacyTrustedDealer asserts the default build
|
||||
// excludes every legacy_trusted_dealer file, that the tag actually gates real
|
||||
// reconstruct files, and that the reconstruct-at-sign combiner is never in prod.
|
||||
func TestCI_ProductionBuildExcludesLegacyTrustedDealer(t *testing.T) {
|
||||
dir, defaultFiles := defaultBuildGoFiles(t)
|
||||
taggedFiles := taggedBuildGoFiles(t, legacyTrustedDealerTag)
|
||||
// TestCI_ReconstructAndDealerRipIsComplete scans every .go file in the package
|
||||
// and fails if a ripped declaration or the legacy_trusted_dealer quarantine tag
|
||||
// reappears. Stronger than the old "production build excludes the tag" check: it
|
||||
// forbids the symbols and the tag EVERYWHERE — production, test, and any
|
||||
// build-constrained file — so the no-reconstruct property is now structural
|
||||
// (there is literally no reconstruct path left to quarantine).
|
||||
func TestCI_ReconstructAndDealerRipIsComplete(t *testing.T) {
|
||||
pkg, err := build.Default.ImportDir(".", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate default-build package: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(pkg.Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read package dir: %v", err)
|
||||
}
|
||||
|
||||
// Files the tag ADDS (present under -tags legacy_trusted_dealer, absent by
|
||||
// default): these are the quarantined reconstruct files.
|
||||
var legacyOnly []string
|
||||
for f := range taggedFiles {
|
||||
if !defaultFiles[f] {
|
||||
legacyOnly = append(legacyOnly, f)
|
||||
const self = "ci_build_invariant_test.go"
|
||||
scanned := 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(legacyOnly) == 0 {
|
||||
t.Fatalf("CI invariant MISCONFIGURED: the %q tag gates NO files — quarantine mechanism is inert", legacyTrustedDealerTag)
|
||||
}
|
||||
|
||||
// Every tag-gated file must (a) be absent from the default/production build
|
||||
// and (b) carry the legacy_trusted_dealer constraint (intentional gating).
|
||||
for _, f := range legacyOnly {
|
||||
if defaultFiles[f] {
|
||||
t.Fatalf("CI FAILED: %s is in BOTH the default and tagged build", f)
|
||||
// This guard file necessarily contains the forbidden strings (they ARE
|
||||
// its scan targets) — it is the ONE legitimate holder of them.
|
||||
if e.Name() == self {
|
||||
scanned++
|
||||
continue
|
||||
}
|
||||
line := goBuildLine(t, filepath.Join(dir, f))
|
||||
if !strings.Contains(line, legacyTrustedDealerTag) {
|
||||
t.Fatalf("CI FAILED: %s is tag-gated but its build line %q does not mention %q", f, line, legacyTrustedDealerTag)
|
||||
}
|
||||
}
|
||||
|
||||
// The reconstruct-at-sign combiner MUST be quarantined, never in prod.
|
||||
if defaultFiles["large_threshold.go"] {
|
||||
t.Fatalf("CI FAILED: large_threshold.go (LargeCombine, reconstruct-at-sign) is in the PRODUCTION build")
|
||||
}
|
||||
if !taggedFiles["large_threshold.go"] {
|
||||
t.Fatalf("CI MISCONFIGURED: large_threshold.go not found even under the legacy tag")
|
||||
}
|
||||
|
||||
// No default-build file may itself require the legacy tag (belt-and-suspenders
|
||||
// against an accidental positive constraint that only builds under the tag).
|
||||
for f := range defaultFiles {
|
||||
if line := goBuildLine(t, filepath.Join(dir, f)); strings.Contains(line, legacyTrustedDealerTag) {
|
||||
t.Fatalf("CI FAILED: default-build file %s carries a %q constraint", f, legacyTrustedDealerTag)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("CI PASS: production build excludes %d legacy_trusted_dealer file(s) %v; LargeCombine quarantined", len(legacyOnly), legacyOnly)
|
||||
}
|
||||
|
||||
// TestCI_TrustedDealerKeygenIsTestOnly asserts the trusted-dealer s1-share
|
||||
// keygen (DealAlgShares) is defined ONLY in _test.go (uncompilable into any
|
||||
// production binary) — the quarantine the no-reconstruct claim rests on.
|
||||
func TestCI_TrustedDealerKeygenIsTestOnly(t *testing.T) {
|
||||
dir, defaultFiles := defaultBuildGoFiles(t)
|
||||
for f := range defaultFiles {
|
||||
src, err := os.ReadFile(filepath.Join(dir, f))
|
||||
src, err := os.ReadFile(filepath.Join(pkg.Dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f, err)
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
if strings.Contains(string(src), "func DealAlgShares(") {
|
||||
t.Fatalf("CI FAILED: DealAlgShares (trusted-dealer keygen) defined in PRODUCTION file %s — must be _test.go only", f)
|
||||
text := string(src)
|
||||
for _, decl := range rippedDeclarations {
|
||||
if strings.Contains(text, decl) {
|
||||
t.Errorf("RIP INCOMPLETE: %s declares %q — the reconstruct-at-sign / trusted-dealer path was removed and must not return", e.Name(), decl)
|
||||
}
|
||||
}
|
||||
if line := goBuildConstraint(text); strings.Contains(line, legacyTrustedDealerTag) {
|
||||
t.Errorf("RIP INCOMPLETE: %s carries a %q build constraint — the quarantine tag was removed (there is no legacy to quarantine)", e.Name(), legacyTrustedDealerTag)
|
||||
}
|
||||
scanned++
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: the real production paths the rip is supposed to LEAVE
|
||||
// IN must still be present (a rip that also deleted the live signer/keygen
|
||||
// would pass a pure forbid-scan). The dealerless keygen and the
|
||||
// no-reconstruct combiner are load-bearing.
|
||||
for _, want := range []struct{ file, decl string }{
|
||||
{"mithril_rss.go", "func MithrilRSSKeygen("}, // dealerless keygen (production)
|
||||
{"distributed_bcc.go", "func AggregateBCC("}, // no-reconstruct sign-combine
|
||||
} {
|
||||
src, err := os.ReadFile(filepath.Join(pkg.Dir, want.file))
|
||||
if err != nil || !strings.Contains(string(src), want.decl) {
|
||||
t.Fatalf("RIP OVERREACHED: production path %q in %s is missing — the dealerless/no-reconstruct surface must remain", want.decl, want.file)
|
||||
}
|
||||
}
|
||||
// Sanity: it IS defined somewhere in the test build (the bootstrap file).
|
||||
if _, err := os.Stat(filepath.Join(dir, "bootstrap_dealer_test.go")); err != nil {
|
||||
t.Fatalf("bootstrap_dealer_test.go missing — trusted-dealer keygen quarantine file gone: %v", err)
|
||||
}
|
||||
t.Logf("CI PASS: DealAlgShares is test-only (bootstrap_dealer_test.go); never in the production build")
|
||||
|
||||
t.Logf("CI PASS: rip complete — %d .go files scanned, none declares LargeCombine/DealAlgShares, none carries the %q tag; dealerless MithrilRSSKeygen + no-reconstruct AggregateBCC present", scanned, legacyTrustedDealerTag)
|
||||
}
|
||||
|
||||
// TestCI_NoAssemblyOrCgoFiles seals GATE-C's blind spot (RED LOW/INFO). GATE-C's
|
||||
|
||||
@@ -5,7 +5,7 @@ package pulsar
|
||||
|
||||
// gate2_reachability_test.go — GATE 2 HARDENED to call-graph REACHABILITY,
|
||||
// closing RED's GATE-2 bypass (the old gate greps only distributed_bcc.go for
|
||||
// `KeyFromSeed(` / `func LargeCombine`, so a reconstruct-then-sign wired via
|
||||
// the reconstruct primitives by name, so a reconstruct-then-sign wired via
|
||||
// deriveKeyMaterial+bccSign elsewhere PASSES the scan).
|
||||
//
|
||||
// The hardened gate proves the no-reconstruct invariant where it matters: NO
|
||||
@@ -51,8 +51,6 @@ var bannedReconstructPrimitives = map[string]bool{
|
||||
"KeyFromSeed": true, // seed -> sk expansion
|
||||
"deriveKeyMaterial": true, // seed -> full FIPS key material
|
||||
"bccSign": true, // single-key reconstruct-then-sign reference
|
||||
"LargeCombine": true, // reconstruct-at-sign combiner
|
||||
"DealAlgShares": true, // trusted-dealer s1-share keygen
|
||||
"shamirReconstruct": true,
|
||||
"shamirReconstructGF": true,
|
||||
"shamirReconstructGFQ": true,
|
||||
@@ -183,9 +181,8 @@ func (g *callGraph) reachBanned(entrypoints []string, banned map[string]bool) (h
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// defaultBuildFuncFiles parses the package's DEFAULT-build (non-test,
|
||||
// tag-honouring) Go files into ASTs — the same file set a production binary
|
||||
// links (legacy_trusted_dealer files excluded).
|
||||
// defaultBuildFuncFiles parses the package's DEFAULT-build (non-test) Go files
|
||||
// into ASTs — the same file set a production binary links (test files excluded).
|
||||
func defaultBuildFuncFiles(t *testing.T) []*ast.File {
|
||||
t.Helper()
|
||||
pkg, err := build.Default.ImportDir(".", 0)
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
//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
|
||||
|
||||
// large_dkg.go -- GF(q) distributed key generation. The parallel of
|
||||
// dkg.go's DKGSession for the wide-committee regime.
|
||||
//
|
||||
// Same three-round structure (deal-via-KEM-envelopes, equivocation-
|
||||
// gate, aggregate-and-derive) as the small-committee path; the only
|
||||
// differences are (i) Shamir is over GF(q), (ii) envelope shares are
|
||||
// 128 bytes wide (vs 64 for GF(257)), (iii) the cap is
|
||||
// TargetCommitteeSize=1,111,111. The output is byte-identical to what
|
||||
// small-committee DKG would produce on the same master seed -- the
|
||||
// field choice does not propagate past the per-committee boundary.
|
||||
//
|
||||
// CR-6/7/8 closure (2026-05-18): the GF(q) path now uses the same
|
||||
// identity stage as the GF(257) path. The vestigial Round-1 commit
|
||||
// field is gone (CR-6 path A); per-recipient envelopes are KEM-wrapped
|
||||
// under each recipient's long-term ML-KEM-768 identity public key
|
||||
// (CR-8); the threshold-sign MAC keys are derived from per-session
|
||||
// per-pair ephemeral session keys (CR-7, see large_threshold.go).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// LargeDKGSession holds one party's state for one GF(q) DKG ceremony.
|
||||
type LargeDKGSession struct {
|
||||
Params *Params
|
||||
Committee []NodeID
|
||||
Threshold int
|
||||
MyID NodeID
|
||||
myIndex int
|
||||
|
||||
// Identity material for per-recipient envelope sealing (CR-8). The
|
||||
// session refuses to construct without both a local identity (for
|
||||
// decrypting incoming envelopes at Round 3) and a directory entry
|
||||
// for every committee member (for sealing outgoing envelopes at
|
||||
// Round 1).
|
||||
myIdentity *IdentityKey
|
||||
directory IdentityDirectory
|
||||
|
||||
rng io.Reader
|
||||
|
||||
myContribution [SeedSize]byte // c_i sampled at Round 1 -- SECRET
|
||||
encapBlindKey [SeedSize]byte // per-session non-secret blind used
|
||||
// // to diversify per-recipient KEM
|
||||
// // encapsulation seeds. Sampled fresh
|
||||
// // at Round 1; NOT derived from
|
||||
// // myContribution -- independence is
|
||||
// // the small-path C2 fix carried over.
|
||||
myShares []shamirShareQ
|
||||
round1Cache []*LargeDKGRound1Msg
|
||||
myDigest [32]byte
|
||||
|
||||
aggregateShare shamirShareQ
|
||||
masterPubkey *PublicKey
|
||||
transcript [48]byte
|
||||
}
|
||||
|
||||
// NewLargeDKGSession constructs a new GF(q) DKG session.
|
||||
//
|
||||
// The committee is canonicalised (byte-ascending NodeID sort). The
|
||||
// cap TargetCommitteeSize = 1,111,111 is enforced here; any larger
|
||||
// committee returns ErrCommitteeAboveCap. The smaller GF(q) limit
|
||||
// of MaxCommitteeQ (q - 1 ≈ 8.38M) would in principle support more,
|
||||
// but the canonical reference target is 1.111M (see params.go).
|
||||
//
|
||||
// myIdentity is the calling party's long-term ML-KEM-768 + ML-DSA-65
|
||||
// keypair; the KEM secret half is used to open incoming envelopes at
|
||||
// Round 3. directory must contain a published IdentityPublicKey for
|
||||
// every committee member (including myID -- the round-trip check
|
||||
// requires we can seal-and-open our own envelope as a sanity gate).
|
||||
func NewLargeDKGSession(
|
||||
params *Params,
|
||||
committee []NodeID,
|
||||
threshold int,
|
||||
myID NodeID,
|
||||
myIdentity *IdentityKey,
|
||||
directory IdentityDirectory,
|
||||
rng io.Reader,
|
||||
) (*LargeDKGSession, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(committee) == 0 {
|
||||
return nil, ErrCommitteeEmpty
|
||||
}
|
||||
if threshold < 1 || len(committee) < threshold {
|
||||
return nil, ErrInvalidThreshold
|
||||
}
|
||||
if len(committee) > TargetCommitteeSize {
|
||||
return nil, ErrCommitteeAboveCap
|
||||
}
|
||||
if myIdentity == nil {
|
||||
return nil, ErrIdentityKeyMissing
|
||||
}
|
||||
if directory == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
|
||||
sorted := make([]NodeID, len(committee))
|
||||
copy(sorted, committee)
|
||||
sort.Slice(sorted, func(i, j int) bool { return nodeIDLess(sorted[i], sorted[j]) })
|
||||
for i := 1; i < len(sorted); i++ {
|
||||
if sorted[i] == sorted[i-1] {
|
||||
return nil, ErrCommitteeDuplicate
|
||||
}
|
||||
}
|
||||
|
||||
// Directory must cover every committee member; missing entries
|
||||
// mean we cannot seal an envelope for that recipient.
|
||||
for _, id := range sorted {
|
||||
if directory[id] == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
}
|
||||
|
||||
myIdx := -1
|
||||
for i := range sorted {
|
||||
if sorted[i] == myID {
|
||||
myIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if myIdx < 0 {
|
||||
return nil, ErrNotInCommittee
|
||||
}
|
||||
|
||||
if rng == nil {
|
||||
rng = rand.Reader
|
||||
}
|
||||
return &LargeDKGSession{
|
||||
Params: params,
|
||||
Committee: sorted,
|
||||
Threshold: threshold,
|
||||
MyID: myID,
|
||||
myIndex: myIdx + 1,
|
||||
myIdentity: myIdentity,
|
||||
directory: directory,
|
||||
rng: rng,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round1 samples this party's contribution, GF(q)-Shamir-shares it
|
||||
// byte-wise, KEM-wraps each per-recipient envelope under the
|
||||
// recipient's long-term ML-KEM-768 public key, and returns the
|
||||
// broadcast.
|
||||
//
|
||||
// CR-6 path A: no commit-and-open; the broadcast carries no separate
|
||||
// commitment field. CR-8: per-recipient envelopes are ML-KEM-768
|
||||
// sealed against the recipient's published identity public key so a
|
||||
// passive network observer learns nothing about per-recipient shares.
|
||||
func (s *LargeDKGSession) Round1() (*LargeDKGRound1Msg, error) {
|
||||
if _, err := io.ReadFull(s.rng, s.myContribution[:]); err != nil {
|
||||
return nil, ErrShortRand
|
||||
}
|
||||
// Per-session non-secret blind for per-recipient KEM encapseed
|
||||
// derivation. Independent of myContribution (small-path C2 carry-
|
||||
// over): a fault on the cSHAKE256 call below cannot leak bits of
|
||||
// the secret contribution.
|
||||
if _, err := io.ReadFull(s.rng, s.encapBlindKey[:]); err != nil {
|
||||
return nil, ErrShortRand
|
||||
}
|
||||
|
||||
// Per-byte GF(q) Shamir share of c_i. Coefficient material is
|
||||
// domain-separated by (committee root, my-index, contribution-
|
||||
// derived seed) so two DKG sessions on the same contribution
|
||||
// never collide.
|
||||
committeeRoot := s.commitCommitteeRoot()
|
||||
keyMaterial := make([]byte, 0, len("PULSAR-DKG-DEALER-V1")+len(committeeRoot)+2+SeedSize)
|
||||
keyMaterial = append(keyMaterial, []byte("PULSAR-DKG-DEALER-V1")...)
|
||||
keyMaterial = append(keyMaterial, committeeRoot[:]...)
|
||||
keyMaterial = append(keyMaterial, byte(s.myIndex>>8), byte(s.myIndex))
|
||||
keyMaterial = append(keyMaterial, s.myContribution[:]...)
|
||||
streamLen := (s.Threshold - 1) * SeedSize * 4
|
||||
if streamLen < 4 {
|
||||
streamLen = 4
|
||||
}
|
||||
stream := cshake256(keyMaterial, streamLen, tagSeedShare)
|
||||
|
||||
// Backend-dispatched GF(q) Shamir; byte-equal to shamirDealRandomQ.
|
||||
// See dkg_gpu.go for the dispatch policy.
|
||||
shares, err := shamirDealRandomQAccel(s.myContribution, len(s.Committee), s.Threshold, stream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.myShares = shares
|
||||
|
||||
// KEM-wrap each per-recipient envelope. Same construction as the
|
||||
// small path -- only the shareWire width (128 bytes for GF(q))
|
||||
// changes; the identity-stage seal primitive is width-agnostic.
|
||||
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.Committee))
|
||||
for posIdx, recipient := range s.Committee {
|
||||
shareBytes := shareToBytesQ(shares[posIdx])
|
||||
|
||||
// Per-recipient deterministic encapsulation seed material.
|
||||
encapBlind := cshake256(
|
||||
append(append(append([]byte{}, s.encapBlindKey[:]...),
|
||||
s.MyID[:]...), recipient[:]...),
|
||||
64,
|
||||
"PULSAR-DKG-ENCAPSEED-V1",
|
||||
)
|
||||
encapSeed := hashForEncapSeed(committeeRoot, s.MyID, recipient, encapBlind)
|
||||
|
||||
recipientIPK := s.directory[recipient]
|
||||
if recipientIPK == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
env, err := sealEnvelope(
|
||||
s.MyID,
|
||||
recipient,
|
||||
committeeRoot,
|
||||
shareBytes[:],
|
||||
s.myContribution,
|
||||
recipientIPK.KEMPub,
|
||||
encapSeed[:],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelopes[recipient] = env
|
||||
}
|
||||
|
||||
return &LargeDKGRound1Msg{
|
||||
NodeID: s.MyID,
|
||||
Envelopes: envelopes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round2 ingests all Round-1 messages and emits the digest broadcast.
|
||||
// The Round-2 step is the equivocation gate: every party computes the
|
||||
// SAME digest over the ordered (sender, envelope-set) tuple; a
|
||||
// Round-2 message bearing a different digest is direct evidence of
|
||||
// equivocation by the sender.
|
||||
func (s *LargeDKGSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRound2Msg, error) {
|
||||
if len(round1) != len(s.Committee) {
|
||||
return nil, ErrTooFewRound1
|
||||
}
|
||||
ordered, err := s.orderRound1ByCommittee(round1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.round1Cache = ordered
|
||||
|
||||
s.myDigest = s.computeRound2Digest(ordered)
|
||||
return &LargeDKGRound2Msg{
|
||||
NodeID: s.MyID,
|
||||
Digest: s.myDigest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round3 verifies digest agreement and aggregates the local share.
|
||||
//
|
||||
// Decrypts every envelope addressed to me. Each envelope reveals
|
||||
// BOTH (a) the dealer's GF(q) Shamir share for me at x=myIndex (which
|
||||
// I aggregate into my own LargeKeyShare for threshold sign) AND (b)
|
||||
// the dealer's full 32-byte contribution c_i (which I sum byte-wise
|
||||
// over GF(257) to derive the master seed).
|
||||
//
|
||||
// The dealer contribution byte-sum uses GF(257) -- not GF(q) -- so
|
||||
// the master-seed cSHAKE256 mix is byte-identical to the small-path
|
||||
// DKG output on the same set of contributions. This preserves
|
||||
// cross-field interchangeability of the final master public key.
|
||||
func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKGRound2Msg) (*LargeDKGOutput, error) {
|
||||
if len(round1) != len(s.Committee) {
|
||||
return nil, ErrTooFewRound1
|
||||
}
|
||||
if len(round2) != len(s.Committee) {
|
||||
return nil, ErrTooFewRound2
|
||||
}
|
||||
ordered, err := s.orderRound1ByCommittee(round1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expected := s.computeRound2Digest(ordered)
|
||||
for _, r2 := range round2 {
|
||||
if !ctEqual32(r2.Digest, expected) {
|
||||
return &LargeDKGOutput{
|
||||
AbortEvidence: &AbortEvidence{
|
||||
Kind: ComplaintEquivocation,
|
||||
Accuser: s.MyID,
|
||||
Accused: r2.NodeID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
committeeRoot := s.commitCommitteeRoot()
|
||||
var aggY [SeedSize]uint32
|
||||
// byteSum aggregates the per-dealer contributions c_i byte-wise
|
||||
// over GF(q). At x=0 of the joint polynomial f(x) = Σ_i f_i(x),
|
||||
// f(0) = Σ_i f_i(0) = Σ_i c_i -- i.e. the byte-sum we need for
|
||||
// the master seed mix. We sum mod q (not mod 257) so the per-party
|
||||
// envelope-derived byteSum matches what LargeCombine recovers via
|
||||
// Lagrange interpolation of the shares in GF(q).
|
||||
var byteSum [SeedSize]uint32
|
||||
for _, m := range ordered {
|
||||
env, ok := m.Envelopes[s.MyID]
|
||||
if !ok {
|
||||
return &LargeDKGOutput{
|
||||
AbortEvidence: &AbortEvidence{
|
||||
Kind: ComplaintBadDelivery,
|
||||
Accuser: s.MyID,
|
||||
Accused: m.NodeID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
senderShareBytes, senderContrib, openErr := sealOpenEnvelope(
|
||||
m.NodeID, s.MyID, committeeRoot, env, shareWireSizeQ, s.myIdentity,
|
||||
)
|
||||
if openErr != nil {
|
||||
return &LargeDKGOutput{
|
||||
AbortEvidence: &AbortEvidence{
|
||||
Kind: ComplaintBadDelivery,
|
||||
Accuser: s.MyID,
|
||||
Accused: m.NodeID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
var shareArr [shareWireSizeQ]byte
|
||||
copy(shareArr[:], senderShareBytes)
|
||||
senderShare := shareFromBytesQ(uint32(s.myIndex), shareArr)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
aggY[b] = uint32((uint64(aggY[b]) + uint64(senderShare.Y[b])) % shamirPrimeQ)
|
||||
byteSum[b] = uint32((uint64(byteSum[b]) + uint64(senderContrib[b])) % shamirPrimeQ)
|
||||
}
|
||||
}
|
||||
s.aggregateShare = shamirShareQ{
|
||||
X: uint32(s.myIndex),
|
||||
Y: aggY,
|
||||
}
|
||||
|
||||
// Derive the master ML-DSA seed from the GF(q) byte-sum + the
|
||||
// canonical committee root. The 4-byte-per-lane big-endian encoding
|
||||
// matches what LargeCombine emits, so a party that ran the DKG
|
||||
// here and a party that runs LargeCombine on the same set of
|
||||
// contributions agree on the master seed.
|
||||
byteSumBytes := make([]byte, SeedSize*4)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
byteSumBytes[4*b] = byte(byteSum[b] >> 24)
|
||||
byteSumBytes[4*b+1] = byte(byteSum[b] >> 16)
|
||||
byteSumBytes[4*b+2] = byte(byteSum[b] >> 8)
|
||||
byteSumBytes[4*b+3] = byte(byteSum[b])
|
||||
}
|
||||
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
|
||||
var masterSeed [SeedSize]byte
|
||||
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
|
||||
|
||||
sk, err := KeyFromSeed(s.Params, masterSeed)
|
||||
if err != nil {
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return nil, err
|
||||
}
|
||||
s.masterPubkey = sk.Pub
|
||||
|
||||
s.transcript = transcriptHash(tagDKGTranscript,
|
||||
committeeRoot[:],
|
||||
expected[:],
|
||||
sk.Pub.Bytes,
|
||||
)
|
||||
|
||||
shareWire := shareToBytesQ(s.aggregateShare)
|
||||
out := &LargeDKGOutput{
|
||||
GroupPubkey: sk.Pub,
|
||||
SecretShare: &LargeKeyShare{
|
||||
NodeID: s.MyID,
|
||||
EvalPoint: uint32(s.myIndex),
|
||||
Share: shareWire,
|
||||
Pub: sk.Pub,
|
||||
Mode: s.Params.Mode,
|
||||
},
|
||||
TranscriptHash: s.transcript,
|
||||
AbortEvidence: nil,
|
||||
}
|
||||
zeroizePrivateKey(sk)
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// computeRound2Digest returns the canonical 32-byte digest over the
|
||||
// ordered Round-1 broadcasts and per-recipient KEM-wrapped envelopes.
|
||||
// Every honest party computes the SAME digest given the same Round-1
|
||||
// inputs because the envelope ciphertext + sealed payload bytes are
|
||||
// deterministic across recipients given the dealer's contribution and
|
||||
// the recipient's published KEM public key.
|
||||
//
|
||||
// committeeRoot binding pins the digest to THIS specific committee
|
||||
// so a colluding dealer + recipient pair cannot replay an envelope
|
||||
// across committees.
|
||||
func (s *LargeDKGSession) computeRound2Digest(ordered []*LargeDKGRound1Msg) [32]byte {
|
||||
committeeRoot := s.commitCommitteeRoot()
|
||||
parts := [][]byte{committeeRoot[:]}
|
||||
for _, m := range ordered {
|
||||
parts = append(parts, m.NodeID[:])
|
||||
recipKeys := make([]NodeID, 0, len(m.Envelopes))
|
||||
for k := range m.Envelopes {
|
||||
recipKeys = append(recipKeys, k)
|
||||
}
|
||||
sort.Slice(recipKeys, func(i, j int) bool { return nodeIDLess(recipKeys[i], recipKeys[j]) })
|
||||
for _, k := range recipKeys {
|
||||
env := m.Envelopes[k]
|
||||
parts = append(parts, k[:])
|
||||
parts = append(parts, env.KEMCiphertext)
|
||||
parts = append(parts, env.Sealed)
|
||||
}
|
||||
}
|
||||
return transcriptHash32(tagDKGCommit, parts...)
|
||||
}
|
||||
|
||||
func (s *LargeDKGSession) commitCommitteeRoot() [32]byte {
|
||||
parts := make([][]byte, 0, len(s.Committee)+1)
|
||||
parts = append(parts, []byte("PULSAR-COMMITTEE-V1"))
|
||||
for _, id := range s.Committee {
|
||||
parts = append(parts, id[:])
|
||||
}
|
||||
return transcriptHash32(tagDKGCommit, parts...)
|
||||
}
|
||||
|
||||
func (s *LargeDKGSession) orderRound1ByCommittee(round1 []*LargeDKGRound1Msg) ([]*LargeDKGRound1Msg, error) {
|
||||
byID := make(map[NodeID]*LargeDKGRound1Msg, len(round1))
|
||||
for _, m := range round1 {
|
||||
if _, dup := byID[m.NodeID]; dup {
|
||||
return nil, ErrCommitteeDuplicate
|
||||
}
|
||||
byID[m.NodeID] = m
|
||||
}
|
||||
ordered := make([]*LargeDKGRound1Msg, 0, len(s.Committee))
|
||||
for _, id := range s.Committee {
|
||||
m, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, ErrTooFewRound1
|
||||
}
|
||||
ordered = append(ordered, m)
|
||||
}
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
// committeeRootFromLargeShares mirrors committeeRootFromShares for
|
||||
// the GF(q) path. Used by LargeCombine to bind a recovered byte-sum
|
||||
// to the canonical committee identity.
|
||||
func committeeRootFromLargeShares(shares []*LargeKeyShare) [32]byte {
|
||||
ids := make([]NodeID, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
ids = append(ids, s.NodeID)
|
||||
}
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && nodeIDLess(ids[j], ids[j-1]); j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
parts := make([][]byte, 0, len(ids)+1)
|
||||
parts = append(parts, []byte("PULSAR-COMMITTEE-V1"))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, id[:])
|
||||
}
|
||||
return transcriptHash32(tagDKGCommit, parts...)
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
//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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeLargeCommittee builds n distinct NodeIDs.
|
||||
func makeLargeCommittee(n int) []NodeID {
|
||||
out := make([]NodeID, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = NodeID{byte(i + 1), byte(i >> 8), 'L'}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestLarge_E2E_DKG_ThresholdSign_Verify is the headline end-to-end
|
||||
// test for the GF(q) protocol stack. It runs:
|
||||
// 1. LargeDKGSession across a (T, N) committee (DKG)
|
||||
// 2. LargeThresholdSigner / LargeCombine on a T-quorum (Sign)
|
||||
// 3. FIPS 204 ML-DSA.Verify on the output (Class N1 manifesto)
|
||||
//
|
||||
// (T, N) = (2, 3) is the canonical Lux production deployment per
|
||||
// Quasar grouped sortition (DefaultGroupSize=3, DefaultGroupThreshold=2).
|
||||
// (3, 5) exercises a larger group for completeness.
|
||||
func TestLarge_E2E_DKG_ThresholdSign_Verify(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
n, t int
|
||||
}{
|
||||
{"3of2", 3, 2}, // canonical Quasar group
|
||||
{"3of3", 3, 3},
|
||||
{"5of3", 5, 3},
|
||||
{"6of4", 6, 4},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
params := MustParamsFor(ModeP65)
|
||||
committee := makeLargeCommittee(tc.n)
|
||||
ident := newIdentityFixture(t, committee, []byte("large-e2e"))
|
||||
|
||||
// ---- DKG ----
|
||||
sessions := make([]*LargeDKGSession, tc.n)
|
||||
for i := 0; i < tc.n; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'D', 'K', 'G', 'Q'})
|
||||
s, err := NewLargeDKGSession(params, committee, tc.t, committee[i], ident.keys[committee[i]], ident.directory, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions[i] = s
|
||||
}
|
||||
|
||||
r1 := make([]*LargeDKGRound1Msg, tc.n)
|
||||
for i, s := range sessions {
|
||||
m, err := s.Round1()
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round1 party %d: %v", i, err)
|
||||
}
|
||||
r1[i] = m
|
||||
}
|
||||
|
||||
r2 := make([]*LargeDKGRound2Msg, tc.n)
|
||||
for i, s := range sessions {
|
||||
m, err := s.Round2(r1)
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round2 party %d: %v", i, err)
|
||||
}
|
||||
r2[i] = m
|
||||
}
|
||||
for i := 1; i < tc.n; i++ {
|
||||
if r2[i].Digest != r2[0].Digest {
|
||||
t.Fatalf("DKG Round-2 digest mismatch between parties 0 and %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
outs := make([]*LargeDKGOutput, tc.n)
|
||||
for i, s := range sessions {
|
||||
out, err := s.Round3(r1, r2)
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round3 party %d: %v", i, err)
|
||||
}
|
||||
if out.AbortEvidence != nil {
|
||||
t.Fatalf("party %d aborted: kind=%s", i, out.AbortEvidence.Kind)
|
||||
}
|
||||
outs[i] = out
|
||||
}
|
||||
// All parties agree on the group pubkey + transcript.
|
||||
groupPK := outs[0].GroupPubkey
|
||||
for i := 1; i < tc.n; i++ {
|
||||
if !outs[i].GroupPubkey.Equal(groupPK) {
|
||||
t.Fatalf("party %d GroupPubkey diverges", i)
|
||||
}
|
||||
if outs[i].TranscriptHash != outs[0].TranscriptHash {
|
||||
t.Fatalf("party %d TranscriptHash diverges", i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Threshold sign ----
|
||||
// Use first t parties as quorum.
|
||||
quorum := committee[:tc.t]
|
||||
myShares := make(map[NodeID]*LargeKeyShare, tc.t)
|
||||
for i := 0; i < tc.t; i++ {
|
||||
myShares[committee[i]] = outs[i].SecretShare
|
||||
}
|
||||
|
||||
sessionID := [16]byte{'S', 'E', 'S', 'S'}
|
||||
attempt := uint32(0)
|
||||
message := []byte("pulsar large e2e roundtrip on quasar (3,2) profile")
|
||||
|
||||
// Establish per-pair session keys for the quorum (CR-7).
|
||||
sessionKeys := ident.quorumSessionKeys(t, quorum, sessionID, message)
|
||||
|
||||
signers := make([]*LargeThresholdSigner, tc.t)
|
||||
for i := 0; i < tc.t; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'S', 'I', 'G'})
|
||||
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, quorum, myShares[committee[i]], sessionKeys[committee[i]], message, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLargeThresholdSigner party %d: %v", i, err)
|
||||
}
|
||||
signers[i] = ts
|
||||
}
|
||||
|
||||
tsR1 := make([]*LargeRound1Message, tc.t)
|
||||
for i, ts := range signers {
|
||||
m, err := ts.Round1(message)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign Round1 party %d: %v", i, err)
|
||||
}
|
||||
tsR1[i] = m
|
||||
}
|
||||
tsR2 := make([]*LargeRound2Message, tc.t)
|
||||
for i, ts := range signers {
|
||||
m, evidence, err := ts.Round2(tsR1)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign Round2 party %d: %v (evidence=%+v)", i, err, evidence)
|
||||
}
|
||||
tsR2[i] = m
|
||||
}
|
||||
|
||||
// Combine needs the FULL committee's KeyShares -- not just
|
||||
// the quorum's -- so committeeRootFromLargeShares matches
|
||||
// the DKG-time root that the master seed was bound to.
|
||||
allShares := make([]*LargeKeyShare, tc.n)
|
||||
for i := 0; i < tc.n; i++ {
|
||||
allShares[i] = outs[i].SecretShare
|
||||
}
|
||||
|
||||
sig, err := LargeCombine(params, groupPK, message, nil, false, sessionID, attempt, quorum, tc.t, tsR1, tsR2, allShares)
|
||||
if err != nil {
|
||||
t.Fatalf("LargeCombine: %v", err)
|
||||
}
|
||||
|
||||
// ---- FIPS 204 ML-DSA.Verify ----
|
||||
if err := Verify(params, groupPK, message, sig); err != nil {
|
||||
t.Fatalf("FIPS 204 ML-DSA.Verify on Pulsar output: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLarge_DKG_AboveCap rejects committees above TargetCommitteeSize.
|
||||
func TestLarge_DKG_AboveCap(t *testing.T) {
|
||||
params := MustParamsFor(ModeP44)
|
||||
// Cap is 1_111_111; (cap+1, 2) must reject.
|
||||
committee := make([]NodeID, TargetCommitteeSize+1)
|
||||
for i := range committee {
|
||||
committee[i] = NodeID{byte(i & 0xff), byte((i >> 8) & 0xff), byte((i >> 16) & 0xff)}
|
||||
}
|
||||
// Cap check fires before identity / directory; pass nil for both.
|
||||
_, err := NewLargeDKGSession(params, committee, 2, committee[0], nil, nil, nil)
|
||||
if err != ErrCommitteeAboveCap {
|
||||
t.Fatalf("want ErrCommitteeAboveCap, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLarge_Reshare_SameCommittee_PubInvariant verifies the
|
||||
// Theorem reshare-pkinv guarantee on the GF(q) path: a reshare with
|
||||
// the same committee on both sides preserves the master public key.
|
||||
// This mirrors TestReshare_SameCommittee_PubInvariant in the
|
||||
// small-committee (GF(257)) path. Cross-committee reshare changes
|
||||
// the committee root and therefore the cSHAKE256-mixed master seed
|
||||
// in the v0.1 reconstruction-aggregator instantiation; see
|
||||
// BLOCKERS.md.
|
||||
func TestLarge_Reshare_SameCommittee_PubInvariant(t *testing.T) {
|
||||
params := MustParamsFor(ModeP65)
|
||||
committee := makeLargeCommittee(5)
|
||||
ident := newIdentityFixture(t, committee, []byte("large-reshare-same"))
|
||||
// Run the initial DKG.
|
||||
dkgSessions := make([]*LargeDKGSession, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'O', 'L', 'D'})
|
||||
s, _ := NewLargeDKGSession(params, committee, 3, committee[i], ident.keys[committee[i]], ident.directory, rng)
|
||||
dkgSessions[i] = s
|
||||
}
|
||||
dkgR1 := make([]*LargeDKGRound1Msg, 5)
|
||||
for i, s := range dkgSessions {
|
||||
m, _ := s.Round1()
|
||||
dkgR1[i] = m
|
||||
}
|
||||
dkgR2 := make([]*LargeDKGRound2Msg, 5)
|
||||
for i, s := range dkgSessions {
|
||||
m, _ := s.Round2(dkgR1)
|
||||
dkgR2[i] = m
|
||||
}
|
||||
dkgOuts := make([]*LargeDKGOutput, 5)
|
||||
for i, s := range dkgSessions {
|
||||
out, _ := s.Round3(dkgR1, dkgR2)
|
||||
dkgOuts[i] = out
|
||||
}
|
||||
oldPK := dkgOuts[0].GroupPubkey
|
||||
oldShares := make([]*LargeKeyShare, 5)
|
||||
for i, o := range dkgOuts {
|
||||
oldShares[i] = o.SecretShare
|
||||
}
|
||||
|
||||
// Run a reshare with the SAME committee on both sides.
|
||||
resh := make([]*LargeReshareSession, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
var oldShare *LargeKeyShare
|
||||
if i < 3 {
|
||||
// Only first 3 (reshare-quorum members) need old shares.
|
||||
oldShare = oldShares[i]
|
||||
}
|
||||
rng := deterministicReader([]byte{byte(i), 'R', 'S'})
|
||||
s, err := NewLargeReshareSession(params, committee, 3, committee, 3,
|
||||
committee[i], oldShare, ident.keys[committee[i]], ident.directory, nil, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLargeReshareSession party %d: %v", i, err)
|
||||
}
|
||||
// New-committee-only parties (3..4) MUST be told the prior pubkey
|
||||
// so Round3 can stamp it deterministically.
|
||||
if oldShare == nil {
|
||||
s.SetPriorGroupPubkey(oldPK)
|
||||
}
|
||||
resh[i] = s
|
||||
}
|
||||
r1 := []*LargeDKGRound1Msg{}
|
||||
for _, s := range resh {
|
||||
if !s.InReshareQuorum() {
|
||||
continue
|
||||
}
|
||||
m, err := s.Round1()
|
||||
if err != nil {
|
||||
t.Fatalf("Reshare Round1: %v", err)
|
||||
}
|
||||
r1 = append(r1, m)
|
||||
}
|
||||
if len(r1) != 3 {
|
||||
t.Fatalf("expected 3 reshare quorum messages, got %d", len(r1))
|
||||
}
|
||||
r2 := []*LargeDKGRound2Msg{}
|
||||
for _, s := range resh {
|
||||
if !s.InReshareQuorum() {
|
||||
continue
|
||||
}
|
||||
m, err := s.Round2(r1)
|
||||
if err != nil {
|
||||
t.Fatalf("Reshare Round2: %v", err)
|
||||
}
|
||||
r2 = append(r2, m)
|
||||
}
|
||||
newShares := make([]*LargeKeyShare, 0, 5)
|
||||
for _, s := range resh {
|
||||
ks, ev, err := s.Round3(r1, r2)
|
||||
if err != nil {
|
||||
t.Fatalf("Reshare Round3: %v (ev=%+v)", err, ev)
|
||||
}
|
||||
newShares = append(newShares, ks)
|
||||
}
|
||||
|
||||
// Reconstruct master byte-sum from 3 new shares; verify cSHAKE256
|
||||
// mix recovers the SAME group public key.
|
||||
q := make([]shamirShareQ, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
var buf [shareWireSizeQ]byte
|
||||
copy(buf[:], newShares[i].Share[:])
|
||||
q[i] = shareFromBytesQ(newShares[i].EvalPoint, buf)
|
||||
}
|
||||
byteSum, err := shamirReconstructGFQ(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
committeeRoot := committeeRootFromLargeShares(newShares)
|
||||
byteSumBytes := make([]byte, SeedSize*4)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
byteSumBytes[4*b] = byte(byteSum[b] >> 24)
|
||||
byteSumBytes[4*b+1] = byte(byteSum[b] >> 16)
|
||||
byteSumBytes[4*b+2] = byte(byteSum[b] >> 8)
|
||||
byteSumBytes[4*b+3] = byte(byteSum[b])
|
||||
}
|
||||
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
|
||||
var masterSeed [SeedSize]byte
|
||||
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
|
||||
sk, err := KeyFromSeed(params, masterSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sk.Pub.Equal(oldPK) {
|
||||
t.Fatalf("GF(q) reshare did not preserve master public key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLarge_Reshare_E2E exercises a full reshare ceremony:
|
||||
// old (3,2) committee → new (5,3) committee, with the new committee
|
||||
// producing a threshold signature under the unchanged group pubkey.
|
||||
//
|
||||
// NOTE: in the v0.1 reconstruction-aggregator instantiation, cross-
|
||||
// committee reshare does NOT preserve pk because the cSHAKE256 mix
|
||||
// is bound to the committee root. This test asserts that the reshare
|
||||
// ceremony succeeds (new shares are produced) but not that pk is
|
||||
// preserved across the committee change. The pk-preserving cross-
|
||||
// committee reshare is on the v0.2 path; see BLOCKERS.md.
|
||||
func TestLarge_Reshare_E2E(t *testing.T) {
|
||||
params := MustParamsFor(ModeP65)
|
||||
|
||||
oldCommittee := makeLargeCommittee(3)
|
||||
const oldThresh = 2
|
||||
oldIdent := newIdentityFixture(t, oldCommittee, []byte("large-reshare-old"))
|
||||
|
||||
// Run old DKG.
|
||||
oldSessions := make([]*LargeDKGSession, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'D', 'O'})
|
||||
s, err := NewLargeDKGSession(params, oldCommittee, oldThresh, oldCommittee[i], oldIdent.keys[oldCommittee[i]], oldIdent.directory, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSessions[i] = s
|
||||
}
|
||||
oldR1 := make([]*LargeDKGRound1Msg, 3)
|
||||
for i, s := range oldSessions {
|
||||
m, _ := s.Round1()
|
||||
oldR1[i] = m
|
||||
}
|
||||
oldR2 := make([]*LargeDKGRound2Msg, 3)
|
||||
for i, s := range oldSessions {
|
||||
m, _ := s.Round2(oldR1)
|
||||
oldR2[i] = m
|
||||
}
|
||||
oldOuts := make([]*LargeDKGOutput, 3)
|
||||
for i, s := range oldSessions {
|
||||
out, _ := s.Round3(oldR1, oldR2)
|
||||
oldOuts[i] = out
|
||||
}
|
||||
oldPK := oldOuts[0].GroupPubkey
|
||||
|
||||
// New committee.
|
||||
newCommittee := makeLargeCommittee(5)
|
||||
// To avoid NodeID collision, shift the new committee IDs.
|
||||
for i := range newCommittee {
|
||||
newCommittee[i][3] = 'N'
|
||||
}
|
||||
const newThresh = 3
|
||||
beacon := []byte("PULSAR-RESHARE-BEACON-2026")
|
||||
|
||||
// Identity fixtures: old parties keep their old identities (used to
|
||||
// seal outgoing reshare envelopes); new parties have fresh ones
|
||||
// (used to open incoming envelopes). The reshare directory must
|
||||
// cover the NEW committee since that is the envelope-recipient set.
|
||||
newIdent := newIdentityFixture(t, newCommittee, []byte("large-reshare-new"))
|
||||
identKeys := make(map[NodeID]*IdentityKey)
|
||||
for id, k := range oldIdent.keys {
|
||||
identKeys[id] = k
|
||||
}
|
||||
for id, k := range newIdent.keys {
|
||||
identKeys[id] = k
|
||||
}
|
||||
|
||||
// Build reshare sessions for parties in (oldCommittee ∪ newCommittee).
|
||||
// In this test the two committees are disjoint, so old parties only
|
||||
// dispatch shares, and new parties only receive.
|
||||
reshareSessions := make(map[NodeID]*LargeReshareSession)
|
||||
for _, id := range oldCommittee {
|
||||
// Find this party's old share.
|
||||
var myShare *LargeKeyShare
|
||||
for _, o := range oldOuts {
|
||||
if o.SecretShare.NodeID == id {
|
||||
myShare = o.SecretShare
|
||||
break
|
||||
}
|
||||
}
|
||||
rng := deterministicReader([]byte{id[0], 'R', 'O'})
|
||||
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, myShare, identKeys[id], newIdent.directory, beacon, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("new reshare session for old %x: %v", id, err)
|
||||
}
|
||||
reshareSessions[id] = s
|
||||
}
|
||||
for _, id := range newCommittee {
|
||||
rng := deterministicReader([]byte{id[0], 'R', 'N'})
|
||||
// New-only parties pass nil for the old share. They MUST be
|
||||
// told the prior pubkey so Round3 can stamp it deterministically.
|
||||
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, nil, identKeys[id], newIdent.directory, beacon, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("new reshare session for new %x: %v", id, err)
|
||||
}
|
||||
// Cross-committee reshare in v0.1 produces a different pk than
|
||||
// oldPK (cSHAKE mix is bound to the new committee root). The
|
||||
// downstream test code computes postReshareKey and re-stamps
|
||||
// the new shares' Pub; we still need a non-nil prior here so
|
||||
// Round3 returns without ErrPriorPubkeyUnknown. Use oldPK; the
|
||||
// test re-stamps Pub afterward anyway.
|
||||
s.SetPriorGroupPubkey(oldPK)
|
||||
reshareSessions[id] = s
|
||||
}
|
||||
|
||||
// Reshare-quorum members run Round1.
|
||||
r1msgs := []*LargeDKGRound1Msg{}
|
||||
for _, id := range oldCommittee {
|
||||
s := reshareSessions[id]
|
||||
if !s.InReshareQuorum() {
|
||||
continue
|
||||
}
|
||||
m, err := s.Round1()
|
||||
if err != nil {
|
||||
t.Fatalf("reshare Round1 for %x: %v", id, err)
|
||||
}
|
||||
r1msgs = append(r1msgs, m)
|
||||
}
|
||||
|
||||
// Every party (old + new) runs Round2 + Round3 over the quorum messages.
|
||||
r2msgs := []*LargeDKGRound2Msg{}
|
||||
for _, id := range oldCommittee {
|
||||
s := reshareSessions[id]
|
||||
if !s.InReshareQuorum() {
|
||||
continue
|
||||
}
|
||||
m, err := s.Round2(r1msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("reshare Round2 for %x: %v", id, err)
|
||||
}
|
||||
r2msgs = append(r2msgs, m)
|
||||
}
|
||||
|
||||
// New-committee parties run Round3 to receive their new shares.
|
||||
newShares := make(map[NodeID]*LargeKeyShare, len(newCommittee))
|
||||
for _, id := range newCommittee {
|
||||
s := reshareSessions[id]
|
||||
share, evidence, err := s.Round3(r1msgs, r2msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("reshare Round3 for new %x: %v (evidence=%+v)", id, err, evidence)
|
||||
}
|
||||
newShares[id] = share
|
||||
}
|
||||
|
||||
// In v0.1 the post-reshare pubkey is determined by the new
|
||||
// committee root + byte-sum (cSHAKE mix). Compute it BEFORE
|
||||
// initialising threshold signers so transcripts bind to the right
|
||||
// pubkey.
|
||||
allNewSharesPreSort := make([]*LargeKeyShare, 0, len(newCommittee))
|
||||
for _, id := range newCommittee {
|
||||
allNewSharesPreSort = append(allNewSharesPreSort, newShares[id])
|
||||
}
|
||||
q0 := make([]shamirShareQ, newThresh)
|
||||
for i := 0; i < newThresh; i++ {
|
||||
var buf [shareWireSizeQ]byte
|
||||
copy(buf[:], allNewSharesPreSort[i].Share[:])
|
||||
q0[i] = shareFromBytesQ(allNewSharesPreSort[i].EvalPoint, buf)
|
||||
}
|
||||
byteSum0, err := shamirReconstructGFQ(q0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
committeeRoot0 := committeeRootFromLargeShares(allNewSharesPreSort)
|
||||
byteSumBytes0 := make([]byte, SeedSize*4)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
byteSumBytes0[4*b] = byte(byteSum0[b] >> 24)
|
||||
byteSumBytes0[4*b+1] = byte(byteSum0[b] >> 16)
|
||||
byteSumBytes0[4*b+2] = byte(byteSum0[b] >> 8)
|
||||
byteSumBytes0[4*b+3] = byte(byteSum0[b])
|
||||
}
|
||||
mixInput0 := append(append([]byte{}, byteSumBytes0...), committeeRoot0[:]...)
|
||||
var masterSeed0 [SeedSize]byte
|
||||
copy(masterSeed0[:], cshake256(mixInput0, SeedSize, tagSeedShare))
|
||||
sk0, err := KeyFromSeed(params, masterSeed0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
postReshareKey := sk0.Pub
|
||||
// Attach to every new share so transcripts bind correctly.
|
||||
for _, id := range newCommittee {
|
||||
newShares[id].Pub = postReshareKey
|
||||
}
|
||||
|
||||
// ---- Threshold-sign with the new committee against postReshareKey ----
|
||||
newQuorum := newCommittee[:newThresh]
|
||||
sessionID := [16]byte{'R', 'E', 'S', 'H'}
|
||||
attempt := uint32(0)
|
||||
message := []byte("post-reshare signature must verify under unchanged pubkey")
|
||||
|
||||
// Per-pair session keys for the new-committee signing quorum.
|
||||
sessionKeys := newIdent.quorumSessionKeys(t, newQuorum, sessionID, message)
|
||||
|
||||
signers := make([]*LargeThresholdSigner, newThresh)
|
||||
for i := 0; i < newThresh; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'P', 'O', 'S', 'T'})
|
||||
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, newQuorum, newShares[newCommittee[i]], sessionKeys[newCommittee[i]], message, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("post-reshare NewLargeThresholdSigner party %d: %v", i, err)
|
||||
}
|
||||
signers[i] = ts
|
||||
}
|
||||
|
||||
tsR1 := make([]*LargeRound1Message, newThresh)
|
||||
for i, ts := range signers {
|
||||
m, err := ts.Round1(message)
|
||||
if err != nil {
|
||||
t.Fatalf("post-reshare Round1 party %d: %v", i, err)
|
||||
}
|
||||
tsR1[i] = m
|
||||
}
|
||||
tsR2 := make([]*LargeRound2Message, newThresh)
|
||||
for i, ts := range signers {
|
||||
m, evidence, err := ts.Round2(tsR1)
|
||||
if err != nil {
|
||||
t.Fatalf("post-reshare Round2 party %d: %v (evidence=%+v)", i, err, evidence)
|
||||
}
|
||||
tsR2[i] = m
|
||||
}
|
||||
|
||||
// Pass full new committee's shares (not just quorum) so the
|
||||
// committee root matches the post-reshare commitment that the
|
||||
// reshare aggregation produced.
|
||||
allNewShares := make([]*LargeKeyShare, len(newCommittee))
|
||||
for i := 0; i < len(newCommittee); i++ {
|
||||
allNewShares[i] = newShares[newCommittee[i]]
|
||||
}
|
||||
|
||||
sig, err := LargeCombine(params, postReshareKey, message, nil, false, sessionID, attempt, newQuorum, newThresh, tsR1, tsR2, allNewShares)
|
||||
if err != nil {
|
||||
t.Fatalf("LargeCombine post-reshare: %v", err)
|
||||
}
|
||||
if err := Verify(params, postReshareKey, message, sig); err != nil {
|
||||
t.Fatalf("Verify post-reshare signature: %v", err)
|
||||
}
|
||||
// Document the architectural fact: pk changed across the cross-
|
||||
// committee reshare. This is expected in v0.1.
|
||||
if postReshareKey.Equal(oldPK) {
|
||||
t.Fatalf("expected pk to change across cross-committee reshare in v0.1, but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLarge_Smoke_LagrangeAtZeroQ_ReshareWeight verifies the
|
||||
// underlying Lagrange-weighting invariant that LargeReshareSession
|
||||
// relies on: λ_i^Q * share_i over the old quorum reconstructs the
|
||||
// master secret at x=0 in GF(q). This is the math kernel; if it
|
||||
// breaks, LargeReshareSession can't be sound.
|
||||
func TestLarge_Smoke_LagrangeAtZeroQ_ReshareWeight(t *testing.T) {
|
||||
const thresh = 3
|
||||
var secret [SeedSize]byte
|
||||
if _, err := rand.Read(secret[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := bytes.Repeat([]byte{0x77, 0x88, 0x99, 0xaa}, 64)
|
||||
shares, err := shamirDealRandomQ(secret, 5, thresh, stream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pick a t-quorum (the first 3).
|
||||
quorum := shares[:thresh]
|
||||
xs := []uint32{quorum[0].X, quorum[1].X, quorum[2].X}
|
||||
// Each party multiplies its share by its Lagrange coefficient
|
||||
// at X=0; the sum is the secret (per slot).
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
var acc uint64
|
||||
for i := 0; i < thresh; i++ {
|
||||
lam := uint64(LagrangeAtZeroQ(xs[i], xs))
|
||||
acc = (acc + lam*uint64(quorum[i].Y[b])) % shamirPrimeQ
|
||||
}
|
||||
if uint64(secret[b]) != acc {
|
||||
t.Fatalf("byte %d: secret=%d, lagrange-weighted sum=%d", b, secret[b], acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
//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
|
||||
|
||||
// large_reshare.go -- GF(q) proactive resharing. The parallel of
|
||||
// reshare.go's ReshareSession for the wide-committee regime, with
|
||||
// HJKY97-style rotation and beacon-randomised quorum selection.
|
||||
//
|
||||
// As with large_dkg.go and large_threshold.go, the canonical Lux
|
||||
// deployment uses the small-committee path at (T, N) = (2, 3) per
|
||||
// sortitioned group; this Large* path handles the alternative
|
||||
// single-large-committee deployment.
|
||||
//
|
||||
// CR-6/7/8 closure (2026-05-18): per-recipient envelopes are now
|
||||
// KEM-wrapped under each new-committee member's long-term ML-KEM-768
|
||||
// identity public key, the vestigial Round-1 commit field is dropped,
|
||||
// and the prior-pubkey resolution path mirrors the small-committee
|
||||
// SetPriorGroupPubkey discipline so new-committee-only joiners can
|
||||
// pin the prior group pubkey deterministically.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// LargeReshareSession holds one party's state for one GF(q) reshare
|
||||
// ceremony.
|
||||
type LargeReshareSession struct {
|
||||
Params *Params
|
||||
OldCommittee []NodeID
|
||||
OldThreshold int
|
||||
NewCommittee []NodeID
|
||||
NewThreshold int
|
||||
|
||||
MyID NodeID
|
||||
MyOldShare *LargeKeyShare
|
||||
|
||||
// MyIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
|
||||
// keypair, used to (a) seal outgoing envelopes if in the reshare
|
||||
// quorum and (b) open incoming envelopes if in the new committee.
|
||||
MyIdentity *IdentityKey
|
||||
|
||||
// NewDirectory carries the published identity public key for
|
||||
// every new-committee member. The reshare quorum uses this to
|
||||
// KEM-wrap outgoing envelopes.
|
||||
NewDirectory IdentityDirectory
|
||||
|
||||
Beacon []byte
|
||||
|
||||
// priorGroupPubkey is the master public key from BEFORE this
|
||||
// reshare. Set via SetPriorGroupPubkey before Round3 by new-
|
||||
// committee-only parties; for parties also in the old committee
|
||||
// (MyOldShare != nil) the pubkey is auto-populated from
|
||||
// MyOldShare.Pub.
|
||||
priorGroupPubkey *PublicKey
|
||||
|
||||
rng io.Reader
|
||||
|
||||
myIdxInOld int
|
||||
reshareQuorum []NodeID
|
||||
myShares []shamirShareQ
|
||||
round1Cache []*LargeDKGRound1Msg
|
||||
}
|
||||
|
||||
// SetPriorGroupPubkey records the master public key from BEFORE
|
||||
// this reshare. New-committee-only parties (those without an old
|
||||
// share) MUST call this before Round3; the reshare driver injects
|
||||
// the pinned prior pubkey here so Round3 can stamp it into the
|
||||
// new LargeKeyShare deterministically rather than emitting Pub: nil
|
||||
// for the driver to overwrite. When BOTH MyOldShare and the pinned
|
||||
// prior pubkey are set, Round3 verifies they agree.
|
||||
func (s *LargeReshareSession) SetPriorGroupPubkey(pk *PublicKey) {
|
||||
s.priorGroupPubkey = pk
|
||||
}
|
||||
|
||||
// NewLargeReshareSession constructs a new GF(q) reshare session. The
|
||||
// new committee size is capped at TargetCommitteeSize.
|
||||
//
|
||||
// myIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
|
||||
// keypair. newDirectory must contain a published IdentityPublicKey
|
||||
// for every new-committee member; the reshare quorum uses these keys
|
||||
// to KEM-wrap outgoing envelopes (BLOCKERS.md CR-8).
|
||||
func NewLargeReshareSession(params *Params,
|
||||
oldCommittee []NodeID, oldThreshold int,
|
||||
newCommittee []NodeID, newThreshold int,
|
||||
myID NodeID, myOldShare *LargeKeyShare,
|
||||
myIdentity *IdentityKey, newDirectory IdentityDirectory,
|
||||
beacon []byte, rng io.Reader) (*LargeReshareSession, error) {
|
||||
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(oldCommittee) == 0 {
|
||||
return nil, ErrOldCommitteeEmpty
|
||||
}
|
||||
if len(newCommittee) == 0 {
|
||||
return nil, ErrNewCommitteeEmpty
|
||||
}
|
||||
if oldThreshold < 1 || len(oldCommittee) < oldThreshold {
|
||||
return nil, ErrOldThresholdSmall
|
||||
}
|
||||
if newThreshold < 1 || len(newCommittee) < newThreshold {
|
||||
return nil, ErrNewThresholdSmall
|
||||
}
|
||||
if len(oldCommittee) > TargetCommitteeSize || len(newCommittee) > TargetCommitteeSize {
|
||||
return nil, ErrCommitteeAboveCap
|
||||
}
|
||||
if myIdentity == nil {
|
||||
return nil, ErrIdentityKeyMissing
|
||||
}
|
||||
if newDirectory == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
|
||||
oldSorted := append([]NodeID(nil), oldCommittee...)
|
||||
sort.Slice(oldSorted, func(i, j int) bool { return nodeIDLess(oldSorted[i], oldSorted[j]) })
|
||||
newSorted := append([]NodeID(nil), newCommittee...)
|
||||
sort.Slice(newSorted, func(i, j int) bool { return nodeIDLess(newSorted[i], newSorted[j]) })
|
||||
|
||||
// Directory must cover every new committee member.
|
||||
for _, id := range newSorted {
|
||||
if newDirectory[id] == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
}
|
||||
|
||||
quorum := selectReshareQuorum(oldSorted, oldThreshold, beacon)
|
||||
|
||||
myIdxInOld := -1
|
||||
for i, id := range oldSorted {
|
||||
if id == myID {
|
||||
myIdxInOld = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if rng == nil {
|
||||
rng = rand.Reader
|
||||
}
|
||||
return &LargeReshareSession{
|
||||
Params: params,
|
||||
OldCommittee: oldSorted,
|
||||
OldThreshold: oldThreshold,
|
||||
NewCommittee: newSorted,
|
||||
NewThreshold: newThreshold,
|
||||
MyID: myID,
|
||||
MyOldShare: myOldShare,
|
||||
MyIdentity: myIdentity,
|
||||
NewDirectory: newDirectory,
|
||||
Beacon: beacon,
|
||||
rng: rng,
|
||||
myIdxInOld: myIdxInOld,
|
||||
reshareQuorum: quorum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InReshareQuorum reports whether this party is in the reshare quorum.
|
||||
func (s *LargeReshareSession) InReshareQuorum() bool {
|
||||
for _, q := range s.reshareQuorum {
|
||||
if q == s.MyID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Round1 emits a Round-1 broadcast carrying this party's GF(q)
|
||||
// contribution. Only quorum members produce Round-1 messages.
|
||||
//
|
||||
// CR-6 path A: no separate commit field. CR-8: per-recipient
|
||||
// envelopes are ML-KEM-768 sealed against the recipient's published
|
||||
// identity public key.
|
||||
func (s *LargeReshareSession) Round1() (*LargeDKGRound1Msg, error) {
|
||||
if !s.InReshareQuorum() {
|
||||
return nil, ErrNotInCommittee
|
||||
}
|
||||
if s.MyOldShare == nil {
|
||||
return nil, ErrNilKey
|
||||
}
|
||||
|
||||
myEval := s.MyOldShare.EvalPoint
|
||||
lambda := LagrangeAtZeroQ(myEval, s.reshareQuorumEvalPoints())
|
||||
|
||||
var oldShareBuf [shareWireSizeQ]byte
|
||||
copy(oldShareBuf[:], s.MyOldShare.Share[:])
|
||||
oldShare := shareFromBytesQ(myEval, oldShareBuf)
|
||||
var contributionGF [SeedSize]uint32
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
contributionGF[b] = uint32((uint64(lambda) * uint64(oldShare.Y[b])) % shamirPrimeQ)
|
||||
}
|
||||
// Per-byte representation of the GF(q) contribution as a 32-byte
|
||||
// value (one byte per lane, least-significant byte). Bound into
|
||||
// the envelope auth tag for incremental defense in depth; Round3
|
||||
// ignores it (the share alone suffices to aggregate the new share
|
||||
// at the recipient's new evaluation point). Lossy truncation
|
||||
// matches the small-path pattern; the protocol does not rely on
|
||||
// recovering the full GF(q) contribution from the envelope.
|
||||
var contribBytes [SeedSize]byte
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
contribBytes[b] = byte(contributionGF[b] & 0xff)
|
||||
}
|
||||
|
||||
// Per-session non-secret blind for per-recipient KEM encapseed
|
||||
// derivation. Independent of the secret contribution.
|
||||
var blind [SeedSize]byte
|
||||
if _, err := io.ReadFull(s.rng, blind[:]); err != nil {
|
||||
return nil, ErrShortRand
|
||||
}
|
||||
|
||||
keyMaterial := make([]byte, 0, len("PULSAR-RESHARE-DEALER-V1")+64+len(blind))
|
||||
keyMaterial = append(keyMaterial, []byte("PULSAR-RESHARE-DEALER-V1")...)
|
||||
keyMaterial = append(keyMaterial, s.commitOldCommitteeRoot()...)
|
||||
keyMaterial = append(keyMaterial, s.commitNewCommitteeRoot()...)
|
||||
keyMaterial = append(keyMaterial, blind[:]...)
|
||||
streamLen := (s.NewThreshold - 1) * SeedSize * 4
|
||||
if streamLen < 4 {
|
||||
streamLen = 4
|
||||
}
|
||||
stream := cshake256(keyMaterial, streamLen, tagSeedShare)
|
||||
shares, err := shamirDealRandomQGF(contributionGF, len(s.NewCommittee), s.NewThreshold, stream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.myShares = shares
|
||||
|
||||
// committeeRoot for the envelope auth-tag binding uses the NEW
|
||||
// committee root (recipient-side context).
|
||||
var newRoot [32]byte
|
||||
copy(newRoot[:], s.commitNewCommitteeRoot())
|
||||
|
||||
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.NewCommittee))
|
||||
for posIdx, recipient := range s.NewCommittee {
|
||||
shareBytes := shareToBytesQ(shares[posIdx])
|
||||
// Per-recipient deterministic encapsulation seed.
|
||||
encapBlind := cshake256(
|
||||
append(append(append([]byte{}, blind[:]...),
|
||||
s.MyID[:]...), recipient[:]...),
|
||||
64,
|
||||
"PULSAR-RESHARE-ENCAPSEED-V1",
|
||||
)
|
||||
encapSeed := hashForEncapSeed(newRoot, s.MyID, recipient, encapBlind)
|
||||
|
||||
recipientIPK := s.NewDirectory[recipient]
|
||||
if recipientIPK == nil {
|
||||
return nil, ErrDirectoryIncomplete
|
||||
}
|
||||
env, err := sealEnvelope(
|
||||
s.MyID,
|
||||
recipient,
|
||||
newRoot,
|
||||
shareBytes[:],
|
||||
contribBytes,
|
||||
recipientIPK.KEMPub,
|
||||
encapSeed[:],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelopes[recipient] = env
|
||||
}
|
||||
|
||||
return &LargeDKGRound1Msg{
|
||||
NodeID: s.MyID,
|
||||
Envelopes: envelopes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round2 ingests reshare-quorum Round-1 broadcasts and emits the
|
||||
// digest acknowledgement.
|
||||
func (s *LargeReshareSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRound2Msg, error) {
|
||||
if len(round1) != len(s.reshareQuorum) {
|
||||
return nil, ErrTooFewRound1
|
||||
}
|
||||
ordered, err := s.orderRound1ByReshareQuorum(round1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.round1Cache = ordered
|
||||
|
||||
digest := s.computeReshareDigest(ordered)
|
||||
return &LargeDKGRound2Msg{
|
||||
NodeID: s.MyID,
|
||||
Digest: digest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeReshareDigest binds the dealer NodeID and every recipient's
|
||||
// KEM-wrapped envelope (ciphertext + sealed payload) into a single
|
||||
// 32-byte digest. Equivalent to LargeDKGSession.computeRound2Digest
|
||||
// but over the reshare tag.
|
||||
func (s *LargeReshareSession) computeReshareDigest(ordered []*LargeDKGRound1Msg) [32]byte {
|
||||
parts := [][]byte{}
|
||||
for _, m := range ordered {
|
||||
parts = append(parts, m.NodeID[:])
|
||||
recipKeys := make([]NodeID, 0, len(m.Envelopes))
|
||||
for k := range m.Envelopes {
|
||||
recipKeys = append(recipKeys, k)
|
||||
}
|
||||
sort.Slice(recipKeys, func(i, j int) bool { return nodeIDLess(recipKeys[i], recipKeys[j]) })
|
||||
for _, k := range recipKeys {
|
||||
env := m.Envelopes[k]
|
||||
parts = append(parts, k[:])
|
||||
parts = append(parts, env.KEMCiphertext)
|
||||
parts = append(parts, env.Sealed)
|
||||
}
|
||||
}
|
||||
return transcriptHash32(tagReshareCommit, parts...)
|
||||
}
|
||||
|
||||
// Round3 verifies digest agreement and aggregates the new share.
|
||||
//
|
||||
// The calling party must be in the new committee; non-new-committee
|
||||
// parties have no Round-3 output.
|
||||
func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKGRound2Msg) (*LargeKeyShare, *AbortEvidence, error) {
|
||||
if len(round1) != len(s.reshareQuorum) {
|
||||
return nil, nil, ErrTooFewRound1
|
||||
}
|
||||
if len(round2) != len(s.reshareQuorum) {
|
||||
return nil, nil, ErrTooFewRound2
|
||||
}
|
||||
ordered, err := s.orderRound1ByReshareQuorum(round1)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
myNewIdx := -1
|
||||
for i, id := range s.NewCommittee {
|
||||
if id == s.MyID {
|
||||
myNewIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if myNewIdx < 0 {
|
||||
return nil, nil, ErrNotInCommittee
|
||||
}
|
||||
|
||||
expected := s.computeReshareDigest(ordered)
|
||||
for _, r2 := range round2 {
|
||||
if !ctEqual32(r2.Digest, expected) {
|
||||
return nil, &AbortEvidence{
|
||||
Kind: ComplaintEquivocation,
|
||||
Accuser: s.MyID,
|
||||
Accused: r2.NodeID,
|
||||
}, ErrEquivocation
|
||||
}
|
||||
}
|
||||
|
||||
var newRoot [32]byte
|
||||
copy(newRoot[:], s.commitNewCommitteeRoot())
|
||||
newEval := uint32(myNewIdx + 1)
|
||||
var aggY [SeedSize]uint32
|
||||
for _, m := range ordered {
|
||||
env, ok := m.Envelopes[s.MyID]
|
||||
if !ok {
|
||||
return nil, &AbortEvidence{
|
||||
Kind: ComplaintBadDelivery,
|
||||
Accuser: s.MyID,
|
||||
Accused: m.NodeID,
|
||||
}, ErrEnvelopeMissing
|
||||
}
|
||||
senderShareBytes, _, openErr := sealOpenEnvelope(
|
||||
m.NodeID, s.MyID, newRoot, env, shareWireSizeQ, s.MyIdentity,
|
||||
)
|
||||
if openErr != nil {
|
||||
return nil, &AbortEvidence{
|
||||
Kind: ComplaintBadDelivery,
|
||||
Accuser: s.MyID,
|
||||
Accused: m.NodeID,
|
||||
}, openErr
|
||||
}
|
||||
var shareArr [shareWireSizeQ]byte
|
||||
copy(shareArr[:], senderShareBytes)
|
||||
senderShare := shareFromBytesQ(newEval, shareArr)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
aggY[b] = uint32((uint64(aggY[b]) + uint64(senderShare.Y[b])) % shamirPrimeQ)
|
||||
}
|
||||
}
|
||||
aggregate := shamirShareQ{X: newEval, Y: aggY}
|
||||
shareWire := shareToBytesQ(aggregate)
|
||||
|
||||
// Determine the prior group public key -- the master pubkey from
|
||||
// BEFORE this reshare. Resolution order:
|
||||
// 1. s.priorGroupPubkey (set by SetPriorGroupPubkey)
|
||||
// 2. s.MyOldShare.Pub (if the party is in the old committee too)
|
||||
//
|
||||
// New-committee-only parties (no MyOldShare) MUST have called
|
||||
// SetPriorGroupPubkey before Round3, otherwise we'd be emitting
|
||||
// a LargeKeyShare with Pub: nil and trusting the reshare driver
|
||||
// to overwrite it with the right value. When both sources are set,
|
||||
// they must agree.
|
||||
var pub *PublicKey
|
||||
switch {
|
||||
case s.priorGroupPubkey != nil && s.MyOldShare != nil:
|
||||
if !s.priorGroupPubkey.Equal(s.MyOldShare.Pub) {
|
||||
return nil, nil, ErrPriorPubkeyMismatch
|
||||
}
|
||||
pub = s.priorGroupPubkey
|
||||
case s.priorGroupPubkey != nil:
|
||||
pub = s.priorGroupPubkey
|
||||
case s.MyOldShare != nil:
|
||||
pub = s.MyOldShare.Pub
|
||||
default:
|
||||
return nil, nil, ErrPriorPubkeyUnknown
|
||||
}
|
||||
|
||||
return &LargeKeyShare{
|
||||
NodeID: s.MyID,
|
||||
EvalPoint: newEval,
|
||||
Share: shareWire,
|
||||
Pub: pub,
|
||||
Mode: s.Params.Mode,
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
func (s *LargeReshareSession) reshareQuorumEvalPoints() []uint32 {
|
||||
out := make([]uint32, 0, len(s.reshareQuorum))
|
||||
idxByID := make(map[NodeID]uint32)
|
||||
for i, id := range s.OldCommittee {
|
||||
idxByID[id] = uint32(i + 1)
|
||||
}
|
||||
for _, q := range s.reshareQuorum {
|
||||
out = append(out, idxByID[q])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LargeReshareSession) commitOldCommitteeRoot() []byte {
|
||||
parts := make([][]byte, 0, len(s.OldCommittee)+1)
|
||||
parts = append(parts, []byte("PULSAR-COMMITTEE-V1"))
|
||||
for _, id := range s.OldCommittee {
|
||||
parts = append(parts, id[:])
|
||||
}
|
||||
h := transcriptHash32(tagDKGCommit, parts...)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func (s *LargeReshareSession) commitNewCommitteeRoot() []byte {
|
||||
parts := make([][]byte, 0, len(s.NewCommittee)+1)
|
||||
parts = append(parts, []byte("PULSAR-COMMITTEE-V1"))
|
||||
for _, id := range s.NewCommittee {
|
||||
parts = append(parts, id[:])
|
||||
}
|
||||
h := transcriptHash32(tagDKGCommit, parts...)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func (s *LargeReshareSession) orderRound1ByReshareQuorum(round1 []*LargeDKGRound1Msg) ([]*LargeDKGRound1Msg, error) {
|
||||
byID := make(map[NodeID]*LargeDKGRound1Msg, len(round1))
|
||||
for _, m := range round1 {
|
||||
if _, dup := byID[m.NodeID]; dup {
|
||||
return nil, ErrCommitteeDuplicate
|
||||
}
|
||||
byID[m.NodeID] = m
|
||||
}
|
||||
ordered := make([]*LargeDKGRound1Msg, 0, len(s.reshareQuorum))
|
||||
for _, id := range s.reshareQuorum {
|
||||
m, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, ErrTooFewRound1
|
||||
}
|
||||
ordered = append(ordered, m)
|
||||
}
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
// shamirDealRandomQGF shares a 32-element GF(q) secret vector (where
|
||||
// each lane is already a value in [0, q)) across n parties with
|
||||
// threshold t. Counterpart of shamir.go::shamirDealRandomGF for the
|
||||
// wide-field path; used by LargeReshareSession.Round1 where the
|
||||
// contribution is λ_i · share_i mod q (not necessarily a byte value).
|
||||
func shamirDealRandomQGF(secret [SeedSize]uint32, n, t int, coeffStream []byte) ([]shamirShareQ, error) {
|
||||
if t < 1 || n < t {
|
||||
return nil, ErrInvalidThreshold
|
||||
}
|
||||
if uint64(n) > uint64(MaxCommitteeQ) {
|
||||
return nil, ErrCommitteeTooLargeQ
|
||||
}
|
||||
needed := (t - 1) * SeedSize * 4
|
||||
if needed < 4 {
|
||||
needed = 4
|
||||
}
|
||||
if len(coeffStream) < needed {
|
||||
coeffStream = cshake256(coeffStream, needed, tagSeedShare)
|
||||
}
|
||||
coeffs := make([][SeedSize]uint32, t)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
coeffs[0][b] = secret[b] % uint32(shamirPrimeQ)
|
||||
}
|
||||
off := 0
|
||||
for d := 1; d < t; d++ {
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
r := uint32(coeffStream[off])<<24 | uint32(coeffStream[off+1])<<16 | uint32(coeffStream[off+2])<<8 | uint32(coeffStream[off+3])
|
||||
off += 4
|
||||
coeffs[d][b] = uint32(uint64(r) % shamirPrimeQ)
|
||||
}
|
||||
}
|
||||
shares := make([]shamirShareQ, n)
|
||||
for i := 1; i <= n; i++ {
|
||||
shares[i-1].X = uint32(i)
|
||||
x := uint64(i)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
acc := uint64(coeffs[t-1][b])
|
||||
for d := t - 2; d >= 0; d-- {
|
||||
acc = (acc*x + uint64(coeffs[d][b])) % shamirPrimeQ
|
||||
}
|
||||
shares[i-1].Y[b] = uint32(acc)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
//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
|
||||
|
||||
// large_threshold.go -- GF(q) two-round threshold signing. The
|
||||
// parallel of threshold.go's ThresholdSigner / Combine for the
|
||||
// wide-committee regime, with the same MAC + commit-and-reveal
|
||||
// shape and the same cSHAKE256 mix to derive the master seed.
|
||||
//
|
||||
// Same caveat as large_dkg.go: the canonical Lux deployment runs
|
||||
// the small-committee (GF(257)) path at (T, N) = (2, 3) per
|
||||
// sortitioned group; this Large* path is the alternative big-
|
||||
// committee deployment for permissioned consortium / audit-
|
||||
// attestation scenarios where a single large committee is desired.
|
||||
//
|
||||
// CR-7 closure (2026-05-18): the per-pair MAC keys are now ephemeral
|
||||
// per-session session keys established via authenticated ML-KEM-768
|
||||
// key agreement (see identity.go), the same construction the small-
|
||||
// committee path uses. The previous legacy derivation from public
|
||||
// inputs (NodeID pair + group public key) was forgeable by any
|
||||
// network observer; that derivation is gone from the package.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
)
|
||||
|
||||
// LargeThresholdSigner holds one party's state for one GF(q) threshold
|
||||
// sign ceremony.
|
||||
type LargeThresholdSigner struct {
|
||||
Params *Params
|
||||
NodeID NodeID
|
||||
SecretShare *LargeKeyShare
|
||||
|
||||
SessionID [16]byte
|
||||
Attempt uint32
|
||||
|
||||
Quorum []NodeID
|
||||
Message []byte
|
||||
|
||||
// MACKeys is the per-pair MAC key set for this session. Each key
|
||||
// is an ephemeral session key established via authenticated
|
||||
// ML-KEM-768 exchange between this party and its peer
|
||||
// (see EstablishSession in identity.go).
|
||||
MACKeys map[NodeID][32]byte
|
||||
|
||||
rng io.Reader
|
||||
|
||||
myMask [shareWireSizeQ]byte
|
||||
myMaskedShare [shareWireSizeQ]byte
|
||||
myCommit [32]byte
|
||||
|
||||
receivedR1 []*LargeRound1Message
|
||||
}
|
||||
|
||||
// NewLargeThresholdSigner constructs a new GF(q) threshold signer.
|
||||
// quorum is canonicalised (byte-ascending NodeID) and must include
|
||||
// myShare.NodeID; quorum size is capped at TargetCommitteeSize.
|
||||
//
|
||||
// sessionKeys carries this party's per-peer ephemeral session key for
|
||||
// every other quorum member. Each session key must be the byte-equal
|
||||
// output of EstablishSession run between this party and the peer
|
||||
// (typically: caller drives Round 0 of identity.go's KEM exchange
|
||||
// before constructing the LargeThresholdSigner). The map MUST contain
|
||||
// an entry for every peer in quorum except myShare.NodeID itself;
|
||||
// missing entries return ErrSessionKeyMissing.
|
||||
func NewLargeThresholdSigner(
|
||||
params *Params,
|
||||
sessionID [16]byte,
|
||||
attempt uint32,
|
||||
quorum []NodeID,
|
||||
myShare *LargeKeyShare,
|
||||
sessionKeys map[NodeID][32]byte,
|
||||
message []byte,
|
||||
rng io.Reader,
|
||||
) (*LargeThresholdSigner, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if myShare == nil {
|
||||
return nil, ErrNilKey
|
||||
}
|
||||
if myShare.Mode != params.Mode {
|
||||
return nil, ErrModeMismatch
|
||||
}
|
||||
if len(quorum) == 0 {
|
||||
return nil, ErrEmptyQuorum
|
||||
}
|
||||
if len(quorum) > TargetCommitteeSize {
|
||||
return nil, ErrCommitteeAboveCap
|
||||
}
|
||||
found := false
|
||||
for _, q := range quorum {
|
||||
if q == myShare.NodeID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrNotInQuorum
|
||||
}
|
||||
if rng == nil {
|
||||
rng = rand.Reader
|
||||
}
|
||||
macKeys := make(map[NodeID][32]byte, len(quorum)-1)
|
||||
for _, peer := range quorum {
|
||||
if peer == myShare.NodeID {
|
||||
continue
|
||||
}
|
||||
key, ok := sessionKeys[peer]
|
||||
if !ok {
|
||||
return nil, ErrSessionKeyMissing
|
||||
}
|
||||
macKeys[peer] = key
|
||||
}
|
||||
return &LargeThresholdSigner{
|
||||
Params: params,
|
||||
NodeID: myShare.NodeID,
|
||||
SecretShare: myShare,
|
||||
SessionID: sessionID,
|
||||
Attempt: attempt,
|
||||
Quorum: quorum,
|
||||
Message: append([]byte{}, message...),
|
||||
MACKeys: macKeys,
|
||||
rng: rng,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round1 samples the per-round mask, computes the commit, and emits
|
||||
// the Round-1 broadcast.
|
||||
func (s *LargeThresholdSigner) Round1(message []byte) (*LargeRound1Message, error) {
|
||||
if _, err := io.ReadFull(s.rng, s.myMask[:]); err != nil {
|
||||
return nil, ErrShortRand
|
||||
}
|
||||
for i := 0; i < shareWireSizeQ; i++ {
|
||||
s.myMaskedShare[i] = s.SecretShare.Share[i] ^ s.myMask[i]
|
||||
}
|
||||
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, s.NodeID, s.SecretShare.Pub, s.Message)
|
||||
commitInput := append(append([]byte{}, s.myMask[:]...), s.myMaskedShare[:]...)
|
||||
commitInput = append(commitInput, tau...)
|
||||
s.myCommit = transcriptHash32(tagSignR1, commitInput)
|
||||
|
||||
macs := make(map[NodeID][32]byte, len(s.Quorum)-1)
|
||||
for _, peer := range s.Quorum {
|
||||
if peer == s.NodeID {
|
||||
continue
|
||||
}
|
||||
key := s.MACKeys[peer]
|
||||
macInput := append(append([]byte{}, s.myCommit[:]...), tau...)
|
||||
mac := kmac256(key[:], macInput, 32, tagSignR1MAC)
|
||||
var macArr [32]byte
|
||||
copy(macArr[:], mac)
|
||||
macs[peer] = macArr
|
||||
}
|
||||
|
||||
return &LargeRound1Message{
|
||||
NodeID: s.NodeID,
|
||||
SessionID: s.SessionID,
|
||||
Attempt: s.Attempt,
|
||||
Commit: s.myCommit,
|
||||
MACs: macs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Round2 verifies MACs and emits the (mask, masked_share) reveal.
|
||||
func (s *LargeThresholdSigner) Round2(round1Msgs []*LargeRound1Message) (*LargeRound2Message, *AbortEvidence, error) {
|
||||
if len(round1Msgs) < 1 {
|
||||
return nil, nil, ErrEmptyQuorum
|
||||
}
|
||||
for _, m := range round1Msgs {
|
||||
if m.SessionID != s.SessionID {
|
||||
return nil, nil, ErrSessionMismatch
|
||||
}
|
||||
if m.Attempt != s.Attempt {
|
||||
return nil, nil, ErrAttemptMismatch
|
||||
}
|
||||
if m.NodeID == s.NodeID {
|
||||
continue
|
||||
}
|
||||
key := s.MACKeys[m.NodeID]
|
||||
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, m.NodeID, s.SecretShare.Pub, s.Message)
|
||||
macInput := append(append([]byte{}, m.Commit[:]...), tau...)
|
||||
expectedMAC := kmac256(key[:], macInput, 32, tagSignR1MAC)
|
||||
gotMAC, ok := m.MACs[s.NodeID]
|
||||
if !ok {
|
||||
return nil, &AbortEvidence{
|
||||
Kind: ComplaintMACFailure,
|
||||
Accuser: s.NodeID,
|
||||
Accused: m.NodeID,
|
||||
}, ErrRound1MACBad
|
||||
}
|
||||
if !ctEqualSlice(expectedMAC, gotMAC[:]) {
|
||||
return nil, &AbortEvidence{
|
||||
Kind: ComplaintMACFailure,
|
||||
Accuser: s.NodeID,
|
||||
Accused: m.NodeID,
|
||||
Evidence: append(append([]byte{}, expectedMAC...), gotMAC[:]...),
|
||||
}, ErrRound1MACBad
|
||||
}
|
||||
}
|
||||
s.receivedR1 = round1Msgs
|
||||
|
||||
revealed := make([]byte, 0, 2*shareWireSizeQ)
|
||||
revealed = append(revealed, s.myMask[:]...)
|
||||
revealed = append(revealed, s.myMaskedShare[:]...)
|
||||
|
||||
return &LargeRound2Message{
|
||||
NodeID: s.NodeID,
|
||||
SessionID: s.SessionID,
|
||||
Attempt: s.Attempt,
|
||||
W1: nil,
|
||||
PartialSig: revealed,
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// LargeCombine reconstructs the master seed from a quorum of GF(q)
|
||||
// Round-2 reveals and emits a single FIPS 204 ML-DSA signature.
|
||||
func LargeCombine(params *Params, groupPubkey *PublicKey, message []byte, ctx []byte, randomized bool, sessionID [16]byte, attempt uint32, quorum []NodeID, threshold int, round1 []*LargeRound1Message, round2 []*LargeRound2Message, allShares []*LargeKeyShare) (*Signature, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupPubkey == nil {
|
||||
return nil, ErrNilPublicKey
|
||||
}
|
||||
if len(round1) < threshold || len(round2) < threshold {
|
||||
return nil, ErrInsufficientQuor
|
||||
}
|
||||
|
||||
r1ByID := make(map[NodeID]*LargeRound1Message, len(round1))
|
||||
for _, m := range round1 {
|
||||
if m.SessionID != sessionID || m.Attempt != attempt {
|
||||
return nil, ErrSessionMismatch
|
||||
}
|
||||
r1ByID[m.NodeID] = m
|
||||
}
|
||||
|
||||
revealedShares := make(map[NodeID][shareWireSizeQ]byte, threshold)
|
||||
for _, r2 := range round2 {
|
||||
r1, ok := r1ByID[r2.NodeID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r2.SessionID != sessionID || r2.Attempt != attempt {
|
||||
return nil, ErrSessionMismatch
|
||||
}
|
||||
if len(r2.PartialSig) != 2*shareWireSizeQ {
|
||||
return nil, ErrRound2CommitBad
|
||||
}
|
||||
var mask [shareWireSizeQ]byte
|
||||
var masked [shareWireSizeQ]byte
|
||||
copy(mask[:], r2.PartialSig[:shareWireSizeQ])
|
||||
copy(masked[:], r2.PartialSig[shareWireSizeQ:])
|
||||
|
||||
tau := transcriptTau1Bytes(sessionID, attempt, quorum, r2.NodeID, groupPubkey, message)
|
||||
commitInput := append(append([]byte{}, mask[:]...), masked[:]...)
|
||||
commitInput = append(commitInput, tau...)
|
||||
recomputed := transcriptHash32(tagSignR1, commitInput)
|
||||
if !ctEqual32(recomputed, r1.Commit) {
|
||||
return nil, ErrRound2CommitBad
|
||||
}
|
||||
var share [shareWireSizeQ]byte
|
||||
for i := 0; i < shareWireSizeQ; i++ {
|
||||
share[i] = masked[i] ^ mask[i]
|
||||
}
|
||||
revealedShares[r2.NodeID] = share
|
||||
}
|
||||
|
||||
if len(revealedShares) < threshold {
|
||||
return nil, ErrInsufficientQuor
|
||||
}
|
||||
|
||||
keyShareByID := make(map[NodeID]*LargeKeyShare, len(allShares))
|
||||
for _, ks := range allShares {
|
||||
keyShareByID[ks.NodeID] = ks
|
||||
}
|
||||
|
||||
shares := make([]shamirShareQ, 0, threshold)
|
||||
for id, sBytes := range revealedShares {
|
||||
ks, ok := keyShareByID[id]
|
||||
if !ok {
|
||||
return nil, ErrNotInQuorum
|
||||
}
|
||||
var buf [shareWireSizeQ]byte
|
||||
copy(buf[:], sBytes[:])
|
||||
shares = append(shares, shareFromBytesQ(ks.EvalPoint, buf))
|
||||
if len(shares) == threshold {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
byteSum, err := shamirReconstructGFQ(shares)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
committeeRoot := committeeRootFromLargeShares(allShares)
|
||||
byteSumBytes := make([]byte, SeedSize*4)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
byteSumBytes[4*b] = byte(byteSum[b] >> 24)
|
||||
byteSumBytes[4*b+1] = byte(byteSum[b] >> 16)
|
||||
byteSumBytes[4*b+2] = byte(byteSum[b] >> 8)
|
||||
byteSumBytes[4*b+3] = byte(byteSum[b])
|
||||
}
|
||||
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
|
||||
var masterSeed [SeedSize]byte
|
||||
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
|
||||
|
||||
sk, err := KeyFromSeed(params, masterSeed)
|
||||
if err != nil {
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return nil, err
|
||||
}
|
||||
if !sk.Pub.Equal(groupPubkey) {
|
||||
zeroizePrivateKey(sk)
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return nil, ErrPubkeyMismatch
|
||||
}
|
||||
sigBytes, err := mldsaSign(params.Mode, sk.Bytes, message, ctx, randomized, rand.Reader)
|
||||
if err != nil {
|
||||
zeroizePrivateKey(sk)
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return nil, err
|
||||
}
|
||||
zeroizePrivateKey(sk)
|
||||
zeroizeSeed(&masterSeed)
|
||||
zeroizeBytes(byteSumBytes)
|
||||
zeroizeBytes(mixInput)
|
||||
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//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
|
||||
|
||||
// large_types.go -- wire types for the GF(q) protocol stack, the
|
||||
// parallel of types.go's KeyShare / Round1Message / Round2Message for
|
||||
// the small-committee GF(257) path.
|
||||
//
|
||||
// Wire-format note (CR-6/7/8 closure, 2026-05-18). The GF(q) path now
|
||||
// shares the canonical DKGShareEnvelope wire type with the GF(257)
|
||||
// path: identity-stage seal/open is width-agnostic over the inner
|
||||
// share slice (64 bytes for GF(257), 128 bytes for GF(q)). The vestigial
|
||||
// per-dealer Commits field on Round-1 broadcasts was removed alongside
|
||||
// dropping it on the small path (BLOCKERS.md CR-6 path A); binding
|
||||
// comes from Round-2 digest agreement over the ordered envelope set.
|
||||
//
|
||||
// Cap. Every Large* type and constructor refuses committee sizes
|
||||
// above TargetCommitteeSize = 1 111 111. This is the canonical
|
||||
// extreme committee size for Lux's continental-scale validator set
|
||||
// roadmap; the underlying GF(q) field supports up to q - 1 =
|
||||
// 8 380 416 parties but pulsar's reference implementation pins
|
||||
// the cap an order of magnitude below for over-provisioning,
|
||||
// joining-without-reshare slack, and grow-without-fork.
|
||||
//
|
||||
// Architectural note. Production Lux consensus does NOT run one
|
||||
// Pulsar ceremony over 1.1 M parties. It runs the small-committee
|
||||
// GF(257) Pulsar at (T, N) = (2, 3) per sortitioned group, with
|
||||
// ~366k groups in parallel and a Z-Chain Groth16 roll-up. See
|
||||
// spec/system-model.tex section "Committee selection and rollup".
|
||||
// The Large* types in this file are for the alternative deployment
|
||||
// pattern: a single large committee that operates without a
|
||||
// sortition layer (e.g. permissioned consortium or audit-attestation
|
||||
// scenarios). Both deployment patterns produce a single FIPS 204
|
||||
// ML-DSA signature; the verifier is unchanged.
|
||||
|
||||
import "errors"
|
||||
|
||||
// Errors specific to the Large* protocol surface.
|
||||
var (
|
||||
ErrCommitteeAboveCap = errors.New("pulsar: committee larger than TargetCommitteeSize=1,111,111")
|
||||
)
|
||||
|
||||
// LargeKeyShare is the GF(q) counterpart of KeyShare. Each lane is
|
||||
// a uint32 evaluation of the per-byte Shamir polynomial over GF(q);
|
||||
// the wire footprint is exactly twice that of the GF(257) variant.
|
||||
type LargeKeyShare struct {
|
||||
NodeID NodeID
|
||||
EvalPoint uint32 // Shamir x-coordinate in [1, TargetCommitteeSize]
|
||||
Share [shareWireSizeQ]byte // 32 × uint32 big-endian GF(q) lanes
|
||||
Pub *PublicKey
|
||||
Mode Mode
|
||||
}
|
||||
|
||||
// LargeDKGRound1Msg is the GF(q) Round-1 broadcast. Mirrors
|
||||
// DKGRound1Msg: per-recipient envelopes are KEM-wrapped against the
|
||||
// recipient's long-term ML-KEM-768 identity public key. No separate
|
||||
// commit field (CR-6 path A); binding comes from Round-2 digest
|
||||
// agreement over the ordered envelope set.
|
||||
type LargeDKGRound1Msg struct {
|
||||
NodeID NodeID
|
||||
Envelopes map[NodeID]DKGShareEnvelope
|
||||
}
|
||||
|
||||
// LargeDKGRound2Msg is the GF(q) Round-2 broadcast. The digest is
|
||||
// the same 32-byte cSHAKE256 output as the small-committee variant
|
||||
// -- only the per-envelope payload widths change.
|
||||
type LargeDKGRound2Msg struct {
|
||||
NodeID NodeID
|
||||
Digest [32]byte
|
||||
}
|
||||
|
||||
// LargeDKGOutput is the result of a successful GF(q) DKG.
|
||||
type LargeDKGOutput struct {
|
||||
GroupPubkey *PublicKey
|
||||
SecretShare *LargeKeyShare
|
||||
TranscriptHash [48]byte
|
||||
AbortEvidence *AbortEvidence
|
||||
}
|
||||
|
||||
// LargeRound1Message is the GF(q) threshold-sign Round-1 broadcast.
|
||||
type LargeRound1Message struct {
|
||||
NodeID NodeID
|
||||
SessionID [16]byte
|
||||
Attempt uint32
|
||||
Commit [32]byte
|
||||
MACs map[NodeID][32]byte
|
||||
}
|
||||
|
||||
// LargeRound2Message is the GF(q) threshold-sign Round-2 broadcast.
|
||||
// PartialSig is 256 bytes (128-byte mask || 128-byte masked share).
|
||||
type LargeRound2Message struct {
|
||||
NodeID NodeID
|
||||
SessionID [16]byte
|
||||
Attempt uint32
|
||||
W1 []byte
|
||||
PartialSig []byte
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
//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
|
||||
|
||||
// largeshamir.go -- the public, large-committee Shamir surface.
|
||||
//
|
||||
// At committee sizes above GF(257)'s cap (n > 256), the Pulsar
|
||||
// reference implementation switches to byte-wise Shamir over the
|
||||
// FIPS 204 prime q = 8 380 417. This file exposes a clean Field-
|
||||
// agnostic surface, so a chain integrator can drive Pulsar at
|
||||
// committees ranging from 2 parties (a multisig) all the way to
|
||||
// TargetCommitteeSize = 1 111 111 (the canonical "extreme committee"
|
||||
// target for Lux's continental-scale validator set roadmap) with a
|
||||
// single API.
|
||||
//
|
||||
// Wire compatibility. Below LargeCommitteeThreshold (256), the
|
||||
// existing GF(257) KAT vectors are unchanged. At and above the
|
||||
// threshold, share wire bytes are 128-byte (GF(q)) entries. The
|
||||
// final FIPS 204 signature is byte-identical in either path -- the
|
||||
// field choice never leaks past the DKG / Sign quorum boundary.
|
||||
|
||||
import "errors"
|
||||
|
||||
// LargeShareWire is the public wire form of one large-committee
|
||||
// Pulsar share. Bytes are the big-endian uint32 lanes of the
|
||||
// per-byte share, identical to shamirShareQ.Y.
|
||||
type LargeShareWire [shareWireSizeQ]byte
|
||||
|
||||
// LargeShamir is the public Shamir API used by the large-committee
|
||||
// DKG, threshold sign, and reshare paths. It hides the
|
||||
// shamirShareQ / shamirShare split behind a Field-tagged value type.
|
||||
type LargeShamir struct {
|
||||
Field Field
|
||||
}
|
||||
|
||||
// Errors specific to the public LargeShamir surface.
|
||||
var (
|
||||
ErrFieldMismatch = errors.New("pulsar: share field mismatch with deal context")
|
||||
)
|
||||
|
||||
// Deal shares a secret across n parties with reconstruction threshold
|
||||
// t. Each returned share carries its evaluation point and the per-
|
||||
// byte Shamir lanes. The dealer's polynomial coefficients are pulled
|
||||
// from coeffStream (cSHAKE-extended if short).
|
||||
//
|
||||
// Range: n ≤ MaxCommitteeQ (8 380 416). t ∈ [1, n]. Wire share size:
|
||||
// shareWireSizeQ = 128 bytes.
|
||||
func (LargeShamir) Deal(secret [SeedSize]byte, n, t int, coeffStream []byte) ([]uint32, []LargeShareWire, error) {
|
||||
shares, err := shamirDealRandomQ(secret, n, t, coeffStream)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
xs := make([]uint32, n)
|
||||
wires := make([]LargeShareWire, n)
|
||||
for i := 0; i < n; i++ {
|
||||
xs[i] = shares[i].X
|
||||
w := shareToBytesQ(shares[i])
|
||||
wires[i] = LargeShareWire(w)
|
||||
}
|
||||
return xs, wires, nil
|
||||
}
|
||||
|
||||
// Reconstruct Lagrange-interpolates a quorum's shares at X = 0 to
|
||||
// recover the 32-byte secret. Returns ErrInvalidShare if any
|
||||
// reconstructed slot exceeds 255 (i.e. at least one share was
|
||||
// tampered with — an honest dealer always sets constant terms to
|
||||
// byte values).
|
||||
func (LargeShamir) Reconstruct(xs []uint32, wires []LargeShareWire) ([SeedSize]byte, error) {
|
||||
if len(xs) != len(wires) {
|
||||
return [SeedSize]byte{}, errors.New("pulsar: LargeShamir.Reconstruct: len(xs) != len(wires)")
|
||||
}
|
||||
shares := make([]shamirShareQ, len(xs))
|
||||
for i := range xs {
|
||||
shares[i] = shareFromBytesQ(xs[i], [shareWireSizeQ]byte(wires[i]))
|
||||
}
|
||||
return shamirReconstructQ(shares)
|
||||
}
|
||||
|
||||
// ReconstructGF returns the raw GF(q) constant-term vector, used by
|
||||
// aggregation paths that re-mix the byte-sum through cSHAKE256
|
||||
// before treating it as a seed (mirroring the small-committee path
|
||||
// in dkg.go).
|
||||
func (LargeShamir) ReconstructGF(xs []uint32, wires []LargeShareWire) ([SeedSize]uint32, error) {
|
||||
if len(xs) != len(wires) {
|
||||
return [SeedSize]uint32{}, errors.New("pulsar: LargeShamir.ReconstructGF: len(xs) != len(wires)")
|
||||
}
|
||||
shares := make([]shamirShareQ, len(xs))
|
||||
for i := range xs {
|
||||
shares[i] = shareFromBytesQ(xs[i], [shareWireSizeQ]byte(wires[i]))
|
||||
}
|
||||
return shamirReconstructGFQ(shares)
|
||||
}
|
||||
|
||||
// EvalAt returns the polynomial-derived share at evaluation point x
|
||||
// for a polynomial whose coefficients are derived from (secret,
|
||||
// coeffStream) the same way Deal would derive them. This is the
|
||||
// "deal a single share without materialising all n" path used by
|
||||
// production deployments at very large N where only the
|
||||
// participating quorum needs concrete shares (non-participants hold
|
||||
// theirs in escrow on chain or in an HSM and only materialise on
|
||||
// demand).
|
||||
//
|
||||
// The cost is O(t * SeedSize) per share, independent of N.
|
||||
func (LargeShamir) EvalAt(secret [SeedSize]byte, x uint32, t int, coeffStream []byte) (LargeShareWire, error) {
|
||||
if t < 1 {
|
||||
return LargeShareWire{}, ErrInvalidThreshold
|
||||
}
|
||||
if x == 0 {
|
||||
return LargeShareWire{}, ErrZeroEvalPoint
|
||||
}
|
||||
if uint64(x) >= shamirPrimeQ {
|
||||
return LargeShareWire{}, ErrCommitteeTooLargeQ
|
||||
}
|
||||
needed := (t - 1) * SeedSize * 4
|
||||
if needed < 4 {
|
||||
needed = 4
|
||||
}
|
||||
if len(coeffStream) < needed {
|
||||
coeffStream = cshake256(coeffStream, needed, tagSeedShare)
|
||||
}
|
||||
coeffs := make([][SeedSize]uint32, t)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
coeffs[0][b] = uint32(secret[b])
|
||||
}
|
||||
off := 0
|
||||
for d := 1; d < t; d++ {
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
r := uint32(coeffStream[off])<<24 | uint32(coeffStream[off+1])<<16 | uint32(coeffStream[off+2])<<8 | uint32(coeffStream[off+3])
|
||||
off += 4
|
||||
coeffs[d][b] = uint32(uint64(r) % shamirPrimeQ)
|
||||
}
|
||||
}
|
||||
var s shamirShareQ
|
||||
s.X = x
|
||||
xu := uint64(x)
|
||||
for b := 0; b < SeedSize; b++ {
|
||||
acc := uint64(coeffs[t-1][b])
|
||||
for d := t - 2; d >= 0; d-- {
|
||||
acc = (acc*xu + uint64(coeffs[d][b])) % shamirPrimeQ
|
||||
}
|
||||
s.Y[b] = uint32(acc)
|
||||
}
|
||||
w := shareToBytesQ(s)
|
||||
return LargeShareWire(w), nil
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLargeShamir_EvalAtTargetCommitteeSize is the canonical "extreme
|
||||
// committee" smoke test: deal exactly TargetCommitteeSize = 1 111 111
|
||||
// virtual parties via the per-party EvalAt path (so we never have to
|
||||
// materialise all 1.1M shares), reconstruct from a t-size quorum
|
||||
// sampled across the index range including the endpoint x = N, and
|
||||
// assert byte-equality against the original secret.
|
||||
func TestLargeShamir_EvalAtTargetCommitteeSize(t *testing.T) {
|
||||
const N = TargetCommitteeSize
|
||||
const thresh = 5
|
||||
|
||||
var secret [SeedSize]byte
|
||||
if _, err := rand.Read(secret[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := bytes.Repeat([]byte{0xa5, 0x5a, 0xc3, 0x3c}, 64)
|
||||
|
||||
ls := LargeShamir{Field: FieldGFq}
|
||||
|
||||
xs := []uint32{1, 7, uint32(N / 3), uint32(N - 1), uint32(N)}
|
||||
wires := make([]LargeShareWire, len(xs))
|
||||
for i, x := range xs {
|
||||
w, err := ls.EvalAt(secret, x, thresh, stream)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalAt x=%d failed: %v", x, err)
|
||||
}
|
||||
wires[i] = w
|
||||
}
|
||||
rec, err := ls.Reconstruct(xs, wires)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconstruct at N=%d failed: %v", N, err)
|
||||
}
|
||||
if rec != secret {
|
||||
t.Fatalf("Reconstruct mismatch at N=%d: got %x want %x", N, rec, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLargeShamir_RoundTrip_Small confirms the public surface
|
||||
// works identically to the internal shamirShareQ helpers at small N.
|
||||
func TestLargeShamir_RoundTrip_Small(t *testing.T) {
|
||||
const N = 16
|
||||
const thresh = 9
|
||||
var secret [SeedSize]byte
|
||||
if _, err := rand.Read(secret[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := bytes.Repeat([]byte{0x77}, (thresh-1)*SeedSize*4+8)
|
||||
|
||||
ls := LargeShamir{Field: FieldGFq}
|
||||
xs, wires, err := ls.Deal(secret, N, thresh, stream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := ls.Reconstruct(xs[:thresh], wires[:thresh])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec != secret {
|
||||
t.Fatalf("round-trip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveField verifies the auto-selection logic between
|
||||
// FieldGF257 and FieldGFq based on the committee size.
|
||||
func TestResolveField(t *testing.T) {
|
||||
type row struct {
|
||||
want Field
|
||||
n int
|
||||
exp Field
|
||||
err bool
|
||||
}
|
||||
cases := []row{
|
||||
{FieldDefault, 1, FieldGF257, false},
|
||||
{FieldDefault, 256, FieldGF257, false},
|
||||
{FieldDefault, 257, FieldGFq, false},
|
||||
{FieldDefault, 1_111_111, FieldGFq, false},
|
||||
{FieldDefault, int(MaxCommitteeQ), FieldGFq, false},
|
||||
{FieldGF257, 256, FieldGF257, false},
|
||||
{FieldGF257, 257, 0, true},
|
||||
{FieldGFq, 1, FieldGFq, false},
|
||||
{FieldGFq, int(MaxCommitteeQ), FieldGFq, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := resolveField(c.want, c.n)
|
||||
if c.err {
|
||||
if err == nil {
|
||||
t.Fatalf("want=%v n=%d expected error", c.want, c.n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("want=%v n=%d unexpected error: %v", c.want, c.n, err)
|
||||
}
|
||||
if got != c.exp {
|
||||
t.Fatalf("want=%v n=%d got=%v exp=%v", c.want, c.n, got, c.exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLargeShamir_FullCeremony_10001 runs a full Deal + Reconstruct
|
||||
// round-trip at N = 10 001 -- the smallest committee at which the
|
||||
// problem statement's "N > 10 000" requirement applies. Allocates
|
||||
// 1.28 MB for the share table; fast on every CI machine.
|
||||
func TestLargeShamir_FullCeremony_10001(t *testing.T) {
|
||||
const N = 10_001
|
||||
const thresh = 7
|
||||
var secret [SeedSize]byte
|
||||
if _, err := rand.Read(secret[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := bytes.Repeat([]byte{0xe7}, (thresh-1)*SeedSize*4+8)
|
||||
ls := LargeShamir{Field: FieldGFq}
|
||||
xs, wires, err := ls.Deal(secret, N, thresh, stream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(xs) != N {
|
||||
t.Fatalf("got %d shares, want %d", len(xs), N)
|
||||
}
|
||||
// Pick a non-trivial quorum (skip indices 0..2 to test offset).
|
||||
rec, err := ls.Reconstruct(xs[2:2+thresh], wires[2:2+thresh])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec != secret {
|
||||
t.Fatalf("N=%d Reconstruct mismatch", N)
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@ package pulsar
|
||||
//
|
||||
// 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).
|
||||
// no KeyFromSeed call in any sign-combine file; the reconstruct-at-sign combiner
|
||||
// LargeCombine has been DELETED from the package entirely —
|
||||
// ci_build_invariant_test.go guards that it never returns) 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."
|
||||
@@ -114,33 +115,23 @@ var keyFromSeedCall = regexp.MustCompile(`(^|[^A-Za-z0-9_])KeyFromSeed\(`)
|
||||
// - 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.
|
||||
// Enumerate the package's PRODUCTION (default-build, non-test) Go files —
|
||||
// the exact set a production binary links.
|
||||
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.
|
||||
// (a) No production file may CALL KeyFromSeed (the seed→sk reconstruct
|
||||
// primitive) except the keygen allowlist. The reconstruct-at-sign combiner
|
||||
// LargeCombine was DELETED from the package; ci_build_invariant_test.go is
|
||||
// the structural guard that it (and the trusted dealer) never reappear.
|
||||
offenders := 0
|
||||
for _, f := range pkg.GoFiles {
|
||||
src, err := os.ReadFile(filepath.Join(pkg.Dir, f))
|
||||
@@ -148,11 +139,6 @@ func TestCommittee_NoReconstruct_Invariant_NoKeyFromSeedInProductionBuild(t *tes
|
||||
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++
|
||||
@@ -162,7 +148,7 @@ func TestCommittee_NoReconstruct_Invariant_NoKeyFromSeedInProductionBuild(t *tes
|
||||
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
|
||||
// (b) 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"))
|
||||
@@ -175,7 +161,7 @@ func TestCommittee_NoReconstruct_Invariant_NoKeyFromSeedInProductionBuild(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("GATE 2 (structural) PASS: %d production files scanned; KeyFromSeed only in %v; LargeCombine absent; AggregateBCC reconstruct-free",
|
||||
t.Logf("GATE 2 (structural) PASS: %d production files scanned; KeyFromSeed only in %v; AggregateBCC reconstruct-free",
|
||||
len(pkg.GoFiles), keysOf(keyFromSeedCallAllowlist))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
//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
|
||||
|
||||
// precompile_e2e_test.go — Class N1 manifesto closure for the EVM
|
||||
// precompile context.
|
||||
//
|
||||
// The Lux Pulsar EVM precompile (at 0x012204, code in
|
||||
// github.com/luxfi/precompile/pulsar) verifies signatures with the
|
||||
// FIPS 204 context string `lux-evm-precompile-pulsar-v1`. The Class
|
||||
// N1 claim is that a threshold-Combine output verifies as a single-
|
||||
// party FIPS 204 ML-DSA signature under the SAME ctx — i.e. the
|
||||
// signature bytes flow unchanged through:
|
||||
//
|
||||
// (DKG) -> (Round1 + Round2) -> (Combine, ctx=PRECOMPILE_CTX) ->
|
||||
// -> mldsa65.Verify(pk, M, ctx=PRECOMPILE_CTX, sig) // raw FIPS 204
|
||||
// -> pulsar.VerifyCtx(params, pk, M, ctx, sig) // wrapper
|
||||
//
|
||||
// Both verifiers MUST accept. If either rejects, the precompile is
|
||||
// not interchangeable with single-party ML-DSA at this ctx, and the
|
||||
// Class N1 manifesto is empirically false.
|
||||
//
|
||||
// CRIT-2 closure: prior to this test, every threshold round-trip
|
||||
// passed ctx=nil and verified under the empty context. The
|
||||
// precompile never appeared in a test, so the ctx-propagation
|
||||
// guarantee (Combine -> mldsaSign -> SignTo's ctx argument) was
|
||||
// unverified end-to-end.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
)
|
||||
|
||||
// precompileCtx mirrors github.com/luxfi/precompile/pulsar.precompileCtx
|
||||
// exactly. Keeping a local copy avoids importing the precompile (which
|
||||
// would create a dependency cycle pulsar-m <-> precompile); the byte
|
||||
// string is the load-bearing value, not the import.
|
||||
var precompileCtx = []byte("lux-evm-precompile-pulsar-v1")
|
||||
|
||||
// TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx is the parallel
|
||||
// Class N1 manifesto closure for the GF(q) (wide-committee) path.
|
||||
//
|
||||
// Same shape as the GF(257) test above, but uses LargeDKGSession /
|
||||
// LargeThresholdSigner / LargeCombine. The precompile dispatches to
|
||||
// the same FIPS 204 verifier regardless of which Combine path the
|
||||
// quorum used to produce the signature.
|
||||
func TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx(t *testing.T) {
|
||||
const n, threshold = 3, 2
|
||||
params := MustParamsFor(ModeP65)
|
||||
committee := makeLargeCommittee(n)
|
||||
ident := newIdentityFixture(t, committee, []byte("precompile-large"))
|
||||
|
||||
// ---- DKG ----
|
||||
sessions := make([]*LargeDKGSession, n)
|
||||
for i := 0; i < n; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'D', 'K', 'G', 'L'})
|
||||
s, err := NewLargeDKGSession(params, committee, threshold, committee[i], ident.keys[committee[i]], ident.directory, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLargeDKGSession party %d: %v", i, err)
|
||||
}
|
||||
sessions[i] = s
|
||||
}
|
||||
r1 := make([]*LargeDKGRound1Msg, n)
|
||||
for i, s := range sessions {
|
||||
m, err := s.Round1()
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round1 party %d: %v", i, err)
|
||||
}
|
||||
r1[i] = m
|
||||
}
|
||||
r2 := make([]*LargeDKGRound2Msg, n)
|
||||
for i, s := range sessions {
|
||||
m, err := s.Round2(r1)
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round2 party %d: %v", i, err)
|
||||
}
|
||||
r2[i] = m
|
||||
}
|
||||
outs := make([]*LargeDKGOutput, n)
|
||||
for i, s := range sessions {
|
||||
out, err := s.Round3(r1, r2)
|
||||
if err != nil {
|
||||
t.Fatalf("DKG Round3 party %d: %v", i, err)
|
||||
}
|
||||
if out.AbortEvidence != nil {
|
||||
t.Fatalf("DKG party %d aborted: %s", i, out.AbortEvidence.Kind)
|
||||
}
|
||||
outs[i] = out
|
||||
}
|
||||
groupPK := outs[0].GroupPubkey
|
||||
|
||||
// ---- Threshold sign ----
|
||||
quorum := committee[:threshold]
|
||||
msg := []byte("pulsar large threshold -> precompile ctx -> FIPS 204")
|
||||
var sid [16]byte
|
||||
copy(sid[:], "precompile-lg-01")
|
||||
attempt := uint32(0)
|
||||
|
||||
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
|
||||
|
||||
signers := make([]*LargeThresholdSigner, threshold)
|
||||
for i := 0; i < threshold; i++ {
|
||||
rng := deterministicReader([]byte{byte(i), 'L', 'P', 'C'})
|
||||
ts, err := NewLargeThresholdSigner(params, sid, attempt, quorum, outs[i].SecretShare, sessionKeys[committee[i]], msg, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLargeThresholdSigner party %d: %v", i, err)
|
||||
}
|
||||
signers[i] = ts
|
||||
}
|
||||
tsR1 := make([]*LargeRound1Message, threshold)
|
||||
for i, ts := range signers {
|
||||
m, err := ts.Round1(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign Round1 party %d: %v", i, err)
|
||||
}
|
||||
tsR1[i] = m
|
||||
}
|
||||
tsR2 := make([]*LargeRound2Message, threshold)
|
||||
for i, ts := range signers {
|
||||
m, ev, err := ts.Round2(tsR1)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign Round2 party %d: %v (ev=%+v)", i, err, ev)
|
||||
}
|
||||
tsR2[i] = m
|
||||
}
|
||||
|
||||
allShares := make([]*LargeKeyShare, n)
|
||||
for i := 0; i < n; i++ {
|
||||
allShares[i] = outs[i].SecretShare
|
||||
}
|
||||
|
||||
sig, err := LargeCombine(params, groupPK, msg, precompileCtx, false, sid, attempt, quorum, threshold, tsR1, tsR2, allShares)
|
||||
if err != nil {
|
||||
t.Fatalf("LargeCombine with precompileCtx: %v", err)
|
||||
}
|
||||
if len(sig.Bytes) != params.SignatureSize {
|
||||
t.Fatalf("signature size %d, want %d", len(sig.Bytes), params.SignatureSize)
|
||||
}
|
||||
|
||||
// (a) Raw FIPS 204 verify.
|
||||
var pkP65 mldsa65.PublicKey
|
||||
var pkBuf [mldsa65.PublicKeySize]byte
|
||||
copy(pkBuf[:], groupPK.Bytes)
|
||||
pkP65.Unpack(&pkBuf)
|
||||
if !mldsa65.Verify(&pkP65, msg, precompileCtx, sig.Bytes) {
|
||||
t.Fatalf("FIPS 204 mldsa65.Verify rejected LargeCombine signature under precompile ctx — Class N1 manifesto FALSE for GF(q) path")
|
||||
}
|
||||
|
||||
// (b) Package wrapper VerifyCtx.
|
||||
if err := VerifyCtx(params, groupPK, msg, precompileCtx, sig); err != nil {
|
||||
t.Fatalf("pulsar.VerifyCtx rejected LargeCombine signature under precompile ctx: %v", err)
|
||||
}
|
||||
|
||||
// Negative control: empty ctx must NOT accept this signature.
|
||||
if mldsa65.Verify(&pkP65, msg, nil, sig.Bytes) {
|
||||
t.Fatalf("FIPS 204 verify accepted precompile-ctx LargeCombine sig under empty ctx — ctx not propagated")
|
||||
}
|
||||
if err := Verify(params, groupPK, msg, sig); err == nil {
|
||||
t.Fatalf("pulsar.Verify (empty ctx) accepted precompile-ctx LargeCombine sig — ctx not propagated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user