diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 1d31c75ca..0a6812fce 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/pqkem" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/updater/installer" nbstatus "github.com/netbirdio/netbird/client/status" @@ -708,6 +709,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("DisableIPv6Discovery: %v\n", g.internalConfig.DisableIPv6Discovery)) configContent.WriteString(fmt.Sprintf("RosenpassEnabled: %v\n", g.internalConfig.RosenpassEnabled)) configContent.WriteString(fmt.Sprintf("RosenpassPermissive: %v\n", g.internalConfig.RosenpassPermissive)) + // ML-KEM (Rosenpass alternative) is env-driven, not part of the config, so read it here. + configContent.WriteString(fmt.Sprintf("MLKEMEnabled: %v\n", pqkem.Enabled())) + configContent.WriteString(fmt.Sprintf("MLKEMStrict: %v\n", pqkem.Strict())) if g.internalConfig.ServerSSHAllowed != nil { configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed)) } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index ea05f46bb..c2e71a7a6 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -1187,7 +1187,13 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool { // 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, pqEstablished bool) bool { - return isRosenpassEnabled(remoteRosenpassPubKey) || pqEstablished + // Rosenpass protects the tunnel only when both sides run it: the local peer has a + // Rosenpass key AND the remote advertised one. Checking only the remote key would + // report a plain (or blocked) tunnel as quantum-resistant when the local side does + // not run Rosenpass — e.g. against a peer that merely advertises it in a mixed + // deployment. A homogeneous Rosenpass deployment (both sides on) is unaffected. + rosenpassActive := conn.config.RosenpassConfig.PubKey != nil && isRosenpassEnabled(remoteRosenpassPubKey) + return rosenpassActive || pqEstablished } func evalConnStatus(in connStatusInputs) guard.ConnStatus { diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 24e3e7fac..e0d3552cb 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -142,6 +142,14 @@ type RosenpassState struct { Permissive bool } +// MLKEMState contains the latest state of the ML-KEM post-quantum exchange, the +// Rosenpass alternative. Strict is the ML-KEM counterpart of Rosenpass non-permissive: +// it fails closed until the KEM PSK is established. +type MLKEMState struct { + Enabled bool + Strict bool +} + // NSGroupState represents the status of a DNS server group, including associated domains, // whether it's enabled, and the last error message encountered during probing. type NSGroupState struct { @@ -159,6 +167,7 @@ type FullStatus struct { SignalState SignalState LocalPeerState LocalPeerState RosenpassState RosenpassState + MLKEMState MLKEMState Relays []relay.ProbeResult NSGroupStates []NSGroupState NumOfForwardingRules int @@ -209,6 +218,8 @@ type Status struct { notifier *notifier rosenpassEnabled bool rosenpassPermissive bool + mlkemEnabled bool + mlkemStrict bool // sessionExpiresAt is the absolute UTC instant at which the peer's SSO // session expires. Zero when the peer is not SSO-tracked or login // expiration is disabled. Populated from management LoginResponse / @@ -952,6 +963,14 @@ func (d *Status) UpdateRosenpass(rosenpassEnabled, rosenpassPermissive bool) { d.rosenpassEnabled = rosenpassEnabled } +// UpdateMLKEM updates the ML-KEM post-quantum exchange configuration. +func (d *Status) UpdateMLKEM(mlkemEnabled, mlkemStrict bool) { + d.mux.Lock() + defer d.mux.Unlock() + d.mlkemEnabled = mlkemEnabled + d.mlkemStrict = mlkemStrict +} + func (d *Status) UpdateLazyConnection(enabled bool) { d.mux.Lock() defer d.mux.Unlock() @@ -1044,6 +1063,15 @@ func (d *Status) GetRosenpassState() RosenpassState { } } +func (d *Status) GetMLKEMState() MLKEMState { + d.mux.RLock() + defer d.mux.RUnlock() + return MLKEMState{ + d.mlkemEnabled, + d.mlkemStrict, + } +} + func (d *Status) GetLazyConnection() bool { d.mux.RLock() defer d.mux.RUnlock() @@ -1174,6 +1202,7 @@ func (d *Status) GetFullStatus() FullStatus { SignalState: d.GetSignalState(), Relays: d.GetRelayStates(), RosenpassState: d.GetRosenpassState(), + MLKEMState: d.GetMLKEMState(), NSGroupStates: d.GetDNSStates(), NumOfForwardingRules: len(d.ForwardingRules()), LazyConnectionEnabled: d.GetLazyConnection(), @@ -1547,6 +1576,8 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort) pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled + pbFullStatus.LocalPeerState.MlkemEnabled = fs.MLKEMState.Enabled + pbFullStatus.LocalPeerState.MlkemStrict = fs.MLKEMState.Strict pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index b438a310a..089dbe7b7 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1652,6 +1652,8 @@ type LocalPeerState struct { Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"` Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"` WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"` + MlkemEnabled bool `protobuf:"varint,10,opt,name=mlkemEnabled,proto3" json:"mlkemEnabled,omitempty"` + MlkemStrict bool `protobuf:"varint,11,opt,name=mlkemStrict,proto3" json:"mlkemStrict,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1749,6 +1751,20 @@ func (x *LocalPeerState) GetWgPort() int32 { return 0 } +func (x *LocalPeerState) GetMlkemEnabled() bool { + if x != nil { + return x.MlkemEnabled + } + return false +} + +func (x *LocalPeerState) GetMlkemStrict() bool { + if x != nil { + return x.MlkemStrict + } + return false +} + // SignalState contains the latest state of a signal connection type SignalState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -7198,7 +7214,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "sshHostKey\x18\x13 \x01(\fR\n" + "sshHostKey\x12\x12\n" + - "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" + + "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\xe2\x02\n" + "\x0eLocalPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" + @@ -7208,7 +7224,10 @@ const file_daemon_proto_rawDesc = "" + "\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" + "\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" + "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" + - "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" + + "\x06wgPort\x18\t \x01(\x05R\x06wgPort\x12\"\n" + + "\fmlkemEnabled\x18\n" + + " \x01(\bR\fmlkemEnabled\x12 \n" + + "\vmlkemStrict\x18\v \x01(\bR\vmlkemStrict\"S\n" + "\vSignalState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index a3e3f4500..5b1eec9c6 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -404,6 +404,8 @@ message LocalPeerState { repeated string networks = 7; string ipv6 = 8; int32 wgPort = 9; + bool mlkemEnabled = 10; + bool mlkemStrict = 11; } // SignalState contains the latest state of a signal connection diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..a688c944f 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -10,6 +10,7 @@ import ( "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/internal/pqkem" "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/proto" ) @@ -156,6 +157,9 @@ func (s *Server) restartEngineForMDMLocked() error { s.config = config s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + // ML-KEM is env-driven today; mirror the Rosenpass update so the status stays in + // sync after an MDM config apply and the wiring is ready if it becomes a config field. + s.statusRecorder.UpdateMLKEM(pqkem.Enabled(), pqkem.Strict()) ctx, cancel := context.WithCancel(s.rootCtx) s.actCancel = cancel diff --git a/client/server/server.go b/client/server/server.go index f33e19075..4be7c28b4 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -32,6 +32,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/pqkem" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" @@ -254,6 +255,7 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + s.statusRecorder.UpdateMLKEM(pqkem.Enabled(), pqkem.Strict()) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -1007,6 +1009,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.statusRecorder.UpdateMLKEM(pqkem.Enabled(), pqkem.Strict()) s.clientRunning = true s.clientRunningChan = make(chan struct{}) @@ -1565,6 +1568,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.statusRecorder.UpdateMLKEM(pqkem.Enabled(), pqkem.Strict()) if msg.GetFullPeerStatus { s.runProbes(ctx, msg.ShouldRunProbes) diff --git a/client/status/status.go b/client/status/status.go index 1c204cdb1..e5c83c995 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -155,6 +155,8 @@ type OutputOverview struct { FQDN string `json:"fqdn" yaml:"fqdn"` RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` + MLKEMEnabled bool `json:"mlkemEnabled" yaml:"mlkemEnabled"` + MLKEMStrict bool `json:"mlkemStrict" yaml:"mlkemStrict"` Networks []string `json:"networks" yaml:"networks"` NumberOfForwardingRules int `json:"forwardingRules" yaml:"forwardingRules"` NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` @@ -205,6 +207,8 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), + MLKEMEnabled: pbFullStatus.GetLocalPeerState().GetMlkemEnabled(), + MLKEMStrict: pbFullStatus.GetLocalPeerState().GetMlkemStrict(), Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), NumberOfForwardingRules: int(pbFullStatus.GetNumberOfForwardingRules()), NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), @@ -511,12 +515,14 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(o.NSServerGroups), len(o.NSServerGroups)) } - rosenpassEnabledStatus := "false" - if o.RosenpassEnabled { - rosenpassEnabledStatus = "true" - if o.RosenpassPermissive { - rosenpassEnabledStatus = "true (permissive)" //nolint:gosec - } + // Quantum resistance is the outcome (post-quantum protected or not); the mechanism + // that provides it — ML-KEM or Rosenpass, strict or permissive — is a separate detail. + mechanism := quantumResistanceMechanism(o.RosenpassEnabled, o.RosenpassPermissive, o.MLKEMEnabled, o.MLKEMStrict) + quantumResistanceStatus := "false" + mechanismLine := "" + if mechanism != "none" { + quantumResistanceStatus = "true" + mechanismLine = fmt.Sprintf("Quantum resistance mechanism: %s\n", mechanism) } lazyConnectionEnabledStatus := "false" @@ -615,6 +621,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "Interface type: %s\n"+ "Wireguard port: %s\n"+ "Quantum resistance: %s\n"+ + "%s"+ "Lazy connection: %s\n"+ "SSH Server: %s\n"+ "Networks: %s\n"+ @@ -634,7 +641,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS ipv6Line, interfaceTypeString, wgPortString, - rosenpassEnabledStatus, + quantumResistanceStatus, + mechanismLine, lazyConnectionEnabledStatus, sshServerStatus, networks, @@ -647,7 +655,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS // FullDetailSummary returns a full detailed summary with peer details and events. func (o *OutputOverview) FullDetailSummary() string { - parsedPeersString := parsePeers(o.Peers, o.RosenpassEnabled, o.RosenpassPermissive) + parsedPeersString := parsePeers(o.Peers, o.RosenpassEnabled, o.RosenpassPermissive, o.MLKEMEnabled, o.MLKEMStrict) parsedEventsString := parseEvents(o.Events) summary := o.GeneralSummary(true, true, true, true) @@ -690,6 +698,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled + pbFullStatus.LocalPeerState.MlkemEnabled = fullStatus.MLKEMState.Enabled + pbFullStatus.LocalPeerState.MlkemStrict = fullStatus.MLKEMState.Strict pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled @@ -755,7 +765,47 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { return &pbFullStatus } -func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bool) string { +// quantumResistanceMechanism reports which post-quantum mechanism the local client runs, +// as an enum: "none", "ML-KEM strict", "ML-KEM permissive", "RP strict", "RP permissive". +// ML-KEM and Rosenpass are mutually exclusive, so at most one is active; ML-KEM takes +// precedence if somehow both are set. Strict/permissive is the fail-closed vs fail-open mode. +func quantumResistanceMechanism(rosenpassEnabled, rosenpassPermissive, mlkemEnabled, mlkemStrict bool) string { + switch { + case mlkemEnabled && mlkemStrict: + return "ML-KEM strict" + case mlkemEnabled: + return "ML-KEM permissive" + case rosenpassEnabled && !rosenpassPermissive: + return "RP strict" + case rosenpassEnabled: + return "RP permissive" + default: + return "none" + } +} + +// peerQuantumResistanceStatus renders the per-peer "Quantum resistance" field. established +// reports whether this peer's tunnel is post-quantum protected (Rosenpass or ML-KEM). When +// it is not, the reason is phrased for whichever mechanism is active locally. +func peerQuantumResistanceStatus(established, rosenpassEnabled, rosenpassPermissive, mlkemEnabled, mlkemStrict bool) string { + if established { + return "true" + } + switch { + case mlkemEnabled && mlkemStrict: + return "false (ML-KEM strict: blocking peer traffic until the exchange converges)" + case mlkemEnabled: + return "false (ML-KEM: not converged yet, or peer does not run the exchange)" + case rosenpassEnabled && rosenpassPermissive: + return "false (remote didn't enable quantum resistance)" + case rosenpassEnabled: + return "false (connection won't work without a permissive mode)" + default: + return "false" + } +} + +func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive, mlkemEnabled, mlkemStrict bool) string { var ( peersString = "" ) @@ -782,22 +832,7 @@ func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bo remoteICEEndpoint = peerState.IceCandidateEndpoint.Remote } - rosenpassEnabledStatus := "false" - if rosenpassEnabled { - if peerState.RosenpassEnabled { - rosenpassEnabledStatus = "true" - } else { - if rosenpassPermissive { - rosenpassEnabledStatus = "false (remote didn't enable quantum resistance)" - } else { - rosenpassEnabledStatus = "false (connection won't work without a permissive mode)" - } - } - } else { - if peerState.RosenpassEnabled { - rosenpassEnabledStatus = "false (connection might not work without a remote permissive mode)" - } - } + rosenpassEnabledStatus := peerQuantumResistanceStatus(peerState.RosenpassEnabled, rosenpassEnabled, rosenpassPermissive, mlkemEnabled, mlkemStrict) networks := "-" if len(peerState.Networks) > 0 { diff --git a/client/status/status_test.go b/client/status/status_test.go index 2babd9342..9e507520f 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -375,6 +375,8 @@ func TestParsingToJSON(t *testing.T) { "fqdn": "some-localhost.awesome-domain.com", "quantumResistance": false, "quantumResistancePermissive": false, + "mlkemEnabled": false, + "mlkemStrict": false, "networks": [ "10.10.0.0/24" ], @@ -494,6 +496,8 @@ wireguardPort: 51820 fqdn: some-localhost.awesome-domain.com quantumResistance: false quantumResistancePermissive: false +mlkemEnabled: false +mlkemStrict: false networks: - 10.10.0.0/24 forwardingRules: 0