diff --git a/network/network.go b/network/network.go index 2593475e9c..56f5a85d0f 100644 --- a/network/network.go +++ b/network/network.go @@ -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() diff --git a/network/peer/peer.go b/network/peer/peer.go index 429e22de9a..bc5db6b6b7 100644 --- a/network/peer/peer.go +++ b/network/peer/peer.go @@ -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 +} + +// 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 +} - 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) +// 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) - if err != nil { - return fmt.Errorf("initiate PQ handshake: %w", 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) + // 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("parse PQ RESP: %w", err) + return ids.EmptyNodeID, zeroKey, 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), diff --git a/network/peer/peer_test.go b/network/peer/peer_test.go index 8359ea09b7..ef5b16c3dd 100644 --- a/network/peer/peer_test.go +++ b/network/peer/peer_test.go @@ -183,6 +183,7 @@ func startTestPeer(self *rawTestPeer, peer *rawTestPeer, conn net.Conn) *testPee throttling.NewNoOutboundThrottler(), ), false, + nil, ), inboundMsgChan: self.inboundMsgChan, } diff --git a/network/peer/test_peer.go b/network/peer/test_peer.go index 2607869fc5..b3c0b91e8b 100644 --- a/network/peer/test_peer.go +++ b/network/peer/test_peer.go @@ -150,6 +150,7 @@ func StartTestPeer( maxMessageToSend, ), false, + nil, ) return peer, peer.AwaitReady(ctx) } diff --git a/node/node.go b/node/node.go index a9083d6aae..72a40902c6 100644 --- a/node/node.go +++ b/node/node.go @@ -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)