Files
corona/dkg2/dkg2.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

816 lines
29 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 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"
"crypto/subtle"
"encoding/binary"
"errors"
"fmt"
"io"
"math/big"
"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})
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).
commits := make([]structs.Vector[ring.Poly], d.t)
for k := 0; k < d.t; k++ {
// NTT-form copies of the secret coefficients.
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] = *d.cCoeffs[k][i].CopyNew()
r.NTT(cNTT[i], cNTT[i])
rNTT[i] = *d.rCoeffs[k][i].CopyNew()
r.NTT(rNTT[i], rNTT[i])
}
ac := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, d.A, cNTT, ac)
br := utils.InitializeVector(r, sign.M)
utils.MatrixVectorMul(r, d.B, rNTT, br)
commits[k] = utils.InitializeVector(r, sign.M)
utils.VectorAdd(r, ac, br, commits[k])
}
// Step 5: build shares and blinds via Horner over (j+1) for each j.
q := new(big.Int).SetUint64(sign.Q)
shares := make(map[int]structs.Vector[ring.Poly], d.n)
blinds := make(map[int]structs.Vector[ring.Poly], d.n)
for j := 0; j < d.n; j++ {
x := big.NewInt(int64(j + 1))
shares[j] = hornerEval(r, d.cCoeffs, x, q)
blinds[j] = hornerEval(r, d.rCoeffs, 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.
// subtle.ConstantTimeCompare returns 1 iff equal; AND across slots.
eq := 1
for ri := 0; ri < sign.M; ri++ {
eq &= constTimePolyEqual(lhs[ri], rhs[ri])
}
if eq != 1 {
return false, ErrShareVerification
}
return true, nil
}
// constTimePolyEqual returns 1 iff a and b have identical coefficient
// arrays at every level, 0 otherwise. The comparison runs in time
// independent of how many coefficients differ — a full scan is always
// performed (no early return).
//
// This is the dkg2 response to Findings 5/6 of
// luxcpp/crypto/corona/RED-DKG-REVIEW.md, which note that the Round 2
// share-comparison loop in the upstream dkg/ package short-circuits on the
// first slot mismatch and so leaks the location of any planted divergence
// to a network observer measuring response timing.
func constTimePolyEqual(a, b ring.Poly) int {
if len(a.Coeffs) != len(b.Coeffs) {
return 0
}
eq := 1
for level := range a.Coeffs {
al := a.Coeffs[level]
bl := b.Coeffs[level]
if len(al) != len(bl) {
eq = 0
continue
}
// Reinterpret each Coeffs[level] as a byte buffer and feed it
// to subtle.ConstantTimeCompare. uint64 little-endian coefficient
// layout is byte-stable on every supported target (amd64, arm64).
ab := uint64SliceToBytes(al)
bb := uint64SliceToBytes(bl)
eq &= subtle.ConstantTimeCompare(ab, bb)
}
return eq
}
// uint64SliceToBytes returns a little-endian byte view of a []uint64. The
// caller must not retain the result past the lifetime of the input.
func uint64SliceToBytes(s []uint64) []byte {
b := make([]byte, 8*len(s))
for i, v := range s {
binary.LittleEndian.PutUint64(b[8*i:8*i+8], v)
}
return b
}
// 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[:]
}