merge Phase-3a: corona onto luxfi/dkg v0.2.0 — dealerless no-reconstruct DKG

keyera.Bootstrap drives luxfi/dkg vss share-dealing + blame; keeps corona's own
Path-(a) noise-flooded Round_Xi finalize (the vss Pedersen T is discarded — Ring-LWE
tolerates large blinding). Deployed group key byte-identical (golden KAT). dkg2 deleted.
This commit is contained in:
2026-06-27 22:29:52 -07:00
20 changed files with 699 additions and 3905 deletions
+7 -6
View File
@@ -1,7 +1,7 @@
// Package main is the Corona cross-runtime KAT oracle.
//
// Emits a single JSON manifest at <out>/cross_runtime_kat.json that ties
// together the three canonical Corona KATs (sign, reshare, dkg2) with
// together the canonical Corona KATs (sign, reshare) with
// the SHA-256 of each individual KAT file. The C++ side
// (luxcpp/crypto/corona/test/cross_runtime_test.cpp) replays each KAT
// in C++ and verifies byte-equality; running this oracle first then
@@ -73,12 +73,14 @@ func main() {
fmt.Fprintf(os.Stderr, "sign_oracle: %v\n", err)
os.Exit(1)
}
// reshare_oracle and dkg2_oracle don't expose --out; they write to
// hardcoded locations in luxcpp/crypto. For the cross-runtime gate
// we hash the canonical paths.
// reshare_oracle doesn't expose --out; it writes to a hardcoded location
// in luxcpp/crypto. For the cross-runtime gate we hash the canonical path.
//
// The Pedersen-DKG KAT is no longer emitted here: corona's DKG share-dealing
// now runs on github.com/luxfi/dkg (vss), which owns its own ring/vss KATs.
// The luxcpp C++ dkg2 cross-runtime leg migrates to luxfi/dkg separately.
signPath := filepath.Join(*out, "sign_kat.json")
resharePath := filepath.Join(luxcppDir(), "crypto/corona/test/kat/reshare_kat.json")
dkg2Path := filepath.Join(luxcppDir(), "crypto/corona/dkg2/test/kat/dkg2_kat.json")
files := []struct {
name string
@@ -86,7 +88,6 @@ func main() {
}{
{"sign", signPath},
{"reshare", resharePath},
{"dkg2", dkg2Path},
}
m := manifest{
-324
View File
@@ -1,324 +0,0 @@
// Copyright (c) 2024-2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// dkg2_oracle — emits byte-equal KATs for the Pedersen-style DKG protocol
// in github.com/luxfi/corona/dkg2.
//
// For each (t, n) configuration in the catalogue, the oracle:
//
// 1. Derives a master 32-byte seed from MasterSeed | tag(n,t) via BLAKE3.
// 2. From that master seed, derives one 32-byte sub-seed per party
// (BLAKE3(master || "party" || BE32(i))).
// 3. Constructs n DKGSessions (party 0..n-1) — they all share the same
// deterministic A and B matrices (derived from b"corona.dkg2.A.v1" /
// b"corona.dkg2.B.v1" via BLAKE3-XOF).
// 4. Each party calls Round1WithSeed(party_seed[i]) to produce its
// Commits, Shares (per recipient), and Blinds (per recipient).
// 5. Each party calls Round2 with the assembled shares/blinds/commits.
//
// The KAT pins, per entry:
// - For each party i: seed_hex[i] (sub-seed)
// - For each party i: SHA-256(Commits[k].WriteTo) × t (commitment-vector hash)
// - For each party i: BLAKE3(serialize(Commits)) (Round 1.5 digest)
// - For each party i, each recipient j:
// SHA-256(Shares[j].WriteTo)
// SHA-256(Blinds[j].WriteTo)
// - For each party j (the recipient running Round2):
// SHA-256(s_j.WriteTo)
// SHA-256(u_j.WriteTo)
// SHA-256(b_ped_j.WriteTo)
//
// Replay invariant: every party agrees on b_ped, so all b_ped hashes inside
// one entry must be identical. The KAT records all n of them so the C++
// port can prove that property too.
//
// Output: <luxcpp/crypto>/corona/dkg2/test/kat/dkg2_kat.json (4 entries:
// 2-of-3, 3-of-5, 5-of-7, 7-of-11).
package main
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/sign"
"github.com/zeebo/blake3"
)
// MasterSeed is the deterministic root for all dkg2 KAT entries. Distinct
// from dkg/'s 0xCAFEBABE_DEADBEEF root so the two oracles never alias.
const MasterSeed uint64 = 0xC0FFEE_F00D_FACE
// deriveMaster expands MasterSeed + a per-entry tag into a 32-byte sub-seed.
func deriveMaster(t, n int) []byte {
h := blake3.New()
var buf [16]byte
binary.BigEndian.PutUint64(buf[0:8], MasterSeed)
binary.BigEndian.PutUint32(buf[8:12], uint32(t))
binary.BigEndian.PutUint32(buf[12:16], uint32(n))
_, _ = h.Write([]byte("dkg2-oracle:master:"))
_, _ = h.Write(buf[:])
return h.Sum(nil)[:32]
}
// derivePartySeed derives party i's 32-byte Round1WithSeed input from the
// master entry seed.
func derivePartySeed(master []byte, partyID int) []byte {
h := blake3.New()
_, _ = h.Write([]byte("dkg2-oracle:party:"))
_, _ = h.Write(master)
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], uint32(partyID))
_, _ = h.Write(buf[:])
return h.Sum(nil)[:32]
}
// hashVectorBytes returns SHA-256 of the WriteTo wire bytes of v.
func hashVectorBytes(v structs.Vector[ring.Poly]) string {
var buf bytes.Buffer
if _, err := v.WriteTo(&buf); err != nil {
panic(err)
}
h := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(h[:])
}
func hashMatrix(m structs.Matrix[ring.Poly]) string {
var buf bytes.Buffer
if _, err := m.WriteTo(&buf); err != nil {
panic(err)
}
h := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(h[:])
}
// PartyEntry is one party's contribution to the KAT.
type PartyEntry struct {
PartyID int `json:"party_id"`
SeedHex string `json:"seed_hex"` // Round1WithSeed input
CommitsHashHex []string `json:"commits_hash_hex"` // length t
CommitDigestHex string `json:"commit_digest_hex"` // BLAKE3 over serialized commits
SharesHashHex []string `json:"shares_hash_hex"` // length n; index j = share to party j
BlindsHashHex []string `json:"blinds_hash_hex"` // length n; index j = blind to party j
SecretShareHash string `json:"secret_share_hash"` // SHA-256(s_j) after Round2
BlindShareHash string `json:"blind_share_hash"` // SHA-256(u_j) after Round2
BPedHash string `json:"b_ped_hash"` // SHA-256(b_ped) after Round2
}
type Entry struct {
T int `json:"t"`
N int `json:"n"`
MasterSeedHex string `json:"master_seed_hex"`
AHashHex string `json:"a_hash_hex"`
BHashHex string `json:"b_hash_hex"`
Parties []PartyEntry `json:"parties"`
}
type OracleOut struct {
Description string `json:"description"`
Q uint64 `json:"q"`
N int `json:"n_ring"`
M int `json:"m"`
Nvec int `json:"nvec"`
Xi int `json:"xi"`
TagAHex string `json:"tag_a_hex"`
TagBHex string `json:"tag_b_hex"`
Entries []Entry `json:"entries"`
}
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
return os.WriteFile(path, b, 0o644)
}
func runEntry(t, n int) Entry {
master := deriveMaster(t, n)
params, err := dkg2.NewParams()
if err != nil {
panic(err)
}
sessions := make([]*dkg2.DKGSession, n)
for i := 0; i < n; i++ {
// Use the legacy BLAKE3 hash suite for the canonical KAT — keeps
// every byte in dkg2_kat.json byte-stable across the Corona-SHA3
// cutover. Production-track callers use hash.Default() (Corona-SHA3).
s, err := dkg2.NewDKGSession(params, i, n, t, nil)
if err != nil {
panic(fmt.Errorf("NewDKGSession(%d, %d, %d): %w", i, n, t, err))
}
sessions[i] = s
}
// Hash the public matrices. All sessions must agree.
aHash := hashMatrix(sessions[0].APublic())
bHash := hashMatrix(sessions[0].BPublic())
for i := 1; i < n; i++ {
if got := hashMatrix(sessions[i].APublic()); got != aHash {
panic(fmt.Errorf("party %d A-matrix mismatch: %s vs %s", i, got, aHash))
}
if got := hashMatrix(sessions[i].BPublic()); got != bHash {
panic(fmt.Errorf("party %d B-matrix mismatch: %s vs %s", i, got, bHash))
}
}
round1Outs := make([]*dkg2.Round1Output, n)
partyEntries := make([]PartyEntry, n)
for i := 0; i < n; i++ {
seed := derivePartySeed(master, i)
out, err := sessions[i].Round1WithSeed(seed)
if err != nil {
panic(fmt.Errorf("Round1WithSeed(party=%d): %w", i, err))
}
round1Outs[i] = out
commitsHash := make([]string, t)
for k := 0; k < t; k++ {
commitsHash[k] = hashVectorBytes(out.Commits[k])
}
sharesHash := make([]string, n)
blindsHash := make([]string, n)
for j := 0; j < n; j++ {
sh, ok := out.Shares[j]
if !ok {
panic(fmt.Errorf("party %d missing share for %d", i, j))
}
bl, ok := out.Blinds[j]
if !ok {
panic(fmt.Errorf("party %d missing blind for %d", i, j))
}
sharesHash[j] = hashVectorBytes(sh)
blindsHash[j] = hashVectorBytes(bl)
}
// commit_digest_hex pins the legacy BLAKE3 path used by the
// pre-cutover KAT. CommitDigestBLAKE3 produces byte-stable bytes
// so the C++ port can replay against the same JSON.
digest, err := out.CommitDigestBLAKE3()
if err != nil {
panic(fmt.Errorf("CommitDigestBLAKE3 (party=%d): %w", i, err))
}
partyEntries[i] = PartyEntry{
PartyID: i,
SeedHex: hex.EncodeToString(seed),
CommitsHashHex: commitsHash,
CommitDigestHex: hex.EncodeToString(digest[:]),
SharesHashHex: sharesHash,
BlindsHashHex: blindsHash,
}
}
for j := 0; j < n; j++ {
shares := map[int]structs.Vector[ring.Poly]{}
blinds := map[int]structs.Vector[ring.Poly]{}
commits := map[int][]structs.Vector[ring.Poly]{}
for i := 0; i < n; i++ {
shares[i] = round1Outs[i].Shares[j]
blinds[i] = round1Outs[i].Blinds[j]
commits[i] = round1Outs[i].Commits
}
s, u, bPed, err := sessions[j].Round2(shares, blinds, commits)
if err != nil {
panic(fmt.Errorf("Round2(party=%d): %w", j, err))
}
partyEntries[j].SecretShareHash = hashVectorBytes(s)
partyEntries[j].BlindShareHash = hashVectorBytes(u)
partyEntries[j].BPedHash = hashVectorBytes(bPed)
}
bp := partyEntries[0].BPedHash
for j := 1; j < n; j++ {
if partyEntries[j].BPedHash != bp {
panic(fmt.Errorf("entry t=%d n=%d: party %d b_ped hash mismatch: %s vs %s",
t, n, j, partyEntries[j].BPedHash, bp))
}
}
return Entry{
T: t,
N: n,
MasterSeedHex: hex.EncodeToString(master),
AHashHex: aHash,
BHashHex: bHash,
Parties: partyEntries,
}
}
func main() {
cases := []struct{ t, n int }{
{2, 3},
{3, 5},
{5, 7},
{7, 11},
}
out := OracleOut{
Description: "Pedersen-style DKG over R = Z_q[X]/(X^256+1), Q=0x1000000004A01. " +
"C_k = A·NTT(c_k) + B·NTT(r_k) — hiding under MLWE on B, binding under " +
"MSIS on [A|B]. A derived from BLAKE3(\"corona.dkg2.A.v1\"); B from " +
"BLAKE3(\"corona.dkg2.B.v1\"). Each entry runs the full t-of-n protocol " +
"with deterministic per-party Round1WithSeed inputs derived from " +
"MasterSeed=0xC0FFEEF00DFACE. Wire format: structs.{Vector,Matrix}[ring.Poly]" +
".WriteTo (LE u64). Hashes are SHA-256 of those wire bytes. CommitDigest " +
"is BLAKE3 over the concatenated serialized commits, exchanged in Round 1.5 " +
"to defeat cross-party inconsistency attacks (Finding 2 of RED-DKG-REVIEW.md).",
Q: sign.Q,
N: 1 << sign.LogN,
M: sign.M,
Nvec: sign.N,
Xi: sign.Xi,
TagAHex: hex.EncodeToString([]byte("corona.dkg2.A.v1")),
TagBHex: hex.EncodeToString([]byte("corona.dkg2.B.v1")),
}
for _, c := range cases {
fmt.Fprintf(os.Stderr, "running t=%d n=%d ...\n", c.t, c.n)
out.Entries = append(out.Entries, runEntry(c.t, c.n))
}
// Default output path: canonical luxcpp KAT directory. Pass an
// argument to override. When the oracle is invoked via `go run` from
// the corona repo root, the relative form ../../../luxcpp/... resolves
// correctly; otherwise pass an absolute path.
outPath := "../../../luxcpp/crypto/corona/dkg2/test/kat/dkg2_kat.json"
if env := os.Getenv("CORONA_DKG2_KAT_PATH"); env != "" {
outPath = env
}
if env := os.Getenv("PULSAR_DKG2_KAT_PATH"); env != "" {
if os.Getenv("CORONA_DKG2_KAT_PATH") == "" {
outPath = env
}
}
if len(os.Args) >= 2 {
outPath = os.Args[1]
}
if err := writeJSON(outPath, out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "wrote %s\n", outPath)
if f, err := os.Open(outPath); err == nil {
defer f.Close()
var head [256]byte
n, _ := io.ReadFull(f, head[:])
fmt.Fprintln(os.Stderr, string(head[:n]))
}
}
-40
View File
@@ -1,40 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dkg2
import (
"crypto/rand"
"io"
"testing"
"github.com/luxfi/corona/sign"
)
// BenchmarkDKG2_Round1_Baseline captures the single-party Round 1 cost
// before GPU dispatch is wired in. The numbers feed the speedup
// comparison in BenchmarkDKG2_Round1_GPU (dkg2_gpu_test.go).
func BenchmarkDKG2_Round1_Baseline_5of7(b *testing.B) { benchRound1Baseline(b, 7, 5) }
func BenchmarkDKG2_Round1_Baseline_7of11(b *testing.B) { benchRound1Baseline(b, 11, 7) }
func BenchmarkDKG2_Round1_Baseline_14of21(b *testing.B) { benchRound1Baseline(b, 21, 14) }
func benchRound1Baseline(b *testing.B, n, t int) {
params, err := NewParams()
if err != nil {
b.Fatal(err)
}
sess, err := NewDKGSession(params, 0, n, t, nil)
if err != nil {
b.Fatal(err)
}
seed := make([]byte, sign.KeySize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := sess.Round1WithSeed(seed); err != nil {
b.Fatal(err)
}
}
}
-424
View File
@@ -1,424 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dkg2
// Complaint workflow and disqualification logic for the Pedersen DKG.
//
// The Pedersen DKG has identifiable abort: any malformed contribution
// (Round 1 commit / Round 1.5 digest / Round 2 share-blind pair) names
// the offending sender. Each detection produces a signed Complaint that
// any honest validator can independently re-check without trusting the
// complainer.
//
// Failure modes (mirrors reshare.ComplaintReason):
//
// (a) ComplaintBadDelivery — sender i shipped (share_{i→j}, blind_{i→j})
// that does not satisfy A·NTT(share) + B·NTT(blind) =
// Σ_k (j+1)^k · C_{i,k}. Evidence: the (share, blind, commits)
// triple. Re-check via VerifyShareAgainstCommits.
//
// (b) ComplaintEquivocation — sender i broadcast different commit
// vectors to different recipients. Detected via Round 1.5 digest
// cross-check: when recipient j observes that sender i delivered
// digest h_i^{(j)} but other recipients k report h_i^{(k)} ≠
// h_i^{(j)}, j emits this complaint. Evidence: two signed digest
// broadcasts under sender i's wire identity that disagree.
//
// (c) ComplaintMissing — sender i failed to deliver Round 1 by the
// cohort deadline. Evidence is the absence; a separate liveness
// round timestamps the deadline.
//
// (d) ComplaintMalformedCommit — sender i's commit vector has the
// wrong length, the wrong dimension, or fails Round 2 sanity
// checks. Evidence: the malformed commit vector. Re-check via
// VerifyShareAgainstCommits returning ErrMalformedCommit.
//
// Disqualification rule (deterministic, identical on every honest party):
//
// - Complaints from the same complainer about the same sender are
// deduplicated by (sender, complainer) tuple.
// - A sender i is disqualified iff at least DisqualificationThreshold
// distinct complainers signed valid complaints against i. The
// default threshold is t-1 (so at most t-1 colluding adversaries
// cannot disqualify an honest sender).
// - After the Round 2 deadline every honest party computes the same
// set Q' = Q \ disqualified. If |Q'| < t the DKG aborts and the
// activation cert binds the abort transcript so the chain stays at
// the previous epoch.
//
// Slashing evidence: every signed Complaint, plus the equivocation pair
// (commits_a, commits_b) where applicable, is admissible at the Quasar
// layer. Wire format mirrors reshare/quasar_integration.go to keep one
// adjudication path for both protocols.
import (
"bytes"
"crypto/ed25519"
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/corona/hash"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// ComplaintReason enumerates the failure modes that justify a complaint.
type ComplaintReason uint8
const (
// ComplaintBadDelivery — share_{i→j} fails the Pedersen identity.
ComplaintBadDelivery ComplaintReason = 1
// ComplaintEquivocation — sender i broadcast disagreeing commit
// digests to different recipients.
ComplaintEquivocation ComplaintReason = 2
// ComplaintMissing — sender i failed to deliver share_{i→j} by
// the round-1 deadline.
ComplaintMissing ComplaintReason = 3
// ComplaintMalformedCommit — sender i's commit vector has wrong
// length / wrong dimension / nil entries.
ComplaintMalformedCommit ComplaintReason = 4
)
// String returns a human-readable name for the reason.
func (r ComplaintReason) String() string {
switch r {
case ComplaintBadDelivery:
return "bad-delivery"
case ComplaintEquivocation:
return "equivocation"
case ComplaintMissing:
return "missing"
case ComplaintMalformedCommit:
return "malformed-commit"
default:
return fmt.Sprintf("unknown(%d)", r)
}
}
// Complaint is a signed assertion that party SenderID misbehaved during
// the Pedersen DKG protocol. The structure mirrors reshare.Complaint to
// keep one adjudication path for both protocols.
//
// SenderID is the misbehaving party (the dealer in dkg2 parlance).
// ComplainerID is the recipient that observed the failure and signs.
//
// TranscriptHash binds the complaint to a specific dkg2 invocation
// (n, t, sessionID, suiteID). A complaint with a stale transcript hash
// is rejected.
//
// Evidence carries the protocol-specific data needed to re-check the
// claim without trusting the complainer:
//
// - ComplaintBadDelivery: serialize(share, blind, commits) for the
// (sender, recipient) pair. Any honest party can re-run
// VerifyShareAgainstCommits and confirm the mismatch.
// - ComplaintEquivocation: two signed commit-digest broadcasts from
// SenderID with disagreeing digests.
// - ComplaintMissing: empty (the absence is the evidence; a separate
// liveness round timestamps the deadline).
// - ComplaintMalformedCommit: serialize(commits) showing the
// malformed structure.
type Complaint struct {
TranscriptHash [32]byte
SenderID int // 0-indexed party ID of the misbehaving dealer
ComplainerID int // 0-indexed party ID of the complainer
Reason ComplaintReason
Evidence []byte // canonical-serialized evidence (see above)
Signature []byte // Ed25519 signature over Bytes() under ComplainerID's wire key
ComplainerKey ed25519.PublicKey
}
// Bytes returns the canonical signed payload for a complaint. The
// Signature field is excluded (it is computed OVER these bytes).
//
// Format:
//
// "corona.dkg2.complaint.v1" || transcript || sender_id_be32 ||
// complainer_id_be32 || reason_u8 || evidence_len_be32 || evidence
func (c *Complaint) Bytes() []byte {
var buf bytes.Buffer
buf.WriteString("corona.dkg2.complaint.v1")
buf.Write(c.TranscriptHash[:])
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(c.SenderID))
buf.Write(b4[:])
binary.BigEndian.PutUint32(b4[:], uint32(c.ComplainerID))
buf.Write(b4[:])
buf.WriteByte(byte(c.Reason))
binary.BigEndian.PutUint32(b4[:], uint32(len(c.Evidence)))
buf.Write(b4[:])
buf.Write(c.Evidence)
return buf.Bytes()
}
// Sign produces a complaint signature using the provided Ed25519 private
// key. Callers MUST use the wire-identity key associated with
// ComplainerID — using a different key produces a complaint that other
// validators reject.
func (c *Complaint) Sign(priv ed25519.PrivateKey) {
c.Signature = ed25519.Sign(priv, c.Bytes())
c.ComplainerKey = priv.Public().(ed25519.PublicKey)
}
// Verify checks the Ed25519 signature against ComplainerKey. Returns nil
// iff the signature is valid. Note: this does NOT verify that the
// reason actually holds (e.g. for ComplaintBadDelivery the evidence must
// be re-checked separately) — only that the complaint was signed by the
// claimed complainer.
func (c *Complaint) Verify() error {
if c == nil || len(c.Signature) == 0 || len(c.ComplainerKey) == 0 {
return errors.New("dkg2: complaint missing signature or key")
}
if !ed25519.Verify(c.ComplainerKey, c.Bytes(), c.Signature) {
return errors.New("dkg2: complaint signature invalid")
}
return nil
}
// ComplaintHash returns a 32-byte digest over the complaint's canonical
// bytes (signature included), bound under the active HashSuite. Used to
// commit to the SET of complaints in the Round 2 transcript and the
// activation message.
//
// Pass nil for the production default (Corona-SHA3).
func ComplaintHash(suite hash.HashSuite, c *Complaint) [32]byte {
s := hash.Resolve(suite)
return s.TranscriptHash([]byte("corona.dkg2.complaint-hash.v1"), c.Bytes(), c.Signature)
}
// DisqualificationThreshold returns the minimum number of distinct,
// validly-signed complaints needed to disqualify a sender.
//
// Default: t-1. Rationale: any single Byzantine validator can emit one
// false complaint to slow the protocol, but to disqualify an honest
// sender the adversary needs t-1 collaborators — which exceeds the
// static-corruption threshold of t-1 by exactly one.
func DisqualificationThreshold(threshold int) int {
if threshold <= 1 {
return 1
}
return threshold - 1
}
// ComputeDisqualifiedSet takes a slice of validated complaints and
// returns the set of sender IDs that meet the disqualification
// threshold. Every honest party that processes the same complaint set
// returns the same disqualified set.
//
// "Validated" means: the complaint's signature has been verified, the
// complaint's transcript hash matches the local view, and (for
// ComplaintBadDelivery / ComplaintEquivocation / ComplaintMalformedCommit)
// the evidence has been re-checked. This function does NOT re-verify
// the underlying claim — the caller is responsible (see VerifyComplaint).
//
// Complaints are deduplicated by (sender, complainer) tuple; if the same
// complainer signs two complaints against the same sender (e.g. for
// different reasons), only one counts toward the threshold.
func ComputeDisqualifiedSet(complaints []*Complaint, threshold int) map[int]struct{} {
cap := DisqualificationThreshold(threshold)
seen := make(map[[2]int]bool)
count := make(map[int]int)
for _, c := range complaints {
key := [2]int{c.SenderID, c.ComplainerID}
if seen[key] {
continue
}
seen[key] = true
count[c.SenderID]++
}
out := make(map[int]struct{})
for sender, n := range count {
if n >= cap {
out[sender] = struct{}{}
}
}
return out
}
// FilterQualifiedQuorum returns the survivor set of party IDs after
// removing disqualified senders. Returns ErrInsufficientQuorum if too
// many parties were disqualified for the protocol to recover.
//
// Sorted ascending for determinism — every honest party that processes
// the same disqualified set computes the same surviving slice.
func FilterQualifiedQuorum(originalQuorum []int, disqualified map[int]struct{}, threshold int) ([]int, error) {
out := make([]int, 0, len(originalQuorum))
for _, id := range originalQuorum {
if _, dq := disqualified[id]; dq {
continue
}
out = append(out, id)
}
if len(out) < threshold {
return nil, fmt.Errorf("%w: %d survivors < threshold %d",
ErrInsufficientQuorum, len(out), threshold)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j-1] > out[j]; j-- {
out[j-1], out[j] = out[j], out[j-1]
}
}
return out, nil
}
// ErrInsufficientQuorum signals that too many parties were disqualified
// for the DKG to recover. The activation cert binds the abort transcript
// and the chain stays at the previous epoch.
var ErrInsufficientQuorum = errors.New("dkg2: qualified quorum below threshold after disqualification")
// NewBadDeliveryComplaint constructs an unsigned ComplaintBadDelivery
// against sender for the (share, blind, commits) triple that
// VerifyShareAgainstCommits rejected. The caller signs with their wire
// identity key via Complaint.Sign before broadcasting.
//
// transcriptHash binds the complaint to the active dkg2 invocation;
// pass the result of TranscriptHash on the canonical session inputs.
func NewBadDeliveryComplaint(
transcriptHash [32]byte,
senderID, complainerID int,
share, blind structs.Vector[ring.Poly],
commits []structs.Vector[ring.Poly],
) (*Complaint, error) {
evidence, err := serializeBadDeliveryEvidence(share, blind, commits)
if err != nil {
return nil, err
}
return &Complaint{
TranscriptHash: transcriptHash,
SenderID: senderID,
ComplainerID: complainerID,
Reason: ComplaintBadDelivery,
Evidence: evidence,
}, nil
}
// NewEquivocationComplaint constructs an unsigned ComplaintEquivocation
// against sender. evidenceA and evidenceB are two distinct, independently
// signed commit-digest broadcasts under sender's wire identity that
// disagree (Round 1.5 cross-party check).
func NewEquivocationComplaint(
transcriptHash [32]byte,
senderID, complainerID int,
evidenceA, evidenceB []byte,
) *Complaint {
var buf bytes.Buffer
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(len(evidenceA)))
buf.Write(b4[:])
buf.Write(evidenceA)
binary.BigEndian.PutUint32(b4[:], uint32(len(evidenceB)))
buf.Write(b4[:])
buf.Write(evidenceB)
return &Complaint{
TranscriptHash: transcriptHash,
SenderID: senderID,
ComplainerID: complainerID,
Reason: ComplaintEquivocation,
Evidence: buf.Bytes(),
}
}
// NewMissingComplaint constructs an unsigned ComplaintMissing against
// sender. Evidence is empty by design — the absence of a Round 1
// delivery before the cohort deadline is the evidence; a separate
// liveness round timestamps the deadline.
func NewMissingComplaint(
transcriptHash [32]byte,
senderID, complainerID int,
) *Complaint {
return &Complaint{
TranscriptHash: transcriptHash,
SenderID: senderID,
ComplainerID: complainerID,
Reason: ComplaintMissing,
Evidence: nil,
}
}
// NewMalformedCommitComplaint constructs an unsigned
// ComplaintMalformedCommit against sender carrying the malformed commit
// vector as evidence.
func NewMalformedCommitComplaint(
transcriptHash [32]byte,
senderID, complainerID int,
commits []structs.Vector[ring.Poly],
) (*Complaint, error) {
evidence, err := serializeCommitsEvidence(commits)
if err != nil {
return nil, err
}
return &Complaint{
TranscriptHash: transcriptHash,
SenderID: senderID,
ComplainerID: complainerID,
Reason: ComplaintMalformedCommit,
Evidence: evidence,
}, nil
}
// serializeBadDeliveryEvidence packs (share, blind, commits) into the
// canonical evidence wire format consumed by VerifyComplaint.
//
// Format:
//
// share_bytes_len_be32 || share_bytes ||
// blind_bytes_len_be32 || blind_bytes ||
// t_be32 || (commits[k]_bytes_len_be32 || commits[k]_bytes) × t
func serializeBadDeliveryEvidence(
share, blind structs.Vector[ring.Poly],
commits []structs.Vector[ring.Poly],
) ([]byte, error) {
var buf bytes.Buffer
if err := writeVector(&buf, share); err != nil {
return nil, err
}
if err := writeVector(&buf, blind); err != nil {
return nil, err
}
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(len(commits)))
buf.Write(b4[:])
for _, v := range commits {
if err := writeVector(&buf, v); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
// serializeCommitsEvidence packs a commits vector into evidence bytes
// for ComplaintMalformedCommit. Format:
//
// t_be32 || (commits[k]_bytes_len_be32 || commits[k]_bytes) × t
func serializeCommitsEvidence(commits []structs.Vector[ring.Poly]) ([]byte, error) {
var buf bytes.Buffer
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(len(commits)))
buf.Write(b4[:])
for _, v := range commits {
if err := writeVector(&buf, v); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
// writeVector emits a length-prefixed serialization of one
// structs.Vector[ring.Poly] using the lattigo WriteTo wire format.
func writeVector(buf *bytes.Buffer, v structs.Vector[ring.Poly]) error {
var inner bytes.Buffer
if _, err := v.WriteTo(&inner); err != nil {
return fmt.Errorf("%w: %v", ErrSerialization, err)
}
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(inner.Len()))
buf.Write(b4[:])
buf.Write(inner.Bytes())
return nil
}
-768
View File
@@ -1,768 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package dkg2 implements a Pedersen-style verifiable-secret-sharing-based
// distributed key generation over the Corona polynomial ring
// R_q = Z_q[X]/(X^256 + 1).
//
// dkg2 is the production parallel-track keygen for Corona. It replaces the
// pseudoinverse-recoverable Feldman commit C_k = A · NTT(c_k) of the
// upstream Corona DKG with a Pedersen commit
//
// C_{i,k} = A · NTT(c_{i,k}) + B · NTT(r_{i,k}) (R_q^M, NTT-Mont)
//
// where A, B are independent uniform public matrices derived deterministically
// from nothing-up-my-sleeve domain-separation tags. Hiding holds under
// decisional MLWE on B; binding holds under MSIS on the wide concatenation
// [A | B]. Formal statements live in
// papers/lp-073-pulsar/sections/07-pedersen-dkg.tex; Lean theorem references
// are in proofs/lean/Crypto/Corona/dkg2.lean.
//
// # Round structure
//
// Round 1 Each party samples Gaussian f_i, g_i in R_q^Nvec[x]_{deg<t},
// broadcasts {C_{i,k}}_{k=0..t-1}, sends (share_{i→j}, blind_{i→j})
// privately to each recipient j over an authenticated p2p channel.
//
// Round 1.5 Every party broadcasts H_i = HashSuite.TranscriptHash(serialize
// (C_{i,0}) || ... || serialize(C_{i,t-1})). Recipients compare
// digests received from each sender across the cohort. Mismatch
// → equivocation → signed Complaint (ComplaintEquivocation) →
// sender disqualified.
//
// Round 2 Each recipient j verifies, for every sender i,
// A · NTT(share_{i→j}) + B · NTT(blind_{i→j})
// ?= Σ_{k=0..t-1} (j+1)^k · C_{i,k} (mod q)
// Verification is exact: both sides are Z_q-linear and NTT is
// bijective. Comparison is constant-time across all M·N_vec slots
// (subtle.ConstantTimeCompare). On mismatch, recipient emits a
// signed Complaint (ComplaintBadDelivery) naming the sender and
// carrying (share, blind, commits) as evidence.
//
// Aggregation: s_j = Σ_i share_{i→j}, u_j = Σ_i blind_{i→j},
// b_ped = Round_Xi(IMForm + INTT(Σ_i C_{i,0})).
//
// # Hash suite
//
// dkg2 routes every cohort-bound digest through the canonical
// hash.HashSuite (Corona-SHA3 in production; Corona-BLAKE3 retained for
// byte-equality with pre-cutover KATs). NewDKGSession accepts a HashSuite;
// nil resolves to the production default. Matrix derivation (A, B) uses a
// dedicated, version-pinned BLAKE3 path to keep KAT bytes stable across
// the SHA3 cutover — the matrix derivation is structural, not transcript-
// bound, so it has its own version tag (corona.dkg2.A.v1 / .B.v1). The
// Round 1.5 commit digest, by contrast, is HashSuite-bound and uses the
// CORONA-TRANSCRIPT-v1 customization of the active suite.
//
// # Identifiable abort
//
// Round1.5 and Round 2 produce signed Complaint records (Ed25519, mirroring
// reshare.Complaint). Aggregating any DisqualificationThreshold complaints
// against the same sender disqualifies that sender deterministically; every
// honest party that processes the same complaint set computes the same
// disqualified set (FilterQualifiedQuorum).
//
// # File-level invariants
//
// - All ring arithmetic uses sign.Q (the Corona 48-bit prime).
// - Sampler parameters reuse sign.SigmaE / sign.BoundE for both c_{i,k}
// and r_{i,k}, mirroring the Corona secret distribution.
// - A is derived from the 16-byte tag b"corona.dkg2.A.v1" via BLAKE3-XOF
// (KAT-pinned).
// - B is derived from the 16-byte tag b"corona.dkg2.B.v1" via BLAKE3-XOF
// (KAT-pinned).
// - Commits are stored in NTT-Montgomery form (matches A, B).
// - Shares are stored in standard coefficient form (NTT=false, mont=false).
// - Round 2 verifier comparison is constant-time across the M-element LHS/
// RHS pair (constant-time AND across all M coefficient blobs).
//
// # KAT contract
//
// Round1WithSeed pins every byte of the protocol output for byte-equal C++
// porting. See cmd/dkg2_oracle for the canonical generator and
// luxcpp/crypto/corona/dkg2/test/kat/dkg2_kat.json for the 4 reference
// entries (2-of-3, 3-of-5, 5-of-7, 7-of-11).
package dkg2
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"math/big"
cgpu "github.com/luxfi/corona/gpu"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/sign"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/lattice/v7/utils/structs"
"github.com/zeebo/blake3"
)
// Domain-separation tags used to derive the public matrices A, B.
//
// The bytes themselves are nothing-up-my-sleeve: an ASCII string identifying
// the matrix and a version suffix. Changing either tag invalidates every
// KAT and every group public key derived in dkg2 — bump the version when
// breaking compatibility.
//
// Matrix derivation is BLAKE3 directly, NOT the active HashSuite. This keeps
// public-matrix bytes stable across the Corona-SHA3 cutover (the matrices
// are structural, not transcript-bound; their version tag covers any future
// rotation).
var (
tagA = []byte("corona.dkg2.A.v1")
tagB = []byte("corona.dkg2.B.v1")
)
// Customization tag bound into the Round 1.5 commit-digest under the active
// HashSuite. The suite ID is bound into the digest input as well so two
// suites can never produce a colliding digest for the same commit vector.
const tagCommitDigest = "CORONA-DKG2-COMMIT-DIGEST-v1"
var (
ErrInvalidThreshold = errors.New("dkg2: threshold must be > 0 and < total parties")
ErrInvalidPartyCount = errors.New("dkg2: need at least 2 parties")
ErrInvalidPartyID = errors.New("dkg2: party ID out of range")
ErrShareVerification = errors.New("dkg2: share verification failed")
ErrMissingData = errors.New("dkg2: missing share, blind, or commitment data")
ErrCommitMismatch = errors.New("dkg2: cross-party commitment digest mismatch")
ErrMalformedCommit = errors.New("dkg2: commit vector malformed")
ErrSerialization = errors.New("dkg2: commit serialization failed")
)
// Params holds ring parameters for the DKG protocol.
//
// Identical to dkg.Params; reused so existing code paths (sign.Q-derived
// rings) match without per-package divergence.
type Params struct {
R *ring.Ring
RXi *ring.Ring
}
// NewParams creates ring parameters for dkg2.
//
// Mirrors dkg.NewParams: the RXi ring is the post-rounding modulus
// (Xi = 30 bits, sign.QXi = 0x40000 = 2^18) — not prime, but that's the
// canonical Corona layout used by sign.Gen and the Round2 b_ped output.
// ring.NewRing returns a non-prime-modulus error here that we deliberately
// ignore (matches dkg/dkg.go:53), and the constructor remains usable
// because RoundVector only needs the ring as a coefficient container.
func NewParams() (*Params, error) {
r, err := ring.NewRing(1<<sign.LogN, []uint64{sign.Q})
if err != nil {
return nil, err
}
rXi, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QXi})
// Best-effort GPU NTT registration when corona/gpu has been opted
// into. No-op when disabled or on non-GPU builds; identical bytes.
cgpu.MaybeRegister(r)
return &Params{R: r, RXi: rXi}, nil
}
// derivePublicMatrix builds an M×Nvec uniform matrix in R_q from a
// nothing-up-my-sleeve tag using BLAKE3 → KeyedPRNG → UniformSampler.
//
// Returned matrix is in NTT-Montgomery form (matches the convention used by
// the Sign Gen path at sign/sign.go:49). BLAKE3 is wired directly here —
// see the package-level documentation for why matrix derivation is
// deliberately suite-independent.
func derivePublicMatrix(r *ring.Ring, tag []byte) (structs.Matrix[ring.Poly], error) {
h := blake3.New()
if _, err := h.Write(tag); err != nil {
return nil, fmt.Errorf("dkg2: derivePublicMatrix: %w", err)
}
seed := h.Sum(nil)[:sign.KeySize]
prng, err := sampling.NewKeyedPRNG(seed)
if err != nil {
return nil, fmt.Errorf("dkg2: derivePublicMatrix: %w", err)
}
uniform := ring.NewUniformSampler(prng, r)
return utils.SamplePolyMatrix(r, sign.M, sign.N, uniform, true, true), nil
}
// DeriveA returns the canonical Pedersen-DKG matrix A in NTT-Mont form.
func DeriveA(r *ring.Ring) structs.Matrix[ring.Poly] {
m, err := derivePublicMatrix(r, tagA)
if err != nil {
// derivePublicMatrix failure means BLAKE3 / KeyedPRNG construction
// failed under fully-controlled inputs — a deterministic crash here
// is correct (cf. sign/local.go's startup posture).
panic(err)
}
return m
}
// DeriveB returns the canonical Pedersen-DKG matrix B in NTT-Mont form.
func DeriveB(r *ring.Ring) structs.Matrix[ring.Poly] {
m, err := derivePublicMatrix(r, tagB)
if err != nil {
panic(err)
}
return m
}
// Round1Output is one party's contribution to Round 1.
//
// Commits[k] is the public Pedersen commitment to (c_{i,k}, r_{i,k}); it is
// broadcast to every other party. Shares[j] and Blinds[j] are the secret
// share-pair sent privately to party j over an authenticated point-to-point
// channel.
type Round1Output struct {
// Commits[k] = A * NTT(c_{i,k}) + B * NTT(r_{i,k}), length t.
// Stored in NTT-Montgomery form.
Commits []structs.Vector[ring.Poly]
// Shares[j] = f_i(j+1), length n. Standard coefficient form.
Shares map[int]structs.Vector[ring.Poly]
// Blinds[j] = g_i(j+1), length n. Standard coefficient form.
// Sent over the same private channel as Shares[j].
Blinds map[int]structs.Vector[ring.Poly]
}
// SerializeCommits returns the canonical wire bytes of Commits. Used as the
// hashing pre-image for Round 1.5 digests, equivocation evidence, and KAT
// pinning. Errors on the underlying lattigo WriteTo are surfaced as
// ErrSerialization.
func (r *Round1Output) SerializeCommits() ([]byte, error) {
var buf bytes.Buffer
for _, v := range r.Commits {
if _, err := v.WriteTo(&buf); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSerialization, err)
}
}
return buf.Bytes(), nil
}
// CommitDigest returns the Round 1.5 cross-party-consistency digest under
// the supplied HashSuite. Passing nil resolves to the production default
// (Corona-SHA3); pass hash.NewCoronaBLAKE3() for byte-equal replay against
// the canonical KATs.
//
// Format (suite-agnostic):
//
// suite.TranscriptHash([]byte(tagCommitDigest), []byte(suite.ID()),
// serialize(Commits[0]) || ... || serialize(Commits[t-1]))
//
// The suite ID is bound in so two suites can never collide on a single
// commit vector. Errors only on serialization failure.
func (r *Round1Output) CommitDigest(suite hash.HashSuite) ([32]byte, error) {
s := hash.Resolve(suite)
body, err := r.SerializeCommits()
if err != nil {
return [32]byte{}, err
}
return s.TranscriptHash([]byte(tagCommitDigest), []byte(s.ID()), body), nil
}
// CommitDigestBLAKE3 returns the legacy BLAKE3 commit digest used by the
// pre-SHA3-cutover KAT (corona/dkg2 oracle, luxcpp dkg2_kat.json). Format:
//
// BLAKE3(serialize(Commits[0]) || ... || serialize(Commits[t-1]))[:32]
//
// Kept for byte-stable replay only — production code paths SHOULD use
// CommitDigest(hash.Default()).
func (r *Round1Output) CommitDigestBLAKE3() ([32]byte, error) {
body, err := r.SerializeCommits()
if err != nil {
return [32]byte{}, err
}
h := blake3.New()
if _, err := h.Write(body); err != nil {
return [32]byte{}, fmt.Errorf("%w: %v", ErrSerialization, err)
}
var out [32]byte
copy(out[:], h.Sum(nil)[:32])
return out, nil
}
// DKGSession tracks the state of one party in the dkg2 protocol.
type DKGSession struct {
params *Params
partyID int
n int
t int
suite hash.HashSuite
A structs.Matrix[ring.Poly] // public uniform M×Nvec, NTT-Mont
B structs.Matrix[ring.Poly] // public uniform M×Nvec, NTT-Mont
// Stashed across Round1 → Round2 for self-consistency checks.
// Standard coefficient form (NTT=false, mont=false).
cCoeffs []structs.Vector[ring.Poly] // f_i polynomial coeffs (length t)
rCoeffs []structs.Vector[ring.Poly] // g_i polynomial coeffs (length t)
}
// NewDKGSession initializes a Pedersen DKG session for the given party.
//
// suite parameterizes the cohort-bound hash routines (Round 1.5 commit
// digest, complaint transcripts). nil resolves to the production default
// (Corona-SHA3). Public-matrix derivation is deliberately HashSuite-
// independent — it uses a dedicated BLAKE3 path so KAT bytes stay stable
// across the Corona-SHA3 cutover (see package documentation).
//
// Mirrors dkg.NewDKGSession exactly except that the matrices A, B are
// derived from public domain-separated tags via BLAKE3, removing the
// "all-zero seedKey" footgun present in dkg/dkg.go:99-105.
func NewDKGSession(params *Params, partyID, n, t int, suite hash.HashSuite) (*DKGSession, error) {
if n < 2 {
return nil, ErrInvalidPartyCount
}
if t < 1 || t >= n {
return nil, ErrInvalidThreshold
}
if partyID < 0 || partyID >= n {
return nil, ErrInvalidPartyID
}
// Set globals consumed by the Sign package (mirrors dkg/dkg.go).
sign.K = n
sign.Threshold = t
A := DeriveA(params.R)
B := DeriveB(params.R)
return &DKGSession{
params: params,
partyID: partyID,
n: n,
t: t,
suite: hash.Resolve(suite),
A: A,
B: B,
}, nil
}
// APublic returns the public matrix A. Exposed for KAT pinning.
func (d *DKGSession) APublic() structs.Matrix[ring.Poly] { return d.A }
// BPublic returns the public matrix B. Exposed for KAT pinning.
func (d *DKGSession) BPublic() structs.Matrix[ring.Poly] { return d.B }
// PartyID returns the party ID for this session.
func (d *DKGSession) PartyID() int { return d.partyID }
// N returns the total party count.
func (d *DKGSession) N() int { return d.n }
// T returns the threshold.
func (d *DKGSession) T() int { return d.t }
// HashSuite returns the cohort-bound hash suite this session uses.
func (d *DKGSession) HashSuite() hash.HashSuite { return d.suite }
// Round1 generates the party's random polynomials f_i, g_i, computes
// commitments, and computes shares (and blinds) for all other parties.
//
// Uses crypto/rand for the per-party Gaussian PRNG seed. For deterministic
// testing / KAT generation, use Round1WithSeed.
func (d *DKGSession) Round1() (*Round1Output, error) {
seed := make([]byte, sign.KeySize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
return nil, fmt.Errorf("dkg2: random read: %w", err)
}
return d.Round1WithSeed(seed)
}
// Round1WithSeed is the deterministic variant of Round1. Same seed →
// byte-equal output for a given (partyID, n, t, A, B) tuple.
//
// Sampling order (BYTE-PINNED — must match the C++ port):
//
// 1. KeyedPRNG(seed) → Gaussian sampler (σ_E, β_E).
// 2. For k = 0..t-1: sample c_{i,k} (Nvec polys, standard form).
// 3. For k = 0..t-1: sample r_{i,k} (Nvec polys, standard form).
// 4. Compute commits[k] = A*NTT(c_k) + B*NTT(r_k).
// 5. Compute shares[j], blinds[j] via Horner over the (j+1) point.
//
// The two-pass sample order (all c's first, then all r's) matters for byte
// equality across the Go reference and the C++ port.
func (d *DKGSession) Round1WithSeed(seed []byte) (*Round1Output, error) {
if len(seed) != sign.KeySize {
return nil, fmt.Errorf("dkg2: Round1WithSeed: expected %d-byte seed, got %d", sign.KeySize, len(seed))
}
r := d.params.R
prng, err := sampling.NewKeyedPRNG(seed)
if err != nil {
return nil, err
}
gauss := ring.NewGaussianSampler(prng, r,
ring.DiscreteGaussian{Sigma: sign.SigmaE, Bound: sign.BoundE}, false)
// Step 2: sample t coefficient vectors c_{i,k} in standard form.
d.cCoeffs = make([]structs.Vector[ring.Poly], d.t)
for k := 0; k < d.t; k++ {
d.cCoeffs[k] = utils.SamplePolyVector(r, sign.N, gauss, false, false)
}
// Step 3: sample t coefficient vectors r_{i,k} in standard form.
d.rCoeffs = make([]structs.Vector[ring.Poly], d.t)
for k := 0; k < d.t; k++ {
d.rCoeffs[k] = utils.SamplePolyVector(r, sign.N, gauss, false, false)
}
// Step 4: build Pedersen commits C_k = A·NTT(c_k) + B·NTT(r_k).
//
// Routed through the backend-dispatched path (dkg2_gpu.go). The CPU
// leg is byte-identical to the historical inline loop; the parallel
// leg fans out independent k iterations across runtime.GOMAXPROCS
// workers. Output bytes are unchanged because the ring routines are
// deterministic per (input, output) pair and there are no shared
// writes across goroutines (each worker owns its commits[k] cell).
commits := computePedersenCommits(r, d.A, d.B, d.cCoeffs, d.rCoeffs)
// Step 5: build shares and blinds via Horner over (j+1) for each j.
q := new(big.Int).SetUint64(sign.Q)
shares, blinds := computeHornerShares(r, d.cCoeffs, d.rCoeffs, d.n,
func(rr *ring.Ring, coeffs []structs.Vector[ring.Poly], j int) structs.Vector[ring.Poly] {
x := big.NewInt(int64(j + 1))
return hornerEval(rr, coeffs, x, q)
})
return &Round1Output{
Commits: commits,
Shares: shares,
Blinds: blinds,
}, nil
}
// hornerEval computes f(x) = Σ_k coeffs[k] * x^k in standard coefficient
// form over R_q^Nvec. Horner's method: f(x) = c_0 + x·(c_1 + x·(c_2 + …)).
//
// The arithmetic is performed by big.Int per coefficient mod q; the
// resulting Vector[Poly] is returned in standard coefficient form.
func hornerEval(r *ring.Ring, coeffs []structs.Vector[ring.Poly], x, q *big.Int) structs.Vector[ring.Poly] {
t := len(coeffs)
result := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
result[vi] = r.NewPoly()
}
for k := t - 1; k >= 0; k-- {
for vi := 0; vi < sign.N; vi++ {
if k < t-1 {
polyMulScalar(r, result[vi], x, q)
}
polyAddCoeffwise(r, result[vi], coeffs[k][vi], q)
}
}
return result
}
// VerifyShareAgainstCommits checks the Pedersen identity
//
// A · NTT(share) + B · NTT(blind) ?= Σ_{k=0..t-1} (recipientID+1)^k · commits[k]
//
// in NTT-Montgomery form (mod sign.Q). Returns (true, nil) on a valid pair,
// (false, ErrShareVerification) on a Pedersen mismatch, and a wrapped
// ErrMissingData / ErrMalformedCommit when inputs are absent or malformed.
//
// Comparison is constant-time across all M·N coefficient slots — no
// short-circuit on the first mismatched slot, which matches the response to
// Findings 5/6 of luxcpp/crypto/corona/RED-DKG-REVIEW.md.
//
// recipientID is 0-indexed (the (j+1) shift to the Lagrange evaluation
// point is applied internally).
func VerifyShareAgainstCommits(
params *Params,
A, B structs.Matrix[ring.Poly],
share, blind structs.Vector[ring.Poly],
commits []structs.Vector[ring.Poly],
recipientID int,
threshold int,
) (bool, error) {
r := params.R
if len(share) != sign.N || len(blind) != sign.N {
return false, fmt.Errorf("%w: share/blind length mismatch", ErrMalformedCommit)
}
if len(commits) != threshold {
return false, fmt.Errorf("%w: %d commits, expected %d", ErrMalformedCommit, len(commits), threshold)
}
for k, v := range commits {
if len(v) != sign.M {
return false, fmt.Errorf("%w: commit[%d] dim %d, expected %d", ErrMalformedCommit, k, len(v), sign.M)
}
}
q := new(big.Int).SetUint64(sign.Q)
// LHS_share = A · NTT(share)
shareNTT := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
shareNTT[vi] = *share[vi].CopyNew()
r.NTT(shareNTT[vi], shareNTT[vi])
}
ash := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, A, shareNTT, ash)
// LHS_blind = B · NTT(blind)
blindNTT := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
blindNTT[vi] = *blind[vi].CopyNew()
r.NTT(blindNTT[vi], blindNTT[vi])
}
bbl := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, B, blindNTT, bbl)
lhs := utils.InitializeVector(r, sign.M)
utils.VectorAdd(r, ash, bbl, lhs)
// RHS = Σ_k (recipientID+1)^k · commits[k] via Horner in NTT domain.
x := big.NewInt(int64(recipientID + 1))
rhs := utils.InitializeVector(r, sign.M)
for k := threshold - 1; k >= 0; k-- {
if k < threshold-1 {
for ri := 0; ri < sign.M; ri++ {
polyMulScalarNTT(r, rhs[ri], x, q)
}
}
utils.VectorAdd(r, rhs, commits[k], rhs)
}
// Constant-time compare across all M slots, all coefficient levels.
// utils.ConstantTimePolyEqual returns 1 iff equal; AND across slots.
// This is the dkg2 response to Findings 5/6 of
// luxcpp/crypto/corona/RED-DKG-REVIEW.md, which note that the upstream
// dkg/ share comparison short-circuits on the first slot mismatch and
// so leaks the location of any planted divergence to a timing observer.
eq := 1
for ri := 0; ri < sign.M; ri++ {
eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri])
}
if eq != 1 {
return false, ErrShareVerification
}
return true, nil
}
// Round2 verifies received shares (and blinds) against commitments, then
// aggregates to (s_j, u_j) plus the Pedersen-shaped group public key.
//
// On a verification failure Round2 returns the global ErrShareVerification
// without identifying the offending sender. For identifiable abort use
// Round2Identify, which returns the failing sender ID and produces a
// signed Complaint suitable for slashing evidence.
//
// receivedShares maps sender i → share i computed for THIS party (j).
// receivedBlinds maps sender i → blind i computed for THIS party (j).
// receivedCommits maps sender i → that sender's t-element commit vector.
//
// Returns (s_j, u_j, b_ped) on success.
//
// s_j = Σ_i share_{i→j} (Corona secret share)
// u_j = Σ_i blind_{i→j} (private; discarded by Corona Sign callers)
// b_ped = Σ_i C_{i,0} (rounded to Xi, Pedersen-shaped pk)
//
// b_ped has shape Round_Xi(A·s + B·t_master). Corona Sign verification
// running in 2-secret mode (path (b)) takes (A, B, b_ped) jointly; see
// papers/lp-073-pulsar/sections/07-pedersen-dkg.tex §Mapping for the
// integration recipe.
func (d *DKGSession) Round2(
receivedShares map[int]structs.Vector[ring.Poly],
receivedBlinds map[int]structs.Vector[ring.Poly],
receivedCommits map[int][]structs.Vector[ring.Poly],
) (structs.Vector[ring.Poly], structs.Vector[ring.Poly], structs.Vector[ring.Poly], error) {
s, u, b, _, err := d.round2Internal(receivedShares, receivedBlinds, receivedCommits, false)
return s, u, b, err
}
// Round2Identify is the identifiable-abort variant of Round2. On success
// returns (s_j, u_j, b_ped, -1, nil). On a Pedersen-mismatch failure
// returns (nil, nil, nil, senderID, wrapped ErrShareVerification) where
// senderID is the first sender in iteration order whose share/blind
// fails the verification. The caller may immediately produce a signed
// ComplaintBadDelivery for that sender via NewBadDeliveryComplaint.
//
// On a missing-input failure returns (nil, nil, nil, senderID,
// wrapped ErrMissingData) where senderID names the absent sender.
func (d *DKGSession) Round2Identify(
receivedShares map[int]structs.Vector[ring.Poly],
receivedBlinds map[int]structs.Vector[ring.Poly],
receivedCommits map[int][]structs.Vector[ring.Poly],
) (structs.Vector[ring.Poly], structs.Vector[ring.Poly], structs.Vector[ring.Poly], int, error) {
return d.round2Internal(receivedShares, receivedBlinds, receivedCommits, true)
}
// round2Internal is the shared implementation behind Round2 and
// Round2Identify. When identify=true the senderID return value names the
// first failing sender on error; otherwise it returns -1.
func (d *DKGSession) round2Internal(
receivedShares map[int]structs.Vector[ring.Poly],
receivedBlinds map[int]structs.Vector[ring.Poly],
receivedCommits map[int][]structs.Vector[ring.Poly],
identify bool,
) (structs.Vector[ring.Poly], structs.Vector[ring.Poly], structs.Vector[ring.Poly], int, error) {
r := d.params.R
// Sanity: every party 0..n-1 must contribute share, blind, commits.
for i := 0; i < d.n; i++ {
if _, ok := receivedShares[i]; !ok {
id := -1
if identify {
id = i
}
return nil, nil, nil, id, fmt.Errorf("%w: missing share from party %d", ErrMissingData, i)
}
if _, ok := receivedBlinds[i]; !ok {
id := -1
if identify {
id = i
}
return nil, nil, nil, id, fmt.Errorf("%w: missing blind from party %d", ErrMissingData, i)
}
if _, ok := receivedCommits[i]; !ok {
id := -1
if identify {
id = i
}
return nil, nil, nil, id, fmt.Errorf("%w: missing commitment from party %d", ErrMissingData, i)
}
}
q := new(big.Int).SetUint64(sign.Q)
// Verification: A·NTT(share_i) + B·NTT(blind_i) ?= Σ_k (j+1)^k · C_{i,k}
for i := 0; i < d.n; i++ {
ok, err := VerifyShareAgainstCommits(
d.params, d.A, d.B,
receivedShares[i], receivedBlinds[i], receivedCommits[i],
d.partyID, d.t,
)
if err != nil {
id := -1
if identify {
id = i
}
if errors.Is(err, ErrMalformedCommit) {
return nil, nil, nil, id, fmt.Errorf("party %d commit malformed: %w", i, err)
}
return nil, nil, nil, id, fmt.Errorf("%w: party %d share/blind do not match commitment", ErrShareVerification, i)
}
if !ok {
// Defensive: VerifyShareAgainstCommits returns (false, err) in
// lockstep, so this branch is unreachable. Keep for clarity.
id := -1
if identify {
id = i
}
return nil, nil, nil, id, fmt.Errorf("%w: party %d", ErrShareVerification, i)
}
}
// Aggregate s_j = Σ_i share_{i→j} (coefficient-domain add).
s := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
s[vi] = r.NewPoly()
}
for i := 0; i < d.n; i++ {
for vi := 0; vi < sign.N; vi++ {
polyAddCoeffwise(r, s[vi], receivedShares[i][vi], q)
}
}
// Aggregate u_j = Σ_i blind_{i→j} (coefficient-domain add).
u := make(structs.Vector[ring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
u[vi] = r.NewPoly()
}
for i := 0; i < d.n; i++ {
for vi := 0; vi < sign.N; vi++ {
polyAddCoeffwise(r, u[vi], receivedBlinds[i][vi], q)
}
}
// Public key: b_ped = Round_Xi(IMForm + INTT(Σ_i C_{i,0})).
pkNTT := utils.InitializeVector(r, sign.M)
for i := 0; i < d.n; i++ {
utils.VectorAdd(r, pkNTT, receivedCommits[i][0], pkNTT)
}
utils.ConvertVectorFromNTT(r, pkNTT)
bPed := utils.RoundVector(r, d.params.RXi, pkNTT, sign.Xi)
return s, u, bPed, -1, nil
}
// AggregateUnroundedCommit returns Σ_i C_{i,0} in NTT-Mont form (no
// rounding, no IMForm/INTT). Used by sign-after-DKG path (b) integration
// where the verifier needs the *unrounded* Pedersen commitment to recompute
// the verification equation.
func AggregateUnroundedCommit(
params *Params,
commits map[int][]structs.Vector[ring.Poly],
n int,
) (structs.Vector[ring.Poly], error) {
r := params.R
for i := 0; i < n; i++ {
if _, ok := commits[i]; !ok {
return nil, fmt.Errorf("%w: missing commitment from party %d", ErrMissingData, i)
}
}
pkNTT := utils.InitializeVector(r, sign.M)
for i := 0; i < n; i++ {
utils.VectorAdd(r, pkNTT, commits[i][0], pkNTT)
}
return pkNTT, nil
}
// polyMulScalar multiplies each coefficient of p by scalar s mod q
// (coefficient domain). Mirrors dkg.polyMulScalar.
func polyMulScalar(r *ring.Ring, p ring.Poly, s, q *big.Int) {
degree := r.N()
for i := 0; i < degree; i++ {
if p.Coeffs[0] == nil {
return
}
val := new(big.Int).SetUint64(p.Coeffs[0][i])
val.Mul(val, s)
val.Mod(val, q)
p.Coeffs[0][i] = val.Uint64()
}
}
// polyAddCoeffwise adds b into a coefficient-wise mod q (coefficient domain).
func polyAddCoeffwise(r *ring.Ring, a, b ring.Poly, q *big.Int) {
degree := r.N()
if a.Coeffs[0] == nil {
a.Coeffs[0] = make([]uint64, degree)
}
bCoeffs := b.Coeffs[0]
if bCoeffs == nil {
return
}
for i := 0; i < degree; i++ {
val := new(big.Int).SetUint64(a.Coeffs[0][i])
val.Add(val, new(big.Int).SetUint64(bCoeffs[i]))
val.Mod(val, q)
a.Coeffs[0][i] = val.Uint64()
}
}
// polyMulScalarNTT multiplies each NTT coefficient of p by scalar s mod q.
// Mirrors dkg.polyMulScalarNTT.
func polyMulScalarNTT(r *ring.Ring, p ring.Poly, s, q *big.Int) {
degree := r.N()
for level := range p.Coeffs {
for i := 0; i < degree; i++ {
val := new(big.Int).SetUint64(p.Coeffs[level][i])
val.Mul(val, s)
val.Mod(val, q)
p.Coeffs[level][i] = val.Uint64()
}
}
}
// EncodeUint32BE writes x in big-endian into a 4-byte slice. Used by the
// KAT oracle; declared here so the C++ port can match seed-derivation
// without depending on the oracle binary.
func EncodeUint32BE(x uint32) []byte {
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], x)
return buf[:]
}
-300
View File
@@ -1,300 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dkg2
// dkg2_gpu.go — GPU-dispatched parallel compute for the Pedersen DKG
// Round 1 hot path.
//
// The R_q^k Pedersen commit construction (dkg2.go Round1WithSeed Step 4)
// and the Horner share evaluation (Step 5) are both embarrassingly
// parallel across the per-coefficient and per-recipient axes:
//
// Step 4: commits[k] = A·NTT(c_k) + B·NTT(r_k)
// - The NTT calls (2·t·N) are independent across k.
// - The MatrixVectorMul calls (2·t) are independent across k.
// - The VectorAdd calls (t) write to disjoint commits[k] cells.
// => Parallel across the k axis (length t = threshold).
//
// Step 5: shares[j] = hornerEval(c_coeffs, j+1)
// blinds[j] = hornerEval(r_coeffs, j+1)
// - Each j is independent (reads coeffs, writes shares[j]/blinds[j]).
// => Parallel across the j axis (length n = committee size).
//
// Byte-equality contract.
//
// The Gaussian sampling step (Steps 2-3) consumes a deterministic
// KeyedPRNG stream in a fixed order; we DO NOT touch that sampling path
// because changing read order changes byte output and breaks the C++
// cross-runtime KAT. Steps 4 and 5 are pure functions of the already-
// sampled coefficient vectors; their outputs are byte-equal regardless
// of the goroutine scheduling because:
//
// - NTT is a deterministic linear transform of the coefficient vector.
// - MatrixVectorMul is a sum of MulCoeffsMontgomeryThenAdd ops; the
// ring routines are deterministic and the per-output-slot reduction
// order is fixed inside MatrixVectorMul.
// - VectorAdd writes element-wise, no cross-element reductions.
// - Horner-method polynomial evaluation is a fixed sequence of polyMul
// + polyAdd per `vi` slot, independent across `j`.
//
// Goroutine scheduling determines the WALL-CLOCK order of writes to
// different cells; it does not change the value written to any one
// cell.
//
// Backend selection.
//
// Same dispatch policy as the pulsar/dkg path: a build-tag flips
// dkg2GPUEnabled; the runtime selector consults GOMAXPROCS and the
// input shape (n, t). The actual luxfi/accel session (Metal/CUDA) is
// reserved for the engine layer; the in-package "GPU" today is the Go
// goroutine fan-out across independent work axes. The dispatch hook
// remains in place for the v0.6+ NIST submission lift where the accel
// MatrixVectorMul kernel becomes available.
import (
"runtime"
"sync"
"github.com/luxfi/corona/sign"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// dkg2ComputeBackend identifies the active compute backend.
type dkg2ComputeBackend uint8
const (
// kDKG2BackendCPU is the single-threaded reference path. Byte-equal to
// the historical dkg2.go Round1WithSeed.
kDKG2BackendCPU dkg2ComputeBackend = iota
// kDKG2BackendParallel runs the per-coefficient and per-recipient
// fan-out across runtime.GOMAXPROCS workers.
kDKG2BackendParallel
)
// dkg2GPUEnabled flips between CPU-only and the parallel fan-out path.
// Set by the build-tagged init() files (dkg2_gpu_accel.go for `-tags gpu`,
// dkg2_gpu_default.go for everything else).
var dkg2GPUEnabled bool
// SetDKG2GPUForTest forces the dispatch backend. Returns the previous
// value so tests can restore it. Test-only.
func SetDKG2GPUForTest(on bool) bool {
prev := dkg2GPUEnabled
dkg2GPUEnabled = on
return prev
}
// DKG2GPUDispatchAvailable reports whether the GPU dispatch path is wired
// in this build.
func DKG2GPUDispatchAvailable() bool {
return dkg2GPUEnabled
}
// resolveDKG2Backend selects the per-call backend.
//
// The Pedersen DKG Round 1 hot path is heavy enough at production sizes
// (n=21, t=14 → 196 NTT calls, 1568 MulCoeffsMontgomeryThenAdd ops,
// 2058 Horner steps) that goroutine setup is fully amortised. The
// threshold below uses NTT-call count as the shape proxy: 2·t·N where
// N is the ring degree dimension (sign.N = Nvec = 7) for the dkg2 ring.
func resolveDKG2Backend(n, t int) dkg2ComputeBackend {
if !dkg2GPUEnabled {
return kDKG2BackendCPU
}
if runtime.GOMAXPROCS(0) < 2 {
return kDKG2BackendCPU
}
// 2·t·sign.N independent NTT calls plus t MatrixVectorMul plus
// 2·n Horner chains. Below this threshold the goroutine setup
// dwarfs the work. Empirically tuned on Apple M1 Max — the
// crossover sits between n=3, t=2 (38 NTTs, 14 ops total) and
// n=5, t=3 (70 NTTs). We pin at 60 NTT-calls.
const kDKG2ParallelMinNTTs = 60
if 2*t*sign.N < kDKG2ParallelMinNTTs {
return kDKG2BackendCPU
}
return kDKG2BackendParallel
}
// computePedersenCommits builds commits[k] = A·NTT(c_k) + B·NTT(r_k) for
// k ∈ [0, t). The CPU leg is byte-identical to the inline Step 4 loop in
// dkg2.go; the parallel leg fans out across the k axis.
//
// Caller owns cCoeffs and rCoeffs (standard form, not NTT) and must NOT
// mutate them — this function CopyNews before transforming.
func computePedersenCommits(
r *ring.Ring,
A, B structs.Matrix[ring.Poly],
cCoeffs, rCoeffs []structs.Vector[ring.Poly],
) []structs.Vector[ring.Poly] {
t := len(cCoeffs)
commits := make([]structs.Vector[ring.Poly], t)
switch resolveDKG2Backend(0 /*n unused for commits*/, t) {
case kDKG2BackendParallel:
// Fan-out across the (kind ∈ {A·c, B·r}) × k product. That gives
// 2·t independent half-commits (each contributing one Vector[Poly]
// summand to commits[k]). Workers write to disjoint slots; a
// single-threaded VectorAdd folds the pairs together at the end,
// keeping the addition order byte-deterministic.
acSlots := make([]structs.Vector[ring.Poly], t)
brSlots := make([]structs.Vector[ring.Poly], t)
type halfJob struct {
isB bool
k int
}
jobs := make([]halfJob, 0, 2*t)
for k := 0; k < t; k++ {
jobs = append(jobs, halfJob{isB: false, k: k})
jobs = append(jobs, halfJob{isB: true, k: k})
}
workers := runtime.GOMAXPROCS(0)
if workers > len(jobs) {
workers = len(jobs)
}
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func(w int) {
defer wg.Done()
for idx := w; idx < len(jobs); idx += workers {
jb := jobs[idx]
if jb.isB {
brSlots[jb.k] = mulMatByNTT(r, B, rCoeffs[jb.k])
} else {
acSlots[jb.k] = mulMatByNTT(r, A, cCoeffs[jb.k])
}
}
}(w)
}
wg.Wait()
// Fold the half-commits in single-threaded VectorAdd order; this
// is the same addition order the CPU leg uses (ac + br per k),
// so the byte output is identical.
for k := 0; k < t; k++ {
commits[k] = utils.InitializeVector(r, sign.M)
utils.VectorAdd(r, acSlots[k], brSlots[k], commits[k])
}
default:
for k := 0; k < t; k++ {
commits[k] = singleCommit(r, A, B, cCoeffs[k], rCoeffs[k])
}
}
return commits
}
// mulMatByNTT computes M · NTT(coeffs) and returns the result. The
// CopyNew + NTT pair matches the historical inline loop (dkg2.go Step 4
// lines 415-419) so the output is byte-equal.
func mulMatByNTT(r *ring.Ring, M structs.Matrix[ring.Poly], coeffs structs.Vector[ring.Poly]) structs.Vector[ring.Poly] {
nttVec := make(structs.Vector[ring.Poly], sign.N)
for i := 0; i < sign.N; i++ {
nttVec[i] = *coeffs[i].CopyNew()
r.NTT(nttVec[i], nttVec[i])
}
out := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, M, nttVec, out)
return out
}
// singleCommit is the per-k inner kernel: NTT the coefficients, multiply
// by A and B, sum. Byte-identical to the inline loop body in dkg2.go
// Step 4 lines 411-426.
func singleCommit(
r *ring.Ring,
A, B structs.Matrix[ring.Poly],
cCoeffsK, rCoeffsK structs.Vector[ring.Poly],
) structs.Vector[ring.Poly] {
cNTT := make(structs.Vector[ring.Poly], sign.N)
rNTT := make(structs.Vector[ring.Poly], sign.N)
for i := 0; i < sign.N; i++ {
cNTT[i] = *cCoeffsK[i].CopyNew()
r.NTT(cNTT[i], cNTT[i])
rNTT[i] = *rCoeffsK[i].CopyNew()
r.NTT(rNTT[i], rNTT[i])
}
ac := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, A, cNTT, ac)
br := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, B, rNTT, br)
out := utils.InitializeVector(r, sign.M)
utils.VectorAdd(r, ac, br, out)
return out
}
// computeHornerShares evaluates f_i(j+1) and g_i(j+1) for j ∈ [0, n). The
// CPU leg matches the inline Step 5 loop. The parallel leg fans out across
// the j axis: each goroutine owns its shares[j] and blinds[j] cells.
//
// hornerEvalFn is injected so the package's existing hornerEval stays the
// only big.Int / polyMulScalar / polyAddCoeffwise call site (the same
// invariant the audit footprint requires).
func computeHornerShares(
r *ring.Ring,
cCoeffs, rCoeffs []structs.Vector[ring.Poly],
n int,
hornerEvalFn func(r *ring.Ring, coeffs []structs.Vector[ring.Poly], j int) structs.Vector[ring.Poly],
) (shares, blinds map[int]structs.Vector[ring.Poly]) {
shares = make(map[int]structs.Vector[ring.Poly], n)
blinds = make(map[int]structs.Vector[ring.Poly], n)
switch resolveDKG2Backend(n, len(cCoeffs)) {
case kDKG2BackendParallel:
// Pre-allocate slot arrays so workers write to distinct cells
// without needing a synchronized map. The map[] copy at the end
// is a single-threaded fold and stays byte-deterministic.
shareSlots := make([]structs.Vector[ring.Poly], n)
blindSlots := make([]structs.Vector[ring.Poly], n)
// Fan out across the (kind ∈ {share, blind}) × j product. That
// gives 2·n independent work items so on a 10-core box at n=14
// we keep every core busy (28 items / 10 workers = ~3 items
// each). On smaller n (3, 5, 7) the 2·n product still gives
// enough cells to amortise the fan-out cost.
type job struct {
isBlind bool
j int
}
jobs := make([]job, 0, 2*n)
for j := 0; j < n; j++ {
jobs = append(jobs, job{isBlind: false, j: j})
jobs = append(jobs, job{isBlind: true, j: j})
}
workers := runtime.GOMAXPROCS(0)
if workers > len(jobs) {
workers = len(jobs)
}
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func(w int) {
defer wg.Done()
for idx := w; idx < len(jobs); idx += workers {
jb := jobs[idx]
if jb.isBlind {
blindSlots[jb.j] = hornerEvalFn(r, rCoeffs, jb.j)
} else {
shareSlots[jb.j] = hornerEvalFn(r, cCoeffs, jb.j)
}
}
}(w)
}
wg.Wait()
// Single-threaded fold into map form (matches existing return shape).
for j := 0; j < n; j++ {
shares[j] = shareSlots[j]
blinds[j] = blindSlots[j]
}
default:
for j := 0; j < n; j++ {
shares[j] = hornerEvalFn(r, cCoeffs, j)
blinds[j] = hornerEvalFn(r, rCoeffs, j)
}
}
return shares, blinds
}
-231
View File
@@ -1,231 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dkg2
import (
"bytes"
"crypto/rand"
"io"
"testing"
"github.com/luxfi/corona/sign"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// TestDKG2_GPU_ByteEqual is the core correctness theorem: the CPU and
// GPU dispatch legs of Round 1 MUST produce byte-identical output for
// the same RNG seed. A divergence here would be a cross-validator KAT
// failure during a live Pedersen DKG and would halt the consensus path
// the ceremony bootstraps.
//
// We exercise the same (n, t) shapes the canonical KATs cover (2-of-3,
// 3-of-5, 5-of-7, 7-of-11) plus the production target n=21, t=14.
func TestDKG2_GPU_ByteEqual(t *testing.T) {
for _, tc := range []struct {
name string
n, t int
}{
{"n3_t2", 3, 2},
{"n5_t3", 5, 3},
{"n7_t5", 7, 5},
{"n11_t7", 11, 7},
{"n21_t14", 21, 14},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params, err := NewParams()
if err != nil {
t.Fatal(err)
}
// Same seed for both legs — byte-equality requires the
// same input.
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x5A ^ (i * 31) ^ tc.n ^ (tc.t << 1))
}
runLeg := func(gpuOn bool) *Round1Output {
prev := SetDKG2GPUForTest(gpuOn)
defer SetDKG2GPUForTest(prev)
sess, err := NewDKGSession(params, 0, tc.n, tc.t, nil)
if err != nil {
t.Fatalf("NewDKGSession: %v", err)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed gpu=%v: %v", gpuOn, err)
}
return out
}
cpu := runLeg(false)
gpu := runLeg(true)
// Commits: byte-equal across both legs.
if len(cpu.Commits) != len(gpu.Commits) {
t.Fatalf("commit count: cpu=%d gpu=%d", len(cpu.Commits), len(gpu.Commits))
}
cpuBytes, err := cpu.SerializeCommits()
if err != nil {
t.Fatal(err)
}
gpuBytes, err := gpu.SerializeCommits()
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(cpuBytes, gpuBytes) {
t.Fatalf("commit bytes mismatch: cpu_len=%d gpu_len=%d head_cpu=%x head_gpu=%x",
len(cpuBytes), len(gpuBytes), cpuBytes[:32], gpuBytes[:32])
}
// Shares and blinds: byte-equal per recipient.
if len(cpu.Shares) != len(gpu.Shares) || len(cpu.Blinds) != len(gpu.Blinds) {
t.Fatalf("share/blind map size mismatch")
}
for j := 0; j < tc.n; j++ {
if !vectorBytesEqual(cpu.Shares[j], gpu.Shares[j]) {
t.Fatalf("share[%d] bytes mismatch", j)
}
if !vectorBytesEqual(cpu.Blinds[j], gpu.Blinds[j]) {
t.Fatalf("blind[%d] bytes mismatch", j)
}
}
})
}
}
// vectorBytesEqual returns true if two Vector[Poly] serialise byte-equal.
func vectorBytesEqual(a, b structs.Vector[ring.Poly]) bool {
if len(a) != len(b) {
return false
}
var bufA, bufB bytes.Buffer
if _, err := a.WriteTo(&bufA); err != nil {
return false
}
if _, err := b.WriteTo(&bufB); err != nil {
return false
}
return bytes.Equal(bufA.Bytes(), bufB.Bytes())
}
// TestDKG2_GPU_RoundTripVerify confirms the GPU-produced shares verify
// against the GPU-produced commits via the existing Pedersen identity
// check. This is the cryptographic correctness gate (independent of the
// byte-equal test): even if the CPU and GPU legs produced different
// outputs, both would have to verify under their own commits — and a
// share that satisfies the Pedersen identity is a valid Pedersen-DKG
// contribution by construction.
func TestDKG2_GPU_RoundTripVerify(t *testing.T) {
prev := SetDKG2GPUForTest(true)
defer SetDKG2GPUForTest(prev)
params, err := NewParams()
if err != nil {
t.Fatal(err)
}
const n, threshold = 7, 5
sess, err := NewDKGSession(params, 0, n, threshold, nil)
if err != nil {
t.Fatal(err)
}
seed := make([]byte, sign.KeySize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
t.Fatal(err)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatal(err)
}
// Every (share[j], blind[j]) MUST verify against the commit vector.
A := sess.APublic()
B := sess.BPublic()
for j := 0; j < n; j++ {
ok, err := VerifyShareAgainstCommits(params, A, B,
out.Shares[j], out.Blinds[j], out.Commits, j, threshold)
if err != nil {
t.Fatalf("VerifyShareAgainstCommits j=%d: %v", j, err)
}
if !ok {
t.Fatalf("GPU-produced share[%d] FAILED Pedersen verify under GPU-produced commits", j)
}
}
}
// TestDKG2_GPU_FullCeremony runs the canonical multi-party Round 1 →
// Round 2 ceremony with GPU dispatch on. Hooks into the same harness
// used by TestDKG2_2of3 etc; only difference is the dispatch toggle.
func TestDKG2_GPU_FullCeremony(t *testing.T) {
prev := SetDKG2GPUForTest(true)
defer SetDKG2GPUForTest(prev)
runPedersenDKG(t, 7, 5)
}
// BenchmarkDKG2_GPU_Round1_*_CPU and _GPU measure the speedup of the
// parallel byte-slot fan-out at production sizes.
//
// On Apple M1 Max with 10 GOMAXPROCS the expected speedup is roughly
// linear in the t·N axis (NTT calls are the dominant cost); the n axis
// also benefits but Horner is cheaper per step than the NTT path.
func BenchmarkDKG2_GPU_Round1_5of7_CPU(b *testing.B) {
prev := SetDKG2GPUForTest(false)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 7, 5)
}
func BenchmarkDKG2_GPU_Round1_5of7_GPU(b *testing.B) {
prev := SetDKG2GPUForTest(true)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 7, 5)
}
func BenchmarkDKG2_GPU_Round1_7of11_CPU(b *testing.B) {
prev := SetDKG2GPUForTest(false)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 11, 7)
}
func BenchmarkDKG2_GPU_Round1_7of11_GPU(b *testing.B) {
prev := SetDKG2GPUForTest(true)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 11, 7)
}
// 14-of-21: the production Lux consensus committee shape (n=21, t=14)
// is the headline benchmark the task asks for.
func BenchmarkDKG2_GPU_Round1_14of21_CPU(b *testing.B) {
prev := SetDKG2GPUForTest(false)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 21, 14)
}
func BenchmarkDKG2_GPU_Round1_14of21_GPU(b *testing.B) {
prev := SetDKG2GPUForTest(true)
defer SetDKG2GPUForTest(prev)
benchRound1(b, 21, 14)
}
func benchRound1(b *testing.B, n, t int) {
params, err := NewParams()
if err != nil {
b.Fatal(err)
}
sess, err := NewDKGSession(params, 0, n, t, nil)
if err != nil {
b.Fatal(err)
}
seed := make([]byte, sign.KeySize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := sess.Round1WithSeed(seed); err != nil {
b.Fatal(err)
}
}
}
-11
View File
@@ -1,11 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dkg2
// dkg2GPUEnabled is the runtime toggle for the goroutine fan-out path of
// the Pedersen DKG hot loop. Default off; tests opt in via
// SetDKG2GPUForTest. This is not a GPU dispatch — real GPU NTT for the
// underlying ring math lives in luxfi/lattice/v7/gpu and is reached via
// the consensus engine accel pipeline.
func init() { dkg2GPUEnabled = false }
-1382
View File
File diff suppressed because it is too large Load Diff
-114
View File
@@ -1,114 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// dkg2 wire-format fuzz harnesses.
//
// FuzzDKG2Round1Output — Round1Output.SerializeCommits bytes (Vector[Vector[Poly]])
// FuzzDKG2Round2Output — Round-2 share vector bytes (Vector[Poly])
//
// Property: every decoder path under fuzzMaxRawSize never escapes a
// panic to the caller, even on attacker-controlled length-prefix
// inputs (luxfi/lattice#2 DoS surface).
package dkg2
import (
"bytes"
"fmt"
"testing"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// fuzzMaxRawSize bounds the input handed to the lattigo decoder so
// the unpatched-upstream-lattigo DoS path (luxfi/lattice#2) is
// neutralized by the recover boundary in <1ms. See
// corona/threshold/fuzz_round_test.go for the rationale.
const fuzzMaxRawSize = 1024
// decodeVectorWithRecover decodes a Vector[Poly] from raw bytes with
// the same defense-in-depth stack as
// corona/threshold/fuzz_round_test.go: hard byte cap + recover.
func decodeVectorWithRecover(raw []byte) (err error) {
if len(raw) > fuzzMaxRawSize {
return fmt.Errorf("input exceeds fuzzMaxRawSize")
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("decode panic recovered: %v", r)
}
}()
r, perr := ring.NewRing(1<<10, []uint64{0x7ffffd8001})
if perr != nil {
return perr
}
v := utils.InitializeVector(r, 8)
_, derr := v.ReadFrom(bytes.NewReader(raw))
_ = structs.Vector[ring.Poly](v) // type-assertion for clarity
return derr
}
// addSmallSeeds adds bounded-size seeds. Real protocol data is
// exercised by TestFuzzCorpus_*Replay, NOT here.
func addSmallSeeds(f *testing.F) {
f.Add([]byte{})
f.Add([]byte{0x00})
f.Add([]byte{0x01, 0x02, 0x03, 0x04})
f.Add(bytes.Repeat([]byte{0xff}, 32))
f.Add(bytes.Repeat([]byte{0xaa}, 64))
f.Add(append([]byte{0x10, 0x00, 0x00, 0x00}, bytes.Repeat([]byte{0xcc}, 24)...))
}
// FuzzDKG2Round1Output fuzzes the Round-1 commitment-vector decoder.
// dkg2.Round1Output.Commits is broadcast publicly in the dkg2 protocol;
// any peer can supply attacker-controlled bytes for this lane.
func FuzzDKG2Round1Output(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
_ = decodeVectorWithRecover(raw)
})
}
// FuzzDKG2Round2Output fuzzes the Round-2 share-vector decoder.
// dkg2 Round-2 outputs are private but transit on an authenticated
// channel; an attacker who compromises a single point-to-point link
// must not be able to crash the receiver via malformed bytes.
func FuzzDKG2Round2Output(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
_ = decodeVectorWithRecover(raw)
})
}
// TestFuzzCorpus_DKG2Round1Replay confirms the small-seed corpus is
// at least decodable to the point of producing a clean error (or
// nil) rather than a panic.
func TestFuzzCorpus_DKG2Round1Replay(t *testing.T) {
for _, raw := range [][]byte{
{},
{0x00},
{0x01, 0x02, 0x03, 0x04},
bytes.Repeat([]byte{0xff}, 32),
} {
if err := decodeVectorWithRecover(raw); err == nil {
// nil is fine — empty/zero input may decode to empty vector
continue
}
}
}
// TestFuzzCorpus_DKG2Round2Replay mirrors the Round-1 replay.
func TestFuzzCorpus_DKG2Round2Replay(t *testing.T) {
for _, raw := range [][]byte{
{},
{0x00},
bytes.Repeat([]byte{0xaa}, 64),
} {
_ = decodeVectorWithRecover(raw)
}
}
+1
View File
@@ -3,6 +3,7 @@ module github.com/luxfi/corona
go 1.26.4
require (
github.com/luxfi/dkg v0.2.0
github.com/luxfi/lattice/v7 v7.1.0
github.com/luxfi/math v1.4.0
github.com/luxfi/zap v0.7.2
+2
View File
@@ -24,6 +24,8 @@ github.com/luxfi/accel v1.1.9 h1:Tsk6gXj2uKE19501bD0ajRYdeCHIlTGb6jYyLc+F8hc=
github.com/luxfi/accel v1.1.9/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/crypto v1.19.17 h1:l2LLu7UFyICtJVfraLDLRi+lFGiDXKHSL18M9/m1gsQ=
github.com/luxfi/crypto v1.19.17/go.mod h1:INjdZtke85k8hX/QAmTMAY8bbZ4gzGZQLqURg3xf6Gk=
github.com/luxfi/dkg v0.2.0 h1:uy1ZrFKcG9CfplIXUdJxDUL7k5JmnLyiBOUEBDR1dvE=
github.com/luxfi/dkg v0.2.0/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
github.com/luxfi/lattice/v7 v7.1.0 h1:mr3HvN6olNTS2LT/xAW/JBhTqfvpsGmsopDMeR7BSJs=
github.com/luxfi/lattice/v7 v7.1.0/go.mod h1:IaaUN+3ysnBG4BA8ILRYG0j80+qtYDP4C5lkaDb2pDE=
github.com/luxfi/math v1.4.0 h1:/sb7Grw3hfO+5INWAWdB95jTvCeXg8fSQxsxDzcFtd4=
+384 -222
View File
@@ -4,26 +4,48 @@
package keyera
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"math/big"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/primitives"
"github.com/luxfi/corona/sign"
"github.com/luxfi/corona/threshold"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
dkgblame "github.com/luxfi/dkg/blame"
dkgchannel "github.com/luxfi/dkg/channel"
dkgring "github.com/luxfi/dkg/ring"
dkgvss "github.com/luxfi/dkg/vss"
lring "github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/lattice/v7/utils/structs"
"github.com/zeebo/blake3"
)
// bootstrap_pedersen.go — dealerless key-era bootstrap.
//
// The no-reconstruct Pedersen-VSS share-dealing (commit & deal, equivocation
// gate, Pedersen verification, identifiable abort) is provided by the shared
// github.com/luxfi/dkg library, parameterized by ring.Ringtail(). corona keeps
// ONLY its scheme-specific finalize: Path (a) noise flooding + Round_Xi, which
// turns the per-party secret shares s_j into the LWE-shaped Ringtail public key
// bTilde = Round_Xi(A·s + e″). The Pedersen aggregate T = A·s1+B·u that vss
// produces is NOT used as the group key (its B·u term is not a small LWE error);
// vss is used purely for the no-reconstruct share distribution + blame.
//
// Byte-stability (golden_bootstrap_kat_test.go): the per-party sampling seed is
// threaded into vss's Round1 so the χ draws — hence the Pedersen commits and the
// secret shares — are byte-identical to the prior dkg2 path; the commit digests
// are recomputed under the corona HashSuite over the same commit bytes. So the
// group key, shares, and transcript are PRESERVED, not re-baselined.
// BootstrapTranscript is the public, byte-stable record produced by a
// BootstrapPedersen run. Every honest validator that observes the same
// cohort messages computes identical transcript bytes; the chain commits
@@ -59,9 +81,14 @@ type BootstrapTranscript struct {
// AbortEvidence captures the signed Complaint set that aborted a
// BootstrapPedersen run. The chain stays at the previous era and the
// returned error wraps ErrBootstrapPedersenAbort.
//
// Complaints are the shared library's scheme-agnostic blame records (a bad
// share is named by the accused validator's NodeID, re-checkable by any third
// party via vss.RecheckBadDelivery). Disqualified maps the committee index of
// each named dealer.
type AbortEvidence struct {
TranscriptHash [32]byte
Complaints []*dkg2.Complaint
Complaints []*dkgblame.Complaint
Disqualified map[int]struct{}
}
@@ -72,7 +99,7 @@ var (
ErrBootstrapPedersenAbort = errors.New("keyera: bootstrap-pedersen aborted with identifiable evidence")
// ErrBootstrapPedersenShape is returned when input parameters violate
// the (1 ≤ t ≤ n) and (n ≥ 2) constraints required by dkg2.
// the (1 ≤ t ≤ n) and (n ≥ 2) constraints required by the DKG.
ErrBootstrapPedersenShape = errors.New("keyera: bootstrap-pedersen parameter shape")
)
@@ -88,57 +115,88 @@ const noiseFloodTag = "CORONA-BOOTSTRAP-PEDERSEN-NOISEFLOOD-v1"
// transcript so two suites can never collide on a single era.
const transcriptTag = "CORONA-BOOTSTRAP-PEDERSEN-TRANSCRIPT-v1"
// PedersenContributions is the raw output of all parties' Round 1 plus
// the per-recipient share/blind tables, suitable for direct injection
// into the FinishBootstrapPedersen orchestrator. Tests use this to
// simulate identifiable-abort scenarios where a single party is
// dishonest: they tamper one entry and then call FinishBootstrapPedersen
// to drive the abort path.
// tagCommitDigest is the customization tag bound into the Round 1.5 commit
// digest under the active HashSuite. The suite ID is bound into the digest
// input as well so two suites can never produce a colliding digest for the
// same commit vector. The string is the historical corona/dkg2 tag, kept
// verbatim so the digest — hence the noise seed and the transcript hash —
// is byte-identical across the move to luxfi/dkg.
const tagCommitDigest = "CORONA-DKG2-COMMIT-DIGEST-v1"
// identityDomainTag derives the per-party long-term DKG channel identities
// deterministically. The identities authenticate the sealed share envelopes;
// they do NOT influence the public commits, the secret shares, or the group
// key (those are functions of the per-party sampling seed only), so a fixed
// derivation keeps the whole in-process run replayable without perturbing any
// byte-stable output.
const identityDomainTag = "CORONA-BOOTSTRAP-PEDERSEN-IDENTITY-v1"
// kemStreamTag derives the per-party KEM-encapsulation randomness stream that
// vss.Round1 consumes after the 32-byte sampling seed. Like the identities,
// this affects only the (immediately-opened, then-discarded) envelope
// ciphertext, never any byte-stable output.
const kemStreamTag = "CORONA-BOOTSTRAP-PEDERSEN-KEMSTREAM-v1"
// pedersenSession bundles the in-process vss machinery for one bootstrap: the
// Ringtail profile, the n party sessions, their deterministic identities, the
// committee NodeIDs, and the era/committee context. corona drives every party
// itself (in-process reference path); production wraps each party over an
// authenticated network at the consensus layer.
type pedersenSession struct {
profile *dkgring.Profile
parties []*dkgvss.Party
ids []*dkgchannel.IdentityKey
nodes []dkgchannel.NodeID
context [32]byte
cr *lring.Ring // corona ring (== profile.Ring.Base()) for Path (a)
crXi *lring.Ring // post-rounding ring for Round_Xi
}
// PedersenContributions is the raw share-dealing output of all parties' Round 1
// plus, per recipient, the opened (share, blind, commits) it verifies in
// Round 3. It feeds FinishBootstrapPedersen. Tests use it to simulate an
// identifiable-abort: they tamper one opened share and call
// FinishBootstrapPedersen to drive the blame path.
//
// In production the per-party contributions arrive over the network and
// the consensus layer assembles them; the kernel exists for tests +
// integration suite.
// In production the per-party Round-1 broadcasts + sealed envelopes arrive over
// the network and the consensus layer assembles them; this kernel exists for
// tests + the single-process integration suite.
type PedersenContributions struct {
// Round1 outputs indexed by sender party ID 0..n-1.
Round1 []*dkg2.Round1Output
// dkg2 ring (matches Round1's sessions). The caller may build a fresh
// dkg2.NewParams() and pass its .R.
Sessions []*dkg2.DKGSession
session *pedersenSession
// commits[i] is dealer i's broadcast Pedersen commit vector (plain-NTT).
commits [][]dkgring.Vector
// digests[i] is the corona-HashSuite commit digest of dealer i's commits.
digests [][32]byte
// inputs[j][i] is recipient j's opened contribution from dealer i.
inputs []map[int]*dkgvss.DealerInput
}
// BootstrapPedersen opens a new key era WITHOUT a trusted dealer.
//
// The construction follows Path (a) of papers/lp-073-pulsar §07:
//
// 1. Each party runs dkg2.Round1 → broadcasts Pedersen commits, sends
// (share_{i→j}, blind_{i→j}) privately to every recipient j.
// 1. Each party runs the shared Pedersen-VSS Round 1 → broadcasts Pedersen
// commits, KEM-seals (share_{i→j}, blind_{i→j}) privately to recipient j.
// 2. Each party verifies all incoming Pedersen pairs (constant-time
// comparison; identifiable abort on mismatch).
// 3. Each party runs dkg2.Round2 → obtains its share s_j of the master
// secret s. NO PARTY ever holds s in memory.
// 4. Each party samples a fresh Gaussian e_j' ~ D(σ”) locally with
// σ” = κ · σ_E · √n (the slack reservation of LP-073 §5) and
// 3. Each party aggregates its share s_j of the master secret s. NO PARTY
// ever holds s in memory (no-reconstruct: vss never sums the contributions).
// 4. Each party samples a fresh Gaussian e_j' ~ D(σ″) locally with
// σ″ = κ · σ_E · √n (the slack reservation of LP-073 §5) and
// broadcasts β_j = A · NTT(λ_j · s_j) + e_j'. The published β_j is
// LWE-protected by e_j' so no party learns more than its own share.
// 5. All parties aggregate b = Σ_j β_j = A · s + e” in NTT-Mont; the
// 5. All parties aggregate b = Σ_j β_j = A · s + e″ in NTT-Mont; the
// Corona-Sign-shaped public key is bTilde = Round_Xi(b).
//
// On success returns:
// On success returns (era, transcript, nil). On identifiable abort returns
// (nil, nil, ErrBootstrapPedersenAbort) with the AbortEvidence wrapped into
// the error (extract via ExtractAbortEvidence).
//
// - era — fully populated *KeyEra with the noise-flooded GroupKey;
// - transcript — the public BootstrapTranscript suitable for chain commit;
// - nil error.
//
// On identifiable abort returns (nil, nil, ErrBootstrapPedersenAbort) and
// the AbortEvidence is wrapped into the returned error via errors.As.
//
// In production each party drives its own dkg2.DKGSession over an
// authenticated network. This in-process kernel drives every party
// itself; it exists to (a) be the trusted-collaborator path for the
// single-process integration tests and (b) provide a reference against
// which the distributed protocol can be byte-equality checked. The
// distributed wrapper at the consensus layer reuses every primitive
// imported here.
// In production each party drives its own vss session over an authenticated
// network. This in-process kernel drives every party itself; it exists to (a)
// be the trusted-collaborator path for the single-process integration tests
// and (b) provide a reference against which the distributed protocol can be
// byte-equality checked.
//
// Pass nil for the production suite default (Corona-SHA3).
func BootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID CoronaGroupID, eraID CoronaKeyEraID, entropy io.Reader) (*KeyEra, *BootstrapTranscript, error) {
@@ -149,14 +207,14 @@ func BootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID
if t < 1 || t > n {
return nil, nil, fmt.Errorf("%w: t=%d n=%d", ErrInvalidThreshold, t, n)
}
// dkg2 requires n ≥ 2 and 1 ≤ t < n (strictly less than for honest-
// abort detection). For t == n we fall back to the trusted-dealer
// path because dkg2 cannot guard a corruption-free quorum below 1.
// The Pedersen DKG requires n ≥ 2 and 1 ≤ t < n (strictly less than for
// honest-abort detection). For t == n callers must use the trusted-dealer
// path because the DKG cannot guard a corruption-free quorum below 1.
if n < 2 {
return nil, nil, fmt.Errorf("%w: n=%d (dkg2 requires n >= 2)", ErrBootstrapPedersenShape, n)
return nil, nil, fmt.Errorf("%w: n=%d (dkg requires n >= 2)", ErrBootstrapPedersenShape, n)
}
if t >= n {
return nil, nil, fmt.Errorf("%w: t=%d n=%d (dkg2 requires t < n)", ErrBootstrapPedersenShape, t, n)
return nil, nil, fmt.Errorf("%w: t=%d n=%d (dkg requires t < n)", ErrBootstrapPedersenShape, t, n)
}
if entropy == nil {
entropy = rand.Reader
@@ -164,24 +222,11 @@ func BootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID
suite = hash.Resolve(suite)
suiteID := suite.ID()
// Initialize dkg2 ring + matrices. The matrices A, B are
// nothing-up-my-sleeve; every party derives them deterministically.
dkgParams, err := dkg2.NewParams()
if err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen dkg2 params: %w", err)
}
// Create one session per party. Each session needs an independent
// random seed for its Round1WithSeed input; we draw all of them up
// front so the entropy stream is sequenced canonically.
sessions := make([]*dkg2.DKGSession, n)
// Draw the per-party sampling seeds from the ceremony entropy, canonically
// sequenced (matches the prior dkg2 path's entropy consumption exactly, so
// the χ draws — hence commits, shares, and bTilde — are byte-stable).
round1Seeds := make([][]byte, n)
for i := 0; i < n; i++ {
sess, err := dkg2.NewDKGSession(dkgParams, i, n, t, suite)
if err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen NewDKGSession[%d]: %w", i, err)
}
sessions[i] = sess
seed := make([]byte, sign.KeySize)
if _, err := io.ReadFull(entropy, seed); err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen entropy[%d]: %w", i, err)
@@ -189,86 +234,171 @@ func BootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID
round1Seeds[i] = seed
}
// Round 1 — produce per-party Pedersen commits + shares/blinds.
round1 := make([]*dkg2.Round1Output, n)
contribs, err := dealPedersen(suite, t, n, round1Seeds)
if err != nil {
return nil, nil, err
}
return finishPedersenContribs(suite, suiteID, t, n, validators, groupID, eraID, contribs)
}
// newPedersenSession builds the in-process committee: the Ringtail profile, n
// deterministic channel identities, NodeIDs, the identity directory, the
// corona rings, and one vss.Party per committee position.
func newPedersenSession(t, n int) (*pedersenSession, error) {
profile, err := dkgring.Ringtail()
if err != nil {
return nil, fmt.Errorf("keyera: bootstrap-pedersen ring.Ringtail: %w", err)
}
crXi, _ := lring.NewRing(1<<sign.LogN, []uint64{sign.QXi}) // non-prime modulus; error deliberately ignored (RoundVector container only)
ids := make([]*dkgchannel.IdentityKey, n)
nodes := make([]dkgchannel.NodeID, n)
entries := make(map[dkgchannel.NodeID]*dkgchannel.IdentityPublicKey, n)
for i := 0; i < n; i++ {
out, err := sessions[i].Round1WithSeed(round1Seeds[i])
id, err := dkgchannel.GenerateIdentity(derivedStream(identityDomainTag, uint32(i)))
if err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen Round1[%d]: %w", i, err)
return nil, fmt.Errorf("keyera: bootstrap-pedersen identity[%d]: %w", i, err)
}
ids[i] = id
nodes[i] = pedersenNodeID(i)
entries[nodes[i]] = id.PublicKey()
}
dir, err := dkgchannel.NewIdentityDirectory(entries)
if err != nil {
return nil, fmt.Errorf("keyera: bootstrap-pedersen directory: %w", err)
}
// The era/committee context binds every envelope to this ceremony. It is
// public and deterministic from (t, n); the consensus layer overrides it
// with a chain transcript root in the distributed deployment.
var context [32]byte
ctx := blake3.New()
_, _ = ctx.Write([]byte("CORONA-BOOTSTRAP-PEDERSEN-CONTEXT-v1"))
_, _ = ctx.Write(u32be(uint32(t)))
_, _ = ctx.Write(u32be(uint32(n)))
copy(context[:], ctx.Sum(nil))
parties := make([]*dkgvss.Party, n)
for i := 0; i < n; i++ {
p, err := dkgvss.NewParty(profile, nodes[i], ids[i], i, n, t, nodes, dir, context)
if err != nil {
return nil, fmt.Errorf("keyera: bootstrap-pedersen NewParty[%d]: %w", i, err)
}
parties[i] = p
}
return &pedersenSession{
profile: profile,
parties: parties,
ids: ids,
nodes: nodes,
context: context,
cr: profile.Ring.Base(),
crXi: crXi,
}, nil
}
// dealPedersen drives the shared Pedersen-VSS Round 1 for every party (with the
// per-party sampling seed threaded so the χ draws are byte-identical to the
// prior dkg2 path), computes the corona commit digests, then opens every sealed
// envelope so each recipient holds its (share, blind, commits) from each dealer.
// The honest-path Pedersen verification + aggregation happens in
// finishPedersenContribs (so a test can tamper an opened share first).
func dealPedersen(suite hash.HashSuite, t, n int, round1Seeds [][]byte) (*PedersenContributions, error) {
if len(round1Seeds) != n {
return nil, fmt.Errorf("%w: seeds=%d n=%d", ErrBootstrapPedersenShape, len(round1Seeds), n)
}
sess, err := newPedersenSession(t, n)
if err != nil {
return nil, err
}
// Round 1: each party samples χ, builds Pedersen commits, seals shares.
round1 := make([]*dkgvss.Round1Out, n)
for i := 0; i < n; i++ {
out, err := sess.parties[i].Round1(seededRound1Rng(round1Seeds[i], i))
if err != nil {
return nil, fmt.Errorf("keyera: bootstrap-pedersen Round1[%d]: %w", i, err)
}
round1[i] = out
}
return finishBootstrapPedersen(suite, suiteID, t, n, validators, groupID, eraID, dkgParams, sessions, round1)
}
// finishBootstrapPedersen drives Rounds 1.5/2 and the Path (a) noise-
// flooding on a pre-computed set of Round 1 outputs. Tests use this
// entrypoint (via FinishBootstrapPedersen) to inject deliberately
// dishonest contributions and exercise the identifiable-abort path.
//
// The in-process orchestrator owns the dkg2 sessions and the round1
// outputs; in production each party drives its own. The transcript bytes
// are independent of which side runs the loop because every input is
// deterministic given the round1 outputs and the validator list.
func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, validators []string, groupID CoronaGroupID, eraID CoronaKeyEraID, dkgParams *dkg2.Params, sessions []*dkg2.DKGSession, round1 []*dkg2.Round1Output) (*KeyEra, *BootstrapTranscript, error) {
r := dkgParams.R
// Round 1.5 — each recipient computes its sender digests under the
// active suite. In a distributed deployment each digest is broadcast
// and cross-checked. The kernel runs in-process so digests are
// deterministic and pinned into the transcript.
// Commit digests under the corona HashSuite (Round 1.5 equivocation gate +
// transcript binding). Computed over the lattigo wire bytes of each dealer's
// commits — byte-identical to the prior dkg2 CommitDigest.
commits := make([][]dkgring.Vector, n)
digests := make([][32]byte, n)
for i := 0; i < n; i++ {
d, err := round1[i].CommitDigest(suite)
commits[i] = round1[i].Commits
d, err := coronaCommitDigest(suite, round1[i].Commits)
if err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen CommitDigest[%d]: %w", i, err)
return nil, fmt.Errorf("keyera: bootstrap-pedersen CommitDigest[%d]: %w", i, err)
}
digests[i] = d
}
// Round 2 — each recipient verifies every share/blind pair against
// the commits, then aggregates to its own (s_j, u_j, b_ped). On any
// identifiable abort we collect signed complaints and return.
//
// s shares per recipient (standard form, dimension sign.N).
sShares := make([]structs.Vector[ring.Poly], n)
// Open every sealed envelope: recipient j recovers (share, blind) from each
// dealer i. The opened values are byte-identical to the dealer's Horner
// evaluation f_i(j+1) / g_i(j+1).
inputs := make([]map[int]*dkgvss.DealerInput, n)
for j := 0; j < n; j++ {
shares := map[int]structs.Vector[ring.Poly]{}
blinds := map[int]structs.Vector[ring.Poly]{}
commits := map[int][]structs.Vector[ring.Poly]{}
row := make(map[int]*dkgvss.DealerInput, n)
for i := 0; i < n; i++ {
shares[i] = round1[i].Shares[j]
blinds[i] = round1[i].Blinds[j]
commits[i] = round1[i].Commits
}
sj, _, _, badID, err := sessions[j].Round2Identify(shares, blinds, commits)
if err != nil {
abortEv, abortErr := buildAbortEvidence(suite, digests, suiteID, n, t,
validators, j, badID, shares, blinds, commits)
if abortErr != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen Round2[%d] + abort-evidence: %v / %w", j, abortErr, err)
share, blind, err := sess.parties[j].OpenDealerShare(i, round1[i].Envelopes[j])
if err != nil {
return nil, fmt.Errorf("keyera: bootstrap-pedersen open[%d<-%d]: %w", j, i, err)
}
return nil, nil, fmt.Errorf("%w: %w", ErrBootstrapPedersenAbort,
abortEvidenceWrap(abortEv, err))
row[i] = &dkgvss.DealerInput{Commits: round1[i].Commits, Share: share, Blind: blind}
}
sShares[j] = sj
inputs[j] = row
}
// Path (a) noise flooding. Compute Lagrange weights at X=0 for the
// full committee T = {0, ..., n-1}; each party's contribution scales
// its share by λ_j so the aggregate equals s. Each party then adds a
// fresh Gaussian e_j' under σ'' = κ · σ_E · √n.
//
// The deterministic noise seed for e_j' is derived from the active
// HashSuite over the canonical transcript prefix so a KAT replay can
// reproduce the bootstrap byte-for-byte from a single entropy source.
A := sessions[0].APublic()
lagrange := computeFullCommitteeLagrange(r, n)
return &PedersenContributions{
session: sess,
commits: commits,
digests: digests,
inputs: inputs,
}, nil
}
// Build aggregated b in NTT-Mont form by summing per-party β_j.
// We also serialize each β_j for the transcript.
bSum := utils.InitializeVector(r, sign.M)
// finishPedersenContribs runs Round 3 (Pedersen verify + aggregate) for every
// recipient over the opened contributions, then the Path (a) noise flooding and
// KeyShare assembly. On the first dealer whose contribution fails verification
// it returns ErrBootstrapPedersenAbort with re-checkable blame evidence.
func finishPedersenContribs(suite hash.HashSuite, suiteID string, t, n int, validators []string, groupID CoronaGroupID, eraID CoronaKeyEraID, contribs *PedersenContributions) (*KeyEra, *BootstrapTranscript, error) {
sess := contribs.session
cr := sess.cr
digests := contribs.digests
// Round 3 — each recipient verifies every dealer's (share, blind) against
// the commits and aggregates its own secret share s_j. On any identifiable
// abort we mint a signed complaint and return.
sShares := make([]structs.Vector[lring.Poly], n)
for j := 0; j < n; j++ {
res, fault, err := sess.parties[j].Round3(contribs.inputs[j])
if err != nil {
if fault == nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen Round3[%d]: %w", j, err)
}
abortEv, abortErr := buildAbortEvidence(sess, suite, suiteID, digests, n, t, validators, fault)
if abortErr != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen Round3[%d] + abort-evidence: %v / %w", j, abortErr, err)
}
return nil, nil, fmt.Errorf("%w: %w", ErrBootstrapPedersenAbort, abortEvidenceWrap(abortEv, err))
}
sShares[j] = res.Share
}
// Path (a) noise flooding. Compute Lagrange weights at X=0 for the full
// committee T = {0, ..., n-1}; each party scales its share by λ_j so the
// aggregate equals s. Each party then adds a fresh Gaussian e_j' under
// σ″ = κ · σ_E · √n. The deterministic noise seed is derived from the
// active HashSuite over the canonical transcript prefix so a KAT replay can
// reproduce the bootstrap byte-for-byte from a single entropy source.
A := sess.profile.A
lagrange := computeFullCommitteeLagrange(cr, n)
bSum := utils.InitializeVector(cr, sign.M)
betaSerialized := make([][]byte, n)
noiseSigma, noiseBound := pathANoiseParameters(n)
@@ -282,35 +412,35 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
for j := 0; j < n; j++ {
// λ_j in NTT-Mont form for the multiplication.
lambda := r.NewPoly()
lambda := cr.NewPoly()
lambda.Copy(lagrange[j])
r.NTT(lambda, lambda)
r.MForm(lambda, lambda)
cr.NTT(lambda, lambda)
cr.MForm(lambda, lambda)
// NTT-Mont share s_j scaled by λ_j: λ_j · NTT-Mont(s_j).
// sShares[j] is in standard coefficient form; convert to NTT-Mont.
sNTT := make(structs.Vector[ring.Poly], sign.N)
sNTT := make(structs.Vector[lring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
sNTT[vi] = *sShares[j][vi].CopyNew()
r.NTT(sNTT[vi], sNTT[vi])
r.MForm(sNTT[vi], sNTT[vi])
cr.NTT(sNTT[vi], sNTT[vi])
cr.MForm(sNTT[vi], sNTT[vi])
}
// (λ_j · s_j) coordinate-wise.
scaled := make(structs.Vector[ring.Poly], sign.N)
scaled := make(structs.Vector[lring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
scaled[vi] = r.NewPoly()
r.MulCoeffsMontgomery(sNTT[vi], lambda, scaled[vi])
scaled[vi] = cr.NewPoly()
cr.MulCoeffsMontgomery(sNTT[vi], lambda, scaled[vi])
}
// β_j = A · (λ_j · s_j) + e_j' where e_j' ~ D(σ'').
Aprod := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, A, scaled, Aprod)
// β_j = A · (λ_j · s_j) + e_j' where e_j' ~ D(σ″).
Aprod := utils.InitializeVector(cr, sign.M)
utils.MatrixVectorMul(cr, A, scaled, Aprod)
ePrime := samplePathANoise(r, suite, noiseSeedTranscript, j, noiseSigma, noiseBound)
ePrime := samplePathANoise(cr, suite, noiseSeedTranscript, j, noiseSigma, noiseBound)
// ePrime is in NTT-Mont; β_j = Aprod + ePrime.
beta := utils.InitializeVector(r, sign.M)
utils.VectorAdd(r, Aprod, ePrime, beta)
beta := utils.InitializeVector(cr, sign.M)
utils.VectorAdd(cr, Aprod, ePrime, beta)
// Serialize β_j for the transcript.
buf, serr := serializeVector(beta)
@@ -320,17 +450,17 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
betaSerialized[j] = buf
// Accumulate.
utils.VectorAdd(r, bSum, beta, bSum)
utils.VectorAdd(cr, bSum, beta, bSum)
}
// b = Σ_j β_j = A·s + e'' in NTT-Mont. Convert to standard form,
// then round to bTilde under the QXi ring.
utils.ConvertVectorFromNTT(r, bSum)
bTilde := utils.RoundVector(r, dkgParams.RXi, bSum, sign.Xi)
// b = Σ_j β_j = A·s + e″ in NTT-Mont. Convert to standard form, then round
// to bTilde under the QXi ring.
utils.ConvertVectorFromNTT(cr, bSum)
bTilde := utils.RoundVector(cr, sess.crXi, bSum, sign.Xi)
// Build Corona threshold.GroupKey. The matrix A is the dkg2 A
// (nothing-up-my-sleeve), so the era is reproducible from the
// transcript alone.
// Build Corona threshold.GroupKey. The matrix A is the Ringtail A
// (nothing-up-my-sleeve, byte-identical to the prior dkg2 A), so the era is
// reproducible from the transcript alone.
thParams, err := threshold.NewParams()
if err != nil {
return nil, nil, fmt.Errorf("keyera: bootstrap-pedersen threshold params: %w", err)
@@ -341,11 +471,11 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
Params: thParams,
}
// Pairwise seeds and MAC keys. In a distributed deployment these come
// from authenticated pairwise KEX (reshare/pairwise.go); the kernel
// derives them deterministically from the transcript so KATs can
// replay. The bytes are independent of any secret share material —
// they form a public symmetric mask that the cohort agrees on.
// Pairwise seeds and MAC keys. In a distributed deployment these come from
// authenticated pairwise KEX (reshare/pairwise.go); the kernel derives them
// deterministically from the transcript so KATs can replay. The bytes are
// independent of any secret share material — they form a public symmetric
// mask the cohort agrees on (protection comes from each party's SkShare).
seedTranscript := suite.TranscriptHash(
[]byte("CORONA-BOOTSTRAP-PEDERSEN-PAIRWISE-v1"),
noiseSeedTranscript[:],
@@ -366,9 +496,9 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
}
for j, v := range validators {
// sShares[j] is in standard form; convert to NTT-Mont for the
// signing-time multiplication path (matches threshold.GenerateKeysTrustedDealer
// signing-time multiplication path (matches the trusted-dealer
// convention).
skNTT := make(structs.Vector[ring.Poly], sign.N)
skNTT := make(structs.Vector[lring.Poly], sign.N)
for vi := 0; vi < sign.N; vi++ {
skNTT[vi] = *sShares[j][vi].CopyNew()
}
@@ -417,21 +547,38 @@ func finishBootstrapPedersen(suite hash.HashSuite, suiteID string, t, n int, val
}, transcript, nil
}
// FinishBootstrapPedersen is the kernel entrypoint that takes a fully
// pre-computed set of Round1 outputs (one per party) and drives the
// remainder of the protocol: Round 1.5 digest computation, Round 2
// DealPedersen runs the share-dealing half of BootstrapPedersen (Round 1 +
// envelope opening) over the supplied per-party sampling seeds and returns the
// opened contributions. Tests use this to tamper one opened share before
// calling FinishBootstrapPedersen, exercising the identifiable-abort path.
//
// seeds must have exactly n = len(validators) entries of sign.KeySize bytes.
func DealPedersen(suite hash.HashSuite, t int, validators []string, seeds [][]byte) (*PedersenContributions, error) {
if len(validators) == 0 {
return nil, ErrEmptyValidators
}
n := len(validators)
if t < 1 || t > n {
return nil, fmt.Errorf("%w: t=%d n=%d", ErrInvalidThreshold, t, n)
}
if n < 2 || t >= n {
return nil, fmt.Errorf("%w: t=%d n=%d", ErrBootstrapPedersenShape, t, n)
}
if len(seeds) != n {
return nil, fmt.Errorf("%w: seeds=%d n=%d", ErrBootstrapPedersenShape, len(seeds), n)
}
return dealPedersen(hash.Resolve(suite), t, n, seeds)
}
// FinishBootstrapPedersen takes a fully assembled set of opened contributions
// (produced by DealPedersen) and drives the remainder of the protocol: Round 3
// verification, Path (a) noise flooding, and KeyShare assembly.
//
// The caller is responsible for producing the dkg2.Round1Output array;
// in production this is the per-party network broadcast. In tests, the
// caller may deliberately tamper a Round1 output to exercise the
// identifiable-abort path.
//
// The dkgParams and sessions arrays MUST correspond to the supplied
// round1 outputs (same n, same t, same A, B matrices).
// In production the contributions arrive over the network; in tests the caller
// may tamper one opened share to exercise the identifiable-abort path.
//
// suite=nil resolves to the production default (Corona-SHA3).
func FinishBootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID CoronaGroupID, eraID CoronaKeyEraID, dkgParams *dkg2.Params, sessions []*dkg2.DKGSession, round1 []*dkg2.Round1Output) (*KeyEra, *BootstrapTranscript, error) {
func FinishBootstrapPedersen(suite hash.HashSuite, t int, validators []string, groupID CoronaGroupID, eraID CoronaKeyEraID, contribs *PedersenContributions) (*KeyEra, *BootstrapTranscript, error) {
if len(validators) == 0 {
return nil, nil, ErrEmptyValidators
}
@@ -442,11 +589,11 @@ func FinishBootstrapPedersen(suite hash.HashSuite, t int, validators []string, g
if n < 2 || t >= n {
return nil, nil, fmt.Errorf("%w: t=%d n=%d", ErrBootstrapPedersenShape, t, n)
}
if len(sessions) != n || len(round1) != n {
return nil, nil, fmt.Errorf("%w: sessions=%d round1=%d, expected %d", ErrBootstrapPedersenShape, len(sessions), len(round1), n)
if contribs == nil || contribs.session == nil || len(contribs.inputs) != n {
return nil, nil, fmt.Errorf("%w: malformed contributions", ErrBootstrapPedersenShape)
}
suite = hash.Resolve(suite)
return finishBootstrapPedersen(suite, suite.ID(), t, n, validators, groupID, eraID, dkgParams, sessions, round1)
return finishPedersenContribs(suite, suite.ID(), t, n, validators, groupID, eraID, contribs)
}
// BootstrapTrustedDealer is the legacy single-trusted-party bootstrap
@@ -482,7 +629,7 @@ func BootstrapTrustedDealerWithSuite(suite hash.HashSuite, t int, validators []s
// pathANoiseParameters returns the (σ, bound) pair for the Path (a)
// noise-flooding sub-protocol over a committee of n parties. The slack
// reservation σ” = κ · σ_E · √n is the LP-073 §5 bound: it is large
// reservation σ″ = κ · σ_E · √n is the LP-073 §5 bound: it is large
// enough that the published β_j = A · (λ_j · s_j) + e_j' leaks no more
// information about s_j than a fresh LWE sample (the standard MLWE
// noise-flooding argument under DDH/MLWE).
@@ -499,10 +646,10 @@ func pathANoiseParameters(n int) (sigma, bound float64) {
//
// The Gaussian PRNG is seeded by HashSuite(noise-tag || party-index) so
// every honest party that re-derives the bootstrap transcript can verify
// the broadcast β_j byte-for-byte. In the production distributed
// protocol the seed comes from each party's own private RNG; here we
// derive deterministically for KAT-replay.
func samplePathANoise(r *ring.Ring, suite hash.HashSuite, transcript [32]byte, partyID int, sigma, bound float64) structs.Vector[ring.Poly] {
// the broadcast β_j byte-for-byte. In the production distributed protocol
// the seed comes from each party's own private RNG; here we derive
// deterministically for KAT-replay.
func samplePathANoise(r *lring.Ring, suite hash.HashSuite, transcript [32]byte, partyID int, sigma, bound float64) structs.Vector[lring.Poly] {
var partyBuf [4]byte
binary.BigEndian.PutUint32(partyBuf[:], uint32(partyID))
seed := suite.TranscriptHash(
@@ -511,8 +658,8 @@ func samplePathANoise(r *ring.Ring, suite hash.HashSuite, transcript [32]byte, p
partyBuf[:],
)
prng, _ := sampling.NewKeyedPRNG(seed[:])
gauss := ring.NewGaussianSampler(prng, r,
ring.DiscreteGaussian{Sigma: sigma, Bound: bound}, false)
gauss := lring.NewGaussianSampler(prng, r,
lring.DiscreteGaussian{Sigma: sigma, Bound: bound}, false)
return utils.SamplePolyVector(r, sign.M, gauss, true, true)
}
@@ -551,35 +698,27 @@ func derivePairwisePedersen(n int, transcriptSeed [32]byte) (map[int][][]byte, [
return seeds, macKeys
}
// buildAbortEvidence packages a single recipient's Round2Identify
// failure into a signed-complaint-shaped AbortEvidence record. The
// senderID, when ≥ 0, names the misbehaving sender that recipient j
// observed.
//
// The kernel does not sign the complaints (signing requires the wire-
// identity ed25519 key, which lives at the consensus layer); it
// constructs unsigned complaints that the consensus layer signs and
// broadcasts. Tests inject an in-memory wire key via the AbortEvidence
// callers, which is fine because the bootstrap is in-process.
func buildAbortEvidence(suite hash.HashSuite, digests [][32]byte, suiteID string, n, t int, validators []string, complainerID, senderID int, shares, blinds map[int]structs.Vector[ring.Poly], commits map[int][]structs.Vector[ring.Poly]) (*AbortEvidence, error) {
if senderID < 0 || senderID >= n {
// Wrap missing-input failures as ComplaintMissing — the recipient
// can still drive disqualification without naming a specific
// commit/share pair.
return &AbortEvidence{
TranscriptHash: suite.TranscriptHash(
[]byte(transcriptTag),
[]byte(suiteID),
framedJoinValidators(validators),
framedJoinUint32([]uint32{uint32(t), uint32(n)}),
framedJoin(digests),
),
Complaints: nil,
Disqualified: map[int]struct{}{
complainerID: {},
},
}, nil
// coronaCommitDigest computes the Round 1.5 commit digest of a dealer's commit
// vector under the corona HashSuite. The digest is taken over the lattigo wire
// bytes of the commits, byte-identical to the prior dkg2 Round1Output.CommitDigest
// (same tag, same suite-ID binding, same serialization), so the noise seed and
// transcript hash are preserved across the move to luxfi/dkg.
func coronaCommitDigest(suite hash.HashSuite, commits []dkgring.Vector) ([32]byte, error) {
var buf bytes.Buffer
for _, v := range commits {
if _, err := v.WriteTo(&buf); err != nil {
return [32]byte{}, err
}
}
return suite.TranscriptHash([]byte(tagCommitDigest), []byte(suite.ID()), buf.Bytes()), nil
}
// buildAbortEvidence packages a recipient's Round-3 VerifyFault into a signed
// blame complaint naming the offending dealer by NodeID, plus the disqualified
// committee index. The accuser is the recipient that observed the fault; it
// signs with its deterministic in-process identity. Any third party re-checks
// the accusation with vss.RecheckBadDelivery — no trust in the accuser.
func buildAbortEvidence(sess *pedersenSession, suite hash.HashSuite, suiteID string, digests [][32]byte, n, t int, validators []string, fault *dkgvss.VerifyFault) (*AbortEvidence, error) {
transcriptHash := suite.TranscriptHash(
[]byte(transcriptTag),
[]byte(suiteID),
@@ -587,20 +726,19 @@ func buildAbortEvidence(suite hash.HashSuite, digests [][32]byte, suiteID string
framedJoinUint32([]uint32{uint32(t), uint32(n)}),
framedJoin(digests),
)
c, err := dkg2.NewBadDeliveryComplaint(
transcriptHash,
senderID,
complainerID,
shares[senderID], blinds[senderID], commits[senderID],
accused := sess.nodes[fault.DealerIndex]
accuser := sess.nodes[fault.RecipientIndex]
c, err := dkgvss.NewBadDeliveryComplaint(
sess.profile, fault, transcriptHash, accused, accuser, sess.ids[fault.RecipientIndex],
)
if err != nil {
return nil, err
}
return &AbortEvidence{
TranscriptHash: transcriptHash,
Complaints: []*dkg2.Complaint{c},
Complaints: []*dkgblame.Complaint{c},
Disqualified: map[int]struct{}{
senderID: {},
fault.DealerIndex: {},
},
}, nil
}
@@ -679,9 +817,44 @@ func asBootstrapAbortErr(err error, out **bootstrapAbortErr) bool {
return false
}
// pedersenNodeID returns the deterministic committee NodeID for index i. The
// first two bytes encode the 1-based position; the rest are a fixed domain tag
// byte. NodeIDs are public committee labels, not secrets.
func pedersenNodeID(i int) dkgchannel.NodeID {
var id dkgchannel.NodeID
binary.BigEndian.PutUint16(id[:2], uint16(i+1))
id[2] = 0xC0
return id
}
// derivedStream returns a deterministic unbounded byte stream from a domain tag
// and a 32-bit selector (party index). Used to seed channel identities and KEM
// randomness reproducibly.
func derivedStream(tag string, sel uint32) io.Reader {
h := blake3.New()
_, _ = h.Write([]byte(tag))
_, _ = h.Write(u32be(sel))
return h.Digest()
}
// seededRound1Rng returns the reader vss.Round1 consumes: the exact 32-byte
// sampling seed first (so the χ draws — hence commits and shares — match the
// prior dkg2 path byte-for-byte), then a deterministic KEM-randomness tail
// (which affects only the immediately-opened envelope ciphertext).
func seededRound1Rng(seed []byte, partyIndex int) io.Reader {
return io.MultiReader(bytes.NewReader(seed), derivedStream(kemStreamTag, uint32(partyIndex)))
}
// u32be returns the 4-byte big-endian encoding of x.
func u32be(x uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], x)
return b[:]
}
// serializeVector returns the canonical wire bytes of a vector via the
// lattigo WriteTo. Length-prefixed so concatenation is unambiguous.
func serializeVector(v structs.Vector[ring.Poly]) ([]byte, error) {
func serializeVector(v structs.Vector[lring.Poly]) ([]byte, error) {
var inner []byte
buf := bytesBuffer{}
if _, err := v.WriteTo(&buf); err != nil {
@@ -695,7 +868,7 @@ func serializeVector(v structs.Vector[ring.Poly]) ([]byte, error) {
}
// bTildeBytes returns the canonical wire bytes of the final bTilde.
func bTildeBytes(bTilde structs.Vector[ring.Poly]) []byte {
func bTildeBytes(bTilde structs.Vector[lring.Poly]) []byte {
buf := bytesBuffer{}
if _, err := bTilde.WriteTo(&buf); err != nil {
// WriteTo on a valid Vector cannot fail with a bytesBuffer.
@@ -764,14 +937,3 @@ func (b *bytesBuffer) Write(p []byte) (int, error) {
}
func (b *bytesBuffer) Bytes() []byte { return b.b }
// computeFullCommitteeLagrange (re-exported here for the path-(a) noise-
// flood path) computes Lagrange coefficients λ_j evaluated at X = 0 for
// the full committee {0, 1, ..., n-1} (1-indexed evaluation points
// 1..n). The result is in standard coefficient form; callers convert to
// NTT-Mont as needed.
//
// Defined in keyera.go alongside the trusted-dealer Bootstrap; redeclared
// here so the function reads as one cohesive Pedersen module.
var _ = primitives.ComputeLagrangeCoefficients
var _ = (*big.Int)(nil)
+40 -37
View File
@@ -8,10 +8,13 @@ import (
"reflect"
"testing"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/sign"
"github.com/luxfi/corona/threshold"
dkgblame "github.com/luxfi/dkg/blame"
dkgring "github.com/luxfi/dkg/ring"
dkgvss "github.com/luxfi/dkg/vss"
)
// TestBootstrapPedersen_RoundTrip — 5 parties run Pedersen DKG with
@@ -126,41 +129,27 @@ func TestBootstrapPedersen_DishonestDealer(t *testing.T) {
validators := []string{"v1", "v2", "v3", "v4", "v5"}
suite := hash.NewCoronaSHA3()
// Reproduce Round1 outputs via the dkg2 API so we can tamper one.
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sessions := make([]*dkg2.DKGSession, n)
round1 := make([]*dkg2.Round1Output, n)
// Run the share-dealing half (vss Round 1 + envelope opening) so we can
// tamper one opened share before the verify/aggregate round.
rng := deterministicRand("bootstrap-pedersen-dishonest")
seeds := make([][]byte, n)
for i := 0; i < n; i++ {
sess, err := dkg2.NewDKGSession(dkgParams, i, n, thr, suite)
if err != nil {
t.Fatalf("NewDKGSession[%d]: %v", i, err)
}
sessions[i] = sess
seed := make([]byte, sign.KeySize)
if _, err := rng.Read(seed); err != nil {
seeds[i] = make([]byte, sign.KeySize)
if _, err := rng.Read(seeds[i]); err != nil {
t.Fatalf("rng.Read[%d]: %v", i, err)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed[%d]: %v", i, err)
}
round1[i] = out
}
contribs, err := DealPedersen(suite, thr, validators, seeds)
if err != nil {
t.Fatalf("DealPedersen: %v", err)
}
// Tamper sender 2's share-to-recipient-0. Round2Identify run from
// the perspective of recipient 0 must name sender 2 as the bad
// actor. The orchestrator runs every recipient in order, so
// recipient 0 detects the abort first.
round1[2].Shares[0][0].Coeffs[0][0] ^= 0x42
// Tamper sender 2's share as recipient 0 opened it. Round 3 from the
// perspective of recipient 0 (processed first) must name sender 2 as the
// bad actor via the Pedersen identity.
contribs.inputs[0][2].Share[0].Coeffs[0][0] ^= 0x42
era, transcript, err := FinishBootstrapPedersen(
suite, thr, validators, 0, 0,
dkgParams, sessions, round1,
)
era, transcript, err := FinishBootstrapPedersen(suite, thr, validators, 0, 0, contribs)
if era != nil || transcript != nil {
t.Fatal("expected (nil, nil) on abort")
}
@@ -181,12 +170,26 @@ func TestBootstrapPedersen_DishonestDealer(t *testing.T) {
if len(ev.Complaints) == 0 {
t.Fatal("expected at least one Complaint in evidence")
}
if ev.Complaints[0].SenderID != 2 {
t.Fatalf("complaint.SenderID: want 2 got %d", ev.Complaints[0].SenderID)
}
if ev.Complaints[0].Reason != dkg2.ComplaintBadDelivery {
if ev.Complaints[0].Reason != dkgblame.ReasonBadDelivery {
t.Fatalf("complaint.Reason: want bad-delivery got %v", ev.Complaints[0].Reason)
}
if ev.Complaints[0].Accused != pedersenNodeID(2) {
t.Fatalf("complaint.Accused: want NodeID of dealer 2, got %x", ev.Complaints[0].Accused)
}
// Third-party re-check: the complaint's evidence confirms the share is
// malformed without trusting the accuser.
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
justified, err := dkgvss.RecheckBadDelivery(prof, ev.Complaints[0])
if err != nil {
t.Fatalf("RecheckBadDelivery: %v", err)
}
if !justified {
t.Fatal("RecheckBadDelivery says the accusation is unjustified — tamper not detected")
}
// The transcript hash on the evidence is non-zero — chain can
// commit the abort transcript and stay at previous epoch.
@@ -247,16 +250,16 @@ func TestBootstrapPedersen_NoMasterSecretInMemory(t *testing.T) {
t.Fatalf("BootstrapPedersen: %v", err)
}
// Structural assertion 1: dkg2.DKGSession exposes no "master secret"
// accessor.
dkgType := reflect.TypeOf((*dkg2.DKGSession)(nil)).Elem()
// Structural assertion 1: the vss.Party exposes no "master secret"
// accessor (no-reconstruct: a party retains only its own aggregated share).
dkgType := reflect.TypeOf((*dkgvss.Party)(nil)).Elem()
for i := 0; i < dkgType.NumField(); i++ {
f := dkgType.Field(i)
// Reject any field whose lower-cased name contains the substring
// "mastersecret" or "masters".
lower := lowercase(f.Name)
if contains(lower, "mastersecret") || contains(lower, "fullsecret") {
t.Fatalf("dkg2.DKGSession has a forbidden field %q — master-secret state leaks", f.Name)
t.Fatalf("dkgvss.Party has a forbidden field %q — master-secret state leaks", f.Name)
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyera
// golden_bootstrap_kat_test.go — frozen byte-stability KAT for BootstrapPedersen.
//
// These digests were captured from the dkg2-backed BootstrapPedersen at the
// Phase-3a cutover point (commit "luxfi/dkg v0.2.0 cutover gate"). They are the
// byte-stability target the vss-backed rewire MUST reproduce EXACTLY: the
// group public key bytes (bTilde), the public bootstrap transcript hash, and
// the per-validator secret-share bytes. If any of these change, the rewire has
// silently altered a deployed-cert-bearing value and the change is a BUG, not a
// re-baseline (see HANDOFF-PHASE3.md PIN 4 + the cutover gate).
//
// The rewire keeps corona's own Path-(a) noise flooding + Round_Xi finalize
// (the group-key convention) and uses luxfi/dkg only for the no-reconstruct
// share-dealing, so these digests are PRESERVED, not regenerated.
import (
"crypto/sha256"
"encoding/hex"
"testing"
"github.com/luxfi/corona/hash"
)
type goldenBootstrapVec struct {
name string
thr, n int
vals []string
seed string
bTilde string // SHA-256 of transcript.BTildeBytes
transcript string // hex of transcript.TranscriptHash
shares string // SHA-256 of concatenated per-validator SkShare coeffs (LE-u64), validator order
}
// goldenBootstrapVecs were captured from the dkg2-backed path at cutover. The
// vss-backed BootstrapPedersen must reproduce them byte-for-byte.
var goldenBootstrapVecs = []goldenBootstrapVec{
{
name: "5/3", thr: 3, n: 5,
vals: []string{"v1", "v2", "v3", "v4", "v5"}, seed: "golden-kat-v1",
bTilde: "7c0262d38cc4ce81143a404bb85c8aeb606b44302250ca86ae4f03ed535e4a69",
transcript: "874e5b951fcc021d6af267915fdb946409c092e3cfd886f2ef0bfccb64cda51d",
shares: "40e6e4a16a827fba45b0ee516a97102d4c1092e0d6f5635c02e3d64c2cf37565",
},
{
name: "3/2", thr: 2, n: 3,
vals: []string{"a", "b", "c"}, seed: "golden-kat-v2",
bTilde: "23a376fccab01479da6ede1793b65b6fea0fa827a591aa156504c49d7986cb84",
transcript: "28574e0d0f44c25e0168844a740d96037f055adae972982e5af398c90b430a53",
shares: "d231a7264d22a8adc8777e2a737372e851b5dbac9d909bfad6a02965f29870ec",
},
}
// TestBootstrapPedersen_GoldenByteStability pins BootstrapPedersen's public
// output to frozen digests. It is the permanent proof that the vss rewire
// preserves the group key, transcript, and shares byte-for-byte.
func TestBootstrapPedersen_GoldenByteStability(t *testing.T) {
for _, v := range goldenBootstrapVecs {
t.Run(v.name, func(t *testing.T) {
era, tr, err := BootstrapPedersen(
hash.NewCoronaSHA3(), v.thr, v.vals, 0, 0, deterministicRand(v.seed),
)
if err != nil {
t.Fatalf("BootstrapPedersen: %v", err)
}
gotBTilde := sha256.Sum256(tr.BTildeBytes)
if got := hex.EncodeToString(gotBTilde[:]); got != v.bTilde {
t.Fatalf("bTilde digest drift:\n want %s\n got %s", v.bTilde, got)
}
if got := hex.EncodeToString(tr.TranscriptHash[:]); got != v.transcript {
t.Fatalf("transcript hash drift:\n want %s\n got %s", v.transcript, got)
}
h := sha256.New()
for _, name := range v.vals {
ks := era.State.Shares[name]
if ks == nil {
t.Fatalf("missing share for %s", name)
}
for _, p := range ks.SkShare {
for _, c := range p.Coeffs[0] {
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(c >> (8 * i))
}
h.Write(b[:])
}
}
}
if got := hex.EncodeToString(h.Sum(nil)); got != v.shares {
t.Fatalf("shares digest drift:\n want %s\n got %s", v.shares, got)
}
})
}
}
+14 -12
View File
@@ -135,15 +135,16 @@ type EpochShareState struct {
// Bootstrap opens a new key era WITHOUT a trusted dealer.
//
// TRUST MODEL — PUBLIC-BFT-SAFE (default).
// This function routes the keygen ceremony through `dkg2/` (Pedersen-
// DKG over R_q) + Path (a) noise flooding so no single party ever
// holds the master secret s at any point in the ceremony. Every
// validator runs `dkg2.Round1` independently; the per-party noise
// contributions aggregate into a Corona-Sign-shaped public key
// This function routes the keygen ceremony through the shared
// github.com/luxfi/dkg Pedersen-VSS (vss, parameterized by
// ring.Ringtail()) + corona's Path (a) noise flooding so no single
// party ever holds the master secret s at any point in the ceremony.
// Every validator runs the vss Round 1 independently; the per-party
// noise contributions aggregate into a Corona-Sign-shaped public key
// `bTilde = Round_Xi(A·s + e”)` where each party adds one Gaussian
// `e_j'` slice under σ” = κ·σ_E·√n. Identifiable abort: a
// misbehaving sender is named in a signed `dkg2.Complaint` carrying
// re-checkable Pedersen evidence; the chain commits the abort
// misbehaving sender is named in a signed luxfi/dkg blame complaint
// carrying re-checkable Pedersen evidence; the chain commits the abort
// transcript and stays at the previous epoch.
//
// On success returns (era, transcript, nil). The transcript is the
@@ -376,11 +377,12 @@ func (era *KeyEra) Reshare(newValidators []string, newThreshold int, randSource
// Corona-SHA3) call ReanchorWithSuite.
//
// TRUST MODEL — PUBLIC-BFT-SAFE.
// This function routes through dkg2/ (Pedersen-DKG over R_q) so no
// party ever holds the master secret s for the new era at any point
// in the rotation. The dealer that previously held s for the prior
// era is NOT re-trusted; every validator runs dkg2.Round1
// independently and Path (a) noise flooding aggregates the result.
// This function routes through the shared github.com/luxfi/dkg
// Pedersen-VSS (vss) so no party ever holds the master secret s for
// the new era at any point in the rotation. The dealer that previously
// held s for the prior era is NOT re-trusted; every validator runs the
// vss Round 1 independently and Path (a) noise flooding aggregates the
// result.
//
// LEGACY ALTERNATIVE: use ReanchorTrustedDealer, which runs the
// single-dealer Shamir share-out (byte-equivalent to the v0.7.3 and
+128
View File
@@ -0,0 +1,128 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyera
// pin4_convention_test.go — the PIN-4 group-key NTT-convention assertion
// (HANDOFF-PHASE3.md). It is the durable, dkg2-free record of WHY the move to
// luxfi/dkg preserved corona's group key.
//
// Two ways to materialize the aggregated Pedersen commit ΣC ∈ R_q^K (plain-NTT)
// into coefficient form:
//
// - IMForm + INTT : the historical corona/dkg2 Round2 b_ped path. Because the
// commits are plain-NTT (Montgomery cancels in the commit multiply), the
// extra IMForm injects one spurious R^{-1}, so this is R^{-1}-scaled.
// - INTT only : the luxfi/dkg vss group-commit T path — the TRUE A·s1+B·u.
//
// The two differ by EXACTLY the Montgomery factor R (MForm(IMForm+INTT) == INTT).
// corona's PRODUCTION group key is NEITHER: keyera Path-(a) noise flooding
// multiplies NTT-Mont operands (A · NTT-Mont(λ_j·s_j)), so the surviving R is
// correctly removed by ConvertVectorFromNTT, yielding the TRUE A·s+e'' —
// convention-aligned with vss's TRUE T. The dkg2 R^{-1}-scaled b_ped was an
// internal Round2 value the production path always discarded, which is why the
// rewire (keep corona's Path-(a) finalize, use vss only for share-dealing)
// preserves the group key with NO convention change.
import (
"testing"
"github.com/luxfi/corona/sign"
dkgring "github.com/luxfi/dkg/ring"
lring "github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// polysEqual reports byte-equality of two single-modulus polynomials over all
// coefficients (Coeffs[0]); the corona ring is single-level so this is total.
func polysEqual(a, b lring.Poly) bool {
if len(a.Coeffs) != len(b.Coeffs) {
return false
}
for lvl := range a.Coeffs {
if len(a.Coeffs[lvl]) != len(b.Coeffs[lvl]) {
return false
}
for i := range a.Coeffs[lvl] {
if a.Coeffs[lvl][i] != b.Coeffs[lvl][i] {
return false
}
}
}
return true
}
func vecsEqual(a, b structs.Vector[lring.Poly]) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !polysEqual(a[i], b[i]) {
return false
}
}
return true
}
// TestPin4_GroupKeyConvention pins the exact NTT-domain relationship between the
// two group-commit conventions against ground truth (see file header).
func TestPin4_GroupKeyConvention(t *testing.T) {
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
r := prof.Ring
// Deterministic plain-NTT "aggregated commit" ΣC ∈ R_q^K: build it as a real
// commit A·NTT(c) + B·NTT(r) so it lives in the exact commit domain.
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x33*i + 1)
}
prng, err := dkgring.NewKeyedPRNG(seed)
if err != nil {
t.Fatalf("NewKeyedPRNG: %v", err)
}
c := prof.SampleSecretVec(prng)
rr := prof.SampleSecretVec(prng)
cN := dkgring.CopyVec(c)
rN := dkgring.CopyVec(rr)
dkgring.NTTVec(r, cN)
dkgring.NTTVec(r, rN)
ac := dkgring.NewVec(r, prof.K)
br := dkgring.NewVec(r, prof.K)
dkgring.MatVecMul(r, prof.A, cN, ac)
dkgring.MatVecMul(r, prof.B, rN, br)
sumC := dkgring.NewVec(r, prof.K) // plain-NTT aggregated commit
dkgring.VecAdd(r, ac, br, sumC)
// vss convention: INTT only → TRUE A·s1+B·u (standard coeff form).
vssT := dkgring.CopyVec(sumC)
dkgring.ConvertVecFromNTT(r, vssT) // INTT only
// dkg2 convention: IMForm + INTT → R^{-1}-scaled.
base := prof.Ring.Base()
dkg2T := dkgring.CopyVec(sumC)
for i := range dkg2T {
base.IMForm(dkg2T[i], dkg2T[i])
base.INTT(dkg2T[i], dkg2T[i])
}
// They must DIFFER (the conventions genuinely diverge)...
if vecsEqual(vssT, dkg2T) {
t.Fatal("expected the IMForm+INTT and INTT-only conventions to differ; they did not")
}
// ...and differ by EXACTLY the Montgomery factor R: MForm(dkg2T) == vssT.
reMont := dkgring.CopyVec(dkg2T)
for i := range reMont {
base.MForm(reMont[i], reMont[i])
}
if !vecsEqual(reMont, vssT) {
t.Fatal("MForm(dkg2 b_ped pre-round) != vss T: the conventions do not differ by exactly R")
}
t.Log("PIN 4 confirmed: dkg2 b_ped (IMForm+INTT) = vss T (INTT-only) scaled by R^{-1}; " +
"production keyera Path-(a) key is the TRUE A·s+e'' (R-cancelled), convention-aligned with vss T")
}
+3 -2
View File
@@ -12,8 +12,9 @@ import (
// ReanchorPedersen opens a new key era WITHOUT a trusted dealer.
//
// This is the public-BFT-safe Reanchor entrypoint: the keygen ceremony
// for the new era routes through dkg2/ (Pedersen-DKG over R_q) + Path
// (a) noise flooding, exactly like BootstrapPedersen does at chain
// for the new era routes through the shared github.com/luxfi/dkg
// Pedersen-VSS (vss) + Path (a) noise flooding, exactly like
// BootstrapPedersen does at chain
// genesis. No party ever holds the master secret s for the new era at
// any point in the rotation. The previous era's GroupKey is discarded
// (Reanchor is the only lifecycle operation that may do so).
+19 -31
View File
@@ -8,9 +8,11 @@ import (
"reflect"
"testing"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/sign"
dkgblame "github.com/luxfi/dkg/blame"
dkgvss "github.com/luxfi/dkg/vss"
)
// TestReanchorPedersen_RoundTrip — open a Pedersen-DKG era, rotate to a
@@ -130,16 +132,16 @@ func TestReanchorPedersen_NoMasterSecret(t *testing.T) {
t.Fatalf("ReanchorPedersen: %v", err)
}
// Structural assertion 1: dkg2.DKGSession exposes no master-secret
// Structural assertion 1: the vss.Party exposes no master-secret
// accessor field. This is shared with BootstrapPedersen but worth
// re-asserting on the Reanchor path so a future regression on
// either is caught.
dkgType := reflect.TypeOf((*dkg2.DKGSession)(nil)).Elem()
dkgType := reflect.TypeOf((*dkgvss.Party)(nil)).Elem()
for i := 0; i < dkgType.NumField(); i++ {
f := dkgType.Field(i)
lower := lowercase(f.Name)
if contains(lower, "mastersecret") || contains(lower, "fullsecret") {
t.Fatalf("dkg2.DKGSession has a forbidden field %q — master-secret state leaks", f.Name)
t.Fatalf("dkgvss.Party has a forbidden field %q — master-secret state leaks", f.Name)
}
}
@@ -187,42 +189,28 @@ func TestReanchorPedersen_DishonestParty(t *testing.T) {
validators := []string{"v1", "v2", "v3", "v4", "v5"}
suite := hash.NewCoronaSHA3()
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sessions := make([]*dkg2.DKGSession, n)
round1 := make([]*dkg2.Round1Output, n)
rng := deterministicRand("reanchor-pedersen-dishonest")
seeds := make([][]byte, n)
for i := 0; i < n; i++ {
sess, err := dkg2.NewDKGSession(dkgParams, i, n, thr, suite)
if err != nil {
t.Fatalf("NewDKGSession[%d]: %v", i, err)
}
sessions[i] = sess
seed := make([]byte, sign.KeySize)
if _, err := rng.Read(seed); err != nil {
seeds[i] = make([]byte, sign.KeySize)
if _, err := rng.Read(seeds[i]); err != nil {
t.Fatalf("rng.Read[%d]: %v", i, err)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed[%d]: %v", i, err)
}
round1[i] = out
}
contribs, err := DealPedersen(suite, thr, validators, seeds)
if err != nil {
t.Fatalf("DealPedersen: %v", err)
}
// Tamper sender 4's share-to-recipient-1. Round2Identify from
// Tamper sender 4's share as recipient 1 opened it. Round 3 from
// recipient 1's perspective must name sender 4 as the bad actor.
round1[4].Shares[1][0].Coeffs[0][0] ^= 0x42
contribs.inputs[1][4].Share[0].Coeffs[0][0] ^= 0x42
// The Reanchor abort path shares its semantics with the Bootstrap
// abort path: FinishBootstrapPedersen is the kernel entrypoint and
// the wrapping for era-id / epoch adjustments in ReanchorPedersen
// occurs strictly AFTER a successful return.
era, transcript, err := FinishBootstrapPedersen(
suite, thr, validators, 0, 1,
dkgParams, sessions, round1,
)
era, transcript, err := FinishBootstrapPedersen(suite, thr, validators, 0, 1, contribs)
if era != nil || transcript != nil {
t.Fatal("expected (nil, nil) on Reanchor abort")
}
@@ -242,10 +230,10 @@ func TestReanchorPedersen_DishonestParty(t *testing.T) {
if len(ev.Complaints) == 0 {
t.Fatal("expected at least one Complaint in evidence")
}
if ev.Complaints[0].SenderID != 4 {
t.Fatalf("complaint.SenderID: want 4 got %d", ev.Complaints[0].SenderID)
if ev.Complaints[0].Accused != pedersenNodeID(4) {
t.Fatalf("complaint.Accused: want NodeID of dealer 4, got %x", ev.Complaints[0].Accused)
}
if ev.Complaints[0].Reason != dkg2.ComplaintBadDelivery {
if ev.Complaints[0].Reason != dkgblame.ReasonBadDelivery {
t.Fatalf("complaint.Reason: want bad-delivery got %v", ev.Complaints[0].Reason)
}
if ev.TranscriptHash == [32]byte{} {
+2 -1
View File
@@ -34,7 +34,8 @@ import (
// engine layer, bypassing the per-poly r.NTT() pinch point. That
// kernel slot exists (see lattice/gpu/gpu_cgo.go::BatchNTT) but is
// not yet plumbed through r.NTT — that's the v0.6+ NIST submission
// pipeline work referenced in corona/dkg2/dkg2_gpu_accel.go.
// pipeline work (the dealerless DKG GPU path now lives in the shared
// github.com/luxfi/dkg library).
//
// CPU vs GPU pairs (one bench function each) let `go test -bench .`
// emit a side-by-side comparison without bench-fixture trickery; the