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.
258 lines
9.1 KiB
Go
258 lines
9.1 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package vm
|
|
|
|
import (
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/params"
|
|
)
|
|
|
|
// pq_gate_test.go — coverage for the cross-family PQ gate.
|
|
//
|
|
// Each gate case names a precompile, the Op it reports, and the
|
|
// family-specific error (*EVM).runPrecompile must return when
|
|
// AllForbidden() is the chain-config profile. Each test constructs a
|
|
// test EVM whose ChainConfig.PQ holds the per-case profile.
|
|
|
|
// newGateEVM constructs a minimal EVM whose chain config has the
|
|
// supplied profile installed. State is nil; runPrecompile does not
|
|
// touch state.
|
|
func newGateEVM(t *testing.T, p *PQProfile) *EVM {
|
|
t.Helper()
|
|
cc := *params.TestChainConfig
|
|
cc.PQ = p
|
|
return NewEVM(BlockContext{BlockNumber: big.NewInt(0)}, nil, &cc, Config{})
|
|
}
|
|
|
|
// gateCase is one row of the gate matrix.
|
|
type gateCase struct {
|
|
name string
|
|
p PrecompiledContract
|
|
input []byte
|
|
op Op
|
|
wantErr error
|
|
}
|
|
|
|
// gateCases enumerates every gated precompile and the error
|
|
// runPrecompile must return for that op under AllForbidden().
|
|
func gateCases() []gateCase {
|
|
// Inputs are size-conforming but otherwise zero. The gate fires
|
|
// before the precompile interprets them; cryptographic output is
|
|
// irrelevant.
|
|
zero64 := make([]byte, 64)
|
|
zero96 := make([]byte, 96)
|
|
zero128 := make([]byte, 128)
|
|
zero160 := make([]byte, 160)
|
|
zero192 := make([]byte, 192)
|
|
zero213 := make([]byte, 213)
|
|
zero256 := make([]byte, 256)
|
|
zero288 := make([]byte, 288)
|
|
zero384 := make([]byte, 384)
|
|
|
|
return []gateCase{
|
|
{"ecrecover", &ecrecover{}, zero128, OpEcrecover, ErrEcrecoverForbidden},
|
|
{"sha256", &sha256hash{}, zero64, OpSHA256, ErrSHA256Forbidden},
|
|
{"ripemd160", &ripemd160hash{}, zero64, OpRIPEMD160, ErrRIPEMD160Forbidden},
|
|
{"blake2F", &blake2F{}, zero213, OpBlake2F, ErrBlake2FForbidden},
|
|
{"bn256AddIstanbul", &bn256AddIstanbul{}, zero128, OpBn256Add, ErrBn256Forbidden},
|
|
{"bn256AddByzantium", &bn256AddByzantium{}, zero128, OpBn256Add, ErrBn256Forbidden},
|
|
{"bn256ScalarMulIstanbul", &bn256ScalarMulIstanbul{}, zero96, OpBn256ScalarMul, ErrBn256Forbidden},
|
|
{"bn256ScalarMulByzantium", &bn256ScalarMulByzantium{}, zero96, OpBn256ScalarMul, ErrBn256Forbidden},
|
|
{"bn256PairingIstanbul", &bn256PairingIstanbul{}, zero192, OpBn256Pairing, ErrBn256Forbidden},
|
|
{"bn256PairingByzantium", &bn256PairingByzantium{}, zero192, OpBn256Pairing, ErrBn256Forbidden},
|
|
{"bls12381G1Add", &bls12381G1Add{}, zero256, OpBLS12381G1Add, ErrBLS12381Forbidden},
|
|
{"bls12381G1MultiExp", &bls12381G1MultiExp{}, zero160, OpBLS12381G1MSM, ErrBLS12381Forbidden},
|
|
{"bls12381G2Add", &bls12381G2Add{}, zero384, OpBLS12381G2Add, ErrBLS12381Forbidden},
|
|
{"bls12381G2MultiExp", &bls12381G2MultiExp{}, zero288, OpBLS12381G2MSM, ErrBLS12381Forbidden},
|
|
{"bls12381Pairing", &bls12381Pairing{}, zero384, OpBLS12381Pairing, ErrBLS12381Forbidden},
|
|
{"bls12381MapG1", &bls12381MapG1{}, zero64, OpBLS12381MapG1, ErrBLS12381Forbidden},
|
|
{"bls12381MapG2", &bls12381MapG2{}, zero128, OpBLS12381MapG2, ErrBLS12381Forbidden},
|
|
{"p256Verify", &p256Verify{}, zero160, OpP256Verify, ErrP256VerifyForbidden},
|
|
{"kzgPointEvaluation", &kzgPointEvaluation{}, zero192, OpKZGPointEval, ErrKZGForbidden},
|
|
}
|
|
}
|
|
|
|
// runGate executes the precompile through runPrecompile with the
|
|
// supplied profile, supplying enough gas that the gate is the only
|
|
// possible failure mode.
|
|
func runGate(t *testing.T, profile *PQProfile, c gateCase) error {
|
|
t.Helper()
|
|
evm := newGateEVM(t, profile)
|
|
gas := c.p.RequiredGas(c.input)
|
|
_, _, err := evm.runPrecompile(c.p, c.input, gas*2)
|
|
return err
|
|
}
|
|
|
|
// TestStrictPQRefusesEveryClassicalPrecompile asserts AllForbidden()
|
|
// refuses every gated precompile with the family-specific error.
|
|
func TestStrictPQRefusesEveryClassicalPrecompile(t *testing.T) {
|
|
for _, tc := range gateCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := runGate(t, AllForbidden(), tc)
|
|
if err == nil {
|
|
t.Fatalf("strict-PQ profile must refuse, got nil")
|
|
}
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("want %v, got %v", tc.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNilProfileAdmitsEveryPrecompile asserts the default state (no
|
|
// profile installed) lets every precompile reach its math.
|
|
func TestNilProfileAdmitsEveryPrecompile(t *testing.T) {
|
|
gateErrors := []error{
|
|
ErrEcrecoverForbidden, ErrP256VerifyForbidden,
|
|
ErrSHA256Forbidden, ErrRIPEMD160Forbidden, ErrBlake2FForbidden,
|
|
ErrBn256Forbidden, ErrBLS12381Forbidden, ErrKZGForbidden,
|
|
}
|
|
for _, tc := range gateCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := runGate(t, nil, tc)
|
|
for _, ge := range gateErrors {
|
|
if errors.Is(err, ge) {
|
|
t.Fatalf("nil profile must not refuse; got %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmptyProfileAdmitsEveryPrecompile asserts a profile with every
|
|
// Forbid* false (the zero value) admits every precompile, just like
|
|
// the nil case.
|
|
func TestEmptyProfileAdmitsEveryPrecompile(t *testing.T) {
|
|
gateErrors := []error{
|
|
ErrEcrecoverForbidden, ErrP256VerifyForbidden,
|
|
ErrSHA256Forbidden, ErrRIPEMD160Forbidden, ErrBlake2FForbidden,
|
|
ErrBn256Forbidden, ErrBLS12381Forbidden, ErrKZGForbidden,
|
|
}
|
|
for _, tc := range gateCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := runGate(t, &PQProfile{}, tc)
|
|
for _, ge := range gateErrors {
|
|
if errors.Is(err, ge) {
|
|
t.Fatalf("empty profile must not refuse; got %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPerFamilyIsolation asserts each Forbid* flag refuses only its
|
|
// own family. Setting ForbidBn256 alone must not refuse BLS12-381,
|
|
// SHA-256, or any other family.
|
|
func TestPerFamilyIsolation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
profile *PQProfile
|
|
op Op
|
|
wantErr error
|
|
}{
|
|
{"only-ecrecover", &PQProfile{ForbidEcrecover: true}, OpEcrecover, ErrEcrecoverForbidden},
|
|
{"only-p256", &PQProfile{ForbidP256Verify: true}, OpP256Verify, ErrP256VerifyForbidden},
|
|
{"only-sha256", &PQProfile{ForbidSHA256: true}, OpSHA256, ErrSHA256Forbidden},
|
|
{"only-ripemd", &PQProfile{ForbidRIPEMD160: true}, OpRIPEMD160, ErrRIPEMD160Forbidden},
|
|
{"only-blake2", &PQProfile{ForbidBlake2F: true}, OpBlake2F, ErrBlake2FForbidden},
|
|
{"only-bn256", &PQProfile{ForbidBn256: true}, OpBn256Pairing, ErrBn256Forbidden},
|
|
{"only-bls", &PQProfile{ForbidBLS12381: true}, OpBLS12381Pairing, ErrBLS12381Forbidden},
|
|
{"only-kzg", &PQProfile{ForbidKZG: true}, OpKZGPointEval, ErrKZGForbidden},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if err := c.profile.RefuseUnder(c.op); !errors.Is(err, c.wantErr) {
|
|
t.Fatalf("want %v, got %v", c.wantErr, err)
|
|
}
|
|
// Every other op must pass under this single-flag profile.
|
|
otherOps := []Op{
|
|
OpEcrecover, OpP256Verify, OpSHA256, OpRIPEMD160, OpBlake2F,
|
|
OpBn256Add, OpBn256ScalarMul, OpBn256Pairing,
|
|
OpBLS12381G1Add, OpBLS12381G1MSM,
|
|
OpBLS12381G2Add, OpBLS12381G2MSM,
|
|
OpBLS12381Pairing, OpBLS12381MapG1, OpBLS12381MapG2,
|
|
OpKZGPointEval,
|
|
}
|
|
for _, op := range otherOps {
|
|
if op == c.op {
|
|
continue
|
|
}
|
|
if sameFamily(op, c.op) {
|
|
continue
|
|
}
|
|
if err := c.profile.RefuseUnder(op); err != nil {
|
|
t.Errorf("single-flag profile %s also refused %v: %v", c.name, op, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// sameFamily reports whether two ops share a Forbid* flag.
|
|
func sameFamily(a, b Op) bool {
|
|
bn := func(op Op) bool {
|
|
return op == OpBn256Add || op == OpBn256ScalarMul || op == OpBn256Pairing
|
|
}
|
|
bls := func(op Op) bool {
|
|
switch op {
|
|
case OpBLS12381G1Add, OpBLS12381G1MSM,
|
|
OpBLS12381G2Add, OpBLS12381G2MSM,
|
|
OpBLS12381Pairing, OpBLS12381MapG1, OpBLS12381MapG2:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
switch {
|
|
case bn(a) && bn(b):
|
|
return true
|
|
case bls(a) && bls(b):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestAllForbiddenSetsAllFlags asserts AllForbidden() sets every
|
|
// Forbid* flag. The canonical strict-PQ projection.
|
|
func TestAllForbiddenSetsAllFlags(t *testing.T) {
|
|
p := AllForbidden()
|
|
cases := map[string]bool{
|
|
"Ecrecover": p.ForbidEcrecover,
|
|
"P256Verify": p.ForbidP256Verify,
|
|
"SHA256": p.ForbidSHA256,
|
|
"RIPEMD160": p.ForbidRIPEMD160,
|
|
"Blake2F": p.ForbidBlake2F,
|
|
"Bn256": p.ForbidBn256,
|
|
"BLS12381": p.ForbidBLS12381,
|
|
"KZG": p.ForbidKZG,
|
|
}
|
|
for name, v := range cases {
|
|
if !v {
|
|
t.Errorf("AllForbidden().Forbid%s must be true", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRefuseUnknownOpIsPermissive documents that
|
|
// (*PQProfile).RefuseUnder(OpUnknown) returns nil even under strict-PQ.
|
|
// Callers MUST use a recognized Op; the gate cannot fail-closed on
|
|
// unknown ops without breaking callers who have not been ported yet.
|
|
// opForPrecompile returns OpUnknown for unrecognized precompile types
|
|
// (e.g. dataCopy, bigModExp, stateful custom precompiles).
|
|
func TestRefuseUnknownOpIsPermissive(t *testing.T) {
|
|
if err := AllForbidden().RefuseUnder(OpUnknown); err != nil {
|
|
t.Errorf("AllForbidden.RefuseUnder(OpUnknown) must be nil; got %v", err)
|
|
}
|
|
// And via runPrecompile with a non-classical precompile (dataCopy
|
|
// is not in the strict-PQ matrix).
|
|
evm := newGateEVM(t, AllForbidden())
|
|
_, _, err := evm.runPrecompile(&dataCopy{}, []byte("anything"), (&dataCopy{}).RequiredGas([]byte("anything")))
|
|
if err != nil {
|
|
t.Errorf("runPrecompile(dataCopy, AllForbidden) must be nil; got %v", err)
|
|
}
|
|
}
|