mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// This file is package contract_test rather than contract because it imports
|
||||
// geth's core/vm, which imports contract. An external test package is how that
|
||||
// cycle is broken, and importing the real VM is the point: the claim under
|
||||
// test is about what geth actually hands a precompile, so reading it from the
|
||||
// VM beats restating it in a comment.
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/core/vm"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCalldataCarriesCallerChosenSpareCapacity is the measurement the Cursor
|
||||
// is designed against, taken from the VM rather than asserted.
|
||||
//
|
||||
// The CALL opcode builds a precompile's input with Memory.GetPtr, which
|
||||
// returns the two-index slice m.store[offset : offset+size]. So cap is the
|
||||
// rest of EVM memory, and EVM memory is whatever the caller MSTOREd. A parser
|
||||
// that reads past its declared input does not panic and does not halt a
|
||||
// validator — it reads bytes the caller chose and returns a verdict over them.
|
||||
//
|
||||
// If geth ever copies the input on the way in, this test fails, and the
|
||||
// reasoning in cursor.go needs rewriting rather than trusting.
|
||||
func TestCalldataCarriesCallerChosenSpareCapacity(t *testing.T) {
|
||||
m := vm.NewMemory()
|
||||
m.Resize(256)
|
||||
chosen := make([]byte, 256)
|
||||
for i := range chosen {
|
||||
chosen[i] = 0xA5
|
||||
}
|
||||
m.Set(0, 256, chosen) // the attacker's MSTOREs
|
||||
|
||||
// instructions.go opCall, verbatim:
|
||||
// args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64())
|
||||
args := m.GetPtr(0, 8)
|
||||
|
||||
require.Len(t, args, 8, "len is the size the caller declared and paid for")
|
||||
require.Greater(t, cap(args), len(args),
|
||||
"a precompile's input carries spare capacity — re-read the threat model in cursor.go")
|
||||
require.Equal(t, byte(0xA5), args[:16][8],
|
||||
"and reading past the declared end yields bytes the caller chose")
|
||||
|
||||
// Poisoned reproduces exactly that shape for use as a fixture.
|
||||
fixture := contract.Poisoned(make([]byte, 8), 8)
|
||||
require.Len(t, fixture, 8)
|
||||
require.Greater(t, cap(fixture), len(fixture))
|
||||
require.Equal(t, byte(0xA5), fixture[:16][8])
|
||||
|
||||
// And the cursor refuses what the slice expression allows.
|
||||
_, err := contract.Read(args).Bytes(16)
|
||||
require.ErrorIs(t, err, contract.ErrShort)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrShort reports that the input ended before the wire format did: either
|
||||
// fewer bytes remain than a fixed field needs, or a length read off the wire
|
||||
// declares more bytes than the calldata holds.
|
||||
//
|
||||
// ErrTrailing reports bytes left over after the format was fully read.
|
||||
//
|
||||
// Both wrap ErrInvalidInput, so a caller that already refuses on
|
||||
// errors.Is(err, ErrInvalidInput) keeps refusing exactly as before. They are
|
||||
// built once at init, so returning one allocates nothing.
|
||||
var (
|
||||
ErrShort = fmt.Errorf("%w: shorter than the wire format requires", ErrInvalidInput)
|
||||
ErrTrailing = fmt.Errorf("%w: trailing bytes after the wire format", ErrInvalidInput)
|
||||
)
|
||||
|
||||
// Cursor reads a wire format off calldata one field at a time. It is the one
|
||||
// way a precompile parses its input.
|
||||
//
|
||||
// Why a type rather than hand-written slice expressions:
|
||||
//
|
||||
// A precompile's input is a window into the EVM memory store. instructions.go
|
||||
// (opCall and its three siblings) takes it with Memory.GetPtr, which returns
|
||||
// the two-index slice m.store[offset : offset+size], and nothing on the path
|
||||
// to Run copies it. So len(input) is the size the caller declared and paid gas
|
||||
// for, while cap(input) is the rest of EVM memory — which that same caller
|
||||
// filled with MSTORE before making the call.
|
||||
//
|
||||
// A Go slice expression s[a:b] refuses only when b exceeds cap(s). Past len
|
||||
// and within cap it succeeds silently. A parser that reaches past its input
|
||||
// therefore does not panic and does not halt a validator: it reads bytes the
|
||||
// attacker chose, hands them to a verifier, and returns a verdict computed
|
||||
// over material the caller never declared and never paid for. That is a
|
||||
// forgery surface, and it is invisible to any test whose fixture leaves the
|
||||
// spare region zeroed.
|
||||
//
|
||||
// Every read below is bounded against Len, never against cap, and never by
|
||||
// letting a slice expression refuse. Every returned slice is capped to its own
|
||||
// length so a downstream re-slice cannot walk forward into the same region. A
|
||||
// parser written on this type cannot have the bug, and it does not depend on
|
||||
// how its fixtures were built.
|
||||
//
|
||||
// The zero Cursor is an empty input: every read refuses.
|
||||
type Cursor struct {
|
||||
buf []byte
|
||||
off int // invariant: 0 <= off <= len(buf)
|
||||
}
|
||||
|
||||
// Read starts a cursor at the front of b.
|
||||
func Read(b []byte) *Cursor { return &Cursor{buf: b} }
|
||||
|
||||
// Len is the number of bytes not yet read. Never negative.
|
||||
func (c *Cursor) Len() int { return len(c.buf) - c.off }
|
||||
|
||||
// Bytes consumes exactly n bytes and returns them, or refuses.
|
||||
//
|
||||
// n is uint64 because it is usually a length read off the wire, and a wire
|
||||
// length converted to int is negative on a 32-bit build for anything above
|
||||
// 2^31 — where a slice expression refuses but a hand-written `< len` guard
|
||||
// passes. The comparison here is a subtraction on the trusted Len, never an
|
||||
// addition against an attacker-chosen n, so nothing can wrap past it.
|
||||
//
|
||||
// The returned slice has cap == len. It is a window on the input, not a copy,
|
||||
// but it cannot be re-sliced forward: a caller that asks for 32 bytes and then
|
||||
// reads 64 gets a panic rather than 32 bytes of somebody else's memory. That
|
||||
// tripwire should never fire, since the point of the cursor is that a caller
|
||||
// receives exactly the field it named. Between a loud failure and a silent
|
||||
// forgery, loud is the correct direction.
|
||||
func (c *Cursor) Bytes(n uint64) ([]byte, error) {
|
||||
if n > uint64(c.Len()) {
|
||||
return nil, ErrShort
|
||||
}
|
||||
lo := c.off
|
||||
hi := lo + int(n)
|
||||
c.off = hi
|
||||
return c.buf[lo:hi:hi], nil
|
||||
}
|
||||
|
||||
// Byte consumes one byte.
|
||||
func (c *Cursor) Byte() (byte, error) {
|
||||
b, err := c.Bytes(1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return b[0], nil
|
||||
}
|
||||
|
||||
// Uint16 consumes a big-endian uint16.
|
||||
func (c *Cursor) Uint16() (uint16, error) {
|
||||
b, err := c.Bytes(2)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint16(b), nil
|
||||
}
|
||||
|
||||
// Uint32 consumes a big-endian uint32.
|
||||
func (c *Cursor) Uint32() (uint32, error) {
|
||||
b, err := c.Bytes(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint32(b), nil
|
||||
}
|
||||
|
||||
// Uint64 consumes a big-endian uint64.
|
||||
func (c *Cursor) Uint64() (uint64, error) {
|
||||
b, err := c.Bytes(8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint64(b), nil
|
||||
}
|
||||
|
||||
// Rest consumes and returns everything left, capped to its own length.
|
||||
// Use it where the format ends in an open field; use End where the format is
|
||||
// pinned exactly.
|
||||
func (c *Cursor) Rest() []byte {
|
||||
b, _ := c.Bytes(uint64(c.Len()))
|
||||
return b
|
||||
}
|
||||
|
||||
// End refuses any bytes left over.
|
||||
//
|
||||
// Whether trailing bytes are refused is a property of the wire format, not of
|
||||
// the parser: a format that admits them lets one signature be presented under
|
||||
// many distinct calldatas. Formats that already document padding as accepted
|
||||
// keep accepting it, and simply do not call End.
|
||||
func (c *Cursor) End() error {
|
||||
if c.Len() != 0 {
|
||||
return ErrTrailing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCursorRefusesPastTheDeclaredEnd is the property the whole type exists
|
||||
// for. A hand-written s[a:b] over this input succeeds and returns poison; the
|
||||
// cursor refuses.
|
||||
func TestCursorRefusesPastTheDeclaredEnd(t *testing.T) {
|
||||
in := Poisoned([]byte{1, 2, 3, 4}, 64)
|
||||
|
||||
// The bug, spelled out: a slice expression does not save you here.
|
||||
require.NotPanics(t, func() {
|
||||
over := in[:20]
|
||||
require.Equal(t, byte(0xA5), over[10],
|
||||
"a hand-written slice reads attacker bytes past the declared end")
|
||||
})
|
||||
|
||||
// The cursor refuses the same read.
|
||||
c := Read(in)
|
||||
_, err := c.Bytes(20)
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
require.ErrorIs(t, err, ErrInvalidInput, "callers refusing on ErrInvalidInput keep refusing")
|
||||
require.Equal(t, 4, c.Len(), "a refused read consumes nothing")
|
||||
|
||||
// And every field reader refuses at the boundary rather than reading on.
|
||||
c = Read(in)
|
||||
_, err = c.Bytes(4)
|
||||
require.NoError(t, err)
|
||||
_, err = c.Byte()
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
_, err = c.Uint16()
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
_, err = c.Uint32()
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
_, err = c.Uint64()
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
}
|
||||
|
||||
// TestCursorReturnsCappedSlices pins the tripwire: a field handed back cannot
|
||||
// be re-sliced forward into the input's spare capacity.
|
||||
func TestCursorReturnsCappedSlices(t *testing.T) {
|
||||
in := Poisoned([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 64)
|
||||
c := Read(in)
|
||||
got, err := c.Bytes(4)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{1, 2, 3, 4}, got)
|
||||
require.Equal(t, len(got), cap(got), "a returned field has no spare capacity")
|
||||
require.Panics(t, func() { _ = got[:8] },
|
||||
"re-slicing a field past its length must fail loudly, not read the next field")
|
||||
|
||||
rest := c.Rest()
|
||||
require.Equal(t, []byte{5, 6, 7, 8}, rest)
|
||||
require.Equal(t, len(rest), cap(rest), "Rest is capped too")
|
||||
require.Panics(t, func() { _ = rest[:len(rest)+1] })
|
||||
}
|
||||
|
||||
// TestCursorReadsFieldsInOrder walks a whole wire format.
|
||||
func TestCursorReadsFieldsInOrder(t *testing.T) {
|
||||
in := Poisoned([]byte{
|
||||
0x01, // byte
|
||||
0x02, 0x03, // uint16
|
||||
0x00, 0x00, 0x00, 0x04, // uint32
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, // uint64
|
||||
0xAA, 0xBB, // rest
|
||||
}, 32)
|
||||
c := Read(in)
|
||||
require.Equal(t, 17, c.Len())
|
||||
|
||||
b, err := c.Byte()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byte(0x01), b)
|
||||
|
||||
u16, err := c.Uint16()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint16(0x0203), u16)
|
||||
|
||||
u32, err := c.Uint32()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint32(4), u32)
|
||||
|
||||
u64, err := c.Uint64()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(5), u64)
|
||||
|
||||
require.Equal(t, 2, c.Len())
|
||||
require.Error(t, c.End())
|
||||
require.ErrorIs(t, c.End(), ErrTrailing)
|
||||
require.ErrorIs(t, c.End(), ErrInvalidInput)
|
||||
|
||||
require.Equal(t, []byte{0xAA, 0xBB}, c.Rest())
|
||||
require.Equal(t, 0, c.Len())
|
||||
require.NoError(t, c.End())
|
||||
require.Empty(t, c.Rest(), "Rest at the end yields nothing")
|
||||
}
|
||||
|
||||
// TestCursorRefusesADeclaredLengthLargerThanTheInput is the length-prefixed
|
||||
// shape every parser here uses, and the one that reads attacker bytes when it
|
||||
// is written by hand.
|
||||
func TestCursorRefusesADeclaredLengthLargerThanTheInput(t *testing.T) {
|
||||
for _, claim := range []uint32{5, 1 << 20, 1 << 30, ^uint32(0)} {
|
||||
body := []byte{byte(claim >> 24), byte(claim >> 16), byte(claim >> 8), byte(claim), 1, 2, 3, 4}
|
||||
c := Read(Poisoned(body, 1<<16))
|
||||
n, err := c.Uint32()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, claim, n)
|
||||
_, err = c.Bytes(uint64(n))
|
||||
require.ErrorIs(t, err, ErrShort, "declared length %d exceeds the calldata", claim)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCursorLengthArithmeticDoesNotWrap pins that the bound is a subtraction
|
||||
// on the trusted length, never an addition against the declared one. A guard
|
||||
// written as `off+n > len` wraps for n near 2^64 and admits the read.
|
||||
func TestCursorLengthArithmeticDoesNotWrap(t *testing.T) {
|
||||
c := Read(Poisoned([]byte{1, 2, 3, 4}, 1<<16))
|
||||
_, err := c.Bytes(1)
|
||||
require.NoError(t, err)
|
||||
for _, n := range []uint64{^uint64(0), ^uint64(0) - 3, 1 << 63, 1 << 40} {
|
||||
c2 := Read(Poisoned([]byte{1, 2, 3, 4}, 1<<16))
|
||||
_, err := c2.Bytes(1)
|
||||
require.NoError(t, err)
|
||||
_, err = c2.Bytes(n)
|
||||
require.ErrorIs(t, err, ErrShort, "n=%d must be refused, not wrapped", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroCursorRefusesEverything pins the fail-secure default.
|
||||
func TestZeroCursorRefusesEverything(t *testing.T) {
|
||||
var c Cursor
|
||||
require.Equal(t, 0, c.Len())
|
||||
_, err := c.Byte()
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
_, err = c.Bytes(1)
|
||||
require.ErrorIs(t, err, ErrShort)
|
||||
require.NoError(t, c.End())
|
||||
require.Empty(t, c.Rest())
|
||||
|
||||
// A zero-length read on an empty input is the one thing that succeeds,
|
||||
// and it yields nothing.
|
||||
got, err := c.Bytes(0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
// TestCursorSentinelsAreDistinct guards the error identities the converted
|
||||
// parsers depend on.
|
||||
func TestCursorSentinelsAreDistinct(t *testing.T) {
|
||||
require.False(t, errors.Is(ErrShort, ErrTrailing))
|
||||
require.False(t, errors.Is(ErrTrailing, ErrShort))
|
||||
require.True(t, errors.Is(ErrShort, ErrInvalidInput))
|
||||
require.True(t, errors.Is(ErrTrailing, ErrInvalidInput))
|
||||
require.False(t, errors.Is(ErrInvalidInput, ErrShort))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
// Poisoned returns b with spare capacity behind it, filled with a
|
||||
// recognisable pattern. It is the one definition of what a precompile's input
|
||||
// actually looks like, and every refusal test in this repository submits one.
|
||||
//
|
||||
// The EVM hands a precompile the two-index slice Memory.GetPtr returns
|
||||
// (instructions.go opCall and its three siblings), and nothing on the path to
|
||||
// Run copies it. So len is the size the caller declared and paid gas for,
|
||||
// while cap is the rest of EVM memory — memory that same caller filled with
|
||||
// MSTORE before making the call.
|
||||
//
|
||||
// A fixture built with append also carries spare capacity, but zeroed or
|
||||
// stale. An over-read into it looks like a harmless run of zeros, and a test
|
||||
// over such a fixture passes whether or not the bound it means to exercise is
|
||||
// present. Chosen bytes are what make an over-read visible as an over-read,
|
||||
// and what make the test's subject the parser rather than the allocator.
|
||||
//
|
||||
// It lives in non-test source because nine packages' tests need the same
|
||||
// definition, and two definitions of the adversary is one too many. Nothing
|
||||
// in a Run path calls it.
|
||||
func Poisoned(b []byte, spare int) []byte {
|
||||
full := make([]byte, len(b)+spare)
|
||||
for i := range full {
|
||||
full[i] = 0xA5
|
||||
}
|
||||
copy(full, b)
|
||||
return full[:len(b)]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrRejected is the outcome of a verifier that ran and did not accept.
|
||||
// ErrNoVerifier is the outcome of a path that could not verify at all — no
|
||||
// verifier is wired, a key carries none of the material the equation needs,
|
||||
// an operation is disabled. The two are different facts and a caller that
|
||||
// treats them alike has misread one of them.
|
||||
var (
|
||||
ErrRejected = errors.New("verification failed")
|
||||
ErrNoVerifier = errors.New("no verifier: nothing checked this")
|
||||
)
|
||||
|
||||
// Verdict is the outcome of one cryptographic check.
|
||||
//
|
||||
// It replaces (bool, error) on verification paths, for a reason that was
|
||||
// measured rather than assumed. Three verifiers in this repository answered
|
||||
// "valid" without verifying anything: one folded its IPA rounds, assigned the
|
||||
// result to _, and returned true; one returned a length check as a verdict, so
|
||||
// every byte string was a valid range proof; one returned true for every
|
||||
// statement. Three existing tests missed all of it because they discarded the
|
||||
// verdict into _.
|
||||
//
|
||||
// (bool, error) has four representable states and three meanings:
|
||||
//
|
||||
// (true, nil) verified, accepted
|
||||
// (false, nil) verified and rejected — OR nothing ran ← the collision
|
||||
// (false, err) an error
|
||||
// (true, err) nonsense, and nothing stops it being written
|
||||
//
|
||||
// The collision is the defect. "I did not verify" has no spelling of its own,
|
||||
// so it borrows the spelling of "I verified and it failed", and a caller
|
||||
// cannot tell a missing verifier from a bad proof. The missing thing is a bit
|
||||
// recording whether a verifier ran. Verdict carries it, and its zero value has
|
||||
// that bit clear — so a fall-through, an unset struct field, a forgotten
|
||||
// branch and a `var v Verdict` all refuse. Fail-secure is the default rather
|
||||
// than something each author has to remember.
|
||||
//
|
||||
// Why not error alone, with a sentinel for "no verifier". It is the more
|
||||
// obvious answer and it is worse. `return nil` is the most ordinary line in
|
||||
// Go: it is what a function tail looks like when nothing went wrong, and under
|
||||
// an error-only signature it MEANS accepted. The accept becomes the default
|
||||
// shape of a forgotten path. The halo2 bug written error-only is
|
||||
// `_ = foldedCommitment; return nil` — the same bug, now indistinguishable
|
||||
// from ordinary success. Fail-secure needs the default to deny, and for error
|
||||
// the default is nil, which admits. So error-only inverts exactly the property
|
||||
// this type exists to hold. (bool, error)'s zero (false, nil) is at least a
|
||||
// refusal; its problems are the collision above and that `true` is a keyword
|
||||
// anyone can type. Verdict fixes both: the collision structurally, and the
|
||||
// keyword by having no exported field — a positive cannot be written as a
|
||||
// literal from another package, only obtained from Checked.
|
||||
//
|
||||
// What that does NOT do: Checked(true) is still writable, and no Go type can
|
||||
// stop it. What it does instead is make every claim of a positive an
|
||||
// enumerable call site — `grep -rn 'contract.Checked'` lists all of them for a
|
||||
// reviewer — and TestCheckedIsNeverALiteral in this package fails the build if
|
||||
// any of them passes a constant. The lie is not impossible; it is impossible
|
||||
// to write by accident, and impossible to leave in the tree.
|
||||
//
|
||||
// Verdict is for security verdicts only. A lookup's (T, bool) reports presence
|
||||
// and stays as it is: ring's scalar(b) ([]*big.Int, bool) refuses a scalar
|
||||
// outside [1, N-1] at parse time and every caller checks it. The difference is
|
||||
// what a wrong true costs — a missing map entry, or an accepted forgery.
|
||||
type Verdict struct {
|
||||
ran bool
|
||||
passed bool
|
||||
why error
|
||||
}
|
||||
|
||||
// Checked records the outcome of a check that ran.
|
||||
//
|
||||
// passed must be the verifier's own closing predicate — the expression that
|
||||
// compares computed evidence against the claim. Never a literal: a literal is
|
||||
// a claim about work, not the work. TestCheckedIsNeverALiteral enforces that
|
||||
// across every package in this module.
|
||||
func Checked(passed bool) Verdict { return Verdict{ran: true, passed: passed} }
|
||||
|
||||
// Refused records that no verifier ran, and why. A nil or ErrRejected reason
|
||||
// is replaced by ErrNoVerifier: "nothing checked this" must never be able to
|
||||
// masquerade as "this was checked and failed", which is the collision the type
|
||||
// exists to remove.
|
||||
func Refused(why error) Verdict {
|
||||
if why == nil || errors.Is(why, ErrRejected) {
|
||||
why = ErrNoVerifier
|
||||
}
|
||||
return Verdict{why: why}
|
||||
}
|
||||
|
||||
// OK reports acceptance, and only acceptance. The zero Verdict is not OK.
|
||||
func (v Verdict) OK() bool { return v.ran && v.passed }
|
||||
|
||||
// Err is nil on acceptance, ErrRejected when a verifier ran and refused, and
|
||||
// the refusal reason when none ran. errors.Is(v.Err(), ErrNoVerifier)
|
||||
// distinguishes "nothing checked this" from "this did not check out".
|
||||
func (v Verdict) Err() error {
|
||||
switch {
|
||||
case v.OK():
|
||||
return nil
|
||||
case v.ran:
|
||||
return ErrRejected
|
||||
case v.why != nil:
|
||||
return v.why
|
||||
default:
|
||||
return ErrNoVerifier
|
||||
}
|
||||
}
|
||||
|
||||
// Word is the 32-byte EVM-ABI encoding of the verdict as a bool: the last byte
|
||||
// is 1 on acceptance and 0 otherwise. Fresh on every call, so a caller may
|
||||
// mutate what it gets without touching the next caller's result.
|
||||
//
|
||||
// A refusal encodes as false. A precompile whose contract is "report the
|
||||
// outcome, never revert" must still say something, and false is the only
|
||||
// honest thing to say about a check that did not happen. A precompile that can
|
||||
// revert should return Err instead, which keeps the distinction.
|
||||
func (v Verdict) Word() []byte {
|
||||
w := make([]byte, 32)
|
||||
if v.OK() {
|
||||
w[31] = 1
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// moduleRoot walks up from this package to the directory holding go.mod.
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("no go.mod above the working directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// walkSource visits every non-test .go file in the module, skipping vendored
|
||||
// and nested-module trees.
|
||||
func walkSource(t *testing.T, visit func(path string, fset *token.FileSet, f *ast.File)) {
|
||||
t.Helper()
|
||||
root := moduleRoot(t)
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // an unreadable directory is not this test's business
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case ".git", "vendor", "node_modules", "testdata":
|
||||
return fs.SkipDir
|
||||
}
|
||||
if path != root {
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return fs.SkipDir // a nested module is not this module
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
f, perr := parser.ParseFile(fset, path, nil, 0)
|
||||
if perr != nil {
|
||||
return nil // unparseable source belongs to whoever is editing it
|
||||
}
|
||||
visit(path, fset, f)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// isConstBool reports whether e is a compile-time true/false, including a
|
||||
// parenthesised or negated one.
|
||||
func isConstBool(e ast.Expr) bool {
|
||||
switch x := e.(type) {
|
||||
case *ast.Ident:
|
||||
return x.Name == "true" || x.Name == "false"
|
||||
case *ast.ParenExpr:
|
||||
return isConstBool(x.X)
|
||||
case *ast.UnaryExpr:
|
||||
return x.Op == token.NOT && isConstBool(x.X)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// calls reports whether e is a call to the named function, either bare
|
||||
// (inside package contract) or qualified as contract.Name.
|
||||
func calls(e ast.Expr, name string) (*ast.CallExpr, bool) {
|
||||
c, ok := e.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch fn := c.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
return c, fn.Name == name
|
||||
case *ast.SelectorExpr:
|
||||
id, ok := fn.X.(*ast.Ident)
|
||||
return c, ok && id.Name == "contract" && fn.Sel.Name == name
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestCheckedIsNeverALiteral is the enforcement half of the Verdict argument.
|
||||
//
|
||||
// The type makes a positive impossible to write as a struct literal from
|
||||
// another package, and makes the zero value a refusal. What it cannot stop is
|
||||
// Checked(true) — no Go type can. This walks every non-test file in the module
|
||||
// and fails if any call to Checked passes a constant.
|
||||
//
|
||||
// That is what closes the measured defect. `return true, nil` is one of the
|
||||
// most common lines in Go and reads as ordinary success; Checked(true) is a
|
||||
// single greppable shape that fails the build. A positive verdict now has to
|
||||
// come from an expression that computed something.
|
||||
func TestCheckedIsNeverALiteral(t *testing.T) {
|
||||
var bad []string
|
||||
walkSource(t, func(path string, fset *token.FileSet, f *ast.File) {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
e, isExpr := n.(ast.Expr)
|
||||
if !isExpr {
|
||||
return true
|
||||
}
|
||||
c, ok := calls(e, "Checked")
|
||||
if !ok || len(c.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
if isConstBool(c.Args[0]) {
|
||||
bad = append(bad, fset.Position(c.Pos()).String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
if len(bad) > 0 {
|
||||
t.Fatalf("Checked was passed a constant — a verdict claimed without a computation:\n %s",
|
||||
strings.Join(bad, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerdictIsNeverDiscarded pins the other half: a caller must not obtain a
|
||||
// Verdict and throw it away.
|
||||
//
|
||||
// The single-value signature already does most of this. Under (bool, error) a
|
||||
// caller could write `_, err := verify(...)` and keep the error while dropping
|
||||
// the verdict — which is exactly how three tests came to miss three verifiers
|
||||
// that answered valid without verifying. A Verdict cannot be split that way:
|
||||
// the verdict and the reason are one value, so there is nothing to take while
|
||||
// leaving the rest.
|
||||
//
|
||||
// What remains possible is dropping the whole thing: `_ = verify(...)`, or the
|
||||
// call as a bare statement. This refuses both, in files that deal in Verdicts.
|
||||
func TestVerdictIsNeverDiscarded(t *testing.T) {
|
||||
var bad []string
|
||||
walkSource(t, func(path string, fset *token.FileSet, f *ast.File) {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(src), "Verdict") {
|
||||
return // only files that traffic in verdicts
|
||||
}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
switch s := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
// `_ = f()` / `_, _ = f()` with a Verdict-shaped callee.
|
||||
allBlank := true
|
||||
for _, lhs := range s.Lhs {
|
||||
id, ok := lhs.(*ast.Ident)
|
||||
if !ok || id.Name != "_" {
|
||||
allBlank = false
|
||||
}
|
||||
}
|
||||
if !allBlank {
|
||||
return true
|
||||
}
|
||||
for _, rhs := range s.Rhs {
|
||||
if verdictShaped(rhs) {
|
||||
bad = append(bad, fset.Position(s.Pos()).String())
|
||||
}
|
||||
}
|
||||
case *ast.ExprStmt:
|
||||
if verdictShaped(s.X) {
|
||||
bad = append(bad, fset.Position(s.Pos()).String())
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
if len(bad) > 0 {
|
||||
t.Fatalf("a verdict was obtained and discarded:\n %s", strings.Join(bad, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// verdictShaped reports whether e is a call that yields a Verdict: either a
|
||||
// constructor, or a function whose name says it verifies.
|
||||
func verdictShaped(e ast.Expr) bool {
|
||||
if _, ok := calls(e, "Checked"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := calls(e, "Refused"); ok {
|
||||
return true
|
||||
}
|
||||
c, ok := e.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var name string
|
||||
switch fn := c.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
name = fn.Name
|
||||
case *ast.SelectorExpr:
|
||||
name = fn.Sel.Name
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(name), "verify")
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestZeroVerdictRefuses is the property the type exists for: the default,
|
||||
// the fall-through, the forgotten branch and the unset struct field all deny.
|
||||
//
|
||||
// This is what (bool, error) could not give and what an error-only signature
|
||||
// gives backwards — under error-only the zero value is nil, which admits.
|
||||
func TestZeroVerdictRefuses(t *testing.T) {
|
||||
var v Verdict
|
||||
require.False(t, v.OK())
|
||||
require.ErrorIs(t, v.Err(), ErrNoVerifier)
|
||||
require.Equal(t, byte(0), v.Word()[31])
|
||||
|
||||
// A struct that forgot to set its verdict field denies too.
|
||||
var holder struct{ V Verdict }
|
||||
require.False(t, holder.V.OK())
|
||||
require.ErrorIs(t, holder.V.Err(), ErrNoVerifier)
|
||||
}
|
||||
|
||||
// TestVerdictStatesAreDistinct pins the collision (bool, error) could not
|
||||
// express: "nothing checked this" and "this did not check out" are different
|
||||
// facts with different errors.
|
||||
func TestVerdictStatesAreDistinct(t *testing.T) {
|
||||
ran := func(b bool) bool { return b } // never a literal; see the gate
|
||||
|
||||
accepted := Checked(ran(true))
|
||||
require.True(t, accepted.OK())
|
||||
require.NoError(t, accepted.Err())
|
||||
require.Equal(t, byte(1), accepted.Word()[31])
|
||||
|
||||
rejected := Checked(ran(false))
|
||||
require.False(t, rejected.OK())
|
||||
require.ErrorIs(t, rejected.Err(), ErrRejected)
|
||||
require.NotErrorIs(t, rejected.Err(), ErrNoVerifier,
|
||||
"a verifier that ran and refused is not a missing verifier")
|
||||
require.Equal(t, byte(0), rejected.Word()[31])
|
||||
|
||||
missing := Refused(ErrNoVerifier)
|
||||
require.False(t, missing.OK())
|
||||
require.ErrorIs(t, missing.Err(), ErrNoVerifier)
|
||||
require.NotErrorIs(t, missing.Err(), ErrRejected,
|
||||
"a missing verifier is not a failed verification")
|
||||
require.Equal(t, byte(0), missing.Word()[31])
|
||||
}
|
||||
|
||||
// TestRefusedNamesItsReason pins that a refusal carries which one it was, so a
|
||||
// caller can tell an incomplete key from a disabled op from an unwired FFI.
|
||||
func TestRefusedNamesItsReason(t *testing.T) {
|
||||
incomplete := errors.New("verifying key carries no IPA commitment key")
|
||||
v := Refused(incomplete)
|
||||
require.ErrorIs(t, v.Err(), incomplete)
|
||||
require.False(t, v.OK())
|
||||
}
|
||||
|
||||
// TestRefusedCannotImpersonateRejection is the collision guard from the other
|
||||
// side. Refused(nil) and Refused(ErrRejected) would both let "nothing ran"
|
||||
// answer as "ran and failed", which is the exact state the type removes.
|
||||
func TestRefusedCannotImpersonateRejection(t *testing.T) {
|
||||
require.ErrorIs(t, Refused(nil).Err(), ErrNoVerifier)
|
||||
require.ErrorIs(t, Refused(ErrRejected).Err(), ErrNoVerifier)
|
||||
|
||||
wrapped := errors.Join(ErrRejected, errors.New("context"))
|
||||
require.ErrorIs(t, Refused(wrapped).Err(), ErrNoVerifier,
|
||||
"a reason that wraps ErrRejected is still not a rejection")
|
||||
}
|
||||
|
||||
// TestVerdictWordIsFreshEachCall pins that a caller may scribble on a result
|
||||
// without corrupting the next one. A shared word would let one consumer flip
|
||||
// another's verification outcome.
|
||||
func TestVerdictWordIsFreshEachCall(t *testing.T) {
|
||||
ran := func(b bool) bool { return b }
|
||||
v := Checked(ran(true))
|
||||
first := v.Word()
|
||||
require.Len(t, first, 32)
|
||||
for i := range first {
|
||||
first[i] = 0xAA
|
||||
}
|
||||
require.Equal(t, byte(1), v.Word()[31])
|
||||
require.Equal(t, byte(0), v.Word()[0])
|
||||
}
|
||||
|
||||
// TestVerdictCannotBeSplit is the structural half of "callers that ignore the
|
||||
// result should be visible".
|
||||
//
|
||||
// Under (bool, error) a caller could write `_, err := verify(...)` and keep the
|
||||
// error while dropping the verdict — which is how three tests came to miss
|
||||
// three verifiers that answered valid without verifying. A Verdict is one
|
||||
// value carrying both, so there is nothing to take while leaving the rest:
|
||||
// `_, err := f()` does not compile against a single-value signature. This
|
||||
// asserts the two readers agree, which is what makes carrying one safe.
|
||||
func TestVerdictCannotBeSplit(t *testing.T) {
|
||||
ran := func(b bool) bool { return b }
|
||||
for _, v := range []Verdict{Checked(ran(true)), Checked(ran(false)), Refused(nil), {}} {
|
||||
require.Equal(t, v.Err() == nil, v.OK(), "OK and Err must never disagree")
|
||||
require.Equal(t, v.OK(), v.Word()[31] == 1, "Word must never disagree either")
|
||||
}
|
||||
}
|
||||
|
||||
// --- the gates, tested against source that violates them -------------------
|
||||
//
|
||||
// A gate that has never failed is not known to be a gate. These parse
|
||||
// synthetic files and assert the detection logic fires, so the two repo-wide
|
||||
// gates are themselves under test rather than merely passing vacuously.
|
||||
|
||||
func parseExprs(t *testing.T, src string) *ast.File {
|
||||
t.Helper()
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "x.go", src, 0)
|
||||
require.NoError(t, err)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestGateCatchesALiteralPositive(t *testing.T) {
|
||||
const src = `package p
|
||||
func a() Verdict { return Checked(true) }
|
||||
func b() Verdict { return contract.Checked(false) }
|
||||
func c() Verdict { return Checked(!true) }
|
||||
func d() Verdict { return Checked((true)) }
|
||||
func e(x bool) Verdict { return Checked(x) }
|
||||
func f(a, b []byte) Verdict { return contract.Checked(bytesEqual(a, b)) }
|
||||
`
|
||||
var caught, allowed int
|
||||
ast.Inspect(parseExprs(t, src), func(n ast.Node) bool {
|
||||
e, ok := n.(ast.Expr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
c, ok := calls(e, "Checked")
|
||||
if !ok || len(c.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
if isConstBool(c.Args[0]) {
|
||||
caught++
|
||||
} else {
|
||||
allowed++
|
||||
}
|
||||
return true
|
||||
})
|
||||
require.Equal(t, 4, caught, "bare, qualified, negated and parenthesised literals must all be caught")
|
||||
require.Equal(t, 2, allowed, "a computed predicate must pass")
|
||||
}
|
||||
|
||||
func TestGateCatchesADiscardedVerdict(t *testing.T) {
|
||||
const src = `package p
|
||||
func a() { _ = verifyThing(x) }
|
||||
func b() { verifyThing(x) }
|
||||
func c() { _ = Checked(y) }
|
||||
func d() { v := verifyThing(x); use(v) }
|
||||
func e() { _ = harmless(x) }
|
||||
func f() { _, _ = contract.Refused(nil), 1 }
|
||||
`
|
||||
var caught int
|
||||
ast.Inspect(parseExprs(t, src), func(n ast.Node) bool {
|
||||
switch s := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
for _, lhs := range s.Lhs {
|
||||
if id, ok := lhs.(*ast.Ident); !ok || id.Name != "_" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rhs := range s.Rhs {
|
||||
if verdictShaped(rhs) {
|
||||
caught++
|
||||
}
|
||||
}
|
||||
case *ast.ExprStmt:
|
||||
if verdictShaped(s.X) {
|
||||
caught++
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
require.Equal(t, 4, caught,
|
||||
"discarded verify calls, discarded constructors and bare-statement calls must all be caught")
|
||||
}
|
||||
|
||||
func TestGateIgnoresUnrelatedCode(t *testing.T) {
|
||||
require.False(t, isConstBool(&ast.Ident{Name: "ok"}))
|
||||
require.False(t, isConstBool(&ast.BasicLit{Kind: token.INT, Value: "1"}))
|
||||
require.False(t, isConstBool(&ast.UnaryExpr{Op: token.SUB, X: &ast.Ident{Name: "true"}}))
|
||||
|
||||
_, ok := calls(&ast.Ident{Name: "Checked"}, "Checked")
|
||||
require.False(t, ok, "a bare identifier is not a call")
|
||||
_, ok = calls(&ast.CallExpr{Fun: &ast.SelectorExpr{
|
||||
X: &ast.Ident{Name: "other"}, Sel: &ast.Ident{Name: "Checked"},
|
||||
}}, "Checked")
|
||||
require.False(t, ok, "another package's Checked is not ours")
|
||||
_, ok = calls(&ast.CallExpr{Fun: &ast.ArrayType{}}, "Checked")
|
||||
require.False(t, ok)
|
||||
|
||||
require.False(t, verdictShaped(&ast.Ident{Name: "x"}))
|
||||
require.False(t, verdictShaped(&ast.CallExpr{Fun: &ast.ArrayType{}}))
|
||||
require.False(t, verdictShaped(&ast.CallExpr{Fun: &ast.Ident{Name: "hash"}}))
|
||||
}
|
||||
+99
-99
@@ -228,15 +228,26 @@ var PrecompileCtx = []byte("lux-evm-precompile-p3q-v1")
|
||||
// ErrUnsupportedMode is returned by Verify, the in-process entry
|
||||
// point, for a mode byte outside {0x44, 0x65, 0x87}.
|
||||
//
|
||||
// Run returns no sentinel of its own except contract.ErrOutOfGas.
|
||||
// Run's own error return carries nothing but contract.ErrOutOfGas.
|
||||
// Every verification failure — malformed input, unknown kind, unknown
|
||||
// mode, length mismatch, FIPS 204 reject — is surfaced as
|
||||
// `(out=[0x00…00], nil error)`, per LP-218 §"Failure modes": no revert
|
||||
// on cryptographic failure, the Solidity caller observes `false` and
|
||||
// decides whether that is fatal. Sentinels for those cases would be
|
||||
// unreachable, and an unreachable sentinel is worse than none: a
|
||||
// caller's errors.Is against it silently answers false forever.
|
||||
var ErrUnsupportedMode = errors.New("p3q: unsupported ML-DSA mode (must be 0x44, 0x65, or 0x87)")
|
||||
// decides whether that is fatal.
|
||||
//
|
||||
// The sentinels below never reach a Solidity caller either. They are
|
||||
// what verify hands contract.Refused, and they exist because the ABI
|
||||
// word cannot tell a check that failed from a check that never ran,
|
||||
// while the code must: a reserved kind and a bad signature are the same
|
||||
// byte on the wire and different facts in the verifier. Reading them
|
||||
// back is what the refusal tests do.
|
||||
var (
|
||||
ErrUnsupportedMode = errors.New("p3q: unsupported ML-DSA mode (must be 0x44, 0x65, or 0x87)")
|
||||
ErrKindReserved = errors.New("p3q: kind reserved, no verifier wired")
|
||||
ErrUnknownKind = errors.New("p3q: kind names no primitive")
|
||||
ErrSigSize = errors.New("p3q: declared signature length is not the mode's size")
|
||||
ErrPubKeySize = errors.New("p3q: declared public key length is not the mode's size")
|
||||
)
|
||||
|
||||
type p3qVerifyPrecompile struct{}
|
||||
|
||||
@@ -267,19 +278,9 @@ func modeParams(mode uint8) (m mldsa.Mode, pkSize, sigSize int, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// abiFalse returns a fresh 32-byte zero slice — the EVM-ABI encoding
|
||||
// of `bool false`. Callers may mutate the return value.
|
||||
func abiFalse() []byte { return make([]byte, 32) }
|
||||
|
||||
// abiTrueClone returns a fresh 32-byte slice with the last byte set
|
||||
// to 0x01 — the EVM-ABI encoding of `bool true`. Callers may mutate
|
||||
// the return value.
|
||||
func abiTrueClone() []byte { b := make([]byte, 32); b[31] = 1; return b }
|
||||
|
||||
// Run verifies a P3Q-family signature per LP-218 / LP-220. Today only
|
||||
// KindPulsar (FIPS 204 ML-DSA byte-equal output) is wired; KindCorona
|
||||
// and KindMagnetar are reserved and return abiFalse until their
|
||||
// verifiers land.
|
||||
// and KindMagnetar are reserved and refuse until their verifiers land.
|
||||
//
|
||||
// Wire format (single-call interface, no function selector):
|
||||
//
|
||||
@@ -297,9 +298,12 @@ func abiTrueClone() []byte { b := make([]byte, 32); b[31] = 1; return b }
|
||||
// Error returns:
|
||||
// - contract.ErrOutOfGas when suppliedGas < RequiredGas(input).
|
||||
//
|
||||
// Verification failures (malformed input, mode mismatch, length
|
||||
// mismatch, FIPS 204 reject) return `(abiFalse, gasLeft, nil)` — the
|
||||
// LP-218 contract: no revert on cryptographic failure.
|
||||
// Every other outcome — malformed input, unknown kind, unknown mode,
|
||||
// length mismatch, FIPS 204 reject — is `(word, gasLeft, nil)` with the
|
||||
// word false, per the LP-218 contract: no revert on cryptographic
|
||||
// failure. The word is the verdict's own encoding, so the two cases
|
||||
// the ABI collapses (a check that failed, a check that never ran) stay
|
||||
// distinct inside this function even though the caller sees one byte.
|
||||
func (p *p3qVerifyPrecompile) Run(
|
||||
_ contract.AccessibleState,
|
||||
_ common.Address,
|
||||
@@ -313,103 +317,99 @@ func (p *p3qVerifyPrecompile) Run(
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return verify(input).Word(), remaining, nil
|
||||
}
|
||||
|
||||
// Structural validation — all on PUBLIC fields (kind byte + mode
|
||||
// byte + length fields + total length). No data-dependent branches
|
||||
// on signature or public-key contents.
|
||||
if len(input) < MinInputLength {
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
// verify parses the wire format and runs the FIPS 204 verifier.
|
||||
//
|
||||
// It reads through a contract.Cursor, so no bound in this function is
|
||||
// written by hand and none can be deleted: an over-read is refused by
|
||||
// the cursor rather than by a slice expression, which under a real
|
||||
// precompile input would not refuse at all. See contract/cursor.go for
|
||||
// why — the short version is that calldata's spare capacity belongs to
|
||||
// the caller, so reaching past the declared end reads chosen bytes and
|
||||
// returns a verdict over them.
|
||||
//
|
||||
// Every branch on kind, mode and the two length fields is on PUBLIC
|
||||
// data. No branch here depends on signature or public-key contents,
|
||||
// which is what keeps the FIPS 204 verifier's constant-time contract
|
||||
// intact at the precompile layer.
|
||||
func verify(input []byte) contract.Verdict {
|
||||
in := contract.Read(input)
|
||||
|
||||
// Family-kind dispatch per LP-218 / LP-220 §"Single slot, kind-byte
|
||||
// dispatch". Today only KindPulsar is wired; KindCorona / KindMagnetar
|
||||
// reserve their slot and fall through to abiFalse. Adding their
|
||||
// verifier paths is a follow-up that does not change the precompile
|
||||
// surface — only the dispatch table grows.
|
||||
kind := input[0]
|
||||
// reserve their slot and refuse. Adding their verifier paths is a
|
||||
// one-line change here and nowhere else.
|
||||
kind, err := in.Byte()
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
switch kind {
|
||||
case KindPulsar:
|
||||
// fall through to the existing FIPS 204 ML-DSA verify path
|
||||
// fall through to the FIPS 204 ML-DSA verify path
|
||||
case KindCorona, KindMagnetar:
|
||||
// Reserved kinds answer false under the LP-218 "no revert on
|
||||
// cryptographic failure" contract, so the surface stays
|
||||
// consistent while their verifiers land. The arm is named
|
||||
// rather than folded into default so that adding a verifier
|
||||
// is a one-line change here and nowhere else.
|
||||
return abiFalse(), remaining, nil
|
||||
return contract.Refused(ErrKindReserved)
|
||||
default:
|
||||
// Unknown kind — abiFalse, same as any malformed input.
|
||||
return abiFalse(), remaining, nil
|
||||
return contract.Refused(ErrUnknownKind)
|
||||
}
|
||||
|
||||
mode := input[KindByte]
|
||||
m, pkSize, sigSize, mErr := modeParams(mode)
|
||||
if mErr != nil {
|
||||
return abiFalse(), remaining, nil
|
||||
mode, err := in.Byte()
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
m, pkSize, sigSize, err := modeParams(mode)
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
|
||||
// Parse pulsarSig: [4 bytes len][N bytes sig].
|
||||
off := KindByte + ModeByte
|
||||
if len(input)-off < LengthFieldSize {
|
||||
return abiFalse(), remaining, nil
|
||||
// pulsarSig: [4 bytes len][N bytes sig]. The declared length must
|
||||
// EQUAL the parameter set's size, not merely fit — a length that
|
||||
// only fits would let a caller pad the field and shift every later
|
||||
// offset, so the bytes the verifier sees are chosen by the length
|
||||
// field rather than by the layout.
|
||||
sigLen, err := in.Uint32()
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
sigLen := binary.BigEndian.Uint32(input[off : off+LengthFieldSize])
|
||||
off += LengthFieldSize
|
||||
if uint64(len(input)-off) < uint64(sigLen) {
|
||||
return abiFalse(), remaining, nil
|
||||
if uint64(sigLen) != uint64(sigSize) {
|
||||
return contract.Refused(ErrSigSize)
|
||||
}
|
||||
if int(sigLen) != sigSize {
|
||||
// Length mismatch against the mode's expected FIPS 204
|
||||
// signature size. The FIPS 204 verifier would reject anyway;
|
||||
// short-circuit here so we don't risk feeding a wrong-length
|
||||
// slice to the underlying primitive.
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
pulsarSig := input[off : off+int(sigLen)]
|
||||
off += int(sigLen)
|
||||
|
||||
// Parse groupPubKey: [4 bytes len][M bytes pk].
|
||||
if len(input)-off < LengthFieldSize {
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
pkLen := binary.BigEndian.Uint32(input[off : off+LengthFieldSize])
|
||||
off += LengthFieldSize
|
||||
if uint64(len(input)-off) < uint64(pkLen) {
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
if int(pkLen) != pkSize {
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
groupPubKey := input[off : off+int(pkLen)]
|
||||
off += int(pkLen)
|
||||
|
||||
// Parse messageHash: exactly 32 bytes at the tail. The wire layout
|
||||
// places the hash AFTER the variable-length blobs so the offset is
|
||||
// content-independent (parseable by reading only the length fields,
|
||||
// no scanning).
|
||||
if len(input)-off < MessageHashSize {
|
||||
return abiFalse(), remaining, nil
|
||||
}
|
||||
messageHash := input[off : off+MessageHashSize]
|
||||
|
||||
// Trailing bytes (off + MessageHashSize < len(input)) are accepted
|
||||
// to match the convention used by the sibling STARK-FRI precompile
|
||||
// (precompile/starkfri/contract.go::TestStarkFRI_Fuzz_PaddedInput).
|
||||
// Padding does not affect verification.
|
||||
|
||||
// Construct typed ML-DSA PublicKey. The mldsa package parses the
|
||||
// FIPS 204 packed-pk byte layout into the runtime struct used by
|
||||
// the verifier.
|
||||
pk, perr := mldsa.PublicKeyFromBytes(groupPubKey, m)
|
||||
if perr != nil {
|
||||
return abiFalse(), remaining, nil
|
||||
pulsarSig, err := in.Bytes(uint64(sigLen))
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
|
||||
// FIPS 204 ML-DSA.Verify under the P3Q domain-separation context.
|
||||
if !pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx) {
|
||||
return abiFalse(), remaining, nil
|
||||
// groupPubKey: [4 bytes len][M bytes pk], same rule.
|
||||
pkLen, err := in.Uint32()
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
return abiTrueClone(), remaining, nil
|
||||
if uint64(pkLen) != uint64(pkSize) {
|
||||
return contract.Refused(ErrPubKeySize)
|
||||
}
|
||||
groupPubKey, err := in.Bytes(uint64(pkLen))
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
|
||||
// messageHash: exactly 32 bytes. The wire layout places it AFTER the
|
||||
// variable-length blobs so its offset is content-independent —
|
||||
// parseable by reading only the length fields, with no scanning.
|
||||
messageHash, err := in.Bytes(MessageHashSize)
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
|
||||
// Trailing bytes are accepted, matching the sibling STARK-FRI
|
||||
// precompile. That is why in.End() is not called here: admitting
|
||||
// padding is this format's documented behaviour, not an oversight.
|
||||
|
||||
pk, err := mldsa.PublicKeyFromBytes(groupPubKey, m)
|
||||
if err != nil {
|
||||
return contract.Refused(err)
|
||||
}
|
||||
return contract.Checked(pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx))
|
||||
}
|
||||
|
||||
// Verify is the in-process Pulsar verification entry-point for callers
|
||||
|
||||
@@ -45,14 +45,14 @@ func TestP3Q_OutOfGas(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestP3Q_ABIBoolEncoding pins the 32-byte EVM-ABI bool output
|
||||
// convention. abiTrueClone() must be 31 zero bytes + 0x01;
|
||||
// convention. abiTrue() must be 31 zero bytes + 0x01;
|
||||
// abiFalse() must be 32 zero bytes.
|
||||
func TestP3Q_ABIBoolEncoding(t *testing.T) {
|
||||
tr := abiTrueClone()
|
||||
tr := abiTrue()
|
||||
require.Len(t, tr, 32)
|
||||
require.Equal(t, byte(0x01), tr[31])
|
||||
for i := 0; i < 31; i++ {
|
||||
require.Equal(t, byte(0x00), tr[i], "abiTrueClone byte %d", i)
|
||||
require.Equal(t, byte(0x00), tr[i], "abiTrue byte %d", i)
|
||||
}
|
||||
fl := abiFalse()
|
||||
require.Len(t, fl, 32)
|
||||
@@ -222,7 +222,7 @@ func TestP3Q_RoundTrip_RealMLDSA(t *testing.T) {
|
||||
supplied := gas * 2
|
||||
out, gasLeft, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, supplied, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, abiTrueClone(), out, "precompile must return abiTrue for a real verifying signature")
|
||||
require.Equal(t, abiTrue(), out, "precompile must return abiTrue for a real verifying signature")
|
||||
require.Equal(t, supplied-gas, gasLeft, "gas accounting must be base + N*per-byte")
|
||||
})
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func TestP3Q_Verify_StandaloneAPI(t *testing.T) {
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
out, _, runErr := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.NoError(t, runErr)
|
||||
require.Equal(t, abiTrueClone(), out)
|
||||
require.Equal(t, abiTrue(), out)
|
||||
}
|
||||
|
||||
// TestP3Q_Verify_SizeMismatchTyped confirms the standalone Verify
|
||||
@@ -411,5 +411,5 @@ func TestP3Q_AcceptsTrailingPadding(t *testing.T) {
|
||||
gas := P3QVerifyPrecompile.RequiredGas(padded) * 2
|
||||
out, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, padded, gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, abiTrueClone(), out, "trailing padding must be ignored, signature must verify")
|
||||
require.Equal(t, abiTrue(), out, "trailing padding must be ignored, signature must verify")
|
||||
}
|
||||
|
||||
+30
-17
@@ -8,29 +8,34 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// exact returns a copy of b whose capacity equals its length.
|
||||
// exact returns b with caller-chosen bytes behind its declared end.
|
||||
//
|
||||
// This matters more than it looks. A Go slice expression s[a:b] panics
|
||||
// only when b exceeds cap(s), not len(s) — so an input assembled with
|
||||
// append, which leaves spare capacity, lets a MISSING bounds check
|
||||
// read uninitialised bytes and carry on. The test then passes while
|
||||
// the guard it was meant to exercise is absent. Calldata handed to a
|
||||
// precompile by the EVM has no spare capacity, so the same input
|
||||
// panics in production, and a panic in geth's precompile dispatch has
|
||||
// no recover: it halts every validator processing the transaction.
|
||||
// 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.
|
||||
//
|
||||
// Every refusal test below therefore submits an exact-capacity slice.
|
||||
func exact(b []byte) []byte {
|
||||
out := make([]byte, len(b))
|
||||
copy(out, b)
|
||||
return out
|
||||
}
|
||||
// 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 exact-capacity input, and it
|
||||
// requires that no input can panic the precompile.
|
||||
// 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)
|
||||
@@ -40,6 +45,14 @@ func verdictExact(t *testing.T, input []byte) bool {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ func TestP3Q_KAT_Accept(t *testing.T) {
|
||||
gas := P3QVerifyPrecompile.RequiredGas(input) * 2
|
||||
out, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, input, gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, abiTrueClone(), out, "frozen KAT vector must verify true on-chain")
|
||||
require.Equal(t, abiTrue(), out, "frozen KAT vector must verify true on-chain")
|
||||
}
|
||||
|
||||
// TestP3Q_KAT_TamperSig is the negative KAT: flipping a single bit
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ func TestP3Q_WireFormat_KindByteMandatory(t *testing.T) {
|
||||
gas := P3QVerifyPrecompile.RequiredGas(good) * 2
|
||||
out, _, err := P3QVerifyPrecompile.Run(nil, common.Address{}, ContractP3QVerifyAddress, good, gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, abiTrueClone(), out, "canonical kind-first encoding must verify")
|
||||
require.Equal(t, abiTrue(), out, "canonical kind-first encoding must verify")
|
||||
|
||||
// Kind-less (legacy / buggy Solidity) encoding of the SAME valid
|
||||
// signature — must be rejected on the kind-dispatch default branch.
|
||||
|
||||
Reference in New Issue
Block a user