mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -101,6 +101,7 @@ func FuzzPeerMessageHandling(f *testing.F) {
|
||||
[]byte{},
|
||||
[]byte{},
|
||||
false,
|
||||
[]byte{},
|
||||
)
|
||||
case 3: // PeerList
|
||||
claimedIPs := []*endpoints.ClaimedIPPort{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user