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

286 lines
9.8 KiB
Go

// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package cggmp21
import (
"crypto/ecdsa"
"errors"
"fmt"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
var (
// ContractCGGMP21VerifyAddress is the address of the CGGMP21 threshold signature precompile (Threshold Signatures range 0x0800)
ContractCGGMP21VerifyAddress = common.HexToAddress("0x0800000000000000000000000000000000000003")
// Singleton instance
CGGMP21VerifyPrecompile = &cggmp21VerifyPrecompile{}
_ contract.StatefulPrecompiledContract = &cggmp21VerifyPrecompile{}
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 CGGMP21 threshold signature verification
// CGGMP21 is more expensive than FROST but has identifiable aborts
CGGMP21VerifyBaseGas uint64 = 75_000 // Base cost for ECDSA threshold verification
CGGMP21VerifyPerSignerGas uint64 = 10_000 // Cost per signer in threshold
// CGGMP21 uses standard ECDSA signatures
CGGMP21PublicKeySize = 65 // Uncompressed public key (0x04 || x || y)
CGGMP21SignatureSize = 65 // ECDSA signature (r || s || v)
CGGMP21MessageHashSize = 32 // 32-byte message hash
ThresholdSize = 4 // uint32 threshold t
TotalSignersSize = 4 // uint32 total signers n
// Minimum input size
MinInputSize = ThresholdSize + TotalSignersSize + CGGMP21PublicKeySize + CGGMP21MessageHashSize + CGGMP21SignatureSize
// 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: verifyECDSASignature is handed a fixed 65-byte key,
// a fixed 32-byte hash and a fixed 65-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 cggmp21VerifyPrecompile struct{}
// claim is one call's submission: the committee it names, and the ECDSA
// 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:73] aggregated public key (65 bytes: 0x04 || x || y)
// [73:105] message hash (32 bytes)
// [105:170] ECDSA signature (65 bytes: r || s || v)
//
// 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(CGGMP21PublicKeySize)
if err != nil {
return claim{}, err
}
hash, err := in.Bytes(CGGMP21MessageHashSize)
if err != nil {
return claim{}, err
}
sig, err := in.Bytes(CGGMP21SignatureSize)
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 CGGMP21 verify precompile
func (p *cggmp21VerifyPrecompile) Address() common.Address {
return ContractCGGMP21VerifyAddress
}
// RequiredGas calculates the gas required for CGGMP21 verification
func (p *cggmp21VerifyPrecompile) RequiredGas(input []byte) uint64 {
return CGGMP21VerifyGasCost(input)
}
// CGGMP21VerifyGasCost calculates the gas cost for CGGMP21 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 CGGMP21VerifyGasCost(input []byte) uint64 {
c, err := parse(input)
if err != nil {
return CGGMP21VerifyBaseGas
}
// Base cost + per-signer cost, on the clamped signer count.
return CGGMP21VerifyBaseGas + (uint64(min(c.signers, MaxBilledSigners)) * CGGMP21VerifyPerSignerGas)
}
// Run implements the CGGMP21 threshold signature verification precompile
func (p *cggmp21VerifyPrecompile) 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. The luxfi/accel SigECDSA kernel
// cannot reproduce this verdict: (1) it framed the 65-byte uncompressed
// key as a 33-byte "compressed" key by truncation (garbage point), and
// (2) it omits the recover-and-compare binding that verifyECDSASignature
// performs below. A GPU "valid" therefore did not imply a CPU "valid"
// (consensus split / forgery surface). Re-introducing accel requires a
// secp256k1 ECDSA kernel proven byte-identical to verifyECDSASignature.
valid, err := verifyECDSASignature(c.key, c.hash, c.sig)
if err != nil {
return nil, remainingGas, err
}
// Return result as 32-byte word (1 = valid, 0 = invalid)
result := make([]byte, 32)
if valid {
result[31] = 1
}
return result, remainingGas, nil
}
// verifyECDSASignature verifies an ECDSA signature
func verifyECDSASignature(publicKeyBytes, messageHash, signatureBytes []byte) (bool, error) {
if len(publicKeyBytes) != 65 {
return false, ErrInvalidPublicKey
}
if len(messageHash) != 32 {
return false, errors.New("invalid message hash length")
}
if len(signatureBytes) != 65 {
return false, ErrInvalidSignature
}
// Parse public key
publicKey, err := crypto.UnmarshalPubkey(publicKeyBytes)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrInvalidPublicKey, err)
}
// Verify signature. CGGMP21 emits standard ECDSA, so r||s verifies
// normally; the recovery byte is consumed by recoverPublicKey below.
sig := make([]byte, 64)
copy(sig[0:32], signatureBytes[0:32]) // r
copy(sig[32:64], signatureBytes[32:64]) // s
valid := crypto.VerifySignature(
crypto.FromECDSAPub(publicKey),
messageHash,
sig,
)
if !valid {
return false, nil
}
// Additional validation: recover public key and compare
recoveredPubKey, err := recoverPublicKey(messageHash, signatureBytes)
if err != nil {
return false, nil
}
// Compare recovered public key with expected
if recoveredPubKey.X.Cmp(publicKey.X) != 0 || recoveredPubKey.Y.Cmp(publicKey.Y) != 0 {
return false, nil
}
return true, nil
}
// recoverPublicKey recovers the public key from signature
func recoverPublicKey(messageHash, signature []byte) (*ecdsa.PublicKey, error) {
if len(signature) != 65 {
return nil, ErrInvalidSignature
}
// The recovery id is [0,3]; anything else is not a second spelling of one,
// it is a different byte that the two builds disagree about. libsecp256k1
// refuses an id above 3, while the pure-Go fallback routes through btcec,
// whose header is 27+id uncompressed and 31+id COMPRESSED — so v=4 survived
// the 27 subtraction, became header 31, and read back as a valid id 0. The
// same 170 bytes then returned 0 on a cgo node and 1 without cgo, which is
// a chain split rather than a leniency. Refuse here, where the byte becomes
// a value, so both builds admit exactly {id, id+27}.
v := signature[64]
if v >= 27 {
v -= 27
}
if v > 3 {
return nil, ErrInvalidSignature
}
// Normalize signature for ecrecover
sig := make([]byte, 65)
copy(sig[0:32], signature[0:32]) // r
copy(sig[32:64], signature[32:64]) // s
sig[64] = v
pubKeyBytes, err := crypto.Ecrecover(messageHash, sig)
if err != nil {
return nil, err
}
return crypto.UnmarshalPubkey(pubKeyBytes)
}