network: build SchemeGate under the SAME predicate as the PQ handshake

The per-chain peer.SchemeGate is now built under profileRequiresPQHandshake,
not merely SecurityProfile != nil. Load-bearing: the gate's pinned scheme
byte (SigSchemeMLDSA65) only becomes presentable once the application-layer
ML-KEM+ML-DSA handshake establishes the ML-DSA identity. A permissive /
classical-compat profile still presents secp256k1 cert schemes, which
SchemeGate.Classify refuses unconditionally — building a gate there would
refuse every peer with no PQ handshake to recover (the 0-peers / 'TLS
upgrade failed' stall). One axis, one predicate: gate + handshake + ML-DSA
identity are built together or not at all.
This commit is contained in:
zeekay
2026-06-23 01:43:53 -07:00
parent b88a2e3fac
commit 67f38f8c9f
2 changed files with 33 additions and 12 deletions
+10 -6
View File
@@ -217,11 +217,15 @@ type Config struct {
// 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 (chains
// that ship no profile remain accepted by the upgrader's nil-safe
// path).
// node.Node.initSecurityProfile). When it mandates the application-layer
// PQ handshake (strict-PQ / FIPS — see profileRequiresPQHandshake), the
// network builds a peer.SchemeGate from it AND runs the ML-KEM + ML-DSA
// handshake; the gate and handshake are gated by one identical
// predicate so an ML-DSA identity always exists for the gate to bind.
// A nil profile, or a non-PQ-handshake profile (permissive), leaves the
// cross-axis gate disabled: peers present classical secp256k1 cert
// schemes the gate would refuse unconditionally, so building it there
// would refuse every connection with no PQ handshake to recover. Such
// chains remain accepted by the upgrader's nil-safe path.
SecurityProfile *consensusconfig.ChainSecurityProfile `json:"-"`
}
+23 -6
View File
@@ -487,11 +487,26 @@ func NewNetwork(
// 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.
// inbound NodeID through the chain's ChainSecurityProfile pin.
//
// The gate is built under the SAME predicate that builds the PQ
// handshake (profileRequiresPQHandshake), not merely "SecurityProfile
// != nil". This is load-bearing: the gate's pinned scheme byte
// (SigSchemeMLDSA65) only becomes presentable by a peer once the
// application-layer PQ handshake establishes the ML-DSA identity. A
// profile that does NOT run the PQ handshake (permissive /
// classical-compat) still presents classical secp256k1 cert schemes,
// which SchemeGate.Classify refuses unconditionally — its
// AcceptsValidatorScheme(_, classicalCompatUnsafe=false) hardcodes the
// classical escape hatch off. Building a gate in that state refuses
// every peer at the upgrade with no PQ handshake to recover, the exact
// "TLS upgrade failed" / 0-peers stall a permissive-pinned classical
// network hits. One axis, one predicate: gate + handshake + ML-DSA
// identity are built together or not at all. Chains that ship no
// profile, or a non-PQ-handshake profile, leave the gate nil —
// upgrader.connToIDAndCert is nil-safe and refuses nobody.
var schemeGate *peer.SchemeGate
if config.SecurityProfile != nil {
if profileRequiresPQHandshake(config.SecurityProfile) {
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, 0)
if err != nil {
return nil, fmt.Errorf("building peer SchemeGate: %w", err)
@@ -506,8 +521,10 @@ func NewNetwork(
// 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) {
// nil and use the legacy bare-TLS path. Same predicate as the
// SchemeGate build above — profileRequiresPQHandshake is nil-safe, so
// the gate and the handshake are guarded by one identical condition.
if profileRequiresPQHandshake(config.SecurityProfile) {
// The PQ peer handshake MUST sign with the node's PERSISTENT
// staking ML-DSA-65 keypair — the one whose public half derives
// MyNodeID — so that completing the handshake proves possession of