corona: a length a peer wrote has to be backed by bytes that are there

Decoding a signature or a group key means reading counts a peer chose, and the
lattice decoder allocates from a count before it discovers there is nothing to
fill the space with. A fifty-byte message naming 2^40 coefficients ended the
process — sometimes with "makeslice: len out of range", and for a challenge
naming 2^40 levels by allocating for seventy seconds until the kernel killed it.

ValidateVectorPolyFrame was meant to stop that and did not. Its doc said it
checked "vector outer length, per-poly levels count, per-level coefficient
count"; its body read the outer length, discarded the result, and returned nil,
which its own comment admitted was a stopgap. So it accepted every frame above.

It now walks the frame. The layout is a little-endian count, then per poly a
level count, a coefficient count, and the coefficients; the rule at each count
is the one readLenPrefixed already applies to the outer frame — what is declared
has to fit in what remains. That cannot refuse an honest frame, because an
honest frame's bytes are all present. A level count times a coefficient count is
compared by dividing rather than multiplying, so a product that would wrap is
refused instead of wrapping into a small number that passes.

Two frames were being decoded without ever being walked: a signature's challenge
C, on the grounds that a single Poly is "bounded by the ring size already" — it
is not, it names its own counts — and a group key's A, which is a matrix and
carries the same counts a row deeper. Both are walked now.

