Files
corona/dkg2/dkg2.go
T
Antje Worring dc15c54a47 harden: hedged 256-bit nonce key, DRY CT comparator, honest CT/proof docs
Closes the CRIT-1 residual (D1-2) sid-entropy gap and the CT/doc hygiene
gaps to bring Corona to the no-leak/no-gap bar. EasyCrypt proofs untouched.

PRIORITY 1 — anti-nonce-reuse / no-leak durability:
- The Round-1 nonce-PRF key no longer keys on a bare 64-bit sid. It now
  derives from a 256-bit domain-separated SessionID (primitives.DeriveSessionID:
  TranscriptHash("corona.sign.session-id.v1" || be64(sid) || T)) AND a fresh
  per-signature 256-bit hedge salt drawn inside the kernel from party.Rand
  (default crypto/rand). PRNGKeyForRound = PRF(skShare, "CoronaNonceV3" ||
  sessionID || salt). Hedging restores threshold-Raccoon's fresh-per-signature
  nonce posture: reuse durability no longer rests on the external consensus
  layer never reissuing an sid — even an sid collision yields distinct R with
  prob 2^-256. A deterministic 256-bit SessionID alone is necessary but NOT
  sufficient (it repeats when sid repeats); the salt is what closes the leak.
- SignRound1 is fail-closed: rejects an all-zero derived SessionID or all-zero
  salt with ErrDegenerateSession, and surfaces a short-read error. The
  consensus slot-uniqueness invariant is documented as a HARD precondition at
  SignRound1 and Signer.Round1 (no longer a buried comment).
- KAT/oracle determinism preserved via one seam: sign.DeterministicNonceSource
  (KeyedPRNG over seed || "corona.sign.nonce-salt.v1" || partyIndex), set on
  Party.Rand / Signer.SetNonceRand only by reproducibility harnesses.
- SignRound1 / Signer.Round1 now return an error; all call sites updated.
- KAT REGEN: only sign_verify_e2e.json changes (nonce-key bytes moved);
  transcript_hash.json / MAC / legacy PRNGKey vectors are byte-stable, proving
  the consensus-agreed transcript path was left untouched. Regenerated via
  `bash scripts/regen-kats.sh`; `--verify` confirms byte-determinism (10 files).
- Regression: sign/nonce_reuse_test.go proves same-(skShare,sid) yields distinct
  D (fresh R), pinned-nonce reproduces byte-identically, and the degenerate-
  session guard fires. TestE2EKATReplayDeterminism rewritten to assert both the
  hedged-differs and pinned-reproduces properties (was a defanged no-op).

PRIORITY 2 — constant-time hygiene:
- reshare/commit.go already used a constant-time comparator; the real gap was
  the verbatim duplication of constTimePolyEqual+uint64SliceToBytes across dkg2
  and reshare. Consolidated to one canonical utils.ConstantTimePolyEqual; both
  delegate (no dkg2<->reshare dependency). Orphaned imports removed.
- FullRankCheck and the reshare commit path are now covered in the CT review
  with their public-operand justification.

PRIORITY 3 — doc accuracy:
- CONSTANT-TIME-REVIEW.md rewritten Corona-specific and file:line-accurate:
  drops the stale Pulsar/lens/warp/secp256k1 content; audits the real call
  sites (hedged nonce key, masking PRF, lattigo samplers as the residual TCB
  axiom, utils.ConstantTimePolyEqual, CheckL2Norm/Verify/FullRankCheck big.Int
  variable-time on PUBLIC operands, activation/commit-digest array equality,
  keyera/reshare zeroization).
- PROOF-CLAIMS.md §1: threshold.Combine / sign.LocalSign (nonexistent) -> the
  real sign.Party.SignFinalize exposed as threshold.Signer.Finalize.
- threshold/threshold.go package doc: Ring-LWE -> Module-LWE.
2026-06-21 08:21:58 -07:00

769 lines
28 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"
"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[:]
}