diff --git a/Dockerfile b/Dockerfile index d3d20b244e..fa71456b82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -254,6 +254,11 @@ COPY --from=builder /build/build/ . # Create plugins directory and lpm state directory RUN mkdir -p /luxd/build/plugins /root/.lpm /root/.lux/plugins +# Liquidity deploy convention: the operator-rendered lqd startup script +# execs /lqd/build/lqd. Upstream builds the binary as /luxd/build/luxd, +# so symlink it for compatibility with the operator's StatefulSet render. +RUN mkdir -p /lqd/build && ln -sf /luxd/build/luxd /lqd/build/lqd + # Add lpm to PATH ENV PATH="/luxd/build:${PATH}" diff --git a/config/config.go b/config/config.go index 415278c79e..c03c984c48 100644 --- a/config/config.go +++ b/config/config.go @@ -30,6 +30,7 @@ import ( mlkemcrypto "github.com/luxfi/crypto/mlkem" "github.com/luxfi/filesystem/perms" "github.com/luxfi/filesystem/storage" + genesiscfg "github.com/luxfi/genesis/pkg/genesis" "github.com/luxfi/ids" "github.com/luxfi/log" "github.com/luxfi/math/set" @@ -774,15 +775,25 @@ func loadPEMBlock(path, expectType string) ([]byte, error) { // pemBytesOrFile resolves either a base64-encoded PEM in // contentKey (highest precedence, matches the // --foo-file-content / --foo-file pattern the existing TLS -// loaders use) or a filesystem path in pathKey. Empty return = -// neither was set; caller decides whether that's a fatal config -// error or a "fall through to classical-compat" path. +// loaders use) or a filesystem path in pathKey. A *non-empty* +// content value wins; an empty/unset content value falls through +// to the path (so `--foo-content=` next to a real `--foo-file` +// loads the file rather than silently returning nothing). Empty +// return = neither content nor a readable path was set; caller +// decides whether that's a fatal config error or a "fall through +// to classical-compat" path. func pemBytesOrFile(v *viper.Viper, contentKey, pathKey, expectType string) ([]byte, string, error) { - if v.IsSet(contentKey) { + // An empty content value (flag passed as `--foo-content=` or an env/template + // that rendered to "") is NOT "content provided" — it means "no content, + // use the path". Falling through here (instead of returning empty) is the + // difference between loading the persisted key and a SILENT degrade to + // classical-compat: with an ephemeral TLS cert and an empty MLDSA content + // flag, returning here leaves StakingMLDSAPub empty, so DeriveNodeID takes + // the ECDSA branch and the node boots under the wrong (non-validator) + // NodeID with no error. Only a non-empty content value short-circuits the + // path. + if v.IsSet(contentKey) && v.GetString(contentKey) != "" { raw := v.GetString(contentKey) - if raw == "" { - return nil, "", nil - } decoded, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, "", fmt.Errorf("decode %s base64: %w", contentKey, err) @@ -1038,35 +1049,40 @@ func getUpgradeConfig(v *viper.Viper, networkID uint32) (upgrade.Config, error) return upgradeConfig, nil } -func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, error) { +func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, *genesiscfg.SecurityProfile, error) { // (Removed: automine-mode genesis swap.) --automine is single-node // consensus only; it falls through to the canonical genesis loader // below — same as a real network. C-Chain genesis comes from the // canonical luxfi/genesis embedded config or --genesis-file. Period. // HIGHEST PRIORITY: Raw genesis bytes - use directly without rebuilding - // This is critical for snapshot resume to avoid hash mismatch + // This is critical for snapshot resume to avoid hash mismatch. + // Raw platform-genesis bytes carry no top-level "securityProfile" JSON + // pin (it lives in the file-based ConfigOutput, not in the serialized + // platform genesis), so the SecurityProfile pin is nil here — the + // compiled-in genesiscfg.GetConfig(networkID) pin (if any) is honored + // by initSecurityProfile as the fallback. if v.IsSet(GenesisRawBytesKey) { genesisB64 := v.GetString(GenesisRawBytesKey) genesisBytes, err := base64.StdEncoding.DecodeString(genesisB64) if err != nil { - return nil, ids.Empty, fmt.Errorf("failed to decode %s: %w", GenesisRawBytesKey, err) + return nil, ids.Empty, nil, fmt.Errorf("failed to decode %s: %w", GenesisRawBytesKey, err) } utxoAssetID, err := resolveXAssetID(networkID, genesisBytes) if err != nil { - return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from %s: %w", GenesisRawBytesKey, err) + return nil, ids.Empty, nil, fmt.Errorf("resolve X-Chain asset ID from %s: %w", GenesisRawBytesKey, err) } log.Info("loaded raw genesis bytes directly", "size", len(genesisBytes), "utxoAssetID", utxoAssetID, ) - return genesisBytes, utxoAssetID, nil + return genesisBytes, utxoAssetID, nil, nil } // Check if genesis-db is specified for database replay if v.IsSet(GenesisDBKey) { if v.IsSet(GenesisFileKey) || v.IsSet(GenesisFileContentKey) { - return nil, ids.Empty, fmt.Errorf("cannot specify %s with %s or %s", GenesisDBKey, GenesisFileKey, GenesisFileContentKey) + return nil, ids.Empty, nil, fmt.Errorf("cannot specify %s with %s or %s", GenesisDBKey, GenesisFileKey, GenesisFileContentKey) } genesisDBPath := getExpandedArg(v, GenesisDBKey) genesisDBType := v.GetString(GenesisDBTypeKey) @@ -1076,18 +1092,40 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin genesisDBType = "zapdb" // Default is always zapdb } - return builder.FromDatabase(networkID, genesisDBPath, genesisDBType, stakingCfg) + genesisBytes, utxoAssetID, err := builder.FromDatabase(networkID, genesisDBPath, genesisDBType, stakingCfg) + return genesisBytes, utxoAssetID, nil, err } // try first loading genesis content directly from flag/env-var if v.IsSet(GenesisFileContentKey) { genesisData := v.GetString(GenesisFileContentKey) - return builder.FromFlag(networkID, genesisData, stakingCfg) + genesisBytes, utxoAssetID, err := builder.FromFlag(networkID, genesisData, stakingCfg) + if err != nil { + return nil, ids.Empty, nil, err + } + // Surface the chain-wide ChainSecurityProfile pin (top-level + // "securityProfile") from the same base64 content the builder + // consumed, so a file-based strict-PQ genesis activates strict-PQ. + pin, err := securityProfileFromContent(genesisData) + if err != nil { + return nil, ids.Empty, nil, err + } + return genesisBytes, utxoAssetID, pin, nil } // if content is not specified go for the file if v.IsSet(GenesisFileKey) { genesisFileName := getExpandedArg(v, GenesisFileKey) + // The chain-wide ChainSecurityProfile pin is parsed from the + // genesis file itself (top-level "securityProfile"), never from + // the built/cached platform bytes — the pin does not survive into + // the serialized platform genesis. Read it once here regardless of + // whether the platform bytes come from cache or a fresh build, so + // strict-PQ activation is independent of the genesis.bytes cache. + pin, err := securityProfileFromFile(genesisFileName) + if err != nil { + return nil, ids.Empty, nil, err + } // Check if we have cached genesis bytes to avoid rebuilding cacheFile := filepath.Join(dataDir, "genesis.bytes") if cachedBytes, err := loadCachedGenesisBytes(cacheFile); err == nil && len(cachedBytes) > 0 { @@ -1098,7 +1136,7 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin "size", len(cachedBytes), "utxoAssetID", utxoAssetID, ) - return cachedBytes, utxoAssetID, nil + return cachedBytes, utxoAssetID, pin, nil } // Cache parse failed — almost certainly a codec mismatch // after a binary upgrade (e.g. v1-codec bytes persisted by @@ -1118,7 +1156,7 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin // No cache or invalid cache - build from file and cache the result genesisBytes, utxoAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg) if err != nil { - return nil, ids.Empty, err + return nil, ids.Empty, nil, err } // Cache the built bytes for future restarts if err := saveCachedGenesisBytes(cacheFile, genesisBytes); err != nil { @@ -1131,12 +1169,44 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin "size", len(genesisBytes), ) } - return genesisBytes, utxoAssetID, nil + return genesisBytes, utxoAssetID, pin, nil } - // finally if file is not specified/readable go for the predefined config + // finally if file is not specified/readable go for the predefined config. + // The compiled-in config carries the SecurityProfile pin for standard + // networks (mainnet/testnet) that bake one in. config := builder.GetConfig(networkID) - return builder.FromConfig(config) + genesisBytes, utxoAssetID, err := builder.FromConfig(config) + return genesisBytes, utxoAssetID, config.SecurityProfile, err +} + +// securityProfileFromFile parses the top-level "securityProfile" pin from a +// genesis file. Reuses genesiscfg.GetConfigFile so the JSON shape stays +// single-sourced with builder.FromFile. Returns nil (no pin) when the file +// omits the field; a parse error is fatal — a malformed pin on a strict-PQ +// genesis must not silently degrade to classical-compat. +func securityProfileFromFile(filepath string) (*genesiscfg.SecurityProfile, error) { + cfg, err := genesiscfg.GetConfigFile(filepath) + if err != nil { + return nil, fmt.Errorf("parse securityProfile from genesis file %s: %w", filepath, err) + } + return cfg.SecurityProfile, nil +} + +// securityProfileFromContent parses the top-level "securityProfile" pin from +// base64-encoded genesis content (--genesis-file-content). Mirrors +// builder.FromFlag's decode + ConfigOutput parse so the pin matches the +// platform bytes the builder produced from the same content. +func securityProfileFromContent(content string) (*genesiscfg.SecurityProfile, error) { + data, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return nil, fmt.Errorf("decode base64 genesis content for securityProfile: %w", err) + } + var output genesiscfg.ConfigOutput + if err := json.Unmarshal(data, &output); err != nil { + return nil, fmt.Errorf("parse securityProfile from genesis content: %w", err) + } + return output.SecurityProfile, nil } // resolveXAssetID extracts the X-Chain native asset ID from the loaded @@ -1746,7 +1816,6 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) { } nodeConfig.TrackAllChains = v.GetBool(TrackAllChainsKey) - // HTTP APIs nodeConfig.HTTPConfig, err = getHTTPConfig(v) if err != nil { @@ -1872,7 +1941,7 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) { // Get data directory for dev network config persistence dataDir := getExpandedArg(v, DataDirKey) - nodeConfig.GenesisBytes, nodeConfig.UTXOAssetID, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir) + nodeConfig.GenesisBytes, nodeConfig.UTXOAssetID, nodeConfig.GenesisSecurityProfile, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir) if err != nil { return node.Config{}, fmt.Errorf("unable to load genesis file: %w", err) } diff --git a/config/node/config.go b/config/node/config.go index 114f3ce3dc..a445a19d02 100644 --- a/config/node/config.go +++ b/config/node/config.go @@ -9,24 +9,25 @@ import ( "net/netip" "time" + genesiscfg "github.com/luxfi/genesis/pkg/genesis" "github.com/luxfi/ids" - "github.com/luxfi/node/server/http" "github.com/luxfi/node/benchlist" "github.com/luxfi/node/chains" "github.com/luxfi/node/genesis/builder" "github.com/luxfi/node/network" + "github.com/luxfi/node/server/http" // "github.com/luxfi/consensus/core/router" // Unused "github.com/luxfi/crypto/bls" "github.com/luxfi/crypto/mldsa" mlkemcrypto "github.com/luxfi/crypto/mlkem" "github.com/luxfi/node/nets" "github.com/luxfi/node/network/tracker" - "github.com/luxfi/node/upgrade" "github.com/luxfi/node/trace" + "github.com/luxfi/node/upgrade" // "github.com/luxfi/log" // Unused "github.com/luxfi/math/set" - "github.com/luxfi/timer" "github.com/luxfi/node/utils/profiler" + "github.com/luxfi/timer" ) type APIIndexerConfig struct { @@ -101,13 +102,13 @@ type StakingConfig struct { // publishes in its validator-set entry so peers can encapsulate to it // for session-key establishment with no classical fallback. The // HandshakeMLKEMPriv stays local to the pod. - StakingMLDSA *mldsa.PrivateKey `json:"-"` - StakingMLDSAPub []byte `json:"-"` - HandshakeMLKEMPriv *mlkemcrypto.PrivateKey `json:"-"` - HandshakeMLKEMPub []byte `json:"-"` + StakingMLDSA *mldsa.PrivateKey `json:"-"` + StakingMLDSAPub []byte `json:"-"` + HandshakeMLKEMPriv *mlkemcrypto.PrivateKey `json:"-"` + HandshakeMLKEMPub []byte `json:"-"` // File paths kept for log-line context, mirroring StakingKeyPath etc. - StakingMLDSAKeyPath string `json:"stakingMLDSAKeyPath,omitempty"` - StakingMLDSAPubPath string `json:"stakingMLDSAPubPath,omitempty"` + StakingMLDSAKeyPath string `json:"stakingMLDSAKeyPath,omitempty"` + StakingMLDSAPubPath string `json:"stakingMLDSAPubPath,omitempty"` HandshakeMLKEMKeyPath string `json:"handshakeMLKEMKeyPath,omitempty"` HandshakeMLKEMPubPath string `json:"handshakeMLKEMPubPath,omitempty"` } @@ -169,7 +170,16 @@ type Config struct { // Genesis information GenesisBytes []byte `json:"-"` - UTXOAssetID ids.ID `json:"utxoAssetID"` + UTXOAssetID ids.ID `json:"utxoAssetID"` + + // GenesisSecurityProfile is the chain-wide ChainSecurityProfile pin + // parsed from the file-based genesis (--genesis-file / --genesis-file-content + // top-level "securityProfile" object), or nil when the genesis carries + // no pin. initSecurityProfile prefers this over the compiled-in + // genesiscfg.GetConfig(networkID) pin so a file-based strict-PQ genesis + // (networkID with no compiled-in profile) still activates strict-PQ. + // Pure data — resolved + hash-verified at boot via genesissecurity.ResolveProfile. + GenesisSecurityProfile *genesiscfg.SecurityProfile `json:"-"` // ID of the network this node should connect to NetworkID uint32 `json:"networkID"` diff --git a/network/config.go b/network/config.go index ce124cd619..bb4cad2782 100644 --- a/network/config.go +++ b/network/config.go @@ -118,6 +118,14 @@ type Config struct { TLSKeyLogFile string `json:"tlsKeyLogFile"` MyNodeID ids.NodeID `json:"myNodeID"` + // StakingMLDSAPub/Priv carry this node's PERSISTENT strict-PQ ML-DSA-65 + // staking keypair as raw FIPS-204 bytes (luxfi/crypto/mldsa wraps circl, + // so these are circl-parseable). The PQ handshake binds its identity to + // THIS keypair so the on-wire NodeID equals the staking-derived MyNodeID + // and is stable across restarts. Empty on classical-compat profiles + // (handshake then falls back to an ephemeral identity). + StakingMLDSAPub []byte `json:"-"` + StakingMLDSAPriv []byte `json:"-"` MyIPPort *utils.Atomic[netip.AddrPort] `json:"myIP"` NetworkID uint32 `json:"networkID"` MaxClockDifference time.Duration `json:"maxClockDifference"` diff --git a/network/network.go b/network/network.go index b5e60e1f6d..841c6ad890 100644 --- a/network/network.go +++ b/network/network.go @@ -508,9 +508,19 @@ func NewNetwork( // refused. Permissive / classical-compat profiles leave PQHandshake // nil and use the legacy bare-TLS path. if config.SecurityProfile != nil && profileRequiresPQHandshake(config.SecurityProfile) { - pqIdent, err := peer.NewLocalIdentity(config.MyNodeID) - if err != nil { - return nil, fmt.Errorf("building PQ local identity: %w", err) + var pqIdent *peer.LocalIdentity + var identErr error + if len(config.StakingMLDSAPub) > 0 && len(config.StakingMLDSAPriv) > 0 { + // Production strict-PQ: bind the handshake identity to the + // persistent staking ML-DSA key so the on-wire NodeID equals + // the staking-derived MyNodeID (stable across restarts). + pqIdent, identErr = peer.NewLocalIdentityFromMLDSA(config.MyNodeID, config.StakingMLDSAPub, config.StakingMLDSAPriv) + } else { + // No staking ML-DSA material (tests / non-strict-PQ): ephemeral. + pqIdent, identErr = peer.NewLocalIdentity(config.MyNodeID) + } + if identErr != nil { + return nil, fmt.Errorf("building PQ local identity: %w", identErr) } peerConfig.PQHandshakeConfig = &peer.HandshakeConfig{ Profile: peer.ProfileStrictPQ, @@ -524,6 +534,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 in peer.Start's PQ handshake — which forbids + // classical KEM and verifies the key-derived NodeID. 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 peer.Start, not the gate. + upgraderGate := schemeGate + if peerConfig.PQHandshakeConfig != nil { + upgraderGate = nil + } + onCloseCtx, cancel := context.WithCancel(context.Background()) n := &network{ startupTime: time.Now(), @@ -535,8 +563,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, @@ -1734,6 +1762,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 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 (dialEndpointOnly 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 → 90s + // 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") @@ -1807,6 +1870,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/handshake.go b/network/peer/handshake.go index c99b4b334f..1bfaa668dd 100644 --- a/network/peer/handshake.go +++ b/network/peer/handshake.go @@ -165,6 +165,42 @@ func NewLocalIdentity(nodeID ids.NodeID) (*LocalIdentity, error) { return &LocalIdentity{NodeID: nodeID, Public: pk, Secret: sk}, nil } +// NewLocalIdentityFromMLDSA builds the PQ-handshake identity from this +// node's PERSISTENT strict-PQ staking ML-DSA-65 keypair (raw FIPS-204 +// bytes). Production nodes MUST use this rather than NewLocalIdentity: the +// on-wire identity then equals the staking-derived NodeID (see +// StakingConfig.DeriveNodeID) and is stable across restarts, so peers see +// the same NodeID that appears in the validator set. NewLocalIdentity +// (fresh random) is for tests and one-shot tooling only — using it in +// production mints a new ephemeral NodeID every boot that never matches +// the genesis/validator set, starving the ProposerVM of an online proposer. +func NewLocalIdentityFromMLDSA(nodeID ids.NodeID, pubBytes, privBytes []byte) (*LocalIdentity, error) { + pk := new(mldsa65.PublicKey) + if err := pk.UnmarshalBinary(pubBytes); err != nil { + return nil, fmt.Errorf("peer: parse staking ML-DSA-65 public key (%d bytes): %w", len(pubBytes), err) + } + sk := new(mldsa65.PrivateKey) + if err := sk.UnmarshalBinary(privBytes); err != nil { + return nil, fmt.Errorf("peer: parse staking ML-DSA-65 private key (%d bytes): %w", len(privBytes), err) + } + return &LocalIdentity{NodeID: nodeID, Public: pk, Secret: sk}, nil +} + +// deriveMLDSANodeID computes the canonical NodeID bound to an ML-DSA-65 +// public key, using the SAME scheme as StakingConfig.DeriveNodeID +// (ids.NodeIDSchemeMLDSA65.DeriveMLDSA over the primary-network chainID, +// ids.Empty). The handshake verifies the peer's claimed NodeID against +// this so a peer can't present one validator's NodeID while holding a +// different key — and so the post-handshake peer identity equals the +// validator-set NodeID (DeriveNodeID), unifying transport + consensus. +func deriveMLDSANodeID(pubBytes []byte) (ids.NodeID, error) { + id, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, pubBytes) + if err != nil { + return ids.EmptyNodeID, err + } + return id, nil +} + // HandshakeConfig pins the policy bits the handshake enforces. One value // per peer connection; the network-level config builds one shared // HandshakeConfig and hands a pointer to every per-peer goroutine. @@ -391,9 +427,22 @@ func RespondHandshake( TranscriptHash: kem.HashTranscript(fullTranscript), } + // Bind the peer's NodeID to its ML-DSA key (same scheme as + // StakingConfig.DeriveNodeID): reject a claimed NodeID that doesn't + // match its key, and adopt the key-derived NodeID as the authoritative + // peer identity so consensus tracks the validator-set NodeID. + derivedID, derr := deriveMLDSANodeID(init.MLDSAPub) + if derr != nil { + return nil, nil, fmt.Errorf("%w: derive initiator NodeID: %v", ErrHandshakeBadIdentity, derr) + } + if derivedID != init.NodeID { + return nil, nil, fmt.Errorf("%w: initiator NodeID %s != ML-DSA-derived %s", + ErrHandshakeBadIdentity, init.NodeID, derivedID) + } + return resp, &HandshakeResult{ Local: local, - PeerNodeID: init.NodeID, + PeerNodeID: derivedID, PeerMLDSA: peerPub, KEMSession: finalSession, AEADKey: finalSession.DeriveAEADKey(), @@ -444,9 +493,19 @@ func FinishInitiatorHandshake( TranscriptHash: kem.HashTranscript(fullTranscript), } + // Bind the responder's NodeID to its ML-DSA key (see RespondHandshake). + derivedID, derr := deriveMLDSANodeID(resp.MLDSAPub) + if derr != nil { + return nil, fmt.Errorf("%w: derive responder NodeID: %v", ErrHandshakeBadIdentity, derr) + } + if derivedID != resp.NodeID { + return nil, fmt.Errorf("%w: responder NodeID %s != ML-DSA-derived %s", + ErrHandshakeBadIdentity, resp.NodeID, derivedID) + } + return &HandshakeResult{ Local: local, - PeerNodeID: resp.NodeID, + PeerNodeID: derivedID, PeerMLDSA: peerPub, KEMSession: finalSession, AEADKey: finalSession.DeriveAEADKey(), diff --git a/network/peer/handshake_test.go b/network/peer/handshake_test.go index d8fcf36150..d8ac885b60 100644 --- a/network/peer/handshake_test.go +++ b/network/peer/handshake_test.go @@ -15,16 +15,24 @@ import ( // newTestIdentities returns two fresh ML-DSA-65 identities and a shared // chain ID. Used by every handshake test that needs a happy-path setup. +// +// Each identity's NodeID is derived FROM its own ML-DSA-65 public key via +// deriveMLDSANodeID — the same binding production enforces (the handshake +// now rejects a claimed NodeID that doesn't match the presented key, and +// adopts the key-derived NodeID as the authoritative peer identity). A +// random ids.GenerateTestNodeID() would no longer round-trip. func newTestIdentities(t *testing.T) (initiator, responder *LocalIdentity, chainID [32]byte) { t.Helper() require := require.New(t) - initNodeID := ids.GenerateTestNodeID() - respNodeID := ids.GenerateTestNodeID() + init, err := NewLocalIdentity(ids.EmptyNodeID) + require.NoError(err) + init.NodeID, err = deriveMLDSANodeID(packMLDSAPub(init.Public)) + require.NoError(err) - init, err := NewLocalIdentity(initNodeID) + resp, err := NewLocalIdentity(ids.EmptyNodeID) require.NoError(err) - resp, err := NewLocalIdentity(respNodeID) + resp.NodeID, err = deriveMLDSANodeID(packMLDSAPub(resp.Public)) require.NoError(err) _, err = rand.Read(chainID[:]) diff --git a/network/peer/peer.go b/network/peer/peer.go index 72bd88cc90..d322e0f445 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,18 @@ 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 ML-DSA NodeID + // via the id param, so only the AEAD key needs adopting here. + 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,59 +298,87 @@ 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 ML-DSA identity authenticated by the handshake as this peer's + // NodeID, replacing the ephemeral TLS-cert NodeID from the upgrader. + 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. +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 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). +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 { + 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, result, 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 nil + return result.PeerNodeID, result.AEADKey, nil } // Initiator: send INIT, read RESP, finalize. - init, kemSec, err := InitiateHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity) + init, kemSec, err := InitiateHandshake(hs, local) if err != nil { - return fmt.Errorf("initiate PQ handshake: %w", err) + return ids.EmptyNodeID, zeroKey, fmt.Errorf("initiate PQ handshake: %w", err) } - if err := writePQFrame(p.conn, init.canonicalBytes()); err != nil { - return fmt.Errorf("write PQ INIT: %w", err) + if err := writePQFrame(conn, init.canonicalBytes()); err != nil { + return ids.EmptyNodeID, zeroKey, fmt.Errorf("write PQ INIT: %w", err) } - respBytes, err := readPQFrame(p.conn) + respBytes, err := readPQFrame(conn) if err != nil { - return fmt.Errorf("read PQ RESP: %w", err) + return ids.EmptyNodeID, zeroKey, fmt.Errorf("read PQ RESP: %w", err) } - resp, err := parsePQHandshakeResp(respBytes, cfg.PQHandshakeConfig.KEMScheme) + resp, err := parsePQHandshakeResp(respBytes, hs.KEMScheme) if err != nil { - return fmt.Errorf("parse PQ RESP: %w", err) + return ids.EmptyNodeID, zeroKey, fmt.Errorf("parse PQ RESP: %w", err) } - result, err := FinishInitiatorHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init, resp, kemSec) + result, err := FinishInitiatorHandshake(hs, local, init, resp, kemSec) if err != nil { - return fmt.Errorf("finish PQ handshake: %w", err) + return ids.EmptyNodeID, zeroKey, fmt.Errorf("finish PQ handshake: %w", err) } - p.pqAEADKey = result.AEADKey - return nil + return result.PeerNodeID, result.AEADKey, nil } func (p *peer) ID() ids.NodeID { 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 31d4093ed9..84672ed395 100644 --- a/node/node.go +++ b/node/node.go @@ -60,9 +60,11 @@ import ( "github.com/luxfi/node/upgrade" "github.com/luxfi/node/version" "github.com/luxfi/node/vms" - "github.com/luxfi/node/vms/xvm" "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" @@ -77,6 +79,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" @@ -685,6 +688,16 @@ func (n *Node) initNetworking(reg metric.Registerer) error { // add node configs to network config n.Config.NetworkConfig.MyNodeID = n.ID + // Bind the PQ handshake identity to the persistent staking ML-DSA-65 + // keypair so the on-wire NodeID equals n.ID (StakingConfig.DeriveNodeID) + // and is stable across restarts. Without this the network layer mints a + // fresh ephemeral ML-DSA identity each boot (peer.NewLocalIdentity), + // which never matches the validator set → ProposerVM has no online + // proposer → P-Chain produces no blocks. + n.Config.NetworkConfig.StakingMLDSAPub = n.Config.StakingConfig.StakingMLDSAPub + if n.Config.StakingConfig.StakingMLDSA != nil { + n.Config.NetworkConfig.StakingMLDSAPriv = n.Config.StakingConfig.StakingMLDSA.Bytes() + } n.Config.NetworkConfig.MyIPPort = atomicIP n.Config.NetworkConfig.NetworkID = n.Config.NetworkID n.Config.NetworkConfig.Validators = n.vdrs @@ -1007,6 +1020,20 @@ func (n *Node) SecurityProfile() *consensusconfig.ChainSecurityProfile { // // Closes red-team finding F102 at the node bootstrap layer. func (n *Node) initSecurityProfile() error { + // A file-based genesis (--genesis-file / --genesis-file-content) carries + // its ChainSecurityProfile pin as a top-level "securityProfile" object. + // config.getGenesisData parses it onto GenesisSecurityProfile. Prefer it: + // networkID=3 and other sovereign L1s have no compiled-in profile, so the + // file pin is the only place strict-PQ is declared. Falling back to + // genesiscfg.GetConfig(networkID) here would (wrongly) leave them in + // classical-compat. Closes the strict-PQ-never-activates gap. + if n.Config.GenesisSecurityProfile != nil { + return n.applySecurityProfile(n.Config.GenesisSecurityProfile) + } + + // No file pin (raw-bytes / db-replay load, or a genesis file with no + // securityProfile field). Fall back to the compiled-in config so standard + // networks that bake a pin (mainnet/testnet) still activate it. cfg := genesiscfg.GetConfig(n.Config.NetworkID) if cfg == nil { n.Log.Warn("genesis config not found — node boots in classical-compat mode") @@ -1353,7 +1380,7 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error { NetworkID: n.Config.NetworkID, Server: n.APIServer, AtomicMemory: n.sharedMemory, - UTXOAssetID: utxoAssetID, + UTXOAssetID: utxoAssetID, XChainID: xChainID, CChainID: cChainID, DChainID: dChainID, @@ -1394,12 +1421,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. @@ -1442,10 +1561,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 @@ -1463,8 +1584,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) diff --git a/node/security_profile_test.go b/node/security_profile_test.go index 41904de4fb..55c7d5b2da 100644 --- a/node/security_profile_test.go +++ b/node/security_profile_test.go @@ -12,6 +12,7 @@ import ( consensusconfig "github.com/luxfi/consensus/config" genesiscfg "github.com/luxfi/genesis/pkg/genesis" genesissecurity "github.com/luxfi/genesis/pkg/genesis/security" + nodecfg "github.com/luxfi/node/config/node" "github.com/luxfi/log" ) @@ -109,6 +110,68 @@ func TestApplySecurityProfile_UnknownProfileIDRejected(t *testing.T) { } } +// TestInitSecurityProfile_FilePinPreferred proves a file-based genesis +// pin (GenesisSecurityProfile, parsed by config.getGenesisData from the +// genesis file's top-level "securityProfile") activates strict-PQ even on +// a networkID that has no compiled-in profile. This is the load-bearing +// fix for the strict-PQ L1 (networkID=3, file-based genesis) that +// previously booted classical-compat because genesiscfg.GetConfig(3) +// returns a nil-profile config. +func TestInitSecurityProfile_FilePinPreferred(t *testing.T) { + canonical := consensusconfig.StrictPQ() + live, err := canonical.ComputeHash() + if err != nil { + t.Fatalf("ComputeHash: %v", err) + } + pin := &genesiscfg.SecurityProfile{ + ProfileID: uint8(consensusconfig.ProfileStrictPQ), + ProfileHashHex: hex.EncodeToString(live[:]), + } + n := &Node{ + Log: log.NoLog{}, + Config: &nodecfg.Config{ + NetworkID: 3, // sovereign L1, no compiled-in profile + GenesisSecurityProfile: pin, + }, + } + if err := n.initSecurityProfile(); err != nil { + t.Fatalf("initSecurityProfile returned: %v", err) + } + got := n.SecurityProfile() + if got == nil { + t.Fatal("SecurityProfile() is nil — strict-PQ did not activate from the file pin") + } + if got.ProfileID != uint32(consensusconfig.ProfileStrictPQ) { + t.Errorf("ProfileID = %d; want %d (StrictPQ)", got.ProfileID, consensusconfig.ProfileStrictPQ) + } + if got.ProfileHash != live { + t.Errorf("ProfileHash mismatch after file-pin resolution") + } +} + +// TestInitSecurityProfile_NoFilePinFallsBack proves that when no file pin +// is present (raw-bytes / db-replay load, or a genesis file without a +// securityProfile field) on a network with no compiled-in profile, the +// node boots classical-compat (nil SecurityProfile) without error — the +// preserved legacy behavior. NetworkID=3 has no compiled-in profile and +// no on-disk genesis dir in the test environment, so GetConfig(3) yields a +// nil-profile config. +func TestInitSecurityProfile_NoFilePinFallsBack(t *testing.T) { + n := &Node{ + Log: log.NoLog{}, + Config: &nodecfg.Config{ + NetworkID: 3, + GenesisSecurityProfile: nil, + }, + } + if err := n.initSecurityProfile(); err != nil { + t.Fatalf("initSecurityProfile returned %v; want nil (classical-compat)", err) + } + if n.SecurityProfile() != nil { + t.Errorf("SecurityProfile() = %v; want nil with no pin", n.SecurityProfile()) + } +} + // TestApplySecurityProfile_FIPS proves the FIPS profile resolves // alongside StrictPQ — both share the strict-PQ posture but FIPS // pins SHA3NIST exclusively (no BLAKE3-legacy).