mirror of
https://github.com/luxfi/geth.git
synced 2026-10-30 15:05:39 +00:00
Closes CR-1 from the cross-stack adversarial audit: the precompile gate
no longer reads a process-global pq.Active() — it reads the per-chain
profile from ChainConfig.PQ. Multi-chain hosts now get the right
posture per EVM instance instead of last-writer-wins.
Decomplecting per Rich Hickey:
- One gate in one place: (*EVM).runPrecompile is the single chokepoint
for every non-stateful precompile call (Call/CallCode/DelegateCall/
StaticCall all funnel through it).
- Per-precompile Run() bodies are PQ-agnostic again: removed 19 ad-hoc
`refuse(OpX)` calls scattered across the body of ecrecover, sha256,
ripemd160, blake2F, bn256*, bls12381*, kzg, p256Verify.
- Profile lives on the value (*pq.Profile field on params.ChainConfig)
not the place (global pq.Active()). Multi-chain hosts host strict-PQ
and permissive chains side-by-side without race.
- Gas is still charged on refusal: PQ refusal must not be detectable
via gas timing — test pinned (TestStrictPQ_GasIsStillCharged).
Surface:
- params.ChainConfig.PQ *pq.Profile (json:"-", not persisted)
- (*EVM).runPrecompile(p, input, gas) — the chokepoint
- opForPrecompile(p PrecompiledContract) Op — type-switch dispatch,
OpUnknown fail-open for stateful / overrider precompiles
- AllForbidden() returns pq.Strict() (alias for ergonomic call sites)
- SetPQProfile / ActivePQProfile preserved as deprecated shims
Module bumps:
- github.com/luxfi/pq v1.0.1-0.20260512064747-3d4c9414e15e
(promotes RefuseUnder from function to method on *Profile)
Tests: core/vm/pq_gate_test.go pins the per-chain semantics —
strict-PQ on chain A refuses ecrecover; permissive chain B in the
same process admits it; gas is still charged in both cases.
117 lines
4.4 KiB
Go
117 lines
4.4 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package vm
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/params"
|
|
)
|
|
|
|
// pq_profile_test.go — ecrecover-focused coverage of the per-chain PQ
|
|
// profile. The gate lives in (*EVM).runPrecompile; tests drive the
|
|
// precompile through a test EVM whose ChainConfig.PQ is set per case.
|
|
//
|
|
// The cross-family matrix lives in pq_gate_test.go.
|
|
|
|
// validEcrecoverInput is a well-known signature valid for ecrecover.
|
|
// The exact recovered address does not matter — only that the
|
|
// precompile returns a 32-byte address under classical semantics and
|
|
// ErrEcrecoverForbidden under strict-PQ.
|
|
const validEcrecoverInputHex = "" +
|
|
"a35a39e7715a7b2c5d2e3a5d8e8f8a8b8c8d8e8f9091929394959697989a9b9c" +
|
|
"000000000000000000000000000000000000000000000000000000000000001c" +
|
|
"7b6d1f1f0a85b5a3aa3f0e57c8c30a1b1c1d1e1f2021222324252627282a2b2c" +
|
|
"3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c"
|
|
|
|
// newTestEVMWithProfile constructs a minimal EVM whose chain config
|
|
// has its PQ profile set as supplied. State is nil; the test only
|
|
// exercises (*EVM).runPrecompile, which does not touch state.
|
|
func newTestEVMWithProfile(t *testing.T, p *PQProfile) *EVM {
|
|
t.Helper()
|
|
cc := *params.TestChainConfig
|
|
cc.PQ = p
|
|
return NewEVM(BlockContext{BlockNumber: big.NewInt(0)}, nil, &cc, Config{})
|
|
}
|
|
|
|
// TestNilProfile_EcrecoverWorks asserts the default state (no profile
|
|
// installed on the chain config) runs ecrecover with classical
|
|
// semantics — runPrecompile does not return the family error.
|
|
func TestNilProfile_EcrecoverWorks(t *testing.T) {
|
|
evm := newTestEVMWithProfile(t, nil)
|
|
p := &ecrecover{}
|
|
input, err := hex.DecodeString(validEcrecoverInputHex)
|
|
if err != nil {
|
|
t.Fatalf("hex decode: %v", err)
|
|
}
|
|
_, _, err = evm.runPrecompile(p, input, p.RequiredGas(input))
|
|
if errors.Is(err, ErrEcrecoverForbidden) {
|
|
t.Fatalf("nil profile must not return ErrEcrecoverForbidden; got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStrictPQ_EcrecoverReturnsError asserts a chain config with
|
|
// ForbidEcrecover=true causes runPrecompile to return
|
|
// ErrEcrecoverForbidden.
|
|
func TestStrictPQ_EcrecoverReturnsError(t *testing.T) {
|
|
evm := newTestEVMWithProfile(t, &PQProfile{ForbidEcrecover: true})
|
|
p := &ecrecover{}
|
|
input, err := hex.DecodeString(validEcrecoverInputHex)
|
|
if err != nil {
|
|
t.Fatalf("hex decode: %v", err)
|
|
}
|
|
_, _, err = evm.runPrecompile(p, input, p.RequiredGas(input))
|
|
if !errors.Is(err, ErrEcrecoverForbidden) {
|
|
t.Fatalf("want ErrEcrecoverForbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStrictPQ_EcrecoverEmptyInputAlsoRefused asserts the gate fires
|
|
// before any input parsing, including on zero-length input. Note
|
|
// RequiredGas is invariant under input here so we supply the same
|
|
// fixed gas budget either way.
|
|
func TestStrictPQ_EcrecoverEmptyInputAlsoRefused(t *testing.T) {
|
|
evm := newTestEVMWithProfile(t, &PQProfile{ForbidEcrecover: true})
|
|
p := &ecrecover{}
|
|
_, _, err := evm.runPrecompile(p, nil, p.RequiredGas(nil))
|
|
if !errors.Is(err, ErrEcrecoverForbidden) {
|
|
t.Fatalf("want ErrEcrecoverForbidden on empty input, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStrictPQ_GasIsStillCharged asserts that under strict-PQ the
|
|
// runPrecompile path charges RequiredGas: refusal must not be
|
|
// detectable via gas timing.
|
|
func TestStrictPQ_GasIsStillCharged(t *testing.T) {
|
|
p := &ecrecover{}
|
|
input := make([]byte, 128)
|
|
cost := p.RequiredGas(input)
|
|
// Classical (nil profile).
|
|
evmClassical := newTestEVMWithProfile(t, nil)
|
|
_, remainingClassical, _ := evmClassical.runPrecompile(p, input, cost*2)
|
|
// Strict-PQ.
|
|
evmStrict := newTestEVMWithProfile(t, &PQProfile{ForbidEcrecover: true})
|
|
_, remainingStrict, _ := evmStrict.runPrecompile(p, input, cost*2)
|
|
if remainingClassical != remainingStrict {
|
|
t.Fatalf("runPrecompile gas accounting must not depend on profile: classical_remaining=%d strict_remaining=%d (cost=%d)",
|
|
remainingClassical, remainingStrict, cost)
|
|
}
|
|
}
|
|
|
|
// TestChainConfigPQ_RoundTrip asserts the profile installed on
|
|
// ChainConfig.PQ is the one observed by runPrecompile.
|
|
func TestChainConfigPQ_RoundTrip(t *testing.T) {
|
|
profile := &PQProfile{ForbidEcrecover: true, ForbidBn256: true}
|
|
evm := newTestEVMWithProfile(t, profile)
|
|
if got := evm.ChainConfig().PQ; got != profile {
|
|
t.Fatalf("ChainConfig.PQ must return the installed pointer")
|
|
}
|
|
if !evm.ChainConfig().PQ.ForbidEcrecover || !evm.ChainConfig().PQ.ForbidBn256 {
|
|
t.Fatalf("installed profile flags lost in round-trip")
|
|
}
|
|
}
|