refactor(corona): delete the dkg2 package — superseded by luxfi/dkg vss

The Pedersen-VSS DKG share-dealing now runs entirely on github.com/luxfi/dkg
(proven byte-identical at cutover); corona's own copy is dead. Remove it and its
now-orphaned tooling, keeping only corona-specific code (Ringtail signing, β
rounding, Path (a) finalize — all in sign/, utils/, keyera/, untouched).

Deleted:
  - dkg2/ (the whole package: Round1/Round2/commits/complaint/GPU dispatch).
  - cmd/dkg2_oracle/ (the dkg2 KAT generator; the DKG KAT now lives in
    luxfi/dkg's own ring/vss vectors).
  - keyera/cutover_gate_test.go (the transitional dkg2-vs-vss equality gate; its
    job is done — permanent byte-stability is pinned by the golden KAT, and the
    PIN-4 convention assertion is preserved dkg2-free in pin4_convention_test.go).

Updated:
  - cmd/cross_runtime_oracle drops the dkg2 manifest leg (the luxcpp C++ dkg2
    cross-runtime gate migrates to luxfi/dkg separately — flagged, not silently
    broken).
  - keyera/reanchor doc comments + threshold bench comment point at luxfi/dkg.

go build ./... + go test ./... -count=1 green (13 ok / 0 fail), go vet + gofmt
clean. The golden byte-stability KAT + Bootstrap→sign→verify + dishonest-dealer
abort all still pass on the vss path.
This commit is contained in:
zeekay
2026-06-27 22:03:55 -07:00
parent 259a1a2cae
commit 2cecc08c63
16 changed files with 155 additions and 4056 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 -1
View File
@@ -259,7 +259,7 @@ func TestBootstrapPedersen_NoMasterSecretInMemory(t *testing.T) {
// "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)
}
}
-440
View File
@@ -1,440 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyera
// cutover_gate_test.go — the Phase-3a cutover gate for replacing corona/dkg2's
// hand-rolled Pedersen-VSS DKG with the shared github.com/luxfi/dkg library
// (vss + ring), parameterized by ring.Ringtail().
//
// Before any rewire, this gate proves — byte-for-byte — that luxfi/dkg's
// Ringtail binding reproduces corona/dkg2's KAT-pinned arithmetic:
//
// (A) the public Pedersen matrices A, B are byte-identical;
// (B) vss's REAL Round1 produces byte-identical Pedersen commits for the same
// per-party sampling seed (same χ sampler, same NTT/Mont convention);
// (C) the secret share f_i(j) (Horner eval) is byte-identical;
// (D) the group-key NTT convention: dkg2's Round2 b_ped uses IMForm+INTT
// (R^{-1}-scaled), vss's group commit T uses INTT-only (TRUE A·s1+B·u);
// the two differ by EXACTLY the Montgomery factor R. corona's PRODUCTION
// group key (keyera Path-(a) noise flooding) is the TRUE A·s+e'' — it does
// NOT use dkg2's b_ped — so it is convention-aligned with vss's TRUE T.
//
// (A)(C) are the share-dealing core: if they hold, a vss-backed Bootstrap that
// keeps corona's own Path-(a) finalize preserves the group key + shares
// byte-for-byte. (D) pins PIN 4 of HANDOFF-PHASE3.md against ground truth.
import (
"bytes"
"crypto/rand"
"io"
"testing"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/sign"
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/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
}
// TestCutover_RingtailMatricesByteIdentical — gate (A). luxfi/dkg's
// ring.Ringtail() must derive the SAME public Pedersen matrices A, B as
// corona/dkg2.DeriveA/DeriveB (both BLAKE3("corona.dkg2.{A,B}.v1") → KeyedPRNG
// → uniform → NTT-Mont). This is the cutover gate: matrix divergence would make
// every commit, share, and group key diverge.
func TestCutover_RingtailMatricesByteIdentical(t *testing.T) {
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
coronaA := dkg2.DeriveA(dkgParams.R)
coronaB := dkg2.DeriveB(dkgParams.R)
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
if len(prof.A) != len(coronaA) || len(prof.B) != len(coronaB) {
t.Fatalf("matrix row mismatch: A %d/%d B %d/%d",
len(prof.A), len(coronaA), len(prof.B), len(coronaB))
}
for i := range coronaA {
for j := range coronaA[i] {
if !polysEqual(prof.A[i][j], coronaA[i][j]) {
t.Fatalf("A[%d][%d] byte-divergent between Ringtail() and dkg2.DeriveA", i, j)
}
if !polysEqual(prof.B[i][j], coronaB[i][j]) {
t.Fatalf("B[%d][%d] byte-divergent between Ringtail() and dkg2.DeriveB", i, j)
}
}
}
t.Logf("Ringtail() A,B byte-identical to dkg2.DeriveA/DeriveB over all %dx%d slots", len(coronaA), len(coronaA[0]))
}
// seededGateRng returns a reader that yields seed first (the 32 bytes vss
// Party.Round1 consumes as its sampling seed) then a deterministic tail (KEM
// encapsulation randomness — does NOT affect the commits, which are computed
// before any sealing). This makes vss's χ draws identical to a corona/dkg2
// Round1WithSeed(seed) run.
func seededGateRng(seed []byte) io.Reader {
tail := make([]byte, 1<<16)
// deterministic, distinct from seed; KEM randomness only.
for i := range tail {
tail[i] = byte(0x5A ^ (i & 0xFF))
}
return io.MultiReader(bytes.NewReader(seed), bytes.NewReader(tail))
}
// buildVSSParty0 constructs party 0 of an n-party vss committee with a directory
// of n fresh identities (identities affect only the sealed envelopes, never the
// public commits). Returns the party ready for Round1.
func buildVSSParty0(t *testing.T, prof *dkgring.Profile, n, thr int) *dkgvss.Party {
return buildVSSPartyAt(t, prof, 0, n, thr)
}
// buildVSSPartyAt constructs party idx of an n-party vss committee with a fresh
// identity directory. Identities affect only sealed envelopes; gate E drives
// Round3 with DealerInputs built directly from dkg2's (gate-B/C-proven identical)
// cleartext contributions, so envelope decryption is not exercised — only vss's
// real verifyPedersen + aggregateShare.
func buildVSSPartyAt(t *testing.T, prof *dkgring.Profile, idx, n, thr int) *dkgvss.Party {
t.Helper()
ids := make([]*dkgchannel.IdentityKey, n)
nodes := make([]dkgchannel.NodeID, n)
entries := make(map[dkgchannel.NodeID]*dkgchannel.IdentityPublicKey, n)
for i := 0; i < n; i++ {
id, err := dkgchannel.GenerateIdentity(rand.Reader)
if err != nil {
t.Fatalf("GenerateIdentity[%d]: %v", i, err)
}
ids[i] = id
nodes[i] = dkgchannel.NodeID{byte(i + 1), 0xC0}
entries[nodes[i]] = id.PublicKey()
}
dir, err := dkgchannel.NewIdentityDirectory(entries)
if err != nil {
t.Fatalf("NewIdentityDirectory: %v", err)
}
p, err := dkgvss.NewParty(prof, nodes[idx], ids[idx], idx, n, thr, nodes, dir, [32]byte{0xCA, 0xFE})
if err != nil {
t.Fatalf("NewParty: %v", err)
}
return p
}
// TestCutover_Round1CommitsByteIdentical — gate (B). vss's REAL Party.Round1
// (which internally samples χ, builds Pedersen commits C_k = A·NTT(c_k)+B·NTT(r_k))
// must produce commits byte-identical to corona/dkg2.Round1WithSeed for the same
// 32-byte sampling seed. This exercises vss's actual computeCommits path, not a
// reimplementation.
func TestCutover_Round1CommitsByteIdentical(t *testing.T) {
const n, thr = 5, 3
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x11*i + 7)
}
// corona/dkg2 reference.
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sess, err := dkg2.NewDKGSession(dkgParams, 0, n, thr, hash.NewCoronaSHA3())
if err != nil {
t.Fatalf("NewDKGSession: %v", err)
}
coronaOut, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed: %v", err)
}
// vss real Round1 with the same sampling seed.
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
party := buildVSSParty0(t, prof, n, thr)
vssOut, err := party.Round1(seededGateRng(seed))
if err != nil {
t.Fatalf("vss Party.Round1: %v", err)
}
if len(vssOut.Commits) != len(coronaOut.Commits) {
t.Fatalf("commit count: vss=%d corona=%d", len(vssOut.Commits), len(coronaOut.Commits))
}
for k := range coronaOut.Commits {
if !vecsEqual(vssOut.Commits[k], coronaOut.Commits[k]) {
t.Fatalf("commit[%d] byte-divergent between vss Round1 and dkg2 Round1WithSeed", k)
}
}
t.Logf("vss Party.Round1 commits byte-identical to dkg2.Round1WithSeed over all %d Pedersen commits", len(coronaOut.Commits))
}
// TestCutover_HornerShareByteIdentical — gate (C). The secret share f_i(j) =
// Σ_k c_{i,k}·(j+1)^k must be byte-identical. corona/dkg2 computes it with a
// big.Int Horner (polyMulScalar/polyAddCoeffwise); vss computes it with the
// lattice ring's Barrett-reduced ScalarMulVec + VecAdd. Both yield the canonical
// representative in [0,q). This replicates vss's hornerEval (3 exported-op lines)
// because vss seals the share into an envelope rather than exposing it; the
// arithmetic under test is vss's own ring.ScalarMulVec / ring.VecAdd.
func TestCutover_HornerShareByteIdentical(t *testing.T) {
const n, thr = 5, 3
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x09*i + 3)
}
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sess, err := dkg2.NewDKGSession(dkgParams, 0, n, thr, hash.NewCoronaSHA3())
if err != nil {
t.Fatalf("NewDKGSession: %v", err)
}
coronaOut, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed: %v", err)
}
// Re-sample the SAME c_{i,k} via luxfi/dkg's Ringtail χ sampler (identical
// PRNG, σ, bound, order) and Horner-evaluate with vss's exported ring ops.
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
r := prof.Ring
prng, err := dkgring.NewKeyedPRNG(seed)
if err != nil {
t.Fatalf("NewKeyedPRNG: %v", err)
}
cCoeffs := make([]dkgring.Vector, thr)
for k := 0; k < thr; k++ {
cCoeffs[k] = prof.SampleSecretVec(prng)
}
// (skip the r_{i,k} draws — not needed for the share value, but keep the
// PRNG honest for any future blind comparison.)
rCoeffs := make([]dkgring.Vector, thr)
for k := 0; k < thr; k++ {
rCoeffs[k] = prof.SampleSecretVec(prng)
}
_ = rCoeffs
// hornerEval at every recipient point j+1, exactly as vss/party.go does.
for j := 0; j < n; j++ {
x := uint64(j + 1)
share := dkgring.NewVec(r, prof.L)
for k := thr - 1; k >= 0; k-- {
if k < thr-1 {
dkgring.ScalarMulVec(r, share, x, share)
}
dkgring.VecAdd(r, share, cCoeffs[k], share)
}
if !vecsEqual(share, coronaOut.Shares[j]) {
t.Fatalf("share to recipient %d byte-divergent (vss Horner vs dkg2 Horner)", j)
}
}
t.Logf("vss-style Horner shares byte-identical to dkg2 shares for all %d recipients", n)
}
// TestCutover_GroupKeyConvention — gate (D), PIN 4. Pins the exact NTT-domain
// relationship between the two group-commit conventions against ground truth:
//
// - dkg2.Round2 b_ped = RoundXi( ConvertVectorFromNTT(ΣC) ) [IMForm + INTT]
// - vss group commit T = RoundXi( ConvertVecFromNTT(ΣC) ) [INTT only]
//
// Because dkg2's commits are plain-NTT (Montgomery cancels in the commit
// multiply), the extra IMForm injects exactly one R^{-1}. So dkg2's pre-round
// vector is vss's TRUE A·s1+B·u scaled by R^{-1}; equivalently MForm(dkg2) == vss.
// This asserts that identity coefficient-wise — proving the two conventions
// differ by EXACTLY the Montgomery factor R, not by anything message-dependent.
//
// The corona PRODUCTION group key is NEITHER of these: 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). dkg2's R^{-1}-scaled b_ped is an
// internal Round2 value the production path discards.
func TestCutover_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: derive 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")
}
// TestCutover_Round3AggregationByteIdentical — gate (E), the LAST byte-equality
// the production swap rests on. Feeding dkg2's (gate-B/C-proven byte-identical)
// per-dealer commits/shares/blinds into vss's REAL Round3 must:
//
// (1) pass vss's verifyPedersen for EVERY dealer — proving vss's verify accepts
// dkg2's contributions cross-implementation (despite dkg2's "NTT-Montgomery"
// vs vss's "plain-NTT" commit-domain comments, the bytes and the Pedersen
// identity agree); and
// (2) produce the aggregated secret share s_j = Σ_i f_i(j) BYTE-IDENTICAL to
// dkg2's Round2Identify.
//
// gate C proved each per-dealer share f_i(j) is identical; this pins the
// AGGREGATION + VERIFY step (vss.aggregateShare / vss.verifyPedersen ==
// dkg2.Round2Identify). With gates AE green, BootstrapPedersen can drive vss
// (Round1 → OpenDealerShare → Round3.ShareResult.Share) for the share-dealing DKG
// and keep corona's own Path-(a) noise-flooding finalize, preserving the group
// key + per-party shares byte-for-byte — i.e. deployed corona signatures survive.
func TestCutover_Round3AggregationByteIdentical(t *testing.T) {
const n, thr = 5, 3
suite := hash.NewCoronaSHA3()
// dkg2 reference: full Round1 for all n parties.
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sessions := make([]*dkg2.DKGSession, n)
round1 := make([]*dkg2.Round1Output, 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)
for k := range seed {
seed[k] = byte(0x07*i + 0x13*k + 1)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed[%d]: %v", i, err)
}
round1[i] = out
}
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
for j := 0; j < n; j++ {
// dkg2 aggregated, verified share s_j (the production Round 2 path).
shares := map[int]structs.Vector[lring.Poly]{}
blinds := map[int]structs.Vector[lring.Poly]{}
commits := map[int][]structs.Vector[lring.Poly]{}
for i := 0; i < n; i++ {
shares[i] = round1[i].Shares[j]
blinds[i] = round1[i].Blinds[j]
commits[i] = round1[i].Commits
}
dkg2Sj, _, _, badID, err := sessions[j].Round2Identify(shares, blinds, commits)
if err != nil {
t.Fatalf("dkg2 Round2Identify[%d] (badID=%d): %v", j, badID, err)
}
// vss real Round3 over the SAME (commits, share, blind) inputs.
party := buildVSSPartyAt(t, prof, j, n, thr)
inputs := make(map[int]*dkgvss.DealerInput, n)
for i := 0; i < n; i++ {
inputs[i] = &dkgvss.DealerInput{
Commits: round1[i].Commits,
Share: round1[i].Shares[j],
Blind: round1[i].Blinds[j],
}
}
res, fault, err := party.Round3(inputs)
if err != nil {
if fault != nil {
t.Fatalf("vss Round3[%d]: verifyPedersen REJECTED dkg2 dealer %d — commit-domain conventions diverge (blocker)", j, fault.DealerIndex)
}
t.Fatalf("vss Round3[%d]: %v", j, err)
}
if !vecsEqual(res.Share, dkg2Sj) {
t.Fatalf("aggregated s_%d byte-divergent: vss Round3 aggregateShare != dkg2 Round2Identify", j)
}
}
t.Logf("vss Round3 (verifyPedersen + aggregateShare) byte-identical to dkg2 Round2Identify for all %d recipients", n)
}
+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).
+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