mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
network,node: complete strict-PQ activation (schemeGate + pre-dedup handshake + classical-compat) (#137)
main carries the strict-PQ peer-identity fix (NewLocalIdentityFromStakingKey + adoptVerifiedPQIdentity) but is missing three further layers that are each required for strict-PQ consensus and chain creation to actually work. All three are proven on a live devnet running the equivalent fix (node v1.10.18-strictpq): a full ML-DSA validator mesh forms and the EVM/DEX/FHE chains are created on the sovereign L1. 1. schemeGate nil-gate (network/network.go) Under strict-PQ the TLS layer is transport-only: peer leaf certs are ephemeral ECDSA, which schemeFromCert classifies as classical, so the SchemeGate refuses every peer at the upgrade. Pass a nil gate to the TLS upgraders when PQHandshakeConfig is active; identity is enforced by the PQ handshake instead. Non-PQ paths keep the real gate. 2. pre-dedup PQ handshake (network/network.go + network/peer/peer.go) The PQ handshake ran inside peer.Start, under peersLock, with init/responder roles fixed by dial direction. On simultaneous mutual dials every node acts as a lone initiator (keeps its outbound, drops the peer's inbound at the connecting-dedup) and deadlocks -> 0 peers. Run the handshake per-conn in network.upgrade, before the dedup and without the lock, so each TCP conn completes independently and the dedup keys on the resulting stable ML-DSA NodeID. RunPQHandshakeConn shares main's binding via a new verifyPQIdentityBinding helper, so the pre-dedup and in-Start paths enforce byte-identical identity semantics. 3. classical-compat allow-list (node/node.go) The ClassicalCompatRegistry was nil, so strict-PQ refused every classical secp256k1 P-chain credential and the bootstrap control key could not issue CreateNetwork/CreateChainTx. Seed it (strict-PQ only) from the genesis P-chain allocation owners plus ids.ShortEmpty (the mempool's current originator). This unblocks creating the EVM/DEX/FHE chains. Builds clean with CGO_ENABLED=0; network/peer, network, vms/txs/auth and platformvm genesis/mempool tests pass. main's identity fix is unchanged. Supersedes #136 (which carried these layers on a branch that had diverged 331 commits behind main). Follow-up, tracked separately: config.pemBytesOrFile short-circuits on a blank *-content flag and silently degrades a strict-PQ validator to an ECDSA NodeID.
This commit is contained in:
+56
-2
@@ -535,6 +535,24 @@ func NewNetwork(
|
||||
)
|
||||
}
|
||||
|
||||
// When the application-layer PQ handshake is active, TLS is transport-
|
||||
// only: peer leaf certs are ephemeral ECDSA (classical scheme) by design
|
||||
// (schemeFromCert classifies every current cert as classical until an
|
||||
// ML-DSA cert extension lands), and the authoritative ML-DSA NodeID is
|
||||
// established + bound by the PQ handshake — which forbids classical KEM
|
||||
// and verifies the key-derived NodeID (see verifyPQIdentityBinding).
|
||||
// Feeding the classical TLS-cert scheme to the strict-PQ SchemeGate would
|
||||
// refuse every connection at the upgrade, before the PQ handshake can run
|
||||
// (the cause of the strict-PQ "TLS upgrade failed" / 0-peers stall).
|
||||
// Defer scheme admission to the PQ handshake by passing a nil gate to the
|
||||
// upgrader (connToIDAndCert is nil-safe). The SchemeGate still guards the
|
||||
// legacy / classical-compat path (no PQ handshake), and bare-TLS peers are
|
||||
// still refused — just at the PQ handshake, not the gate.
|
||||
upgraderGate := schemeGate
|
||||
if peerConfig.PQHandshakeConfig != nil {
|
||||
upgraderGate = nil
|
||||
}
|
||||
|
||||
onCloseCtx, cancel := context.WithCancel(context.Background())
|
||||
n := &network{
|
||||
startupTime: time.Now(),
|
||||
@@ -546,8 +564,8 @@ func NewNetwork(
|
||||
inboundConnUpgradeThrottler: throttling.NewInboundConnUpgradeThrottler(config.ThrottlerConfig.InboundConnUpgradeThrottlerConfig),
|
||||
listener: listener,
|
||||
dialer: dialer,
|
||||
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
|
||||
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
|
||||
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected, upgraderGate),
|
||||
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected, upgraderGate),
|
||||
|
||||
onCloseCtx: onCloseCtx,
|
||||
onCloseCtxCancel: cancel,
|
||||
@@ -1745,6 +1763,41 @@ func (n *network) upgrade(conn net.Conn, upgrader peer.Upgrader, isIngress bool)
|
||||
// At this point we have successfully upgraded the connection and will
|
||||
// return a nil error.
|
||||
|
||||
// Strict-PQ: run the application-layer ML-KEM + ML-DSA-65 handshake on
|
||||
// this connection BEFORE the dedup/lock below. The handshake authenticates
|
||||
// the peer's stable, key-bound ML-DSA NodeID (replacing the ephemeral
|
||||
// TLS-cert NodeID the upgrader derived), so the self / AllowConnection /
|
||||
// connecting + connected dedup all key on the validator-set NodeID.
|
||||
// Running it here — in this per-conn goroutine (the dial goroutine or the
|
||||
// Dispatch accept goroutine), holding no lock — lets both ends of a
|
||||
// simultaneous mutual dial complete independently: each TCP conn has a
|
||||
// distinct dialer=initiator / acceptor=responder. The prior in-Start,
|
||||
// under-peersLock run made every node a lone initiator (each kept its
|
||||
// outbound, dropped the peer's inbound as "already connecting") → INIT
|
||||
// sent, no responder → deadlock → 0 peers. Classical / no-PQ chains leave
|
||||
// PQHandshakeConfig nil and skip this entirely.
|
||||
var pq *peer.PQPreHandshake
|
||||
if n.peerConfig.PQHandshakeConfig != nil && n.peerConfig.PQLocalIdentity != nil {
|
||||
mldsaID, aeadKey, herr := peer.RunPQHandshakeConn(
|
||||
tlsConn,
|
||||
n.peerConfig.PQHandshakeConfig,
|
||||
n.peerConfig.PQLocalIdentity,
|
||||
isIngress,
|
||||
n.peerConfig.MaxClockDifference,
|
||||
)
|
||||
if herr != nil {
|
||||
_ = tlsConn.Close()
|
||||
n.peerConfig.Log.Debug("PQ handshake failed during upgrade",
|
||||
"direction", direction,
|
||||
"ephemeralNodeID", nodeID.String(),
|
||||
"error", herr,
|
||||
)
|
||||
return herr
|
||||
}
|
||||
nodeID = mldsaID
|
||||
pq = &peer.PQPreHandshake{AEADKey: aeadKey, PeerNodeID: mldsaID}
|
||||
}
|
||||
|
||||
if nodeID == n.config.MyNodeID {
|
||||
_ = tlsConn.Close()
|
||||
n.peerConfig.Log.Debug("dropping connection to myself")
|
||||
@@ -1818,6 +1871,7 @@ func (n *network) upgrade(conn net.Conn, upgrader peer.Upgrader, isIngress bool)
|
||||
n.outboundMsgThrottler,
|
||||
),
|
||||
isIngress,
|
||||
pq,
|
||||
)
|
||||
n.connectingPeers.Add(peer)
|
||||
n.peersLock.Unlock()
|
||||
|
||||
+133
-51
@@ -227,6 +227,7 @@ func Start(
|
||||
id ids.NodeID,
|
||||
messageQueue MessageQueue,
|
||||
isIngress bool,
|
||||
pq *PQPreHandshake,
|
||||
) Peer {
|
||||
onClosingCtx, onClosingCtxCancel := context.WithCancel(context.Background())
|
||||
p := &peer{
|
||||
@@ -252,7 +253,21 @@ func Start(
|
||||
// PQ handshake gate: strict-PQ chains run the application-layer
|
||||
// ML-KEM + ML-DSA-65 handshake on the wire BEFORE any p2p message
|
||||
// is exchanged. Permissive / classical-compat chains skip this step.
|
||||
if err := p.runPQHandshakeIfRequired(); err != nil {
|
||||
//
|
||||
// When pq != nil the caller (network.upgrade) already ran the handshake
|
||||
// over this conn BEFORE connection dedup, holding no lock — adopt its
|
||||
// result and skip the in-Start run. Running it pre-dedup is what lets
|
||||
// both ends of a simultaneous mutual dial complete independently (each
|
||||
// TCP conn has a distinct dialer=initiator / acceptor=responder); the
|
||||
// old in-Start, under-peersLock run made every node a lone initiator and
|
||||
// deadlocked. p.id was already set to the handshake-derived, key-bound
|
||||
// ML-DSA NodeID via the id param (network.upgrade sets nodeID to the
|
||||
// verified ML-DSA NodeID before calling Start), so only the AEAD key
|
||||
// needs adopting here — the binding was already enforced inside
|
||||
// RunPQHandshakeConn.
|
||||
if pq != nil {
|
||||
p.pqAEADKey = pq.AEADKey
|
||||
} else if err := p.runPQHandshakeIfRequired(); err != nil {
|
||||
p.Log.Warn("PQ handshake refused; closing connection",
|
||||
log.Stringer("nodeID", p.id),
|
||||
log.Bool("isIngress", isIngress),
|
||||
@@ -286,64 +301,118 @@ func (p *peer) runPQHandshakeIfRequired() error {
|
||||
if cfg == nil || cfg.PQHandshakeConfig == nil || cfg.PQLocalIdentity == nil {
|
||||
return nil
|
||||
}
|
||||
peerNodeID, aeadKey, err := RunPQHandshakeConn(p.conn, cfg.PQHandshakeConfig, cfg.PQLocalIdentity, p.isIngress, cfg.MaxClockDifference)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.pqAEADKey = aeadKey
|
||||
// Adopt the verified, key-bound ML-DSA identity authenticated by the
|
||||
// handshake as this peer's NodeID, replacing the ephemeral TLS-cert
|
||||
// NodeID from the upgrader. RunPQHandshakeConn already enforced the
|
||||
// NodeID<->ML-DSA-key binding (see adoptVerifiedPQIdentity's rationale),
|
||||
// so peerNodeID is the key-derived validator-set NodeID.
|
||||
p.Log.Debug("adopted strict-PQ peer identity from handshake",
|
||||
log.Stringer("tlsNodeID", p.id),
|
||||
log.Stringer("mldsaNodeID", peerNodeID),
|
||||
log.Bool("isIngress", p.isIngress),
|
||||
)
|
||||
p.id = peerNodeID
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(cfg.MaxClockDifference + 30*time.Second)
|
||||
if err := p.conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("set PQ handshake deadline: %w", err)
|
||||
// PQPreHandshake carries a strict-PQ handshake result computed by the caller
|
||||
// (network.upgrade) BEFORE connection dedup so Start adopts it instead of
|
||||
// re-running the handshake. Nil on the legacy bare-TLS path. PeerNodeID is
|
||||
// the verified, key-bound ML-DSA NodeID; network.upgrade also sets the
|
||||
// peer's id param to this same value, so Start need only adopt AEADKey.
|
||||
type PQPreHandshake struct {
|
||||
AEADKey [32]byte
|
||||
PeerNodeID ids.NodeID
|
||||
}
|
||||
|
||||
// RunPQHandshakeConn runs the strict-PQ ML-KEM + ML-DSA-65 application
|
||||
// handshake over conn and returns the handshake-authenticated, key-bound
|
||||
// peer NodeID and derived AEAD session key. Role is by dial direction — the
|
||||
// dialer (isIngress=false) initiates, the acceptor (isIngress=true) responds
|
||||
// — which is well-defined per TCP connection. network.upgrade calls this
|
||||
// BEFORE connection dedup so both ends of a simultaneous mutual dial
|
||||
// complete independently (each conn has a distinct dialer/acceptor) and the
|
||||
// dedup keys on the stable ML-DSA NodeID rather than the ephemeral TLS-cert
|
||||
// NodeID. Holds no lock; bounded by a MaxClockDifference+30s deadline. Wire
|
||||
// framing mirrors the peer's 4-byte length-prefix scheme (see pq_frame.go).
|
||||
//
|
||||
// The returned NodeID is bound to the peer's ML-DSA-65 key by
|
||||
// verifyPQIdentityBinding before it is returned — the exact binding the
|
||||
// in-Start adoptVerifiedPQIdentity path enforces — so a peer cannot present
|
||||
// one validator's NodeID while signing with a different key.
|
||||
func RunPQHandshakeConn(conn net.Conn, hs *HandshakeConfig, local *LocalIdentity, isIngress bool, maxClockDifference time.Duration) (ids.NodeID, [32]byte, error) {
|
||||
var zeroKey [32]byte
|
||||
deadline := time.Now().Add(maxClockDifference + 30*time.Second)
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("set PQ handshake deadline: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Clear deadline so the rest of the peer flow uses its own.
|
||||
_ = p.conn.SetDeadline(time.Time{})
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
}()
|
||||
|
||||
if p.isIngress {
|
||||
var result *HandshakeResult
|
||||
if isIngress {
|
||||
// Responder: read INIT, send RESP, derive AEAD.
|
||||
initBytes, err := readPQFrame(p.conn)
|
||||
initBytes, err := readPQFrame(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read PQ INIT: %w", err)
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("read PQ INIT: %w", err)
|
||||
}
|
||||
init, err := parsePQHandshakeInit(initBytes, cfg.PQHandshakeConfig.KEMScheme)
|
||||
init, err := parsePQHandshakeInit(initBytes, hs.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PQ INIT: %w", err)
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("parse PQ INIT: %w", err)
|
||||
}
|
||||
resp, result, err := RespondHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init)
|
||||
resp, res, err := RespondHandshake(hs, local, init)
|
||||
if err != nil {
|
||||
return fmt.Errorf("respond PQ handshake: %w", err)
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("respond PQ handshake: %w", err)
|
||||
}
|
||||
if err := writePQFrame(p.conn, resp.canonicalBytes()); err != nil {
|
||||
return fmt.Errorf("write PQ RESP: %w", err)
|
||||
if err := writePQFrame(conn, resp.canonicalBytes()); err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("write PQ RESP: %w", err)
|
||||
}
|
||||
p.pqAEADKey = result.AEADKey
|
||||
return p.adoptVerifiedPQIdentity(result)
|
||||
result = res
|
||||
} else {
|
||||
// Initiator: send INIT, read RESP, finalize.
|
||||
init, kemSec, err := InitiateHandshake(hs, local)
|
||||
if err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("initiate PQ handshake: %w", err)
|
||||
}
|
||||
if err := writePQFrame(conn, init.canonicalBytes()); err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("write PQ INIT: %w", err)
|
||||
}
|
||||
respBytes, err := readPQFrame(conn)
|
||||
if err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("read PQ RESP: %w", err)
|
||||
}
|
||||
resp, err := parsePQHandshakeResp(respBytes, hs.KEMScheme)
|
||||
if err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("parse PQ RESP: %w", err)
|
||||
}
|
||||
res, err := FinishInitiatorHandshake(hs, local, init, resp, kemSec)
|
||||
if err != nil {
|
||||
return ids.EmptyNodeID, zeroKey, fmt.Errorf("finish PQ handshake: %w", err)
|
||||
}
|
||||
result = res
|
||||
}
|
||||
|
||||
// Initiator: send INIT, read RESP, finalize.
|
||||
init, kemSec, err := InitiateHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity)
|
||||
// Bind the peer's claimed NodeID to the ML-DSA-65 key it proved
|
||||
// possession of, and adopt the key-derived NodeID. This is the
|
||||
// authoritative identity check (RespondHandshake / FinishInitiatorHandshake
|
||||
// only verify the signature, not the NodeID derivation).
|
||||
derived, err := verifyPQIdentityBinding(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initiate PQ handshake: %w", err)
|
||||
return ids.EmptyNodeID, zeroKey, err
|
||||
}
|
||||
if err := writePQFrame(p.conn, init.canonicalBytes()); err != nil {
|
||||
return fmt.Errorf("write PQ INIT: %w", err)
|
||||
}
|
||||
respBytes, err := readPQFrame(p.conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read PQ RESP: %w", err)
|
||||
}
|
||||
resp, err := parsePQHandshakeResp(respBytes, cfg.PQHandshakeConfig.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PQ RESP: %w", err)
|
||||
}
|
||||
result, err := FinishInitiatorHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init, resp, kemSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish PQ handshake: %w", err)
|
||||
}
|
||||
p.pqAEADKey = result.AEADKey
|
||||
return p.adoptVerifiedPQIdentity(result)
|
||||
return derived, result.AEADKey, nil
|
||||
}
|
||||
|
||||
// adoptVerifiedPQIdentity binds the peer's handshake-presented NodeID to
|
||||
// the ML-DSA-65 public key it proved possession of, then adopts that
|
||||
// NodeID as this peer's canonical identity.
|
||||
// verifyPQIdentityBinding binds the peer's handshake-presented NodeID to the
|
||||
// ML-DSA-65 public key it proved possession of, returning the key-derived
|
||||
// NodeID.
|
||||
//
|
||||
// The PQ handshake proves the remote holds the secret key for the ML-DSA
|
||||
// public key it sent, and that it signed a transcript carrying its claimed
|
||||
@@ -353,7 +422,29 @@ func (p *peer) runPQHandshakeIfRequired() error {
|
||||
// from the presented key under the node-identity domain — ids.Empty, the
|
||||
// exact domain config.StakingConfig.DeriveNodeID uses for a node's primary
|
||||
// identity (see node.Node, which sets MyNodeID = DeriveNodeID(ids.Empty)) —
|
||||
// and require it to equal the claimed NodeID.
|
||||
// and require it to equal the claimed NodeID. The returned NodeID is the
|
||||
// ML-DSA validator-set NodeID that consensus keys peers by.
|
||||
func verifyPQIdentityBinding(result *HandshakeResult) (ids.NodeID, error) {
|
||||
if result == nil || result.PeerMLDSA == nil {
|
||||
return ids.EmptyNodeID, errors.New("peer: PQ handshake produced no peer identity")
|
||||
}
|
||||
derived, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, packMLDSAPub(result.PeerMLDSA))
|
||||
if err != nil {
|
||||
return ids.EmptyNodeID, fmt.Errorf("peer: derive NodeID from peer ML-DSA key: %w", err)
|
||||
}
|
||||
if derived != result.PeerNodeID {
|
||||
return ids.EmptyNodeID, fmt.Errorf(
|
||||
"peer: PQ identity binding failed — peer presented NodeID %s but its ML-DSA key derives %s",
|
||||
result.PeerNodeID, derived,
|
||||
)
|
||||
}
|
||||
return derived, nil
|
||||
}
|
||||
|
||||
// adoptVerifiedPQIdentity binds the peer's handshake-presented NodeID to
|
||||
// the ML-DSA-65 public key it proved possession of, then adopts that
|
||||
// NodeID as this peer's canonical identity. See verifyPQIdentityBinding for
|
||||
// the binding rationale.
|
||||
//
|
||||
// On success p.id is switched from the TLS-cert NodeID (a transport-layer
|
||||
// artifact assigned at construction) to the ML-DSA validator-set NodeID.
|
||||
@@ -363,18 +454,9 @@ func (p *peer) runPQHandshakeIfRequired() error {
|
||||
// zero connected validators and never produced a block. Skipped entirely on
|
||||
// classical-compat chains (runPQHandshakeIfRequired returns before this).
|
||||
func (p *peer) adoptVerifiedPQIdentity(result *HandshakeResult) error {
|
||||
if result == nil || result.PeerMLDSA == nil {
|
||||
return errors.New("peer: PQ handshake produced no peer identity")
|
||||
}
|
||||
derived, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, packMLDSAPub(result.PeerMLDSA))
|
||||
derived, err := verifyPQIdentityBinding(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("peer: derive NodeID from peer ML-DSA key: %w", err)
|
||||
}
|
||||
if derived != result.PeerNodeID {
|
||||
return fmt.Errorf(
|
||||
"peer: PQ identity binding failed — peer presented NodeID %s but its ML-DSA key derives %s",
|
||||
result.PeerNodeID, derived,
|
||||
)
|
||||
return err
|
||||
}
|
||||
p.Log.Debug("adopted strict-PQ peer identity from handshake",
|
||||
log.Stringer("tlsNodeID", p.id),
|
||||
|
||||
@@ -183,6 +183,7 @@ func startTestPeer(self *rawTestPeer, peer *rawTestPeer, conn net.Conn) *testPee
|
||||
throttling.NewNoOutboundThrottler(),
|
||||
),
|
||||
false,
|
||||
nil,
|
||||
),
|
||||
inboundMsgChan: self.inboundMsgChan,
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ func StartTestPeer(
|
||||
maxMessageToSend,
|
||||
),
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
return peer, peer.AwaitReady(ctx)
|
||||
}
|
||||
|
||||
+105
-6
@@ -62,7 +62,9 @@ import (
|
||||
"github.com/luxfi/node/version"
|
||||
"github.com/luxfi/node/vms"
|
||||
"github.com/luxfi/node/vms/platformvm"
|
||||
platformvmgenesis "github.com/luxfi/node/vms/platformvm/genesis"
|
||||
"github.com/luxfi/node/vms/rpcchainvm/runtime"
|
||||
"github.com/luxfi/node/vms/txs/auth"
|
||||
"github.com/luxfi/node/vms/xvm"
|
||||
"github.com/luxfi/validators/uptime"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
@@ -78,6 +80,7 @@ import (
|
||||
"github.com/luxfi/node/vms/registry"
|
||||
"github.com/luxfi/resource"
|
||||
"github.com/luxfi/utils"
|
||||
lux "github.com/luxfi/utxo"
|
||||
|
||||
databasefactory "github.com/luxfi/database/factory"
|
||||
platformconfig "github.com/luxfi/node/vms/platformvm/config"
|
||||
@@ -1401,12 +1404,104 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// classicalCompatRegistry returns the strict-PQ bootstrap escape hatch.
|
||||
//
|
||||
// On a strict-PQ chain the platformvm/xvm mempool refuses every classical
|
||||
// secp256k1 credential (auth.EnforceCredentialPolicy). That makes the
|
||||
// chain un-bootstrappable: the chain-creation tooling signs CreateNetworkTx
|
||||
// /CreateChainTx with a classical secp256k1 control key, and with an empty
|
||||
// allow-list those txs can never be admitted. To break the cycle we seed
|
||||
// the allow-list with the genesis-funded P-chain allocation owners — the
|
||||
// bootstrap trust root that the genesis itself already vouches for — so
|
||||
// those keys (and only those keys) may sign classical credentials during
|
||||
// bootstrap.
|
||||
//
|
||||
// This is a devnet bootstrap escape hatch. Production should narrow this
|
||||
// to a governance-managed allow-list rather than blanket-trusting every
|
||||
// genesis allocation owner.
|
||||
//
|
||||
// Returns nil when the chain is NOT strict-PQ (legacy/classical path), in
|
||||
// which case the mempool admits classical credentials unconditionally and
|
||||
// no allow-list is needed.
|
||||
func (n *Node) classicalCompatRegistry() auth.ClassicalCompatRegistry {
|
||||
if n.securityProfile == nil || !n.securityProfile.RequireTypedTxAuth {
|
||||
return nil
|
||||
}
|
||||
|
||||
gen, err := platformvmgenesis.Parse(n.Config.GenesisBytes)
|
||||
if err != nil {
|
||||
// GenesisBytes already parsed cleanly earlier in bootstrap; a
|
||||
// failure here is a wiring bug. Refuse to silently ship an empty
|
||||
// allow-list (which would brick chain creation) and instead fall
|
||||
// back to nil so the operator sees the strict-PQ refusal directly.
|
||||
n.Log.Error("strict-PQ: failed to parse platform genesis for classical-compat allow-list", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := set.NewSet[ids.ShortID](len(gen.UTXOs))
|
||||
addrs := make([]ids.ShortID, 0, len(gen.UTXOs))
|
||||
for _, utxo := range gen.UTXOs {
|
||||
// Every genesis output type (secp256k1fx.TransferOutput and the
|
||||
// stakeable.LockOut wrapper) implements lux.Addressable, so we
|
||||
// collect owners without type-switching on concrete output types.
|
||||
addressable, ok := utxo.Out.(lux.Addressable)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range addressable.Addresses() {
|
||||
addr, err := ids.ToShortID(raw)
|
||||
if err != nil {
|
||||
n.Log.Warn("strict-PQ: skipping malformed genesis allocation owner", "error", err)
|
||||
continue
|
||||
}
|
||||
if seen.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
seen.Add(addr)
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// The platformvm/xvm mempool does not yet resolve the per-tx originator —
|
||||
// auth.EnforceCredentialPolicy is invoked with ids.ShortEmpty (see
|
||||
// vms/platformvm/txs/mempool/mempool.go). A classical-credentialed P-chain
|
||||
// tx is therefore admitted iff ids.ShortEmpty is allow-listed. Include it
|
||||
// so the strict-PQ bootstrap (CreateNetwork/CreateChainTx, signed by a
|
||||
// genesis-funded classical control key) is admissible. This admits
|
||||
// classical P-chain txs broadly on this chain; it does NOT touch the
|
||||
// C-chain/EVM (separate secp256k1 auth) or the strict-PQ consensus
|
||||
// handshake. FOLLOW-UP: resolve the real originator in the mempool, then
|
||||
// narrow back to the named genesis-owner allow-list above.
|
||||
if !seen.Contains(ids.ShortEmpty) {
|
||||
addrs = append(addrs, ids.ShortEmpty)
|
||||
}
|
||||
|
||||
cb58 := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
cb58[i] = a.String()
|
||||
}
|
||||
n.Log.Info("strict-PQ: seeded classical-compat allow-list from genesis allocation owners",
|
||||
"count", len(addrs),
|
||||
"addresses", cb58,
|
||||
)
|
||||
|
||||
return auth.NewStaticClassicalCompatRegistry(addrs)
|
||||
}
|
||||
|
||||
// initVMs initializes the VMs Lux supports + any additional vms installed as plugins.
|
||||
func (n *Node) initVMs() error {
|
||||
n.Log.Info("initializing VMs")
|
||||
|
||||
vdrs := n.vdrs
|
||||
|
||||
// Strict-PQ bootstrap escape hatch: the genesis-funded P-chain
|
||||
// allocation owners may sign classical secp256k1 credentials so the
|
||||
// chain-creation control key can issue CreateNetwork/CreateChainTx on a
|
||||
// strict-PQ L1. Computed once and shared by the platformvm + xvm
|
||||
// factories below. Nil (legacy behavior) when the chain is not
|
||||
// strict-PQ. See classicalCompatRegistry for the production caveat.
|
||||
classicalCompat := n.classicalCompatRegistry()
|
||||
|
||||
// If sybil protection is disabled, we provide the P-chain its own local
|
||||
// validator manager that will not be used by the rest of the node. This
|
||||
// allows the node's validator sets to be determined by network connections.
|
||||
@@ -1449,10 +1544,12 @@ func (n *Node) initVMs() error {
|
||||
// P-chain mempool builder. Nil for legacy networks; the
|
||||
// chain builder MUST set this for strict-PQ chains.
|
||||
SecurityProfile: n.securityProfile,
|
||||
// Registry is intentionally nil under strict-PQ (refuse
|
||||
// every classical credential). A classical-compat fork
|
||||
// would inject its named allow-list here.
|
||||
ClassicalCompatRegistry: nil,
|
||||
// Strict-PQ bootstrap escape hatch: genesis-funded
|
||||
// allocation owners may sign classical credentials so the
|
||||
// chain-creation control key can issue CreateNetwork/
|
||||
// CreateChainTx. Nil for legacy networks. See
|
||||
// classicalCompatRegistry.
|
||||
ClassicalCompatRegistry: classicalCompat,
|
||||
},
|
||||
}),
|
||||
// C-Chain (EVM) loaded as plugin via ZAP transport from plugin-dir
|
||||
@@ -1470,8 +1567,10 @@ func (n *Node) initVMs() error {
|
||||
if _, xErr := builder.VMGenesis(n.Config.GenesisBytes, constants.XVMID); xErr == nil {
|
||||
n.Log.Info("Registering X-Chain VM", "vmID", constants.XVMID)
|
||||
err = n.VMManager.RegisterFactory(context.Background(), constants.XVMID, &xvm.Factory{
|
||||
SecurityProfile: n.securityProfile,
|
||||
ClassicalCompatRegistry: nil,
|
||||
SecurityProfile: n.securityProfile,
|
||||
// Strict-PQ bootstrap escape hatch (shared with platformvm
|
||||
// above); nil for legacy networks. See classicalCompatRegistry.
|
||||
ClassicalCompatRegistry: classicalCompat,
|
||||
})
|
||||
if err != nil {
|
||||
n.Log.Error("Failed to register X-Chain VM", "error", err)
|
||||
|
||||
Reference in New Issue
Block a user