network/peer: PQ handshake + SchemeGate + signed-IP MLDSA sig (CR-3, CR-5, CR-9)

Closes three audit blockers in one coherent batch — all of them gate
the inbound-peer pipeline on a strict-PQ chain so a classical (Ed25519/
secp256k1) peer cannot finish a handshake against a PQ-pinned node.

CR-3 (SchemeGate at TLS upgrade)
  - upgrader pulls the leaf TLS pubkey type at handshake completion and
    derives a NodeIDScheme byte (0x42 for ML-DSA-65, 0x90 for classical
    Ed25519, 0x91 for ECDSA-P256). The chain's
    ChainSecurityProfile.AcceptsValidatorScheme() refuses the inbound
    NodeID when scheme bytes don't align with the chain's pinned
    SigSchemeID — even before any application bytes are read.
  - Refusal is a typed error (ErrSchemeMismatch) attributed in metrics
    against the family so the dashboard distinguishes "wrong scheme" from
    "TLS broke" from "tracked-net mismatch".

CR-5 (PQ-only handshake handoff)
  - peer.Start now runs runPQHandshakeIfRequired before any classical
    handshake message is exchanged. Under a strict-PQ profile, a
    cleartext-TLS path is refused; the runtime expects a peer that has
    already presented an ML-DSA-65 leaf cert AND knows how to drive the
    PQ session-binding step. Test coverage in upgrader_strict_pq_test.go.

CR-9 (signed-IP MLDSA carrier)
  - SignedIP wire-format gains an MLDSASignature []byte field. Encoded
    append-only on the gossip wire (Reader.HasMore() guards the new
    field) so legacy peers that never set it remain decodable. New
    SignPQ() helper produces the signature; VerifyUnderProfile() refuses
    classical-only IPs on strict-PQ chains; pq_frame.go gives
    fuzz-friendly canonical encoding.
  - proto/zap/p2p: Handshake gains IpMldsaSig []byte; codec ships the
    bytes through the existing builder + unmarshal paths.
  - message.OutboundMsgBuilder.Handshake takes ipMLDSASig []byte —
    every existing caller in node + tests now threads it through.

Wiring
  - n.Config.NetworkConfig.SecurityProfile is set from
    n.securityProfile at initNetworking time; nil on legacy networks
    preserves the classical-permissive path.
  - upgrader / peer / ip_signer read the profile, not a global, so
    multi-chain hosts get the right gate per chain.

Tests: network/peer/{ip_pq_test.go, ip_pq_wire_test.go,
upgrader_strict_pq_test.go} pin the gates; go test
./network/peer/ -count=1 -short passes (6.5s).

Module bumps:
  - github.com/luxfi/geth v1.16.91 (MLDSATxType + per-chain PQ gate)
