Files
zeekay 414aa8fa29 precompiles: parse calldata through one cursor, answer with one verdict
Class 1. Every parser walked its wire format with hand-written slice
expressions, and the reason that is dangerous is not the one written in the
old fixtures' comments. A precompile's input is the two-index slice
Memory.GetPtr returns; nothing on the path to Run copies it. len is what the
caller declared and paid for, cap is the rest of EVM memory, and the caller
MSTOREd it. So s[a:b] past len does not panic and does not halt a validator --
it returns bytes the attacker chose and the verifier answers over them.
Reverting one cursor read in frost and declaring the input one byte short
returns verdict word 1 over a byte nobody sent: an accept, not a crash.

contract.Cursor bounds every read against len, caps what it returns so a field
cannot be re-sliced forward, and refuses a declared count before it sizes
anything -- Fields divides rather than multiplying, because n*w wraps and a
wrapped product compares as small. STARKVerifyPrecompile did make([]uint64, n)
for an attacker-chosen uint32, up to 34 GB, and only then checked.

Class 2. (bool, error) has four states and three meanings: (false, nil) is
both "verified and rejected" and "nothing ran". That collision is why one
verifier could fold its rounds, discard the result, return true, and read as
ordinary code. contract.Verdict carries the missing bit, so its zero value
denies and a refusal is distinguishable from a rejection. A positive can only
come from Checked, and Checked(true) fails the build -- reintroduced in
fflonkVerify, the compiler and go vet both pass it, the gate names the line.

Nine parse functions across eight packages carry zero hand-written bounds now.
The class rests on two checks in Cursor; deleting either fails every suite,
including with fixtures degraded back to plain append -- which is what the
class used to depend on.
2026-08-28 12:10:59 -07:00

245 lines
8.4 KiB
Go

// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package frost
import (
"errors"
"fmt"
"sync"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/protocols/frost/sign"
)
var (
// ContractFROSTVerifyAddress is the address of the FROST threshold signature precompile (Threshold Signatures range 0x0800)
ContractFROSTVerifyAddress = common.HexToAddress("0x0800000000000000000000000000000000000002")
// Singleton instance
FROSTVerifyPrecompile = &frostVerifyPrecompile{}
_ contract.StatefulPrecompiledContract = &frostVerifyPrecompile{}
ErrInvalidInputLength = contract.ErrInvalidInput
ErrInvalidThreshold = errors.New("invalid threshold: t must be > 0 and <= n")
ErrInvalidPublicKey = errors.New("invalid public key")
ErrInvalidSignature = errors.New("invalid signature")
ErrSignatureVerifyFail = errors.New("signature verification failed")
)
const (
// Gas costs for FROST threshold signature verification
// FROST is more efficient than ECDSA threshold (CMP/CGGMP21)
FROSTVerifyBaseGas uint64 = 50_000 // Base cost for Schnorr verification
FROSTVerifyPerSignerGas uint64 = 5_000 // Cost per signer in threshold
// FROST uses 32-byte Schnorr signatures (Ed25519 or secp256k1)
FROSTPublicKeySize = 32 // Compressed public key
FROSTSignatureSize = 64 // Schnorr signature (R || s)
FROSTMessageHashSize = 32 // SHA-256 message hash
ThresholdSize = 4 // uint32 threshold t
TotalSignersSize = 4 // uint32 total signers n
// Minimum input size
MinInputSize = ThresholdSize + TotalSignersSize + FROSTPublicKeySize + FROSTMessageHashSize + FROSTSignatureSize
// MaxBilledSigners clamps the signer count read from calldata before it
// is multiplied into the fee. `n` is attacker-chosen and the verifier
// never consults it: verifySchnorrSignature is handed a fixed 32-byte
// key, a fixed 32-byte hash and a fixed 64-byte signature, so the work
// is the same for every declared n. Without the clamp a caller quotes an
// arbitrary fee -- up to 2^32-1 signers -- for constant work. Matches the
// clamp the sibling threshold precompiles apply.
MaxBilledSigners uint32 = 1000
)
type frostVerifyPrecompile struct{}
// claim is one call's submission: the committee it names, and the Schnorr
// signature it asks to have checked against a key and a message hash.
type claim struct {
threshold uint32
signers uint32
key []byte
hash []byte
sig []byte
}
// parse reads the wire format off calldata:
//
// [0:4] threshold t (uint32, big-endian)
// [4:8] total signers n (uint32, big-endian)
// [8:40] aggregated public key (32 bytes)
// [40:72] message hash (32 bytes)
// [72:136] Schnorr signature (64 bytes: R || s)
//
// Every field is bounded by a contract.Cursor rather than by a slice
// expression, and the difference is not stylistic. A precompile's input is a
// window into EVM memory: opCall takes it with Memory.GetPtr, which returns
// the two-index slice m.store[off : off+size], 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 memory that same caller filled with MSTORE beforehand. A
// slice expression past len but within cap does not refuse -- it returns bytes
// the caller chose, and the verifier answers over material nobody declared.
// The cursor bounds every read against Len and caps what it hands back, so a
// field cannot be completed out of the next one or out of spare capacity.
// See contract/cursor.go.
//
// The format is fixed-size, so parse refuses exactly when the calldata holds
// fewer than MinInputSize bytes -- the same condition the hand-written length
// check tested, now enforced per field and not by a line that can be deleted.
//
// Trailing bytes are accepted. This format has always admitted them (it took
// len(input) >= MinInputSize, never ==), so parse reads its fields and stops
// rather than calling End. Whether to keep admitting them is a question about
// the wire format, not about the parser: padding lets one signature ride many
// distinct calldatas.
func parse(input []byte) (claim, error) {
in := contract.Read(input)
threshold, err := in.Uint32()
if err != nil {
return claim{}, err
}
signers, err := in.Uint32()
if err != nil {
return claim{}, err
}
key, err := in.Bytes(FROSTPublicKeySize)
if err != nil {
return claim{}, err
}
hash, err := in.Bytes(FROSTMessageHashSize)
if err != nil {
return claim{}, err
}
sig, err := in.Bytes(FROSTSignatureSize)
if err != nil {
return claim{}, err
}
return claim{threshold: threshold, signers: signers, key: key, hash: hash, sig: sig}, nil
}
// Address returns the address of the FROST verify precompile
func (p *frostVerifyPrecompile) Address() common.Address {
return ContractFROSTVerifyAddress
}
// RequiredGas calculates the gas required for FROST verification
func (p *frostVerifyPrecompile) RequiredGas(input []byte) uint64 {
return FROSTVerifyGasCost(input)
}
// FROSTVerifyGasCost calculates the gas cost for FROST verification.
//
// Calldata too short to carry a signer count is billed at base: parse refuses
// exactly below MinInputSize, which is the boundary this function has always
// priced at.
func FROSTVerifyGasCost(input []byte) uint64 {
c, err := parse(input)
if err != nil {
return FROSTVerifyBaseGas
}
// Base cost + per-signer cost, on the clamped signer count.
return FROSTVerifyBaseGas + (uint64(min(c.signers, MaxBilledSigners)) * FROSTVerifyPerSignerGas)
}
// Run implements the FROST threshold signature verification precompile
func (p *frostVerifyPrecompile) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
// Calculate required gas
gasCost := p.RequiredGas(input)
remainingGas, err := contract.DeductGas(suppliedGas, gasCost)
if err != nil {
return nil, 0, err
}
// parse reads the fields through a contract.Cursor; see its doc comment
// for why a slice expression is the wrong tool on calldata.
c, err := parse(input)
if err != nil {
// parse refuses only by running out of calldata, and the deployed
// error names both sizes, so keep that wording rather than the
// cursor's.
return nil, remainingGas, fmt.Errorf("%w: expected at least %d bytes, got %d",
ErrInvalidInputLength, MinInputSize, len(input))
}
// Validate threshold
if c.threshold == 0 || c.threshold > c.signers {
return nil, remainingGas, ErrInvalidThreshold
}
// CPU is the single source of truth. FROST produces SCHNORR signatures;
// the luxfi/accel SigECDSA kernel verifies a different equation entirely
// (and mis-frames the 32-byte x-only key against its 33-byte ECDSA key
// layout). A GPU "valid" for an ECDSA check does not imply a valid Schnorr
// signature -- that was a wrong-primitive forgery surface and a CPU/GPU
// consensus split. Re-introducing accel requires a Schnorr (secp256k1)
// kernel proven byte-identical to verifySchnorrSignature.
valid := verifySchnorrSignature(c.key, c.hash, c.sig)
// Return result as 32-byte word (1 = valid, 0 = invalid)
result := make([]byte, 32)
if valid {
result[31] = 1
}
return result, remainingGas, nil
}
// liftXCache caches decompressed public key points.
// LiftX (modular square root) is 80% of FROST verify cost — caching eliminates it
// for repeated verifications with the same key (which is every block from same validators).
var (
liftXCache = make(map[[32]byte]curve.Point)
liftXCacheMu sync.RWMutex
)
// verifySchnorrSignature verifies a FROST Schnorr signature using the threshold library.
func verifySchnorrSignature(publicKey, messageHash, signature []byte) bool {
if len(publicKey) != 32 || len(messageHash) != 32 || len(signature) != 64 {
return false
}
group := curve.Secp256k1{}
// Cache LiftX result — square root is 80% of verify cost
var pkKey [32]byte
copy(pkKey[:], publicKey)
liftXCacheMu.RLock()
pubPoint, cached := liftXCache[pkKey]
liftXCacheMu.RUnlock()
if !cached {
var err error
pubPoint, err = group.LiftX(publicKey)
if err != nil {
return false
}
liftXCacheMu.Lock()
if len(liftXCache) < 1024 { // bounded cache
liftXCache[pkKey] = pubPoint
}
liftXCacheMu.Unlock()
}
var sig sign.Signature
if err := sig.UnmarshalBinary(group, signature); err != nil {
return false
}
return sig.Verify(pubPoint, messageHash)
}