Files
corona/dkg2/complaint.go
T
Hanzo AI a2c1d1da31 corona: symmetric domain separation — PULSAR-* tags -> CORONA-*
Leftover from Corona's "Pulsar-R" lineage. Pulsar (M-LWE) and Corona
(R-LWE) are independent constructions with separate hardness assumptions,
so their cSHAKE personalisation strings must be distinct.

Changes (Go, non-luxcpp):
  - hash tags: PULSAR-HC-v1 -> CORONA-HC-v1, etc. (HU, TRANSCRIPT, PRF, MAC, PAIRWISE)
  - profile IDs: "Pulsar-SHA3" -> "Corona-SHA3", "Pulsar-BLAKE3" -> "Corona-BLAKE3"
  - context strings: pulsar.dkg2.A.v1 -> corona.dkg2.A.v1, etc.
  - env vars: PULSAR_RESHARE_KAT_PATH -> CORONA_RESHARE_KAT_PATH, etc.
  - struct names: pulsarSHA3 -> coronaSHA3
  - KAT derive roots: sign_e2e_pulsar -> sign_e2e_corona

What's preserved (different scope):
  - luxcpp/crypto/pulsar/* path references in comments (separate repo,
    out of scope; the C++ side will rename in its own commit)
  - Cross-runtime KAT files on disk (will regenerate next CI run)

All 11 packages test green: dkg, dkg2, hash, keyera, networking,
primitives, reshare, sign, threshold, utils, wire.
2026-05-13 14:19:13 -07:00

425 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}