From d04ce331dfd70ec838d6a826264b223d7e49ee49 Mon Sep 17 00:00:00 2001 From: riccardom Date: Thu, 30 Jul 2026 09:29:12 +0200 Subject: [PATCH] pqkem: strict (fail-closed) mode + wire status Quantum resistance Strict mode (NB_PQ_MLKEM_STRICT, default off) closes the initial PQ-vulnerable window (NET-1408): when enabled, conn.presharedKey programs a per-conn random sentinel PSK until the ML-KEM exchange derives the real one, so no session can form on a non-PQ key (the real PSK is pushed via SetPresharedKey once it converges). Default stays opportunistic. Also surface PQ status: the peer 'Quantum resistance' flag (RosenpassEnabled) is now true when an ML-KEM PSK has been derived for the peer, not only for Rosenpass. --- client/internal/engine.go | 1 + client/internal/peer/conn.go | 43 ++++++++++++++++++++++++++++++++++-- client/internal/pqkem/env.go | 22 ++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index 12ad97a86..6498e470e 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1917,6 +1917,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV } if e.pqkemManager != nil { config.PQ = pqHandshaker{mgr: e.pqkemManager} + config.PQStrict = pqkem.Strict() } serviceDependencies := peer.ServiceDependencies{ diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 412dc8227..ddc57c0d5 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -123,6 +123,9 @@ type ConnConfig struct { // PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled. PQ PQHandshaker + // PQStrict fails closed: block peer traffic until the ML-KEM PSK is established, + // instead of letting the tunnel come up classically and upgrading to PQ later. + PQStrict bool // ICEConfig ICE protocol configuration ICEConfig icemaker.Config @@ -182,6 +185,11 @@ type Conn struct { // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real // transport is up. pendingFirstPacket []byte + + // pqBlockingKey is a per-conn random sentinel PSK used in PQ strict mode to fail + // closed: it is programmed until the real ML-KEM PSK is derived, so no session can + // form on a non-PQ key. Per-conn random so two strict peers never match by chance. + pqBlockingKey *wgtypes.Key } // injectPendingFirstPacket replays the captured handshake through the proxy if present, else @@ -239,6 +247,14 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { metricsRecorder: services.MetricsRecorder, } + if config.PQ != nil && config.PQStrict { + if k, err := wgtypes.GenerateKey(); err != nil { + connLog.Errorf("pqkem: failed to generate strict-mode sentinel key, strict fail-closed disabled for this peer: %v", err) + } else { + conn.pqBlockingKey = &k + } + } + return conn, nil } @@ -760,7 +776,7 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by ConnStatus: conn.evalStatus(), Relayed: conn.isRelayed(), RelayServerAddress: relayServerAddr, - RosenpassEnabled: isRosenpassEnabled(rosenpassPubKey), + RosenpassEnabled: conn.quantumResistant(rosenpassPubKey), } err := conn.statusRecorder.UpdatePeerRelayedState(peerState) @@ -779,7 +795,7 @@ func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time) RemoteIceCandidateType: iceConnInfo.RemoteIceCandidateType, LocalIceCandidateEndpoint: iceConnInfo.LocalIceCandidateEndpoint, RemoteIceCandidateEndpoint: iceConnInfo.RemoteIceCandidateEndpoint, - RosenpassEnabled: isRosenpassEnabled(iceConnInfo.RosenpassPubKey), + RosenpassEnabled: conn.quantumResistant(iceConnInfo.RosenpassPubKey), } err := conn.statusRecorder.UpdatePeerICEState(peerState) @@ -1060,6 +1076,14 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key { if psk, ok := conn.config.PQ.PSK(conn.config.Key); ok { return &psk } + if conn.config.PQStrict && conn.pqBlockingKey != nil { + // Fail closed: program a non-matching sentinel so no session forms on a + // non-PQ key until the ML-KEM exchange derives the real PSK (pushed via + // SetPresharedKey once it converges). "pending" — turns into a "stuck" + // warning from the manager if the exchange keeps failing (see raiseFailure). + conn.Log.Debugf("pqkem: strict mode — no PQ PSK yet, blocking peer traffic until the ML-KEM exchange converges") + return conn.pqBlockingKey + } } if conn.config.RosenpassConfig.PubKey == nil { @@ -1101,6 +1125,21 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool { return remoteRosenpassPubKey != nil } +// quantumResistant reports whether the peer's tunnel is post-quantum protected, for +// the status "Quantum resistance" field: either Rosenpass (the remote advertised a +// Rosenpass key) or the ML-KEM exchange (a PQ PSK has been derived for this peer). +func (conn *Conn) quantumResistant(remoteRosenpassPubKey []byte) bool { + if isRosenpassEnabled(remoteRosenpassPubKey) { + return true + } + if conn.config.PQ != nil { + if _, ok := conn.config.PQ.PSK(conn.config.Key); ok { + return true + } + } + return false +} + func evalConnStatus(in connStatusInputs) guard.ConnStatus { // "Relay up and needed" — the peer uses relay and the transport is connected. relayUsedAndUp := in.peerUsesRelay && in.relayConnected diff --git a/client/internal/pqkem/env.go b/client/internal/pqkem/env.go index f299d5fce..6eaa9e129 100644 --- a/client/internal/pqkem/env.go +++ b/client/internal/pqkem/env.go @@ -35,6 +35,28 @@ func Enabled() bool { return enabled } +// EnvStrict enables strict (fail-closed) mode: block peer traffic until the ML-KEM +// PSK has been established, instead of the default opportunistic behaviour that lets +// the tunnel come up classically and upgrades to PQ once the exchange converges. +const EnvStrict = "NB_PQ_MLKEM_STRICT" + +// Strict reports whether strict (fail-closed) mode is enabled via the environment. +// An empty or unrecognized value is treated as disabled (opportunistic). +func Strict() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict))) { + case "on": + return true + case "", "off": + return false + } + enabled, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(EnvStrict))) + if err != nil { + log.Warnf("failed to parse %s value %q: %v", EnvStrict, os.Getenv(EnvStrict), err) + return false + } + return enabled +} + // EnvLogLevel overrides the ML-KEM manager's slog level (trace/debug/info/warn/error). // Defaults to info. The verbose per-exchange lifecycle logs are emitted at trace. const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"