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

374 lines
12 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) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Corona threshold-kernel wire-format fuzz harnesses.
//
// Each FuzzCorona* harness fuzzes one external wire surface of the
// corona/threshold kernel:
//
// - FuzzCoronaSign1Round1Data — Round1Data.D matrix bytes (sign-1)
// - FuzzCoronaSign2Round2Data — Round2Data.Z vector bytes (sign-2)
// - FuzzCoronaKeyShareSerialize — KeyShare.SkShare wire bytes
// - FuzzCoronaGroupKeySerialize — GroupKey.A,BTilde wire bytes
//
// Property: the corresponding decoder NEVER panics on arbitrary input.
// Companion TestFuzzCorpus_*Replay tests deterministically replay the
// seeds from CI without invoking the fuzz engine.
package threshold
import (
"bytes"
"crypto/rand"
"encoding/binary"
"fmt"
"sync"
"testing"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// maxLatticeUintSliceLen mirrors warp/pulsar.MaxLatticeUintSliceLen and
// bounds every length field a lattigo wire frame can declare. A
// canonical Corona Poly has 256 coefficients per level; a Vector/Matrix
// has at most M*N = 8*32 = 256 polys. This cap is structural — frames
// that declare more are not legitimate Corona protocol bytes.
//
// IMPORTANT: this duplicate cap exists because the upstream lattice
// library has TWO DoS surfaces:
// 1. ReadUintNSlice unbounded recursion — fixed by luxfi/lattice#3
// (open as of 2026-05-04).
// 2. Vector.ReadFrom calling make([]T, size) before any bound check —
// NOT addressed by PR #3. A 9-byte input
// `\xad\x93\xd8\x5a\x00\x04\x00\x00\\` reads size=0x40005AD893AD
// (~70T entries) and OOMs the goroutine before the slice reader
// runs. Found by FuzzCoronaSign1Round1Data on 2026-05-04.
//
// The walker below pre-validates the wire frame BEFORE handing it to
// lattigo, mirroring warp/pulsar.validateVectorPolyFrame.
const maxLatticeUintSliceLen = 4096
// validateVectorPolyFrameInline walks a lattigo Vector[Poly] wire
// frame end-to-end (one 8-byte vector length header followed by N
// concatenated Poly frames; each Poly = 8-byte levels header + per-
// level 8 + 8*coeff_count). Returns nil on a structurally valid
// frame, error otherwise. Mirrors warp/pulsar.validateVectorPolyFrame.
func validateVectorPolyFrameInline(frame []byte) error {
if len(frame) < 8 {
return fmt.Errorf("vector frame too short: %d < 8", len(frame))
}
n := binary.LittleEndian.Uint64(frame[:8])
if n > maxLatticeUintSliceLen {
return fmt.Errorf("vector length %d exceeds %d", n, maxLatticeUintSliceLen)
}
rest := frame[8:]
for i := uint64(0); i < n; i++ {
if len(rest) < 8 {
return fmt.Errorf("vector poly %d: header truncated (%d)", i, len(rest))
}
levels := binary.LittleEndian.Uint64(rest[:8])
if levels > maxLatticeUintSliceLen {
return fmt.Errorf("vector poly %d: levels %d exceeds %d", i, levels, maxLatticeUintSliceLen)
}
rest = rest[8:]
for k := uint64(0); k < levels; k++ {
if len(rest) < 8 {
return fmt.Errorf("vector poly %d level %d: header truncated", i, k)
}
coeffs := binary.LittleEndian.Uint64(rest[:8])
rest = rest[8:]
if coeffs > maxLatticeUintSliceLen {
return fmt.Errorf("vector poly %d level %d: coeff count %d exceeds %d", i, k, coeffs, maxLatticeUintSliceLen)
}
need := coeffs * 8
if uint64(len(rest)) < need {
return fmt.Errorf("vector poly %d level %d: need %d coeff bytes, have %d", i, k, need, len(rest))
}
rest = rest[need:]
}
}
return nil
}
// makeEmptyPolyVector returns a length-N vector of zero-initialized
// Poly's bound to ring r. This is the destination shape every
// ReadFrom-fuzz target writes into.
func makeEmptyPolyVector(r *ring.Ring, length int) structs.Vector[ring.Poly] {
return utils.InitializeVector(r, length)
}
// fuzzMaxRawSize bounds the raw input handed to the lattigo decoder.
//
// We use 1024 bytes — much tighter than warp/corona's
// MaxPulseFrameSize=32KB — because Go's recover() cannot catch the
// runtime-fatal "goroutine stack exceeds 1000000000-byte limit"
// kill that an unpatched lattigo v7.0.1 produces on the
// luxfi/lattice#2 DoS path. The 1024-byte cap is small enough that
// even unpatched lattigo cannot recurse deeply enough to OOM the
// goroutine. Production callers SHOULD use the patched lattigo
// (luxfi/lattice#3) plus the warp/pulsar.validatePolyFrame
// frame-walker; this cap is a defense-in-depth knob for the fuzz
// harness alone.
const fuzzMaxRawSize = 1024
// decodeVectorWithRecover decodes a Vector[Poly] from raw bytes with
// the production defense-in-depth stack:
//
// 1. Hard byte-length cap (fuzzMaxRawSize) — rejects giant inputs in
// O(1) before any decoder runs.
// 2. defer-recover boundary that converts any escaping panic from the
// upstream lattigo decoder into a returned error, mirroring
// warp/pulsar.DeserializePulse's Layer 4
// (papers/lux-warp-v2 §"defer-recover boundary").
//
// Production callers MUST also use the patched lattigo
// (github.com/luxfi/lattice#3) for correctness; this helper exists so
// the fuzz harness can be CI-green even against an unpinned upstream.
func decodeVectorWithRecover(raw []byte) (err error) {
if len(raw) > fuzzMaxRawSize {
return fmt.Errorf("input exceeds fuzzMaxRawSize")
}
// Pre-validate the wire frame BEFORE handing to lattigo. This is
// defense-in-depth against the second lattice DoS surface
// (Vector.ReadFrom -> make([]T, size) with no bound check), which
// luxfi/lattice#3 does not address. recover() cannot catch the
// fatal "out of memory" thrown when make() requests > available
// memory, so the only safe path is to reject malformed frames
// before the allocation runs.
if vErr := validateVectorPolyFrameInline(raw); vErr != nil {
return vErr
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("decode panic recovered: %v", r)
}
}()
params, perr := NewParams()
if perr != nil {
return perr
}
v := makeEmptyPolyVector(params.R, 16)
_, derr := v.ReadFrom(bytes.NewReader(raw))
return derr
}
// makeEmptyPolyMatrix returns a length-rows × length-cols matrix of
// zero-initialized Poly's bound to ring r.
func makeEmptyPolyMatrix(r *ring.Ring, rows, cols int) structs.Matrix[ring.Poly] {
out := make(structs.Matrix[ring.Poly], rows)
for i := range out {
out[i] = make([]ring.Poly, cols)
for j := range out[i] {
out[i][j] = r.NewPoly()
}
}
return out
}
// kernelOnce caches one canonical 3-of-2 ceremony so each fuzz seed
// can be derived without rerunning DKG (which costs ~150ms on dev
// hardware and would dominate the 10s fuzz budget).
var kernelOnce sync.Once
var (
kShares []*KeyShare
kGroupKey *GroupKey
kSignSeed []byte // serialized round-1 D matrix bytes for party 0
kRound2 []byte // serialized round-2 Z vector bytes for party 0
kPRFKey []byte
)
func mustKernelCeremony(tb testing.TB) {
kernelOnce.Do(func() {
shares, gk, err := GenerateKeys(2, 3, rand.Reader)
if err != nil {
tb.Fatalf("GenerateKeys: %v", err)
}
kShares = shares
kGroupKey = gk
signers := []int{0, 1, 2}
prfKey := make([]byte, 32)
if _, err := rand.Read(prfKey); err != nil {
tb.Fatalf("rand: %v", err)
}
kPRFKey = prfKey
// Round 1
parties := make([]*Signer, 3)
for i := range parties {
parties[i] = NewSigner(shares[i])
}
r1 := make(map[int]*Round1Data, 3)
for i, p := range parties {
d, err := p.Round1(1, prfKey, signers)
if err != nil {
tb.Fatalf("Round1: %v", err)
}
r1[i] = d
}
// Serialize party-0 Round1Data.D (Matrix[Poly]) wire bytes.
var b1 bytes.Buffer
if _, err := r1[0].D.WriteTo(&b1); err != nil {
tb.Fatalf("Round1Data.D WriteTo: %v", err)
}
kSignSeed = b1.Bytes()
// Round 2
r2 := make(map[int]*Round2Data, 3)
for i, p := range parties {
d, err := p.Round2(1, "fuzz-pulsar-round-test", prfKey, signers, r1)
if err != nil {
tb.Fatalf("Round2 party %d: %v", i, err)
}
r2[i] = d
}
// Serialize party-0 Round2Data.Z (Vector[Poly]) wire bytes.
var b2 bytes.Buffer
if _, err := r2[0].Z.WriteTo(&b2); err != nil {
tb.Fatalf("Round2Data.Z WriteTo: %v", err)
}
kRound2 = b2.Bytes()
})
}
// addSmallSeeds adds structural-shape seeds (no large protocol-real
// payload) so the fuzz engine can exercise the decoder framing layer
// without the seed itself triggering deep recursion. Real protocol
// data is exercised in TestFuzzCorpus_*Replay.
func addSmallSeeds(f *testing.F) {
f.Add([]byte{})
f.Add([]byte{0x00})
f.Add([]byte{0x01, 0x00, 0x00, 0x00})
f.Add([]byte{0xff, 0xff, 0xff, 0xff})
f.Add(bytes.Repeat([]byte{0xaa}, 32))
// Plausible-looking length prefixes followed by short payloads.
f.Add(append([]byte{0x10, 0x00, 0x00, 0x00}, bytes.Repeat([]byte{0xcc}, 16)...))
}
// FuzzCoronaSign1Round1Data fuzzes the Vector[Poly] decoder used to
// reconstruct a peer's Round-1 D matrix row. A panic here corresponds
// to a malicious peer being able to take down a Round-1 receiver.
func FuzzCoronaSign1Round1Data(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
// Property: external decoder never escapes a panic.
// The lattigo Vector.ReadFrom can panic on attacker-controlled
// length-prefix inputs (see luxfi/lattice#2 + luxfi/lattice#3
// for the upstream fix). Production callers MUST wrap the
// decoder in a recover boundary; the fuzz harness asserts that
// the recover boundary is sufficient under the warp/pulsar
// MaxPulseFrameSize cap.
_ = decodeVectorWithRecover(raw)
})
}
// FuzzCoronaSign2Round2Data fuzzes the Vector[Poly] decoder used to
// reconstruct a peer's Round-2 Z vector. Matches the Round-1 surface
// but exercises the smaller payload.
func FuzzCoronaSign2Round2Data(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
_ = decodeVectorWithRecover(raw)
})
}
// FuzzCoronaKeyShareSerialize fuzzes the KeyShare.SkShare wire decoder.
// A KeyShare is the persisted output of DKG; corrupted on-disk shares
// must surface as errors, not panics.
func FuzzCoronaKeyShareSerialize(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
_ = decodeVectorWithRecover(raw)
})
}
// FuzzCoronaGroupKeySerialize fuzzes the GroupKey.BTilde wire decoder
// (the persistent public key portion of a group key).
func FuzzCoronaGroupKeySerialize(f *testing.F) {
addSmallSeeds(f)
f.Fuzz(func(t *testing.T, raw []byte) {
_ = decodeVectorWithRecover(raw)
})
}
// TestFuzzCorpus_CoronaSign1Replay replays the canonical seed
// deterministically without invoking the fuzz engine. The Sign1 seed
// is a serialized Matrix[Poly]; reading it into a fresh Matrix[Poly]
// must succeed and return a non-zero byte count.
func TestFuzzCorpus_CoronaSign1Replay(t *testing.T) {
mustKernelCeremony(t)
if len(kSignSeed) == 0 {
t.Fatal("empty Sign1 seed")
}
// Reconstruct the Matrix shape (D was M rows × N polys).
params, err := NewParams()
if err != nil {
t.Fatalf("NewParams: %v", err)
}
m := makeEmptyPolyMatrix(params.R, len(kShares[0].SkShare), 1)
if _, err := m.ReadFrom(bytes.NewReader(kSignSeed)); err != nil {
t.Fatalf("Sign1 seed ReadFrom: %v", err)
}
}
// TestFuzzCorpus_CoronaSign2Replay replays the Round-2 seed.
func TestFuzzCorpus_CoronaSign2Replay(t *testing.T) {
mustKernelCeremony(t)
if len(kRound2) == 0 {
t.Fatal("empty Sign2 seed")
}
params, err := NewParams()
if err != nil {
t.Fatalf("NewParams: %v", err)
}
v := makeEmptyPolyVector(params.R, 16)
if _, err := v.ReadFrom(bytes.NewReader(kRound2)); err != nil {
t.Fatalf("Sign2 seed ReadFrom: %v", err)
}
}
// TestFuzzCorpus_CoronaKeyShareReplay confirms the KeyShare decoder
// accepts the canonical share bytes.
func TestFuzzCorpus_CoronaKeyShareReplay(t *testing.T) {
mustKernelCeremony(t)
var b bytes.Buffer
if _, err := kShares[0].SkShare.WriteTo(&b); err != nil {
t.Fatalf("KeyShare WriteTo: %v", err)
}
params, err := NewParams()
if err != nil {
t.Fatalf("NewParams: %v", err)
}
v := makeEmptyPolyVector(params.R, 16)
if _, err := v.ReadFrom(bytes.NewReader(b.Bytes())); err != nil {
t.Fatalf("KeyShare seed ReadFrom: %v", err)
}
}
// TestFuzzCorpus_CoronaGroupKeyReplay confirms the GroupKey decoder
// accepts the canonical bytes.
func TestFuzzCorpus_CoronaGroupKeyReplay(t *testing.T) {
mustKernelCeremony(t)
var b bytes.Buffer
if _, err := kGroupKey.BTilde.WriteTo(&b); err != nil {
t.Fatalf("GroupKey WriteTo: %v", err)
}
params, err := NewParams()
if err != nil {
t.Fatalf("NewParams: %v", err)
}
v := makeEmptyPolyVector(params.R, 16)
if _, err := v.ReadFrom(bytes.NewReader(b.Bytes())); err != nil {
t.Fatalf("GroupKey seed ReadFrom: %v", err)
}
}