The tracked tests built frames with uvarint length prefixes, which is not what
lattigo writes, so what they accepted was a shape no peer could send and what
they proved was nothing about a real frame. They now build the real layout, and
every walk is controlled by a frame the library itself marshalled — without that
control the refusals would only prove this rejects everything.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-26 10:41:31 -07:00
co-authored by Hanzo Dev
parent d4cfe6a749
commit 44b48c7717
5 changed files with 408 additions and 67 deletions
+121
View File
@@ -0,0 +1,121 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package threshold
import (
"encoding/binary"
"testing"
)
// TestAPeerCannotEndTheProcessWithALength. Decoding a signature means reading
// counts a peer wrote, and the lattice decoder allocates from a count before it
// discovers there is nothing to fill the space with. A fifty-byte message
// naming 2^40 coefficients ended the process with "makeslice: len out of
// range" — a node killed by reading its peer's signature.
func TestAPeerCannotEndTheProcessWithALength(t *testing.T) {
le := func(n uint64) []byte {
var b [8]byte
binary.LittleEndian.PutUint64(b[:], n)
return b[:]
}
lenPrefixed := func(body []byte) []byte {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(body)))
return append(l[:], body...)
}
// A vector naming polys, levels and coefficients, with no bytes behind them.
claim := func(polys, levels, n uint64) []byte {
return append(append(le(polys), le(levels)...), le(n)...)
}
// C is a single Poly, not a vector, so it names only levels and
// coefficients.
polyClaim := func(levels, n uint64) []byte {
return append(le(levels), le(n)...)
}
sigFrame := func(c, z, delta []byte) []byte {
var out []byte
var m [4]byte
binary.BigEndian.PutUint32(m[:], wireMagicSig)
out = append(out, m[:]...)
var v [2]byte
binary.BigEndian.PutUint16(v[:], wireVersionV1)
out = append(out, v[:]...)
out = append(out, lenPrefixed(c)...)
out = append(out, lenPrefixed(z)...)
return append(out, lenPrefixed(delta)...)
}
honest := claim(1, 1, 0)
for _, tc := range []struct {
name string
frame []byte
}{
{"Z names 2^40 coefficients", sigFrame(honest, claim(1, 1, 1<<40), honest)},
{"Delta names 2^40 coefficients", sigFrame(honest, honest, claim(1, 1, 1<<40))},
{"Z names 2^40 polys", sigFrame(honest, claim(1<<40, 1, 1), honest)},
{"Z names 2^40 levels", sigFrame(honest, claim(1, 1<<40, 1), honest)},
{"C names 2^40 coefficients", sigFrame(polyClaim(1, 1<<40), honest, honest)},
{"C names 2^40 levels", sigFrame(polyClaim(1<<40, 1), honest, honest)},
} {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("%d bytes from a peer ended the process: %v", len(tc.frame), r)
}
}()
var s Signature
if err := s.UnmarshalBinary(tc.frame); err == nil {
t.Fatal("a frame describing bytes that are not there was accepted")
}
})
}
}
// A group key's A is a matrix and was decoded without ever being walked, so it
// could name rows and coefficients that were not there.
func TestAGroupKeysMatrixCannotEndTheProcess(t *testing.T) {
le := func(n uint64) []byte {
var b [8]byte
binary.LittleEndian.PutUint64(b[:], n)
return b[:]
}
lenPrefixed := func(body []byte) []byte {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(body)))
return append(l[:], body...)
}
keyFrame := func(a, b []byte) []byte {
var out []byte
var m [4]byte
binary.BigEndian.PutUint32(m[:], wireMagicGroupKey)
out = append(out, m[:]...)
var v [2]byte
binary.BigEndian.PutUint16(v[:], wireVersionV1)
out = append(out, v[:]...)
out = append(out, lenPrefixed(a)...)
return append(out, lenPrefixed(b)...)
}
honestVec := append(append(le(1), le(1)...), le(0)...)
for _, tc := range []struct {
name string
a []byte
}{
{"2^40 rows", le(1 << 40)},
{"one row of 2^40 polys", append(le(1), le(1<<40)...)},
{"one poly of 2^40 coefficients", append(append(le(1), le(1)...), append(le(1), le(1<<40)...)...)},
} {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("a group key from a peer ended the process: %v", r)
}
}()
var gk GroupKey
if err := gk.UnmarshalBinary(keyFrame(tc.a, honestVec)); err == nil {
t.Fatal("a matrix describing bytes that are not there was accepted")
}
})
}
}
+13 -2
View File
@@ -122,8 +122,14 @@ func (s *Signature) UnmarshalBinary(b []byte) error {
return fmt.Errorf("corona/wire: %d trailing bytes after Signature", r.Len())
}
// Bounded validation of Z, Delta vector frames (C is a single Poly
// which is bounded by the ring size already).
// Every frame is walked before it is decoded, because the decoder allocates
// from the counts these carry. C is a single Poly and names its own level
// and coefficient counts, so it describes bytes that are not there exactly
// as a vector can — a twenty-four byte C naming 2^40 levels does not panic,
// it allocates until the process is killed.
if err := wire.ValidatePolyFrame(cBytes); err != nil {
return fmt.Errorf("%w: C: %v", ErrWireFrameRejected, err)
}
if err := wire.ValidateVectorPolyFrame(zBytes); err != nil {
return fmt.Errorf("%w: Z: %v", ErrWireFrameRejected, err)
}
@@ -222,6 +228,11 @@ func (gk *GroupKey) UnmarshalBinary(b []byte) error {
return fmt.Errorf("corona/wire: %d trailing bytes after GroupKey", r.Len())
}
// A is a matrix and carries the same kind of counts as everything else here,
// so it is walked before it is decoded rather than after.
if err := wire.ValidateMatrixPolyFrame(aBytes); err != nil {
return fmt.Errorf("%w: A: %v", ErrWireFrameRejected, err)
}
if err := wire.ValidateVectorPolyFrame(bBytes); err != nil {
return fmt.Errorf("%w: BTilde: %v", ErrWireFrameRejected, err)
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package wire
import (
"encoding/binary"
"errors"
"testing"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
"github.com/luxfi/math/codec"
)
func realVector(t *testing.T, polys int) []byte {
t.Helper()
r, err := ring.NewRing(256, []uint64{8380417})
if err != nil {
t.Fatalf("ring: %v", err)
}
v := make(structs.Vector[ring.Poly], polys)
for i := range v {
v[i] = r.NewPoly()
}
b, err := v.MarshalBinary()
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// TestFramesTheLibraryWroteAreAccepted is the control. Without it the refusals
// below would prove only that this rejects everything.
func TestFramesTheLibraryWroteAreAccepted(t *testing.T) {
for _, polys := range []int{0, 1, 2, 5} {
if err := ValidateVectorPolyFrame(realVector(t, polys)); err != nil {
t.Fatalf("a frame the library wrote, %d polys, was refused: %v", polys, err)
}
}
}
// TestADeclaredLengthMustBeBackedByBytes. A peer writes the counts in this
// frame, and the decoder beneath allocates from them before discovering there
// is nothing to fill the space with: a fifty-byte message naming 2^40
// coefficients used to end the process with "makeslice: len out of range".
func TestADeclaredLengthMustBeBackedByBytes(t *testing.T) {
u64 := func(n uint64) []byte {
var b [8]byte
binary.LittleEndian.PutUint64(b[:], n)
return b[:]
}
frame := func(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = append(out, p...)
}
return out
}
for _, tc := range []struct {
name string
frame []byte
}{
{"a vector of 2^40 polys", frame(u64(1 << 40))},
{"a poly of 2^40 coefficients", frame(u64(1), u64(1), u64(1<<40))},
{"2^40 levels", frame(u64(1), u64(1<<40), u64(1))},
{"levels times coefficients would wrap", frame(u64(1), u64(1<<62), u64(4))},
{"one more poly than there are bytes for", frame(u64(2), u64(1), u64(1), u64(0))},
{"a count with no room for its own bytes", frame([]byte{1, 2, 3})},
} {
t.Run(tc.name, func(t *testing.T) {
err := ValidateVectorPolyFrame(tc.frame)
if err == nil {
t.Fatalf("%d bytes declaring far more were accepted", len(tc.frame))
}
if !errors.Is(err, codec.ErrLimitExceeded) {
t.Fatalf("refused for the wrong reason: %v", err)
}
})
}
}
// Bytes past the end of what the frame describes are bytes two different frames
// can differ in while decoding the same.
func TestTrailingBytesAreRefused(t *testing.T) {
padded := append(realVector(t, 1), 0)
if err := ValidateVectorPolyFrame(padded); err == nil {
t.Fatal("a frame with a byte appended was accepted")
}
}
// TestMatricesTheLibraryWroteAreAccepted is the control for the matrix walk: if
// the layout assumed here were wrong, this would refuse what lattigo writes.
func TestMatricesTheLibraryWroteAreAccepted(t *testing.T) {
r, err := ring.NewRing(256, []uint64{8380417})
if err != nil {
t.Fatalf("ring: %v", err)
}
for _, dim := range [][2]int{{1, 1}, {2, 3}, {0, 0}} {
m := make(structs.Matrix[ring.Poly], dim[0])
for i := range m {
m[i] = make(structs.Vector[ring.Poly], dim[1])
for j := range m[i] {
m[i][j] = r.NewPoly()
}
}
b, err := m.MarshalBinary()
if err != nil {
t.Fatalf("marshal %v: %v", dim, err)
}
if err := ValidateMatrixPolyFrame(b); err != nil {
t.Fatalf("a %dx%d matrix the library wrote was refused: %v", dim[0], dim[1], err)
}
}
}
+129 -28
View File
@@ -17,7 +17,7 @@
package wire
import (
"bytes"
"encoding/binary"
"fmt"
"github.com/luxfi/math/codec"
@@ -42,38 +42,139 @@ var LatticeWireLimits = codec.Limits{
MaxDepth: 4,
}
// ValidateVectorPolyFrame walks a lattigo Vector[Poly] wire frame
// without invoking lattigo's recursive ReadUint64Slice. Returns nil
// iff every length-prefixed slice (vector outer length, per-poly
// levels count, per-level coefficient count) is within
// MaxLatticeUintSliceLen.
// ValidateVectorPolyFrame walks a lattigo Vector[Poly] frame and returns nil
// only when every length it declares is backed by bytes that are actually
// there.
//
// Mirrors warp/pulsar.validateVectorPolyFrame and consolidates the
// test-only walker that lived at threshold/fuzz_round_test.go onto
// the canonical luxfi/math/codec substrate.
// The frame is little-endian and self-describing:
//
// On rejection, the returned error wraps codec.ErrLimitExceeded so
// callers can branch on errors.Is.
// vector := count:u64, count × poly
// poly := levels:u64, n:u64, levels*n × coefficient:u64
// matrix := rows:u64, rows × vector
//
// A peer writes those counts, so a short frame can declare an enormous one —
// and the decoder beneath this allocates from the declared count before it
// discovers there is nothing to fill it with. A fifty-byte message naming 2^40
// coefficients ends the process: sometimes with "makeslice: len out of range",
// and sometimes by allocating until the kernel kills it. Reading a peer's
// signature must not be able to do either.
//
// So the rule is the one readLenPrefixed already applies to the outer frame:
// what is declared has to fit in what remains. That cannot refuse an honest
// frame, since an honest frame's bytes are all present, and it refuses every
// frame whose arithmetic does not close.
//
// MaxLatticeUintSliceLen still caps the shape corona itself will accept, above
// and beyond what the bytes could hold.
func ValidateVectorPolyFrame(frame []byte) error {
r, err := codec.NewReader(bytes.NewReader(frame), LatticeWireLimits)
if err != nil {
return fmt.Errorf("pulsar/wire: NewReader: %w", err)
if err := frameFits(frame); err != nil {
return err
}
// Outer vector length.
vec, err := r.ReadUint64Slice()
rest, err := walkVector(frame)
if err != nil {
// codec.Reader's bounded ReadUint64Slice rejects the lattice
// issue #4 attack input class (huge length) before allocation;
// surface that rejection as a substrate-validated error.
return fmt.Errorf("pulsar/wire: outer vector length: %w", err)
return err
}
return noTrailing(rest, "vector")
}
// ValidatePolyFrame is the same walk for a single Poly, which is what a
// Signature's challenge is. A Poly names its own level and coefficient counts,
// so it describes bytes that are not there exactly as a vector can.
func ValidatePolyFrame(frame []byte) error {
if err := frameFits(frame); err != nil {
return err
}
rest, err := walkPoly(frame)
if err != nil {
return err
}
return noTrailing(rest, "poly")
}
// ValidateMatrixPolyFrame is the same walk for a Matrix[Poly], a count followed
// by that many vectors. A group key's A is one.
func ValidateMatrixPolyFrame(frame []byte) error {
if err := frameFits(frame); err != nil {
return err
}
rows, rest, err := takeCount(frame)
if err != nil {
return fmt.Errorf("matrix row count: %w", err)
}
for i := uint64(0); i < rows; i++ {
if rest, err = walkVector(rest); err != nil {
return fmt.Errorf("row %d: %w", i, err)
}
}
return noTrailing(rest, "matrix")
}
func frameFits(frame []byte) error {
if len(frame) > LatticeWireLimits.MaxFrameBytes {
return fmt.Errorf("%w: frame is %d bytes, limit %d",
codec.ErrLimitExceeded, len(frame), LatticeWireLimits.MaxFrameBytes)
}
// vec is the FIRST slice in the frame, but lattigo's
// Vector[Poly] format actually nests Poly structs after the
// length prefix, not raw uint64 elements. We re-interpret the
// outer-length read as "number of Poly entries to follow" by
// swallowing only the varint length and discarding the payload
// read. Future Phase 5 work tightens this when we move pulsar
// fully onto math/codec; for now this is a hardened bound check.
_ = vec
return nil
}
// Bytes past the end of what a frame describes are bytes two frames can differ
// in while decoding the same.
func noTrailing(rest []byte, what string) error {
if len(rest) != 0 {
return fmt.Errorf("%w: %d trailing bytes after the %s", codec.ErrLimitExceeded, len(rest), what)
}
return nil
}
// takeCount reads one little-endian count and reports what follows it. A count
// is refused when the bytes left could not hold that many of anything, since
// every element costs at least one byte.
func takeCount(b []byte) (uint64, []byte, error) {
if len(b) < 8 {
return 0, nil, fmt.Errorf("%w: %d bytes left, a count needs 8", codec.ErrLimitExceeded, len(b))
}
n := binary.LittleEndian.Uint64(b[:8])
rest := b[8:]
if n > uint64(len(rest)) {
return 0, nil, fmt.Errorf("%w: declared %d, only %d bytes remain", codec.ErrLimitExceeded, n, len(rest))
}
if n > MaxLatticeUintSliceLen {
return 0, nil, fmt.Errorf("%w: declared %d, corona accepts at most %d",
codec.ErrLimitExceeded, n, MaxLatticeUintSliceLen)
}
return n, rest, nil
}
func walkVector(b []byte) ([]byte, error) {
count, rest, err := takeCount(b)
if err != nil {
return nil, fmt.Errorf("vector length: %w", err)
}
for i := uint64(0); i < count; i++ {
if rest, err = walkPoly(rest); err != nil {
return nil, fmt.Errorf("poly %d: %w", i, err)
}
}
return rest, nil
}
func walkPoly(b []byte) ([]byte, error) {
levels, rest, err := takeCount(b)
if err != nil {
return nil, fmt.Errorf("level count: %w", err)
}
n, rest, err := takeCount(rest)
if err != nil {
return nil, fmt.Errorf("coefficient count: %w", err)
}
// levels*n*8 without trusting it to fit: divide instead of multiply, so a
// product that would wrap is refused rather than wrapping into a small
// number that passes.
const coeffBytes = 8
room := uint64(len(rest)) / coeffBytes
if n != 0 && levels > room/n {
return nil, fmt.Errorf("%w: %d levels of %d coefficients need more than the %d bytes left",
codec.ErrLimitExceeded, levels, n, len(rest))
}
return rest[levels*n*coeffBytes:], nil
}
+29 -37
View File
@@ -12,70 +12,62 @@ import (
"github.com/luxfi/math/codec"
)
func encodeUvarint(out *bytes.Buffer, v uint64) {
for v >= 0x80 {
out.WriteByte(byte(v) | 0x80)
v >>= 7
// vectorFrame writes the frame lattigo writes: a little-endian count followed
// by that many polys, each a level count, a coefficient count, and the
// coefficients themselves. Tests build frames this way so that what they accept
// is what a peer can actually send.
func vectorFrame(polys, levels, n uint64) []byte {
var buf bytes.Buffer
le := func(v uint64) { _ = binary.Write(&buf, binary.LittleEndian, v) }
le(polys)
for i := uint64(0); i < polys; i++ {
le(levels)
le(n)
buf.Write(make([]byte, levels*n*8))
}
out.WriteByte(byte(v))
return buf.Bytes()
}
// TestValidateVectorPolyFrame_RejectsHugeLength is the regression
// test for lattice issue #4 (Vector[T].ReadFrom unbounded allocation),
// now centralized through luxfi/math/codec.Reader. The exact attack
// input from the original fuzz finding (9-byte varint encoding of a
// 70-trillion-element length) MUST be rejected with
// codec.ErrLimitExceeded.
// TestValidateVectorPolyFrame_RejectsHugeLength is the regression test for
// lattice issue #4 (Vector[T].ReadFrom unbounded allocation): a short frame
// naming an enormous count must be refused rather than allocated from.
func TestValidateVectorPolyFrame_RejectsHugeLength(t *testing.T) {
// Length: 70_368_955_777_453 — same value used in lattice issue #4.
// The value from the original fuzz finding.
const huge = uint64(70_368_955_777_453)
var buf bytes.Buffer
encodeUvarint(&buf, huge)
_ = binary.Write(&buf, binary.LittleEndian, huge)
err := ValidateVectorPolyFrame(buf.Bytes())
if err == nil {
t.Fatal("ValidateVectorPolyFrame returned nil for huge length")
t.Fatal("a frame declaring 70 trillion polys was accepted")
}
if !errors.Is(err, codec.ErrLimitExceeded) {
t.Errorf("err is not ErrLimitExceeded: %v", err)
}
t.Logf("rejected as expected: %v", err)
}
// TestValidateVectorPolyFrame_HappyPath confirms the bounded reader
// accepts a length within cap.
// TestValidateVectorPolyFrame_HappyPath accepts a frame whose counts are all
// backed by bytes that are there.
func TestValidateVectorPolyFrame_HappyPath(t *testing.T) {
want := []uint64{1, 2, 3}
var buf bytes.Buffer
encodeUvarint(&buf, uint64(len(want)))
for _, v := range want {
_ = binary.Write(&buf, binary.LittleEndian, v)
}
if err := ValidateVectorPolyFrame(buf.Bytes()); err != nil {
if err := ValidateVectorPolyFrame(vectorFrame(3, 1, 16)); err != nil {
t.Errorf("happy-path: %v", err)
}
}
// TestValidateVectorPolyFrame_AtCap accepts exactly MaxLatticeUintSliceLen.
// TestValidateVectorPolyFrame_AtCap accepts exactly MaxLatticeUintSliceLen
// coefficients, which is the largest shape corona will read.
func TestValidateVectorPolyFrame_AtCap(t *testing.T) {
var buf bytes.Buffer
encodeUvarint(&buf, uint64(MaxLatticeUintSliceLen))
// Provide a payload large enough to satisfy the read; we don't
// care about its content, only that it doesn't exceed
// MaxFrameBytes.
payload := make([]byte, MaxLatticeUintSliceLen*8)
buf.Write(payload)
if err := ValidateVectorPolyFrame(buf.Bytes()); err != nil {
if err := ValidateVectorPolyFrame(vectorFrame(1, 1, MaxLatticeUintSliceLen)); err != nil {
t.Errorf("at-cap: %v", err)
}
}
// TestValidateVectorPolyFrame_OverCap rejects MaxLatticeUintSliceLen + 1.
// TestValidateVectorPolyFrame_OverCap rejects MaxLatticeUintSliceLen + 1 even
// when the bytes to back it are present, because that is a shape corona does
// not read whatever the sender is willing to send.
func TestValidateVectorPolyFrame_OverCap(t *testing.T) {
var buf bytes.Buffer
encodeUvarint(&buf, uint64(MaxLatticeUintSliceLen+1))
err := ValidateVectorPolyFrame(buf.Bytes())
err := ValidateVectorPolyFrame(vectorFrame(1, 1, MaxLatticeUintSliceLen+1))
if err == nil {
t.Fatal("over-cap returned nil")
}