mirror of
https://github.com/luxfi/corona.git
synced 2026-07-27 02:50:34 +00:00
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.
370 lines
12 KiB
Go
370 lines
12 KiB
Go
// 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 {
|
||
r1[i] = p.Round1(1, prfKey, signers)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|