Files
precompile/p3q/dispatch_test.go
zeekay 9cc8cc7bb7 contract: one cursor holds every calldata bound, one verdict cannot be faked
A precompile's input is the two-index slice Memory.GetPtr returns, and
nothing on the path to Run copies it. len is what the caller declared and
paid for; cap is the rest of EVM memory, which that same caller filled with
MSTORE. So a parser reaching past its input does not panic and does not halt
a validator -- it reads bytes the attacker chose and answers over them.
Measured, not assumed: TestCalldataCarriesCallerChosenSpareCapacity drives
the VM and reads back len=8 cap=256 with 0xA5 past the end.

Cursor bounds every read against len, never against cap and never by letting
a slice expression refuse, and caps what it returns so a field cannot be
re-sliced forward. Verdict carries the bit (bool, error) was missing -- did a
verifier run -- so "nothing checked this" stops borrowing the spelling of
"this failed", and its zero value denies. Poisoned is the one definition of
the adversarial fixture: append-built input has spare capacity too, but
zeroed, so an over-read reads as a harmless run of zeros.

p3q parses through the cursor. Its six hand-written bounds become zero; the
class now rests on the single check in Cursor.Bytes, and deleting that check
fails both suites -- including with fixtures degraded back to plain append,
which is what the class used to depend on.
2026-08-28 11:47:04 -07:00

279 lines
12 KiB
Go

// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package p3q
import (
"encoding/binary"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/stretchr/testify/require"
)
// exact returns b with caller-chosen bytes behind its declared end.
//
// This matters more than it looks, and it is not what the name once
// promised. A precompile's input is the two-index slice
// Memory.GetPtr returns (instructions.go opCall), and nothing on the
// path to Run copies it: len is the size the caller declared and paid
// gas for, cap is the rest of EVM memory, and the caller filled that
// memory with MSTORE before making the call.
//
// So a Go slice expression s[a:b] reaching past len does NOT panic and
// does NOT halt a validator — it succeeds, returning bytes the attacker
// chose, and the verifier answers over material nobody declared. The
// failure is silent forgery, not a crash.
//
// A fixture built with append also has spare capacity, but zeroed or
// stale, so an over-read looks like a harmless run of zeros. Filling
// the spare region with a recognisable pattern is what makes an
// over-read visible as one. Every refusal test below submits such an
// input; contract.Poisoned holds the one definition.
func exact(b []byte) []byte { return contract.Poisoned(b, 256) }
// verdictExact is verdict() over an input whose spare capacity holds
// attacker bytes, and it requires that no input can panic the
// precompile.
func verdictExact(t *testing.T, input []byte) bool {
t.Helper()
in := exact(input)
var out bool
require.NotPanics(t, func() { out = verdict(t, in) },
"a %d-byte input must be refused, never panic", len(in))
return out
}
// abiTrue and abiFalse are the only two words Run can return: the
// EVM-ABI encoding of bool. Production builds them through
// contract.Verdict.Word, which is why they are spelled out here rather
// than imported — a test that expected the same helper the code under
// test uses would agree with it by construction and check nothing.
func abiTrue() []byte { w := make([]byte, 32); w[31] = 1; return w }
func abiFalse() []byte { return make([]byte, 32) }
func be32(v uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
return b[:]
}
// wire assembles the P3Q calldata from parts without re-deriving any
// length, so a test can declare a length that disagrees with the bytes
// it supplies.
func wire(kind, mode uint8, sigLen uint32, sig []byte, pkLen uint32, pk, hash []byte) []byte {
out := []byte{kind, mode}
out = append(out, be32(sigLen)...)
out = append(out, sig...)
out = append(out, be32(pkLen)...)
out = append(out, pk...)
out = append(out, hash...)
return exact(out)
}
// TestNoInputPanicsTheVerifier sweeps every truncation of a fully
// valid call, at exact capacity. Each prefix must produce a verdict,
// never a panic — this is the property that keeps a malformed
// transaction from halting the chain.
func TestNoInputPanicsTheVerifier(t *testing.T) {
k := katSets()[0]
full := exact(EncodeInput(k.mode, mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)))
require.True(t, verdictExact(t, full), "control: the whole input verifies")
// Every prefix: the boundaries between fields are where an
// off-by-one lives, and there is no reason to guess which.
for n := 0; n <= len(full); n++ {
in := exact(full[:n])
require.NotPanics(t, func() {
out, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress,
in, P3QVerifyPrecompile.RequiredGas(in), true)
require.NoError(t, err)
require.Len(t, out, 32)
if n < len(full) {
require.Equal(t, byte(0), out[31], "prefix of length %d must not verify", n)
}
}, "prefix of length %d panicked", n)
}
}
// TestKindDispatchIsBinding is the test the reserved-kind branches
// actually need. Checking that a reserved kind rejects GARBAGE proves
// nothing — garbage rejects under every kind. The question is whether
// the kind byte is load-bearing, so submit material that WOULD verify
// under KindPulsar and require that it does not verify under any other
// kind.
//
// If the dispatch fell through, a rollup could present a Pulsar
// signature as a Corona or Magnetar attestation, and a consumer
// switching on the kind byte would be told a lattice-threshold
// signature had been checked by a verifier that never ran.
func TestKindDispatchIsBinding(t *testing.T) {
k := katSets()[1] // ML-DSA-65
sig, pk, msg := mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)
require.True(t, verdictExact(t, EncodeInputKind(KindPulsar, k.mode, sig, pk, msg)),
"control: this material verifies under KindPulsar")
for _, kind := range []uint8{KindCorona, KindMagnetar} {
require.False(t, verdictExact(t, EncodeInputKind(kind, k.mode, sig, pk, msg)),
"kind 0x%02x is reserved: valid Pulsar material must not verify under it", kind)
}
// And every byte that names no kind at all.
named := map[uint8]bool{KindPulsar: true, KindCorona: true, KindMagnetar: true}
for b := 0; b < 256; b++ {
if named[uint8(b)] {
continue
}
require.False(t, verdictExact(t, EncodeInputKind(uint8(b), k.mode, sig, pk, msg)),
"unknown kind 0x%02x must not verify valid Pulsar material", b)
}
}
// TestModeDispatchIsBinding is the same argument one field along. A
// mode byte outside {0x44, 0x65, 0x87} must refuse material that
// verifies under its true mode — never silently fall back to a default
// parameter set that happens to fit.
func TestModeDispatchIsBinding(t *testing.T) {
k := katSets()[1] // ML-DSA-65, the set a careless default would pick
sig, pk, msg := mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)
require.True(t, verdictExact(t, EncodeInput(k.mode, sig, pk, msg)), "control")
known := map[uint8]bool{ModeMLDSA44: true, ModeMLDSA65: true, ModeMLDSA87: true}
for b := 0; b < 256; b++ {
if known[uint8(b)] {
continue
}
require.False(t, verdictExact(t, EncodeInput(uint8(b), sig, pk, msg)),
"mode 0x%02x names no parameter set: ML-DSA-65 material must not verify under it", b)
}
}
// TestDeclaredLengthsMustBeExact pins that both length fields are
// checked for equality, not merely for fitting. A parser that accepted
// "at least the right size" would let a caller pad a field and shift
// every later offset, so the bytes the verifier sees are chosen by the
// length field rather than by the layout.
func TestDeclaredLengthsMustBeExact(t *testing.T) {
k := katSets()[0] // ML-DSA-44
sig, pk, msg := mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)
sz, pkSz := uint32(k.sigSize), uint32(k.pkSize)
require.True(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, pkSz, pk, msg)), "control")
// Signature length declared larger than the parameter set allows,
// with the bytes to back it up.
over := append(append([]byte{}, sig...), 0x00)
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz+1, over, pkSz, pk, msg)),
"an oversized declared signature length must be refused")
// ...and smaller, likewise backed by bytes.
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz-1, sig[:k.sigSize-1], pkSz, pk, msg)),
"an undersized declared signature length must be refused")
// Public key length declared larger, with bytes present.
overPK := append(append([]byte{}, pk...), 0x00)
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, pkSz+1, overPK, msg)),
"an oversized declared public key length must be refused")
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, pkSz-1, pk[:k.pkSize-1], msg)),
"an undersized declared public key length must be refused")
// Zero-length declarations, structurally padded out to clear
// MinInputLength.
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, 0, nil, pkSz, pk, msg)))
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, 0, nil, msg)))
}
// TestDeclaredLengthsCannotOverrun pins the bounds half: a length
// field that claims more bytes than the calldata holds must be
// refused before the slice is taken. Exact capacity is what makes this
// test meaningful.
func TestDeclaredLengthsCannotOverrun(t *testing.T) {
k := katSets()[0]
sig, pk, msg := mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)
sz, pkSz := uint32(k.sigSize), uint32(k.pkSize)
for _, claim := range []uint32{sz + 1, sz + 1000, 1 << 30, ^uint32(0)} {
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, claim, sig, pkSz, pk, msg)),
"declared signature length %d exceeds the calldata", claim)
}
for _, claim := range []uint32{pkSz + 1, 1 << 30, ^uint32(0)} {
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, claim, pk, msg)),
"declared public key length %d exceeds the calldata", claim)
}
// Too few bytes left to hold the public-key length field at all.
short := []byte{KindPulsar, k.mode}
short = append(short, be32(sz)...)
short = append(short, sig...)
short = append(short, 0x00, 0x00, 0x00) // three bytes: one short of the field
require.GreaterOrEqual(t, len(short), MinInputLength)
require.False(t, verdictExact(t, short))
// Message hash truncated to 31 bytes, and absent entirely.
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, pkSz, pk, msg[:31])))
require.False(t, verdictExact(t, wire(KindPulsar, k.mode, sz, sig, pkSz, pk, nil)))
}
// TestOutputWordIsFreshEachCall pins the documented promise that a
// caller may mutate the returned slice. If the true and false words
// were package-level singletons, one caller scribbling on a verdict
// would corrupt every later caller's — a verification result flipping
// underneath consumers that never touched the verifier.
func TestOutputWordIsFreshEachCall(t *testing.T) {
k := katSets()[1]
good := exact(EncodeInput(k.mode, mustHex(t, k.sig), mustHex(t, k.pub), mustHex(t, k.msgHexVar)))
bad := exact(EncodeInput(k.mode, make([]byte, k.sigSize), mustHex(t, k.pub), mustHex(t, k.msgHexVar)))
call := func(in []byte) []byte {
out, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress,
in, P3QVerifyPrecompile.RequiredGas(in), true)
require.NoError(t, err)
return out
}
first := call(good)
require.Equal(t, byte(1), first[31])
for i := range first {
first[i] = 0xAA
}
require.Equal(t, byte(1), call(good)[31], "a mutated result must not affect the next call")
firstFalse := call(bad)
require.Equal(t, byte(0), firstFalse[31])
for i := range firstFalse {
firstFalse[i] = 0xAA
}
require.Equal(t, byte(0), call(bad)[31])
require.Equal(t, byte(1), call(good)[31])
}
// TestVerifyNamesTheFieldItRefused pins that the standalone entry
// point reports WHICH precondition failed. Run collapses every failure
// into a false word by design, so Verify's typed errors are the only
// place a caller can learn that a size was wrong rather than a
// signature — and the only place a test can tell a real size check
// from an incidental downstream rejection.
func TestVerifyNamesTheFieldItRefused(t *testing.T) {
k := katSets()[1]
pk, sig, msg := mustHex(t, k.pub), mustHex(t, k.sig), mustHex(t, k.msgHexVar)
require.NoError(t, Verify(k.mode, pk, sig, msg), "control")
require.EqualError(t, Verify(k.mode, pk[:k.pkSize-1], sig, msg), "p3q: groupPubKey size mismatch")
require.EqualError(t, Verify(k.mode, append(pk, 0), sig, msg), "p3q: groupPubKey size mismatch")
require.EqualError(t, Verify(k.mode, pk, sig[:k.sigSize-1], msg), "p3q: pulsarSig size mismatch")
require.EqualError(t, Verify(k.mode, pk, append(sig, 0), msg), "p3q: pulsarSig size mismatch")
require.EqualError(t, Verify(k.mode, pk, sig, msg[:31]), "p3q: messageHash must be 32 bytes")
require.EqualError(t, Verify(k.mode, pk, sig, append(msg, 0)), "p3q: messageHash must be 32 bytes")
// A genuine cryptographic rejection must be distinguishable from
// all of the above.
require.EqualError(t, Verify(k.mode, pk, make([]byte, k.sigSize), msg),
"p3q: FIPS 204 ML-DSA verify rejected")
}