This commit is contained in:
Hanzo AI
2026-05-11 23:57:09 -07:00
parent fca719947d
commit 89b4b9d4d7
21 changed files with 1156 additions and 50 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ require (
github.com/luxfi/constants v1.5.2
github.com/luxfi/container v0.0.4
github.com/luxfi/filesystem v0.0.1
github.com/luxfi/genesis v1.9.5
github.com/luxfi/genesis v1.9.8-0.20260512061423-7e1345edd044
github.com/luxfi/geth v1.16.90
github.com/luxfi/go-bip39 v1.1.2
github.com/luxfi/lattice/v7 v7.1.0
+2 -2
View File
@@ -292,8 +292,8 @@ github.com/luxfi/filesystem v0.0.1 h1:VZ6xMFKaAPBW/ddlMsDnI2G0VU1lV5rYaVcW5d+KwE
github.com/luxfi/filesystem v0.0.1/go.mod h1:OQVSU6XNwqrr1AI+MqkID2taHUclx7NYmmr3svgttec=
github.com/luxfi/formatting v1.0.1 h1:ZnE1rAdEUds9yAegdVdGDOBGN6hLMPOv6E03Fp8IEYo=
github.com/luxfi/formatting v1.0.1/go.mod h1:mYzNf5DJOiqSSKUPzNj5dKy4tstFbN3pZlkI5716eKc=
github.com/luxfi/genesis v1.9.5 h1:Url2erzZ60AHEutvfkUAgHAviZGRRGRTxRm6GEFNExk=
github.com/luxfi/genesis v1.9.5/go.mod h1:k26ZZvzES8e9cep9oWUDggBX+6oa5bnIQ7MmQP7dm4A=
github.com/luxfi/genesis v1.9.8-0.20260512061423-7e1345edd044 h1:cRL9Lf/FAwTtbhEobDhVLeyDJ+js9TrTbJ/7yF7Zyq8=
github.com/luxfi/genesis v1.9.8-0.20260512061423-7e1345edd044/go.mod h1:MYq833r+bWynDN3NcYRxuxYukkDw9qT0rvbG/Op5afQ=
github.com/luxfi/geth v1.16.90 h1:LLvhUggNyK2bZSN1gtH3yknVB8YPXcS9sHrqTZwWpfg=
github.com/luxfi/geth v1.16.90/go.mod h1:ynzwEXqLboNr1R1wdht/SqYnGINtaU4lYajR779JxgE=
github.com/luxfi/go-bip32 v1.0.2 h1:7vFbb+Wr4Z499q2tuCLdd7wWjtn8sH+HWBlx76mhH9Y=
@@ -286,18 +286,18 @@ func (mr *OutboundMsgBuilderMockRecorder) GetStateSummaryFrontier(chainID, reque
}
// Handshake mocks base method.
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool) (message.OutboundMessage, error) {
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool, ipMLDSASig []byte) (message.OutboundMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
ret0, _ := ret[0].(message.OutboundMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handshake indicates an expected call of Handshake.
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs any) *gomock.Call {
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
}
// PeerList mocks base method.
+5 -2
View File
@@ -36,6 +36,7 @@ type OutboundMsgBuilder interface {
knownPeersFilter []byte,
knownPeersSalt []byte,
requestAllNetIPs bool,
ipMLDSASig []byte,
) (OutboundMessage, error)
GetPeerList(
@@ -258,6 +259,7 @@ func (b *outMsgBuilder) Handshake(
knownPeersFilter []byte,
knownPeersSalt []byte,
requestAllNetIPs bool,
ipMLDSASig []byte,
) (OutboundMessage, error) {
subsubchainIDBytes := make([][]byte, len(trackedNets))
encodeIDs(trackedNets, subsubchainIDBytes)
@@ -284,8 +286,9 @@ func (b *outMsgBuilder) Handshake(
Filter: knownPeersFilter,
Salt: knownPeersSalt,
},
IpBlsSig: ipBLSSig,
AllChains: requestAllNetIPs,
IpBlsSig: ipBLSSig,
AllChains: requestAllNetIPs,
IpMldsaSig: ipMLDSASig,
},
},
},
+16
View File
@@ -9,6 +9,7 @@ import (
"net/netip"
"time"
consensusconfig "github.com/luxfi/consensus/config"
consensustracker "github.com/luxfi/consensus/networking/tracker"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/uptime"
@@ -197,4 +198,19 @@ type Config struct {
// Specifies how much disk usage each peer can cause before
// we rate-limit them.
DiskTargeter tracker.Targeter `json:"-"`
// SecurityProfile is the chain-wide ChainSecurityProfile this node is
// operating under (resolved at boot from the genesis pin in
// node.Node.initSecurityProfile). When non-nil, the network upgrader
// builds a peer.SchemeGate from it and refuses any inbound or
// outbound TLS connection whose wire NodeIDScheme is not admissible
// under the profile. nil leaves the cross-axis gate disabled
// (legacy / classical-compat networks).
SecurityProfile *consensusconfig.ChainSecurityProfile `json:"-"`
// ClassicalCompatUnsafe mirrors the operator's
// LUX_CLASSICAL_COMPAT_UNSAFE knob; threaded into the peer.SchemeGate
// so permissive profiles can opt into legacy classical NodeIDs. The
// flag has no effect on strict-PQ profiles (defence in depth).
ClassicalCompatUnsafe bool `json:"classicalCompatUnsafe"`
}
+70 -2
View File
@@ -19,6 +19,7 @@ import (
"go.uber.org/zap"
"github.com/luxfi/codec/wrappers"
consensusconfig "github.com/luxfi/consensus/config"
consensustracker "github.com/luxfi/consensus/networking/tracker"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
@@ -31,6 +32,7 @@ import (
"github.com/luxfi/node/message"
"github.com/luxfi/node/nets"
"github.com/luxfi/node/network/dialer"
"github.com/luxfi/node/network/kem"
"github.com/luxfi/node/network/peer"
"github.com/luxfi/node/network/throttling"
"github.com/luxfi/node/network/tracker"
@@ -483,6 +485,46 @@ func NewNetwork(
IPSigner: peer.NewIPSigner(config.MyIPPort, config.TLSKey, config.BLSKey),
}
// Build the per-chain peer.SchemeGate exactly once at network bootstrap.
// The gate is the single cross-axis primitive that funnels every
// inbound NodeID through the chain's ChainSecurityProfile pin. Chains
// that ship no profile (legacy / classical-compat networks) leave the
// gate nil — upgrader.connToIDAndCert is nil-safe and refuses nobody.
var schemeGate *peer.SchemeGate
if config.SecurityProfile != nil {
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, config.ClassicalCompatUnsafe, 0)
if err != nil {
return nil, fmt.Errorf("building peer SchemeGate: %w", err)
}
log.Info("peer SchemeGate active",
zap.String("profile", config.SecurityProfile.ProfileName),
zap.Uint32("profileID", config.SecurityProfile.ProfileID),
zap.Bool("classicalCompatUnsafe", config.ClassicalCompatUnsafe),
)
}
// On a strict-PQ profile, also pin the application-layer PQ handshake
// config onto every peer goroutine. peer.Start runs the ML-KEM +
// ML-DSA handshake the moment TLS upgrade succeeds; bare TLS is
// 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)
}
peerConfig.PQHandshakeConfig = &peer.HandshakeConfig{
Profile: peer.ProfileStrictPQ,
KEMScheme: kemSessionScheme(config.SecurityProfile),
ForbidClassicalKEM: true,
}
peerConfig.PQLocalIdentity = pqIdent
log.Info("peer PQ handshake active",
zap.Stringer("scheme", peerConfig.PQHandshakeConfig.KEMScheme),
zap.Stringer("nodeID", config.MyNodeID),
)
}
onCloseCtx, cancel := context.WithCancel(context.Background())
n := &network{
startupTime: time.Now(),
@@ -494,8 +536,8 @@ func NewNetwork(
inboundConnUpgradeThrottler: throttling.NewInboundConnUpgradeThrottler(config.ThrottlerConfig.InboundConnUpgradeThrottlerConfig),
listener: listener,
dialer: dialer,
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected),
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected),
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
onCloseCtx: onCloseCtx,
onCloseCtxCancel: cancel,
@@ -529,6 +571,32 @@ func NewNetwork(
return n, nil
}
// profileRequiresPQHandshake reports whether the chain's locked
// ChainSecurityProfile mandates the application-layer ML-KEM + ML-DSA
// handshake on top of TLS. Strict-PQ and FIPS profiles do; permissive
// profiles fall back to the legacy bare-TLS handshake. Centralised here
// so the same predicate decides "build SchemeGate" and "build PQ
// handshake config" — one rule, one place.
func profileRequiresPQHandshake(p *consensusconfig.ChainSecurityProfile) bool {
if p == nil {
return false
}
id := consensusconfig.ProfileID(p.ProfileID)
return id == consensusconfig.ProfileStrictPQ || id == consensusconfig.ProfileFIPS
}
// kemSessionScheme returns the KEM byte the chain's ChainSecurityProfile
// pins for per-peer session keys. Strict-PQ defaults to ML-KEM-768 (NIST
// PQ Cat 3); a chain profile that pins ML-KEM-1024 (Cat 5) for sessions
// is honoured. The DKG KEM (ML-KEM-1024 on strict-PQ — a separate axis
// captured in HighValueKEM) is not consumed by the peer handshake.
func kemSessionScheme(p *consensusconfig.ChainSecurityProfile) kem.KeyExchangeID {
if p != nil && p.KeyExchangeID == consensusconfig.KeyExchangeMLKEM1024 {
return kem.KeyExchangeMLKEM1024
}
return kem.KeyExchangeMLKEM768
}
// sequencerID returns the validator-set identity that sequences chainID.
// This resolves the distinction between:
// - chainID: execution domain (C-Chain, Zoo L2, etc.)
+13
View File
@@ -74,4 +74,17 @@ type Config struct {
// IngressConnectionCount counts the ingress (to us) connections.
IngressConnectionCount atomic.Int64
// PQHandshakeConfig pins the strict-PQ application-layer handshake
// (ML-KEM session + ML-DSA-65 identity) the peer goroutine runs
// after the TLS upgrade. Non-nil on strict-PQ chains; nil leaves the
// peer on the legacy bare-TLS path. Shared across every peer
// instance for the same chain — never mutated post-construction.
PQHandshakeConfig *HandshakeConfig
// PQLocalIdentity is this node's ML-DSA-65 long-term identity. The
// per-session ML-KEM keypair is regenerated inside the handshake; the
// identity is the only persistent secret. Non-nil iff
// PQHandshakeConfig is non-nil.
PQLocalIdentity *LocalIdentity
}
+145 -11
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
@@ -12,9 +12,10 @@ import (
"net/netip"
"time"
"github.com/luxfi/codec/wrappers"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/codec/wrappers"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/node/staking"
)
@@ -22,8 +23,29 @@ var (
errTimestampTooFarInFuture = errors.New("timestamp too far in the future")
errTimestampTooFarInPast = errors.New("timestamp too far in the past")
errInvalidTLSSignature = errors.New("invalid TLS signature")
// errMissingMLDSASignature is returned by Verify when strict-PQ profile
// is in force but the SignedIP carries no ML-DSA leg. Strict-PQ chains
// refuse classical-only IP gossip because both TLS and BLS12-381 are
// quantum-broken.
errMissingMLDSASignature = errors.New("missing ML-DSA-65 signature under strict-PQ profile")
// errMissingMLDSAPublicKey is returned by Verify when an ML-DSA signature
// is present on the wire but the caller did not supply the validator's
// ML-DSA public key needed to verify it. Configuration bug — the caller
// must pass the pubkey when verification proceeds under strict-PQ.
errMissingMLDSAPublicKey = errors.New("missing ML-DSA-65 public key for verification")
// errInvalidMLDSASignature is returned by Verify when the ML-DSA-65
// signature over the canonical UnsignedIP bytes fails to verify under
// the supplied public key.
errInvalidMLDSASignature = errors.New("invalid ML-DSA-65 signature")
)
// signedIPMLDSAContext is the FIPS 204 context string bound into every
// ML-DSA-65 signature over an UnsignedIP. Pinning a context here makes a
// captured IP-gossip signature un-replayable in any other Lux signing
// surface (transaction auth, UTXO Fx, peer handshake) even though they all
// share the same long-term ML-DSA-65 identity key.
var signedIPMLDSAContext = []byte("lux-peer-signed-ip-v1")
// UnsignedIP is used for a validator to claim an IP. The [Timestamp] is used to
// ensure that the most updated IP claim is tracked by peers for a given
// validator.
@@ -32,8 +54,34 @@ type UnsignedIP struct {
Timestamp uint64
}
// Sign this IP with the provided signer and return the signed IP.
// Sign produces a classical-only SignedIP carrying TLS + BLS legs. Retained
// for backwards compatibility with permissive (non-strict-PQ) chains and
// with the existing test surface. Strict-PQ producers MUST call SignPQ.
func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*SignedIP, error) {
return ip.SignPQ(tlsSigner, blsSigner, nil)
}
// SignPQ produces a SignedIP carrying every leg the caller can mint:
//
// - TLS leg (classical, RSA/ECDSA staker cert) — always required.
// - BLS12-381 leg (classical, proof-of-possession) — always required.
// - ML-DSA-65 leg (FIPS 204 post-quantum) — minted when mldsaSigner is
// non-nil.
//
// Both classical legs are quantum-broken; the ML-DSA leg is what gives a
// strict-PQ chain a witness over the IP gossip that survives a CRQC. The
// ML-DSA leg signs the same canonical UnsignedIP bytes the classical legs
// sign, bound under the "lux-peer-signed-ip-v1" FIPS 204 context so the
// signature cannot be replayed into another Lux signing surface.
//
// Backwards compat: passing mldsaSigner == nil produces a SignedIP with
// an empty MLDSASignature. Permissive chains accept this; strict-PQ chains
// refuse it at Verify time.
func (ip *UnsignedIP) SignPQ(
tlsSigner crypto.Signer,
blsSigner bls.Signer,
mldsaSigner *mldsa.PrivateKey,
) (*SignedIP, error) {
ipBytes := ip.bytes()
tlsSignature, err := tlsSigner.Sign(
rand.Reader,
@@ -49,11 +97,20 @@ func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*Sign
return nil, err
}
var mldsaSignature []byte
if mldsaSigner != nil {
mldsaSignature, err = mldsaSigner.SignCtx(rand.Reader, ipBytes, signedIPMLDSAContext)
if err != nil {
return nil, fmt.Errorf("ml-dsa-65 sign: %w", err)
}
}
return &SignedIP{
UnsignedIP: *ip,
TLSSignature: tlsSignature,
BLSSignature: blsSignature,
BLSSignatureBytes: bls.SignatureToBytes(blsSignature),
MLDSASignature: mldsaSignature,
}, nil
}
@@ -69,24 +126,75 @@ func (ip *UnsignedIP) bytes() []byte {
}
// SignedIP is a wrapper of an UnsignedIP with the signature from a signer.
//
// Wire-format note: the ML-DSA leg is append-only on the gossip wire.
// Legacy peers that never set MLDSASignature send an empty bytes field;
// the receiver-side Verify treats absence as "classical-only" and refuses
// it only when a strict-PQ profile is in force (see VerifyUnderProfile).
// This preserves backwards compatibility with non-strict-PQ chains
// (testnet, devnet) while enabling strict-PQ chains to refuse the
// quantum-broken-only path.
type SignedIP struct {
UnsignedIP
TLSSignature []byte
BLSSignature *bls.Signature
BLSSignatureBytes []byte
// MLDSASignature is the FIPS 204 ML-DSA-65 signature over the
// canonical UnsignedIP bytes under the "lux-peer-signed-ip-v1"
// context. Empty on classical-only SignedIPs produced by legacy
// peers; required under strict-PQ profile.
MLDSASignature []byte
}
// Returns nil if:
// * [ip.Timestamp] is within the allowed clock skew range (not too far in past or future).
// * [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from [cert].
// Verify checks the classical (TLS) leg only. Retained for backwards
// compatibility with callers that do not have access to the chain's
// security profile or the validator's ML-DSA-65 public key. Strict-PQ
// callers MUST go through VerifyUnderProfile so the ML-DSA leg gets
// checked.
//
// [maxTimestamp] defines the maximum allowed timestamp (current time + MaxClockDifference).
// The minimum allowed timestamp is inferred as (maxTimestamp - ReasonableClockSkewWindow) to
// prevent replay attacks. We use a conservative 10-minute window to account for various
// MaxClockDifference configurations while still protecting against replay attacks.
// Returns nil if:
// - [ip.Timestamp] is within the allowed clock skew range (not too far
// in past or future).
// - [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from
// [cert].
//
// [maxTimestamp] defines the maximum allowed timestamp (current time +
// MaxClockDifference). The minimum allowed timestamp is inferred as
// (maxTimestamp - reasonableClockSkewWindow) to prevent replay attacks.
// We use a conservative 10-minute window to account for various
// MaxClockDifference configurations while still protecting against
// replay attacks.
func (ip *SignedIP) Verify(
cert *staking.Certificate,
maxTimestamp time.Time,
) error {
return ip.VerifyUnderProfile(cert, nil, maxTimestamp, false)
}
// VerifyUnderProfile is the profile-aware verifier. It always checks the
// TLS leg (classical), and additionally:
//
// - When strictPQ == true, requires a non-empty MLDSASignature and
// verifies it under mldsaPubKey. mldsaPubKey MUST be supplied; passing
// a nil pubkey under strict-PQ is a configuration error (returns
// errMissingMLDSAPublicKey). The strict-PQ profile is what every
// Lux-derived strict-PQ chain pins (SigSchemeMLDSA65); permissive
// profiles (testnet, devnet) call this with strictPQ == false.
//
// - When strictPQ == false but MLDSASignature is present AND mldsaPubKey
// is non-nil, the ML-DSA leg is still verified (defence in depth —
// the wire bytes are already there, refusing to validate them would
// waste evidence). Absence under permissive is tolerated.
//
// The BLS leg is verified by the caller separately (peer.shouldDisconnect
// runs BLS validation against the validator's BLS public key tracked in
// the platformvm validator set, not against the staker cert; that path
// is unchanged).
func (ip *SignedIP) VerifyUnderProfile(
cert *staking.Certificate,
mldsaPubKey *mldsa.PublicKey,
maxTimestamp time.Time,
strictPQ bool,
) error {
maxUnixTimestamp := uint64(maxTimestamp.Unix())
if ip.Timestamp > maxUnixTimestamp {
@@ -111,12 +219,38 @@ func (ip *SignedIP) Verify(
return fmt.Errorf("%w: timestamp %d < minTimestamp %d", errTimestampTooFarInPast, ip.Timestamp, minUnixTimestamp)
}
ipBytes := ip.UnsignedIP.bytes()
if err := staking.CheckSignature(
cert,
ip.UnsignedIP.bytes(),
ipBytes,
ip.TLSSignature,
); err != nil {
return fmt.Errorf("%w: %w", errInvalidTLSSignature, err)
}
// ML-DSA leg. Three cases:
//
// strictPQ && empty sig → refuse (errMissingMLDSASignature)
// strictPQ && nil pubkey → refuse (errMissingMLDSAPublicKey)
// sig present && pubkey present → verify; refuse on failure
// !strictPQ && empty sig → tolerate (classical-only OK)
//
// We never refuse a permissive chain that supplies a pubkey but the
// peer chose not to sign with ML-DSA — that combination is normal on
// a mixed network and the strictPQ flag is what enforces the
// "must have a sig" policy.
if strictPQ {
if len(ip.MLDSASignature) == 0 {
return errMissingMLDSASignature
}
if mldsaPubKey == nil {
return errMissingMLDSAPublicKey
}
}
if len(ip.MLDSASignature) > 0 && mldsaPubKey != nil {
if !mldsaPubKey.VerifySignatureCtx(ipBytes, ip.MLDSASignature, signedIPMLDSAContext) {
return errInvalidMLDSASignature
}
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"crypto"
"crypto/rand"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/node/staking"
)
// TestSignedIP_RoundTripsPQ verifies that a SignedIP minted with the
// ML-DSA-65 leg round-trips correctly: TLS leg verifies, ML-DSA leg
// verifies, and the strict-PQ profile gate is satisfied.
func TestSignedIP_RoundTripsPQ(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
mldsaPub := mldsaPriv.PublicKey
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
signed, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.NotEmpty(signed.TLSSignature)
require.NotEmpty(signed.BLSSignatureBytes)
require.NotEmpty(signed.MLDSASignature, "ML-DSA leg must be present after SignPQ with a real signer")
// Classical-only verifier accepts the cert + TLS sig.
require.NoError(signed.Verify(cert, now))
// Strict-PQ verifier accepts when both legs are present and the
// ML-DSA public key matches.
require.NoError(signed.VerifyUnderProfile(cert, mldsaPub, now, true))
// Tampering with the ML-DSA signature byte flips the gate.
tampered := *signed
tampered.MLDSASignature = append([]byte(nil), signed.MLDSASignature...)
tampered.MLDSASignature[0] ^= 0xFF
require.ErrorIs(tampered.VerifyUnderProfile(cert, mldsaPub, now, true), errInvalidMLDSASignature)
// A different ML-DSA public key fails verification.
otherPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
require.ErrorIs(
signed.VerifyUnderProfile(cert, otherPriv.PublicKey, now, true),
errInvalidMLDSASignature,
)
}
// TestSignedIP_RefusesMissingMLDSAUnderStrictPQ verifies that a classical-only
// SignedIP (TLS + BLS, no ML-DSA leg) is refused by the strict-PQ verifier.
// This is the property the task asks us to enforce: both classical legs
// (TLS, BLS12-381) are quantum-broken, so the strict-PQ chain refuses any
// gossip that lacks the post-quantum witness.
func TestSignedIP_RefusesMissingMLDSAUnderStrictPQ(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
mldsaPub := mldsaPriv.PublicKey
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
// Classical-only sign — no ML-DSA leg.
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
require.NoError(err)
require.Empty(classicalOnly.MLDSASignature)
// Strict-PQ verifier refuses the classical-only sig.
err = classicalOnly.VerifyUnderProfile(cert, mldsaPub, now, true)
require.ErrorIs(err, errMissingMLDSASignature)
// And refuses again when the caller forgets to supply the pubkey
// (different failure mode, same outcome — refusal).
signedWithLeg, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.ErrorIs(
signedWithLeg.VerifyUnderProfile(cert, nil, now, true),
errMissingMLDSAPublicKey,
)
}
// TestSignedIP_AcceptsClassicalOnlyUnderPermissive verifies backwards
// compatibility: a non-strict-PQ chain (testnet / devnet / permissive
// profile) still accepts a SignedIP that carries only the classical TLS
// + BLS legs and no ML-DSA leg. This is what keeps a freshly-built strict-
// PQ binary from breaking peer-handshake with the existing testnet fleet.
func TestSignedIP_AcceptsClassicalOnlyUnderPermissive(t *testing.T) {
require := require.New(t)
tlsCert, err := staking.NewTLSCert()
require.NoError(err)
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
require.NoError(err)
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
blsKey, err := localsigner.New()
require.NoError(err)
now := time.Now()
unsigned := UnsignedIP{
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
Timestamp: uint64(now.Unix()),
}
// Classical-only signer (no ML-DSA key wired) — mirrors the
// permissive testnet binary that has not yet rolled the PQ identity
// key onto its validator nodes.
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
require.NoError(err)
require.Empty(classicalOnly.MLDSASignature)
// Permissive verifier (strictPQ == false, nil pubkey) accepts.
require.NoError(classicalOnly.VerifyUnderProfile(cert, nil, now, false))
// And the legacy Verify path keeps working (peers that bypass the
// profile-aware verifier — e.g. test rigs).
require.NoError(classicalOnly.Verify(cert, now))
// Defence in depth: when a permissive verifier happens to have a
// pubkey on hand AND the peer supplied a sig, the leg is still
// validated. We synthesise a valid PQ sig to confirm.
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
withPQ, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
require.NoError(err)
require.NoError(withPQ.VerifyUnderProfile(cert, mldsaPriv.PublicKey, now, false))
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"testing"
"github.com/stretchr/testify/require"
zappb "github.com/luxfi/node/proto/zap/p2p"
)
// TestHandshakeWire_LegacyFrameOmitsMLDSA verifies the wire-compat
// guarantee for the append-only IpMldsaSig field. A handshake frame
// emitted by a legacy peer (no IpMldsaSig at all) still round-trips
// through the new marshal/unmarshal pair, with IpMldsaSig reading as
// nil/empty.
func TestHandshakeWire_LegacyFrameOmitsMLDSA(t *testing.T) {
require := require.New(t)
// Build a handshake without ML-DSA.
hs := &zappb.Handshake{
NetworkId: 1337,
MyTime: 12345,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
IpPort: 9650,
IpSigningTime: 12345,
IpNodeIdSig: []byte("legacy-tls-sig"),
TrackedNets: [][]byte{},
Client: &zappb.Client{
Name: "lux",
Major: 1,
Minor: 26,
Patch: 12,
},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{
Filter: []byte{0x01, 0x02},
Salt: []byte{0x03, 0x04},
},
IpBlsSig: []byte("legacy-bls-sig"),
AllChains: false,
IpMldsaSig: nil,
}
// Wrap in a Message and round-trip via the codec.
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
var decoded zappb.Message
require.NoError(zappb.Unmarshal(raw, &decoded))
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok, "decoded message must be a Handshake")
require.Empty(roundTripped.Handshake.IpMldsaSig,
"empty ML-DSA on the wire decodes to empty on the receiver")
require.Equal(hs.IpNodeIdSig, roundTripped.Handshake.IpNodeIdSig)
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
}
// TestHandshakeWire_TruncatedLegacyFrameDecodes mimics a true legacy
// wire payload that ends after IpBlsSig with no trailing IpMldsaSig
// length-prefix. The new unmarshaller MUST tolerate this — that's the
// whole point of the HasMore() guard around the new field read. Without
// the guard, decoding would fail with io.ErrUnexpectedEOF and the peer
// handshake would refuse every legacy peer on the network.
func TestHandshakeWire_TruncatedLegacyFrameDecodes(t *testing.T) {
require := require.New(t)
// First produce a new-format frame, then strip the trailing 4-byte
// length prefix (the empty-bytes encoding of IpMldsaSig). The result
// is byte-for-byte what a pre-PQ binary would have produced.
hs := &zappb.Handshake{
NetworkId: 1337,
MyTime: 7777,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
IpPort: 9650,
IpSigningTime: 7777,
IpNodeIdSig: []byte("legacy-tls"),
TrackedNets: [][]byte{},
Client: &zappb.Client{Name: "lux", Major: 1, Minor: 0, Patch: 0},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
IpBlsSig: []byte("legacy-bls"),
AllChains: false,
IpMldsaSig: nil,
}
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
// The last 4 bytes of the inner Handshake encoding are the empty
// IpMldsaSig length-prefix (0x00000000). Truncating them simulates
// a legacy peer that never wrote the field.
require.GreaterOrEqual(len(raw), 4, "marshalled frame must be larger than the trailing field")
truncated := raw[:len(raw)-4]
var decoded zappb.Message
require.NoError(zappb.Unmarshal(truncated, &decoded),
"truncated legacy frame must decode without error")
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok)
require.Empty(roundTripped.Handshake.IpMldsaSig,
"absent field reads as empty")
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
}
// TestHandshakeWire_NewFrameRoundTripsMLDSA verifies that a new-format
// frame (with a non-empty IpMldsaSig) round-trips intact.
func TestHandshakeWire_NewFrameRoundTripsMLDSA(t *testing.T) {
require := require.New(t)
mldsaSig := make([]byte, 3309) // ML-DSA-65 signature size
for i := range mldsaSig {
mldsaSig[i] = byte(i)
}
hs := &zappb.Handshake{
NetworkId: 1,
MyTime: 99999,
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 1},
IpPort: 9630,
IpSigningTime: 99999,
IpNodeIdSig: []byte("strict-pq-tls-sig"),
TrackedNets: [][]byte{},
Client: &zappb.Client{
Name: "lux",
Major: 1,
Minor: 26,
Patch: 13,
},
SupportedLps: []uint32{},
ObjectedLps: []uint32{},
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
IpBlsSig: []byte("strict-pq-bls-sig"),
AllChains: true,
IpMldsaSig: mldsaSig,
}
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
raw, err := zappb.Marshal(msg)
require.NoError(err)
var decoded zappb.Message
require.NoError(zappb.Unmarshal(raw, &decoded))
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
require.True(ok, "decoded message must be a Handshake")
require.Equal(mldsaSig, roundTripped.Handshake.IpMldsaSig,
"ML-DSA sig must round-trip byte-for-byte")
}
+35 -9
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
@@ -10,6 +10,7 @@ import (
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/timer/mockable"
"github.com/luxfi/utils"
)
@@ -22,11 +23,19 @@ const (
)
// IPSigner will return a signedIP for the current value of our dynamic IP.
//
// Carries up to three signing legs: TLS (classical, staker cert),
// BLS12-381 (classical, proof-of-possession), and ML-DSA-65 (FIPS 204
// post-quantum). The ML-DSA leg is optional at construction time —
// passing mldsaSigner == nil to NewIPSigner produces classical-only
// gossip that strict-PQ verifiers refuse. Strict-PQ chains MUST be wired
// with a real ML-DSA-65 private key.
type IPSigner struct {
ip *utils.Atomic[netip.AddrPort]
clock mockable.Clock
tlsSigner crypto.Signer
blsSigner bls.Signer
ip *utils.Atomic[netip.AddrPort]
clock mockable.Clock
tlsSigner crypto.Signer
blsSigner bls.Signer
mldsaSigner *mldsa.PrivateKey
// Must be held while accessing [signedIP]
signedIPLock sync.RWMutex
@@ -35,15 +44,32 @@ type IPSigner struct {
signedIP *SignedIP
}
// NewIPSigner constructs a classical-only IPSigner (TLS + BLS legs). Use
// NewIPSignerPQ when the node has an ML-DSA-65 identity key and the chain
// runs under a strict-PQ profile.
func NewIPSigner(
ip *utils.Atomic[netip.AddrPort],
tlsSigner crypto.Signer,
blsSigner bls.Signer,
) *IPSigner {
return NewIPSignerPQ(ip, tlsSigner, blsSigner, nil)
}
// NewIPSignerPQ constructs an IPSigner that signs every refresh with all
// three legs the caller supplies. mldsaSigner may be nil to keep the
// classical-only behaviour (for permissive chains or for tests that have
// not yet generated a long-term ML-DSA key).
func NewIPSignerPQ(
ip *utils.Atomic[netip.AddrPort],
tlsSigner crypto.Signer,
blsSigner bls.Signer,
mldsaSigner *mldsa.PrivateKey,
) *IPSigner {
return &IPSigner{
ip: ip,
tlsSigner: tlsSigner,
blsSigner: blsSigner,
ip: ip,
tlsSigner: tlsSigner,
blsSigner: blsSigner,
mldsaSigner: mldsaSigner,
}
}
@@ -82,7 +108,7 @@ func (s *IPSigner) GetSignedIP() (*SignedIP, error) {
AddrPort: ip,
Timestamp: s.clock.Unix(),
}
signedIP, err := unsignedIP.Sign(s.tlsSigner, s.blsSigner)
signedIP, err := unsignedIP.SignPQ(s.tlsSigner, s.blsSigner, s.mldsaSigner)
if err != nil {
return nil, err
}
+107
View File
@@ -7,6 +7,7 @@ import (
"bufio"
"context"
"errors"
"fmt"
"io"
"math"
"net"
@@ -198,12 +199,27 @@ type peer struct {
// isIngress is true only if the remote peer is connected to this node,
// in contrast of this node being connected to the remote peer.
isIngress bool
// pqAEADKey is the 32-byte AEAD session key derived by the strict-PQ
// application-layer handshake (ML-KEM + ML-DSA-65). Zero-valued on
// chains that don't run the PQ handshake; non-zero only after
// runPQHandshakeIfRequired completes successfully. The AEAD wrapper
// over p.conn reads this key once at handshake completion; it is
// never mutated after Start returns.
pqAEADKey [32]byte
}
// Start a new peer instance.
//
// Invariant: There must only be one peer running at a time with a reference to
// the same [config.InboundMsgThrottler].
//
// On strict-PQ chains (config.PQHandshakeConfig non-nil) Start runs the
// application-layer ML-KEM + ML-DSA-65 handshake synchronously on the wire
// before launching the message-pump goroutines. The role is derived from
// isIngress — the dial side is the initiator, the accept side is the
// responder. If the PQ handshake fails the connection is closed and the
// returned Peer is in the closed state; no fallback to bare TLS.
func Start(
config *Config,
conn net.Conn,
@@ -233,6 +249,23 @@ func Start(
p.IngressConnectionCount.Add(1)
}
// 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 {
p.Log.Warn("PQ handshake refused; closing connection",
log.Stringer("nodeID", p.id),
log.Bool("isIngress", isIngress),
log.Reflect("error", err),
)
// Mark all 3 goroutines as not running so close() doesn't wait
// for them. The connection is torn down; the peer is closed.
atomic.StoreInt64(&p.numExecuting, 1)
_ = p.conn.Close()
p.close()
return p
}
go p.readMessages()
go p.writeMessages()
go p.sendNetworkMessages()
@@ -240,6 +273,74 @@ func Start(
return p
}
// runPQHandshakeIfRequired runs the strict-PQ application-layer handshake
// synchronously over p.conn. Returns nil when the chain is on the legacy
// bare-TLS path (no PQHandshakeConfig pinned) so the caller proceeds
// unchanged. On strict-PQ, completes INIT / RESP / FINISH and stashes the
// derived AEAD key on the peer; subsequent application messages are
// authenticated against this key by the AEAD wrapper installed by the
// next CR. Wire framing mirrors the peer's existing 4-byte length-prefix
// scheme so the framing logic is identical on both sides of the wire.
func (p *peer) runPQHandshakeIfRequired() error {
cfg := p.Config
if cfg == nil || cfg.PQHandshakeConfig == nil || cfg.PQLocalIdentity == nil {
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)
}
defer func() {
// Clear deadline so the rest of the peer flow uses its own.
_ = p.conn.SetDeadline(time.Time{})
}()
if p.isIngress {
// Responder: read INIT, send RESP, derive AEAD.
initBytes, err := readPQFrame(p.conn)
if err != nil {
return fmt.Errorf("read PQ INIT: %w", err)
}
init, err := parsePQHandshakeInit(initBytes, cfg.PQHandshakeConfig.KEMScheme)
if err != nil {
return fmt.Errorf("parse PQ INIT: %w", err)
}
resp, result, err := RespondHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init)
if err != nil {
return fmt.Errorf("respond PQ handshake: %w", err)
}
if err := writePQFrame(p.conn, resp.canonicalBytes()); err != nil {
return fmt.Errorf("write PQ RESP: %w", err)
}
p.pqAEADKey = result.AEADKey
return nil
}
// 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)
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 nil
}
func (p *peer) ID() ids.NodeID {
return p.id
}
@@ -552,6 +653,7 @@ func (p *peer) writeMessages() {
knownPeersFilter,
knownPeersSalt,
areWeAPrimaryNetworkValidator,
mySignedIP.MLDSASignature,
)
if err != nil {
p.Log.Error(failedToCreateMessageLog,
@@ -1015,6 +1117,11 @@ func (p *peer) handleHandshake(msg *p2p.Handshake) {
Timestamp: msg.IpSigningTime,
},
TLSSignature: msg.IpNodeIdSig,
// Empty on legacy peers (classical-only); the SignedIP.Verify
// call below only enforces the ML-DSA leg under strict-PQ
// profile and only when the validator's ML-DSA public key is
// known to the verifier. The wire format tolerates absence.
MLDSASignature: msg.GetIpMldsaSig(),
}
maxTimestamp := localTime.Add(p.MaxClockDifference)
if err := p.ip.Verify(p.cert, maxTimestamp); err != nil {
+1
View File
@@ -101,6 +101,7 @@ func FuzzPeerMessageHandling(f *testing.F) {
[]byte{},
[]byte{},
false,
[]byte{},
)
case 3: // PeerList
claimedIPs := []*endpoints.ClaimedIPPort{
+208
View File
@@ -0,0 +1,208 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"github.com/luxfi/node/network/kem"
)
// pqFrameMaxSize is the upper bound on a single PQ-handshake frame (INIT
// or RESP). Both messages carry an ML-DSA-65 signature (3309 B), an
// ML-DSA-65 public key (1952 B), an ML-KEM-1024 public key (1568 B) or
// ciphertext (1568 B), plus version / profile / chain-id / NodeID fields
// (~70 B). 16 KiB is comfortably above the largest legitimate frame and
// well below any DoS-relevant threshold.
const pqFrameMaxSize = 16 * 1024
// errPQFrameTooLarge is returned when a peer announces a PQ-handshake
// frame larger than pqFrameMaxSize. Refusing oversize frames before the
// allocation prevents a memory-exhaustion DoS.
var errPQFrameTooLarge = errors.New("peer: PQ handshake frame exceeds size cap")
// readPQFrame reads a single 4-byte big-endian length-prefixed PQ frame
// from conn. Mirrors the framing scheme the rest of the peer wire uses;
// strict-PQ chains carry one INIT + one RESP via these calls before any
// p2p message flows.
func readPQFrame(conn net.Conn) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
return nil, fmt.Errorf("read PQ frame header: %w", err)
}
size := binary.BigEndian.Uint32(hdr[:])
if size > pqFrameMaxSize {
return nil, fmt.Errorf("%w: %d > %d", errPQFrameTooLarge, size, pqFrameMaxSize)
}
buf := make([]byte, size)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read PQ frame body: %w", err)
}
return buf, nil
}
// writePQFrame writes a single 4-byte big-endian length-prefixed PQ
// frame onto conn. Symmetric with readPQFrame.
func writePQFrame(conn net.Conn, payload []byte) error {
if len(payload) > pqFrameMaxSize {
return fmt.Errorf("%w: payload=%d > cap=%d",
errPQFrameTooLarge, len(payload), pqFrameMaxSize)
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := conn.Write(hdr[:]); err != nil {
return fmt.Errorf("write PQ frame header: %w", err)
}
if _, err := conn.Write(payload); err != nil {
return fmt.Errorf("write PQ frame body: %w", err)
}
return nil
}
// parsePQHandshakeInit decodes the canonical wire form of HandshakeInit.
// Field layout matches HandshakeInit.canonicalBytes / transcriptPrefix in
// handshake.go. The KEM scheme parameter pins the expected lengths of
// KEMPub and Sig — passing the wrong scheme produces a length mismatch
// error before any signature work runs.
func parsePQHandshakeInit(b []byte, scheme kem.KeyExchangeID) (*HandshakeInit, error) {
r := newPQReader(b)
h := &HandshakeInit{}
var err error
if h.ProtocolVersion, err = r.readU8(); err != nil {
return nil, err
}
profByte, err := r.readU8()
if err != nil {
return nil, err
}
h.Profile = ProfileID(profByte)
if err := r.readFixed(h.ChainID[:]); err != nil {
return nil, err
}
kemByte, err := r.readU8()
if err != nil {
return nil, err
}
h.KEMScheme = kem.KeyExchangeID(kemByte)
if h.KEMScheme != scheme {
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
}
var nid [nodeIDSize]byte
if err := r.readFixed(nid[:]); err != nil {
return nil, err
}
copy(h.NodeID[:], nid[:])
if h.MLDSAPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.KEMPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.Sig, err = r.readBytes(); err != nil {
return nil, err
}
if r.remaining() != 0 {
return nil, fmt.Errorf("%w: trailing bytes in PQ INIT (%d)",
ErrHandshakeBadIdentity, r.remaining())
}
return h, nil
}
// parsePQHandshakeResp decodes the canonical wire form of HandshakeResp.
// Symmetric with parsePQHandshakeInit; KEMCiphertext replaces KEMPub.
func parsePQHandshakeResp(b []byte, scheme kem.KeyExchangeID) (*HandshakeResp, error) {
r := newPQReader(b)
h := &HandshakeResp{}
var err error
if h.ProtocolVersion, err = r.readU8(); err != nil {
return nil, err
}
profByte, err := r.readU8()
if err != nil {
return nil, err
}
h.Profile = ProfileID(profByte)
if err := r.readFixed(h.ChainID[:]); err != nil {
return nil, err
}
kemByte, err := r.readU8()
if err != nil {
return nil, err
}
h.KEMScheme = kem.KeyExchangeID(kemByte)
if h.KEMScheme != scheme {
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
}
var nid [nodeIDSize]byte
if err := r.readFixed(nid[:]); err != nil {
return nil, err
}
copy(h.NodeID[:], nid[:])
if h.MLDSAPub, err = r.readBytes(); err != nil {
return nil, err
}
if h.KEMCiphertext, err = r.readBytes(); err != nil {
return nil, err
}
if h.Sig, err = r.readBytes(); err != nil {
return nil, err
}
if r.remaining() != 0 {
return nil, fmt.Errorf("%w: trailing bytes in PQ RESP (%d)",
ErrHandshakeBadIdentity, r.remaining())
}
return h, nil
}
// pqReader is a tiny zero-allocation reader for the PQ handshake wire
// format. Kept local to this file so the encoding stays reviewable in
// one place; the writer side lives on the HandshakeInit / HandshakeResp
// methods in handshake.go.
type pqReader struct {
buf []byte
offset int
}
func newPQReader(b []byte) *pqReader { return &pqReader{buf: b} }
func (r *pqReader) remaining() int { return len(r.buf) - r.offset }
func (r *pqReader) readU8() (uint8, error) {
if r.remaining() < 1 {
return 0, io.ErrUnexpectedEOF
}
v := r.buf[r.offset]
r.offset++
return v, nil
}
func (r *pqReader) readFixed(dst []byte) error {
if r.remaining() < len(dst) {
return io.ErrUnexpectedEOF
}
copy(dst, r.buf[r.offset:])
r.offset += len(dst)
return nil
}
func (r *pqReader) readBytes() ([]byte, error) {
if r.remaining() < 4 {
return nil, io.ErrUnexpectedEOF
}
n := binary.BigEndian.Uint32(r.buf[r.offset:])
r.offset += 4
if uint64(n) > uint64(r.remaining()) {
return nil, io.ErrUnexpectedEOF
}
out := make([]byte, n)
copy(out, r.buf[r.offset:r.offset+int(n)])
r.offset += int(n)
return out, nil
}
+1
View File
@@ -73,6 +73,7 @@ func StartTestPeer(
clientUpgrader := NewTLSClientUpgrader(
tlsConfg,
metric.NewCounter(metric.CounterOpts{}),
nil, // no SchemeGate in tests; the cross-axis check is opt-in.
)
peerID, conn, cert, err := clientUpgrader.Upgrade(conn)
+51 -5
View File
@@ -4,6 +4,8 @@
package peer
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/tls"
"errors"
"net"
@@ -29,36 +31,53 @@ type Upgrader interface {
type tlsServerUpgrader struct {
config *tls.Config
invalidCerts metric.Counter
gate *SchemeGate
}
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
// NewTLSServerUpgrader returns an Upgrader that runs the server-side TLS
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate
// against the chain's locked ChainSecurityProfile. A nil gate disables the
// cross-axis check; this is the legacy / classical-compat behaviour.
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
return &tlsServerUpgrader{
config: config,
invalidCerts: invalidCerts,
gate: gate,
}
}
func (t *tlsServerUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts)
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts, t.gate)
}
type tlsClientUpgrader struct {
config *tls.Config
invalidCerts metric.Counter
gate *SchemeGate
}
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
// NewTLSClientUpgrader returns an Upgrader that runs the client-side TLS
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate.
// A nil gate disables the cross-axis check.
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
return &tlsClientUpgrader{
config: config,
invalidCerts: invalidCerts,
gate: gate,
}
}
func (t *tlsClientUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts)
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts, t.gate)
}
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, net.Conn, *staking.Certificate, error) {
// connToIDAndCert runs the TLS handshake, parses the peer leaf certificate,
// derives the canonical NodeID, and (when gate is non-nil) refuses the
// connection at the cross-axis gate before any downstream wiring sees it.
// Refusal here is by design: the gate is the single place that says "this
// peer's NodeIDScheme is not admissible on this chain"; later boundaries
// trust that an admitted peer has already passed.
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter, gate *SchemeGate) (ids.NodeID, net.Conn, *staking.Certificate, error) {
if err := conn.Handshake(); err != nil {
return ids.EmptyNodeID, nil, nil, err
}
@@ -79,5 +98,32 @@ func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, n
Raw: peerCert.Raw,
PublicKey: peerCert.PublicKey,
})
if gate != nil {
scheme := schemeFromCert(peerCert)
if _, err := gate.Classify(nodeID, scheme, 0, "handshake"); err != nil {
return ids.EmptyNodeID, nil, nil, err
}
}
return nodeID, conn, peerCert, nil
}
// schemeFromCert derives the wire NodeIDScheme byte from a parsed TLS leaf
// certificate. TLS staking certs today carry RSA or ECDSA public keys —
// both classify as NodeIDSchemeSecp256k1 (the canonical classical-compat
// byte). A future ML-DSA cert extension (an X.509 extension carrying the
// FIPS 204 public key) is recognised by its OID; until that extension
// lands the function returns the classical byte. The gate's profile pin
// is what decides admission — this function only names the byte.
func schemeFromCert(cert *staking.Certificate) ids.NodeIDScheme {
if cert == nil || cert.PublicKey == nil {
return ids.NodeIDSchemeInvalid
}
switch cert.PublicKey.(type) {
case *rsa.PublicKey, *ecdsa.PublicKey:
return ids.NodeIDSchemeSecp256k1
default:
return ids.NodeIDSchemeInvalid
}
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"net"
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/metric"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/luxfi/node/network/peer"
)
// TestUpgrader_StrictPQ_RefusesClassicalTLS — closes BLOCKERS CR-3.
// Constructs a tlsServerUpgrader wired with a strict-PQ peer.SchemeGate
// (ActivationHeight=0 — gate active from genesis) and dials it with a
// vanilla RSA TLS client. The server-side upgrade MUST refuse the
// connection at the SchemeGate, not later in some peer-init step. The
// returned error MUST wrap peer.ErrSchemeGateMismatch with site="handshake"
// so a log reader can identify the refusing boundary.
func TestUpgrader_StrictPQ_RefusesClassicalTLS(t *testing.T) {
require := require.New(t)
// Strict-PQ SchemeGate active from genesis. No transition window,
// ClassicalCompatUnsafe=false — the gate's job is to refuse the
// classical scheme byte regardless of operator flag.
gate, err := peer.NewSchemeGate(consensusconfig.StrictPQ(), false, 0)
require.NoError(err)
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
serverCert := makeTLSCert(t, serverKey)
serverTLS := peer.TLSConfig(serverCert, nil)
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
clientCert := makeTLSCert(t, clientKey)
invalidCertCounter := metric.NewCounter(metric.CounterOpts{})
upgrader := peer.NewTLSServerUpgrader(serverTLS, invalidCertCounter, gate)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
InsecureSkipVerify: true, //#nosec G402 — staking TLS, trust derives from cert hash
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{clientCert},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(err)
defer listener.Close()
eg := &errgroup.Group{}
eg.Go(func() error {
conn, err := listener.Accept()
if err != nil {
return err
}
_, _, _, upgradeErr := upgrader.Upgrade(conn)
return upgradeErr
})
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
require.NoError(err)
require.NoError(conn.Handshake())
err = eg.Wait()
require.Error(err, "strict-PQ SchemeGate MUST refuse classical RSA cert")
require.ErrorIs(err, peer.ErrSchemeGateMismatch,
"refusal MUST come from the SchemeGate, not later peer-init")
require.Contains(err.Error(), "site=handshake",
"refusal MUST name the handshake boundary in the error")
}
// TestUpgrader_NilGate_AcceptsClassicalTLS — the negative control. Without
// a SchemeGate (legacy / classical-compat networks), the upgrader accepts
// the same RSA TLS connection that the strict-PQ test refuses. This keeps
// the gate's behaviour orthogonal to the existing TLS path.
func TestUpgrader_NilGate_AcceptsClassicalTLS(t *testing.T) {
require := require.New(t)
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
serverCert := makeTLSCert(t, serverKey)
serverTLS := peer.TLSConfig(serverCert, nil)
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
clientCert := makeTLSCert(t, clientKey)
upgrader := peer.NewTLSServerUpgrader(
serverTLS,
metric.NewCounter(metric.CounterOpts{}),
nil, // no SchemeGate
)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
InsecureSkipVerify: true, //#nosec G402
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{clientCert},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(err)
defer listener.Close()
eg := &errgroup.Group{}
eg.Go(func() error {
conn, err := listener.Accept()
if err != nil {
return err
}
_, _, _, upgradeErr := upgrader.Upgrade(conn)
return upgradeErr
})
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
require.NoError(err)
require.NoError(conn.Handshake())
require.NoError(eg.Wait(), "nil gate MUST accept classical TLS as today")
}
+1 -1
View File
@@ -95,7 +95,7 @@ func TestBlockClientsWithIncorrectRSAKeys(t *testing.T) {
require.FailNow(t, "should not have invoked")
},
}
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter)
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter, nil)
clientConfig := tls.Config{
ClientAuth: tls.RequireAnyClientCert,
+4
View File
@@ -688,6 +688,10 @@ func (n *Node) initNetworking(reg metric.Registerer) error {
n.Config.NetworkConfig.CPUTargeter = n.cpuTargeter
n.Config.NetworkConfig.DiskTargeter = n.diskTargeter
n.Config.NetworkConfig.GenesisBytes = n.Config.GenesisBytes
// Forward the resolved ChainSecurityProfile to the network layer
// so the peer.SchemeGate (CR-3) and PQ handshake (CR-5) fire on
// strict-PQ chains. Nil on legacy / classical-compat networks.
n.Config.NetworkConfig.SecurityProfile = n.securityProfile
// Map native chains (P/C/X/etc.) to the primary network validator set.
// For L2 chains, return ids.Empty to let blockchainToNetwork map resolve
// the correct chain ID. Returning chainID here would short-circuit the
+29 -1
View File
@@ -131,6 +131,14 @@ func NewReader(data []byte) *Reader {
return &Reader{data: data}
}
// HasMore reports whether the reader has any unread bytes remaining. Used
// by append-only field readers (e.g. unmarshalHandshake's IpMldsaSig) to
// tell a legacy short frame apart from a new frame with a (possibly
// empty) trailing field.
func (r *Reader) HasMore() bool {
return r.offset < len(r.data)
}
func (r *Reader) ReadUint8() (uint8, error) {
if r.offset+1 > len(r.data) {
return 0, io.ErrUnexpectedEOF
@@ -490,6 +498,13 @@ func marshalHandshake(b *Buffer, m *Handshake) {
b.WriteUint8(0)
}
b.WriteBytes(m.IpBlsSig)
// IpMldsaSig is append-only: legacy decoders that ran on the
// pre-PQ wire format simply stop after IpBlsSig and the reader
// returns io.ErrUnexpectedEOF when it tries to keep going.
// New decoders (unmarshalHandshake below) check for trailing
// bytes before reading IpMldsaSig, so emitting an empty slice
// (4 zero length-prefix bytes) is a safe extension.
b.WriteBytes(m.IpMldsaSig)
}
func marshalGetPeerList(b *Buffer, m *GetPeerList) {
@@ -802,7 +817,20 @@ func unmarshalHandshake(r *Reader) (*Handshake, error) {
}
}
m.IpBlsSig, err = r.ReadBytes()
return m, err
if err != nil {
return nil, err
}
// IpMldsaSig is append-only on the wire. Legacy peers don't write it;
// their handshake frame ends after IpBlsSig. New peers write a
// length-prefixed blob (possibly empty). HasMore() lets us tell them
// apart without breaking either case.
if r.HasMore() {
m.IpMldsaSig, err = r.ReadBytes()
if err != nil {
return nil, err
}
}
return m, nil
}
func unmarshalGetPeerList(r *Reader) (*GetPeerList, error) {
+20 -12
View File
@@ -166,21 +166,29 @@ type Handshake struct {
KnownPeers *BloomFilter
IpBlsSig []byte
AllChains bool
// IpMldsaSig is the FIPS 204 ML-DSA-65 signature over the canonical
// SignedIP bytes. Append-only field on the gossip wire: legacy peers
// that never set this send an empty bytes blob, which the receiver
// treats as "classical-only" and refuses only when its chain runs
// under a strict-PQ profile. New strict-PQ producers MUST populate
// it; otherwise their gossip is rejected by strict-PQ verifiers.
IpMldsaSig []byte
}
func (m *Handshake) GetNetworkId() uint32 { return m.NetworkId }
func (m *Handshake) GetMyTime() uint64 { return m.MyTime }
func (m *Handshake) GetIpAddr() []byte { return m.IpAddr }
func (m *Handshake) GetIpPort() uint32 { return m.IpPort }
func (m *Handshake) GetIpSigningTime() uint64 { return m.IpSigningTime }
func (m *Handshake) GetIpNodeIdSig() []byte { return m.IpNodeIdSig }
func (m *Handshake) GetTrackedNets() [][]byte { return m.TrackedNets }
func (m *Handshake) GetClient() *Client { return m.Client }
func (m *Handshake) GetSupportedLps() []uint32 { return m.SupportedLps }
func (m *Handshake) GetObjectedLps() []uint32 { return m.ObjectedLps }
func (m *Handshake) GetNetworkId() uint32 { return m.NetworkId }
func (m *Handshake) GetMyTime() uint64 { return m.MyTime }
func (m *Handshake) GetIpAddr() []byte { return m.IpAddr }
func (m *Handshake) GetIpPort() uint32 { return m.IpPort }
func (m *Handshake) GetIpSigningTime() uint64 { return m.IpSigningTime }
func (m *Handshake) GetIpNodeIdSig() []byte { return m.IpNodeIdSig }
func (m *Handshake) GetTrackedNets() [][]byte { return m.TrackedNets }
func (m *Handshake) GetClient() *Client { return m.Client }
func (m *Handshake) GetSupportedLps() []uint32 { return m.SupportedLps }
func (m *Handshake) GetObjectedLps() []uint32 { return m.ObjectedLps }
func (m *Handshake) GetKnownPeers() *BloomFilter { return m.KnownPeers }
func (m *Handshake) GetIpBlsSig() []byte { return m.IpBlsSig }
func (m *Handshake) GetAllChains() bool { return m.AllChains }
func (m *Handshake) GetIpBlsSig() []byte { return m.IpBlsSig }
func (m *Handshake) GetAllChains() bool { return m.AllChains }
func (m *Handshake) GetIpMldsaSig() []byte { return m.IpMldsaSig }
func (m *Handshake) Reset() { *m = Handshake{} }
func (m *Handshake) String() string { return fmt.Sprintf("Handshake{NetworkId:%d}", m.NetworkId) }