mirror of
https://github.com/luxfi/coreth.git
synced 2026-10-30 17:00:51 +00:00
F102 wired the chain-wide ChainSecurityProfile into the P-Chain and X-Chain VM factories at node bootstrap, but the C-Chain (coreth) plugin loads via the rpcchainvm transport in a separate process and never received the profile. Under strict-PQ with ForbidECDSAWallets the EVM mempool continued to admit legacy ECDSA-signed transactions. This change propagates the profile across the plugin boundary inside the JSON config bytes that already cross it on every chain Initialize: - plugin/evm/config/config.go adds LuxSecurityProfilePin (profileID + profileHashHex). The legacy LuxStrictPQ bool is retained for one release as a fallback path. - plugin/evm/vm.go calls installSecurityProfile() during Initialize. Resolve runs ProfileByID(profileID) → Validate → ComputeHash and constant-time-compares against the pinned hash. A forked binary that swapped canonical profile content fails the compare and the VM refuses to start. Adds VM.SecurityProfile() accessor. - plugin/evm/eth_gossiper.go threads the resolved profile into GossipEthTxPool via NewGossipEthTxPoolWithProfile. When ForbidECDSAWallets is true, Add refuses every Ethereum tx type (Legacy / AccessList / DynamicFee / Blob / SetCode) with ErrClassicalTxForbidden — they are all secp256k1-signed and there is no PQAuthTx type yet. - Precompile gate (ecrecover at 0x01) now derives from the resolved profile's ForbidECDSAContractAuth bit rather than the bool. Six F118 tests: - TestCorethVM_ReceivesSecurityProfile — Initialize with the pin resolves the canonical LuxStrictPQ, exposes it via VM.SecurityProfile(), and wires the precompile gate. - TestCorethVM_RefusesProfileHashMismatch — pinned hash that does not match the live canonical content fails Initialize. - TestCorethVM_RefusesECDSATxUnderStrictPQ — GossipEthTxPool.Add returns ErrClassicalTxForbidden under strict-PQ. - TestCorethVM_AcceptsClassicalTxUnderClassicalCompat — symmetric path: nil profile reaches the mempool admission code (no short-circuit). - TestCorethVM_RefusesEveryClassicalTxType — Legacy / AccessList / DynamicFee all rejected. - TestCorethVM_AcceptsMLDSATxUnderStrictPQ — forward-compat stub documenting the gate contract until a PQAuthTx type lands. go.mod bumps consensus v1.22.84 → v1.23.9 to pick up the canonical ChainSecurityProfile types (added in v1.23.x). All transitive deps are tidy.
219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// TODO: move to network
|
|
|
|
package evm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
consensusconfig "github.com/luxfi/consensus/config"
|
|
ethcommon "github.com/luxfi/geth/common"
|
|
log "github.com/luxfi/log"
|
|
"github.com/luxfi/metric"
|
|
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/p2p/gossip"
|
|
|
|
"github.com/luxfi/coreth/core"
|
|
"github.com/luxfi/coreth/core/txpool"
|
|
"github.com/luxfi/coreth/eth"
|
|
"github.com/luxfi/coreth/plugin/evm/config"
|
|
"github.com/luxfi/geth/core/types"
|
|
)
|
|
|
|
// ErrClassicalTxForbidden is returned by GossipEthTxPool.Add when the
|
|
// chain runs under a ChainSecurityProfile with ForbidECDSAWallets=true
|
|
// and a classical ECDSA (v,r,s)-signed Ethereum transaction is offered
|
|
// for admission. The C-chain refuses every legacy tx type
|
|
// (Legacy/AccessList/DynamicFee/Blob/SetCode) under strict-PQ until a
|
|
// dedicated PQAuthTx type lands. Closes red-team finding F118.
|
|
var ErrClassicalTxForbidden = errors.New("coreth: classical ECDSA-signed tx refused under strict-PQ ForbidECDSAWallets")
|
|
|
|
const pendingTxsBuffer = 10
|
|
|
|
var (
|
|
_ gossip.Gossipable = (*GossipEthTx)(nil)
|
|
_ gossip.Marshaller[*GossipEthTx] = (*GossipEthTxMarshaller)(nil)
|
|
_ gossip.Set[*GossipEthTx] = (*GossipEthTxPool)(nil)
|
|
|
|
_ eth.PushGossiper = (*EthPushGossiper)(nil)
|
|
)
|
|
|
|
func NewGossipEthTxPool(mempool *txpool.TxPool, registerer metric.Registerer) (*GossipEthTxPool, error) {
|
|
return NewGossipEthTxPoolWithProfile(mempool, registerer, nil)
|
|
}
|
|
|
|
// NewGossipEthTxPoolWithProfile is the F118 entry point. profile is the
|
|
// resolved chain-wide ChainSecurityProfile; when its ForbidECDSAWallets
|
|
// bit is true, Add refuses every classical Ethereum tx type. Pass nil
|
|
// to preserve classical-compat behaviour (legacy chains, dev mode).
|
|
func NewGossipEthTxPoolWithProfile(
|
|
mempool *txpool.TxPool,
|
|
registerer metric.Registerer,
|
|
profile *consensusconfig.ChainSecurityProfile,
|
|
) (*GossipEthTxPool, error) {
|
|
bloom, err := gossip.NewBloomFilter(
|
|
registerer,
|
|
"eth_tx_bloom_filter",
|
|
config.TxGossipBloomMinTargetElements,
|
|
config.TxGossipBloomTargetFalsePositiveRate,
|
|
config.TxGossipBloomResetFalsePositiveRate,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize bloom filter: %w", err)
|
|
}
|
|
|
|
return &GossipEthTxPool{
|
|
mempool: mempool,
|
|
pendingTxs: make(chan core.NewTxsEvent, pendingTxsBuffer),
|
|
bloom: bloom,
|
|
securityProfile: profile,
|
|
}, nil
|
|
}
|
|
|
|
type GossipEthTxPool struct {
|
|
mempool *txpool.TxPool
|
|
pendingTxs chan core.NewTxsEvent
|
|
|
|
bloom *gossip.BloomFilter
|
|
lock sync.RWMutex
|
|
|
|
// subscribed is set to true when the gossip subscription is active
|
|
// mostly used for testing
|
|
subscribed atomic.Bool
|
|
|
|
// securityProfile, when non-nil with ForbidECDSAWallets=true, makes
|
|
// Add refuse every classical Ethereum tx type (legacy / EIP-2930 /
|
|
// EIP-1559 / EIP-4844 / EIP-7702). All of those are secp256k1-signed
|
|
// and there is no PQAuthTx type yet, so this is a blanket gate.
|
|
// Closes red-team finding F118 at the mempool admission boundary.
|
|
securityProfile *consensusconfig.ChainSecurityProfile
|
|
}
|
|
|
|
// IsSubscribed returns whether or not the gossip subscription is active.
|
|
func (g *GossipEthTxPool) IsSubscribed() bool {
|
|
return g.subscribed.Load()
|
|
}
|
|
|
|
func (g *GossipEthTxPool) Subscribe(ctx context.Context) {
|
|
sub := g.mempool.SubscribeTransactions(g.pendingTxs, false)
|
|
if sub == nil {
|
|
log.Warn("failed to subscribe to new txs event")
|
|
return
|
|
}
|
|
g.subscribed.CompareAndSwap(false, true)
|
|
defer func() {
|
|
sub.Unsubscribe()
|
|
g.subscribed.CompareAndSwap(true, false)
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Debug("shutting down subscription")
|
|
return
|
|
case pendingTxs := <-g.pendingTxs:
|
|
g.lock.Lock()
|
|
optimalElements := (g.mempool.PendingSize(txpool.PendingFilter{}) + len(pendingTxs.Txs)) * config.TxGossipBloomChurnMultiplier
|
|
for _, pendingTx := range pendingTxs.Txs {
|
|
tx := &GossipEthTx{Tx: pendingTx}
|
|
g.bloom.Add(tx)
|
|
reset, err := gossip.ResetBloomFilterIfNeeded(g.bloom, optimalElements)
|
|
if err != nil {
|
|
log.Error("failed to reset bloom filter", "err", err)
|
|
continue
|
|
}
|
|
|
|
if reset {
|
|
log.Debug("resetting bloom filter", "reason", "reached max filled ratio")
|
|
|
|
g.mempool.IteratePending(func(tx *types.Transaction) bool {
|
|
g.bloom.Add(&GossipEthTx{Tx: tx})
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
g.lock.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add enqueues the transaction to the mempool. Subscribe should be called
|
|
// to receive an event if tx is actually added to the mempool or not.
|
|
//
|
|
// Under a ChainSecurityProfile with ForbidECDSAWallets=true, every
|
|
// classical Ethereum tx type is refused at this boundary with
|
|
// ErrClassicalTxForbidden — there is no PQAuthTx type yet, so the gate
|
|
// is a blanket refusal. Closes red-team finding F118.
|
|
func (g *GossipEthTxPool) Add(tx *GossipEthTx) error {
|
|
if g.securityProfile != nil && g.securityProfile.ForbidECDSAWallets {
|
|
// Every Ethereum tx type produced today is (v,r,s)-secp256k1
|
|
// signed. There is no PQ-signed Ethereum tx type. Refuse.
|
|
return ErrClassicalTxForbidden
|
|
}
|
|
return g.mempool.Add([]*types.Transaction{tx.Tx}, false, false)[0]
|
|
}
|
|
|
|
// Has should just return whether or not the [txID] is still in the mempool,
|
|
// not whether it is in the mempool AND pending.
|
|
func (g *GossipEthTxPool) Has(txID ids.ID) bool {
|
|
return g.mempool.Has(ethcommon.Hash(txID))
|
|
}
|
|
|
|
func (g *GossipEthTxPool) Iterate(f func(tx *GossipEthTx) bool) {
|
|
g.mempool.IteratePending(func(tx *types.Transaction) bool {
|
|
return f(&GossipEthTx{Tx: tx})
|
|
})
|
|
}
|
|
|
|
func (g *GossipEthTxPool) GetFilter() ([]byte, []byte) {
|
|
g.lock.RLock()
|
|
defer g.lock.RUnlock()
|
|
|
|
return g.bloom.Marshal()
|
|
}
|
|
|
|
type GossipEthTxMarshaller struct{}
|
|
|
|
func (g GossipEthTxMarshaller) MarshalGossip(tx *GossipEthTx) ([]byte, error) {
|
|
return tx.Tx.MarshalBinary()
|
|
}
|
|
|
|
func (g GossipEthTxMarshaller) UnmarshalGossip(bytes []byte) (*GossipEthTx, error) {
|
|
tx := &GossipEthTx{
|
|
Tx: &types.Transaction{},
|
|
}
|
|
|
|
return tx, tx.Tx.UnmarshalBinary(bytes)
|
|
}
|
|
|
|
type GossipEthTx struct {
|
|
Tx *types.Transaction
|
|
}
|
|
|
|
func (tx *GossipEthTx) GossipID() ids.ID {
|
|
return ids.ID(tx.Tx.Hash())
|
|
}
|
|
|
|
// EthPushGossiper is used by the ETH backend to push transactions issued over
|
|
// the RPC and added to the mempool to peers.
|
|
type EthPushGossiper struct {
|
|
vm *VM
|
|
}
|
|
|
|
func (e *EthPushGossiper) Add(tx *types.Transaction) {
|
|
// eth.Backend is initialized before the [ethTxPushGossiper] is created, so
|
|
// we just ignore any gossip requests until it is set.
|
|
ethTxPushGossiper := e.vm.ethTxPushGossiper.Get()
|
|
if ethTxPushGossiper == nil {
|
|
return
|
|
}
|
|
ethTxPushGossiper.Add(&GossipEthTx{tx})
|
|
}
|