diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index fc7801aa0c1c5..3317a4d8a43b5 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -55,6 +55,16 @@ of pulling the upstream container image from Docker Hub. If you want the old beh - The Bash implementation of the `nixos-rebuild` program is removed. All switchable systems now use the Python rewrite. Any prior usage of `system.rebuild.enableNg` must now be removed. If you have any outstanding issues with the new implementation, please open an issue on GitHub. +- The `networking.wireless` module has been security hardened: the `wpa_supplicant` daemon now runs under an unprivileged user with restricted access to the system. + + As part of these changes, `/etc/wpa_supplicant.conf` has been deprecated: the NixOS-generated configuration file is now linked to `/etc/wpa_supplicant/nixos.conf` and `/etc/wpa_supplicant/imperative.conf` has been added for imperatively configuring `wpa_supplicant` or when using [allowAuxiliaryImperativeNetworks](#opt-networking.wireless.allowAuxiliaryImperativeNetworks). + + If client certificates, keys or other files are needed, these should be stored under `/etc/wpa_supplicant` and owned by `wpa_supplicant` to ensure the daemon can read them. + + Also, the {option}`networking.wireless.userControlled.group` option has been removed since there is now a dedicated `wpa_supplicant` group to control the daemon, and {option}`networking.wireless.userControlled.enable` has been renamed to [](#opt-networking.wireless.userControlled). + + No functionality should have been impacted by these changes (including controlling via `wpa_cli`, integration with NetworkManager or connman), but if you find any problems, please open an issue on GitHub. + - `services.angrr` now uses TOML for configuration. Define policies with `services.angrr.settings` (generate TOML file) or point to a file using `services.angrr.configFile`. The legacy options `services.angrr.period`, `services.angrr.ownedOnly`, and `services.angrr.removeRoot` have been removed. See `man 5 angrr` and the description of `services.angrr.settings` options for examples and details. - `services.pingvin-share` has been removed as the `pingvin-share.backend` package was broken and the project was archived upstream. diff --git a/nixos/modules/services/networking/connman.nix b/nixos/modules/services/networking/connman.nix index d3a8e236358e3..45168c643ee90 100644 --- a/nixos/modules/services/networking/connman.nix +++ b/nixos/modules/services/networking/connman.nix @@ -165,6 +165,7 @@ in wireless = { enable = lib.mkIf (!enableIwd) true; dbusControlled = true; + autoDetectInterfaces = false; iwd = lib.mkIf enableIwd { enable = true; }; diff --git a/nixos/modules/services/networking/networkmanager.nix b/nixos/modules/services/networking/networkmanager.nix index 9295157b793b8..f76e649d75e90 100644 --- a/nixos/modules/services/networking/networkmanager.nix +++ b/nixos/modules/services/networking/networkmanager.nix @@ -11,7 +11,8 @@ let cfg = config.networking.networkmanager; ini = pkgs.formats.ini { }; - delegateWireless = config.networking.wireless.enable == true && cfg.unmanaged != [ ]; + # Whether wpa_supplicant is managed independently + delegateWireless = config.networking.wireless.networks != { } && cfg.unmanaged != [ ]; enableIwd = cfg.wifi.backend == "iwd"; @@ -136,10 +137,7 @@ let cfg.package ] ++ cfg.plugins - ++ pluginRuntimeDeps - ++ lib.optionals (!delegateWireless && !enableIwd) [ - pkgs.wpa_supplicant - ]; + ++ pluginRuntimeDeps; in { @@ -541,9 +539,9 @@ in assertions = [ { - assertion = config.networking.wireless.enable == true -> cfg.unmanaged != [ ]; + assertion = config.networking.wireless.networks != { } -> cfg.unmanaged != [ ]; message = '' - You can not use networking.networkmanager with networking.wireless. + You can not use networking.networkmanager with networking.wireless.networks. Except if you mark some interfaces as unmanaged by NetworkManager. ''; } @@ -676,6 +674,13 @@ in useDHCP = false; }) + (mkIf (!delegateWireless && !enableIwd) { + # Enable wpa_supplicant but fully control it over DBus + wireless.enable = true; + wireless.autoDetectInterfaces = false; + wireless.dbusControlled = true; + }) + (mkIf enableIwd { wireless.iwd.enable = true; }) diff --git a/nixos/modules/services/networking/wpa_supplicant.nix b/nixos/modules/services/networking/wpa_supplicant.nix index 5bcc5e5c38d85..124bd9c9eda0d 100644 --- a/nixos/modules/services/networking/wpa_supplicant.nix +++ b/nixos/modules/services/networking/wpa_supplicant.nix @@ -48,31 +48,6 @@ let else networkList; - # Content of wpa_supplicant.conf - generatedConfig = concatStringsSep "\n" ( - (map mkNetwork allNetworks) - ++ optional cfg.userControlled.enable ( - concatStringsSep "\n" [ - "ctrl_interface=/run/wpa_supplicant" - "ctrl_interface_group=${cfg.userControlled.group}" - "update_config=1" - ] - ) - ++ [ "pmf=1" ] - ++ optional (cfg.secretsFile != null) "ext_password_backend=file:${cfg.secretsFile}" - ++ optional cfg.scanOnLowSignal ''bgscan="simple:30:-70:3600"'' - ++ optional (cfg.extraConfig != "") cfg.extraConfig - ); - - configIsGenerated = with cfg; networks != { } || extraConfig != "" || userControlled.enable; - - # the original configuration file - configFile = - if configIsGenerated then - pkgs.writeText "wpa_supplicant.conf" generatedConfig - else - "/etc/wpa_supplicant.conf"; - # Creates a network block for wpa_supplicant.conf mkNetwork = opts: @@ -104,6 +79,12 @@ let } ''; + hasDeclarative = lib.any id [ + (cfg.networks != { }) + (cfg.extraConfig != "") + cfg.userControlled + ]; + # Creates a systemd unit for wpa_supplicant bound to a given (or any) interface mkUnit = iface: @@ -114,9 +95,11 @@ let configStr = ( if cfg.allowAuxiliaryImperativeNetworks then - "-c /etc/wpa_supplicant.conf -I ${configFile}" + "-c /etc/wpa_supplicant/imperative.conf -I /etc/wpa_supplicant/nixos.conf" + else if hasDeclarative then + "-c /etc/wpa_supplicant/nixos.conf" else - "-c ${configFile}" + "-c /etc/wpa_supplicant/imperative.conf" ) + lib.concatMapStrings (p: " -I " + p) cfg.extraConfigFiles; in @@ -128,32 +111,100 @@ let wants = [ "network.target" ]; requires = deviceUnit; wantedBy = [ "multi-user.target" ]; + stopIfChanged = false; + restartTriggers = [ config.environment.etc."wpa_supplicant/nixos.conf".source ]; path = [ pkgs.wpa_supplicant ]; - # if `userControl.enable`, the supplicant automatically changes the permissions - # and owning group of the runtime dir; setting `umask` ensures the generated - # config file isn't readable (except to root); see nixpkgs#267693 - serviceConfig.UMask = "066"; - serviceConfig.RuntimeDirectory = "wpa_supplicant"; - serviceConfig.RuntimeDirectoryMode = "700"; + serviceConfig = { + User = "wpa_supplicant"; + Group = "wpa_supplicant"; + RuntimeDirectory = "wpa_supplicant"; + AmbientCapabilities = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + ]; + CapabilityBoundingSet = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + ]; + RootDirectory = "/run/wpa_supplicant"; + RootDirectoryStartOnly = true; + BindPaths = [ + "/etc/wpa_supplicant" # to write wpa_supplicant.conf{,.tmp} + "/run/wpa_supplicant" # to make control sockets + # to set up interfaces + "/proc/sys/net" + "/dev/rfkill" + ] + ++ lib.optional cfg.dbusControlled "/run/dbus" + ++ lib.optional cfg.allowAuxiliaryImperativeNetworks "/etc/wpa_supplicant"; + BindReadOnlyPaths = [ + builtins.storeDir + "/etc/" + ] + ++ lib.optional (cfg.secretsFile != null) cfg.secretsFile; + DeviceAllow = "/dev/rfkill rw"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + PrivateUsers = false; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + IPAddressDeny = "any"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_PACKET" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallFilter = [ + "@system-service" + "~@keyring" + "~@resources" + ]; + SystemCallArchitectures = "native"; + UMask = "0077"; + + ExecStartPre = + lib.optionals (cfg.allowAuxiliaryImperativeNetworks || !hasDeclarative) [ + # set up imperative config file + "+${pkgs.coreutils}/bin/touch /etc/wpa_supplicant/imperative.conf" + "+${pkgs.coreutils}/bin/chmod 664 /etc/wpa_supplicant/imperative.conf" + "+${pkgs.coreutils}/bin/chown -R wpa_supplicant:wpa_supplicant /etc/wpa_supplicant" + ] + ++ lib.optionals cfg.userControlled [ + # set up client sockets directory + "+${pkgs.coreutils}/bin/mkdir /run/wpa_supplicant/client" + "+${pkgs.coreutils}/bin/chown wpa_supplicant:wpa_supplicant /run/wpa_supplicant/client" + "+${pkgs.coreutils}/bin/chmod g=u /run/wpa_supplicant/client" + ]; + }; script = '' - ${optionalString (configIsGenerated && !cfg.allowAuxiliaryImperativeNetworks) '' - if [ -f /etc/wpa_supplicant.conf ]; then - echo >&2 "<3>/etc/wpa_supplicant.conf present but ignored. Generated ${configFile} is used instead." - fi - ''} - - # ensure wpa_supplicant.conf exists, or the daemon will fail to start - ${optionalString cfg.allowAuxiliaryImperativeNetworks '' - touch /etc/wpa_supplicant.conf - ''} - iface_args="-s ${optionalString cfg.dbusControlled "-u"} -D${cfg.driver} ${configStr}" - ${ - if iface == null then + if iface != null then + '' + # add known interface to the daemon arguments + args="-i${iface} $iface_args" + '' + else if cfg.autoDetectInterfaces then '' # detect interfaces automatically @@ -176,10 +227,7 @@ let done '' else - '' - # add known interface to the daemon arguments - args="-i${iface} $iface_args" - '' + "args=$iface_args" } # finally start daemon @@ -205,7 +253,8 @@ in "wlan1" ]; description = '' - The interfaces {command}`wpa_supplicant` will use. If empty, it will + The interfaces {command}`wpa_supplicant` will use. If empty and + [](#opt-networking.wireless.autoDetectInterfaces) is true it will automatically use all wireless interfaces. ::: {.note} @@ -214,6 +263,10 @@ in ''; }; + autoDetectInterfaces = mkEnableOption "automatic detection of wireless interfaces" // { + default = true; + }; + driver = mkOption { type = types.str; default = "nl80211,wext"; @@ -503,27 +556,36 @@ in ''; }; - userControlled = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Allow normal users to control wpa_supplicant through wpa_gui or wpa_cli. - This is useful for laptop users that switch networks a lot and don't want - to depend on a large package such as NetworkManager just to pick nearby - access points. - - When using a declarative network specification you cannot persist any - settings via wpa_gui or wpa_cli. - ''; - }; + userControlled = mkOption { + type = + with types; + coercedTo attrs ( + val: + if builtins.isAttrs val && val ? enable then + trace "Obsolete option `networking.wireless.userControlled.enable' is used. It was renamed to networking.wireless.userControlled" val.enable + else if builtins.isAttrs val && val ? group then + trace + "The option definition `networking.wireless.userControlled.group' no longer has any effect. The group is now fixed to `wpa_supplicant'." + (val.enable or false) + else if builtins.isBool val then + val + else + false + ) bool; + default = false; + description = '' + Allow users of the `wpa_supplicant` group to control wpa_supplicant + through wpa_gui or wpa_cli. + This is useful for laptop users that switch networks a lot and don't want + to depend on a large package such as NetworkManager just to pick nearby + access points. - group = mkOption { - type = types.str; - default = "wheel"; - example = "network"; - description = "Members of this group can control wpa_supplicant."; - }; + ::: {.note} + When networks are configured declaratively, you cannot persist any settings + via wpa_gui or wpa_cli, unless {option}`allowAuxiliaryImperativeNetworks` + is used. + ::: + ''; }; dbusControlled = mkOption { @@ -624,9 +686,33 @@ in } ]; + users.groups.wpa_supplicant = { }; + users.users.wpa_supplicant = { + isSystemUser = true; + group = "wpa_supplicant"; + description = "WPA Supplicant user"; + }; + hardware.wirelessRegulatoryDatabase = true; environment.systemPackages = [ pkgs.wpa_supplicant ]; + + # NixOS-generated configuration files + environment.etc."wpa_supplicant/nixos.conf".text = concatStringsSep "\n" ( + (map mkNetwork allNetworks) + ++ optional cfg.userControlled ( + concatStringsSep "\n" [ + "ctrl_interface=/run/wpa_supplicant/control" + "ctrl_interface_group=wpa_supplicant" + "update_config=1" + ] + ) + ++ [ "pmf=1" ] + ++ optional (cfg.secretsFile != null) "ext_password_backend=file:${cfg.secretsFile}" + ++ optional cfg.scanOnLowSignal ''bgscan="simple:30:-70:3600"'' + ++ optional (cfg.extraConfig != "") cfg.extraConfig + ); + services.dbus.packages = optional cfg.dbusControlled pkgs.wpa_supplicant; systemd.services = diff --git a/nixos/tests/wpa_supplicant.nix b/nixos/tests/wpa_supplicant.nix index d7aaaf8a14299..921c111cfc1fd 100644 --- a/nixos/tests/wpa_supplicant.nix +++ b/nixos/tests/wpa_supplicant.nix @@ -94,20 +94,30 @@ let }; }; + # Note: secrets are stored outside /etc/ and /nix/store to + # test for accessibility of these paths + system.activationScripts.wpa-secrets = { + deps = [ + "users" + "specialfs" + ]; + text = '' + install -Dm600 -o wpa_supplicant ${pkgs.writeText "wpa" '' + psk_nixos_test=${naughtyPassphrase} + ''} /var/lib/secrets/wpa + ''; + }; + # wireless client networking.wireless = lib.mkMerge [ { # the override is needed because the wifi is # disabled with mkVMOverride in qemu-vm.nix. enable = lib.mkOverride 0 true; - userControlled.enable = true; + userControlled = true; interfaces = [ "wlan1" ]; fallbackToWPA2 = lib.mkDefault true; - - # secrets - secretsFile = pkgs.writeText "wpa-secrets" '' - psk_nixos_test=${naughtyPassphrase} - ''; + secretsFile = "/var/lib/secrets/wpa"; } extraConfig ]; @@ -142,7 +152,8 @@ in # the override is needed because the wifi is # disabled with mkVMOverride in qemu-vm.nix. enable = lib.mkOverride 0 true; - userControlled.enable = true; + userControlled = true; + dbusControlled = true; fallbackToWPA2 = true; networks = { @@ -198,9 +209,14 @@ in assert "Failed to connect" not in status, \ "Failed to connect to the daemon" - # get the configuration file - cmdline = machine.succeed("cat /proc/$(pgrep wpa)/cmdline").split('\x00') - config_file = cmdline[cmdline.index("-c") + 1] + with subtest("D-Bus interface is working"): + dbus_command = "dbus-send --system --print-reply --dest=fi.w1.wpa_supplicant1 " \ + "/fi/w1/wpa_supplicant1 fi.w1.wpa_supplicant1.GetInterface string:wlan0" + machine.succeed(dbus_command) # as root + machine.succeed(f"sudo -g wpa_supplicant {dbus_command}") # as wpa_supplicant group + + # generated configuration file + config_file = "/etc/static/wpa_supplicant/nixos.conf" with subtest("WPA2 fallbacks have been generated"): assert int(machine.succeed(f"grep -c sae-only {config_file}")) == 1 @@ -218,6 +234,9 @@ in # save file for manual inspection machine.copy_from_vm(config_file) + + # check hardening options + machine.succeed("systemd-analyze security wpa_supplicant >&2") ''; }; @@ -233,25 +252,27 @@ in # wireless client networking.wireless = { enable = lib.mkOverride 0 true; - userControlled.enable = true; + userControlled = true; allowAuxiliaryImperativeNetworks = true; interfaces = [ "wlan1" ]; }; }; testScript = '' + wpa_cli = "sudo -u nobody -g wpa_supplicant wpa_cli" + with subtest("Daemon is running and accepting connections"): machine.wait_for_unit("wpa_supplicant-wlan1.service") - status = machine.wait_until_succeeds("wpa_cli -i wlan1 status") + status = machine.wait_until_succeeds(f"{wpa_cli} -i wlan1 status") assert "Failed to connect" not in status, \ "Failed to connect to the daemon" with subtest("Daemon can be configured imperatively"): - machine.succeed("wpa_cli -i wlan1 add_network") - machine.succeed("wpa_cli -i wlan1 set_network 0 ssid '\"nixos-test\"'") - machine.succeed("wpa_cli -i wlan1 set_network 0 psk '\"reproducibility\"'") - machine.succeed("wpa_cli -i wlan1 save_config") - machine.succeed("grep -q nixos-test /etc/wpa_supplicant.conf") + machine.succeed(f"{wpa_cli} -i wlan1 add_network") + machine.succeed(f"{wpa_cli} -i wlan1 set_network 0 ssid '\"nixos-test\"'") + machine.succeed(f"{wpa_cli} -i wlan1 set_network 0 psk '\"reproducibility\"'") + machine.succeed(f"{wpa_cli} -i wlan1 save_config") + machine.succeed("grep -q nixos-test /etc/wpa_supplicant/imperative.conf") ''; }; diff --git a/pkgs/os-specific/linux/wpa_supplicant/default.nix b/pkgs/os-specific/linux/wpa_supplicant/default.nix index 204481e0c0960..91377303c2298 100644 --- a/pkgs/os-specific/linux/wpa_supplicant/default.nix +++ b/pkgs/os-specific/linux/wpa_supplicant/default.nix @@ -14,6 +14,7 @@ readline, withPcsclite ? !stdenv.hostPlatform.isStatic, pcsclite, + unprivileged ? true, }: stdenv.mkDerivation rec { @@ -33,8 +34,6 @@ stdenv.mkDerivation rec { hash = "sha256-X6mBbj7BkW66aYeSCiI3JKBJv10etLQxaTRfRgwsFmM="; revert = true; }) - ./unsurprising-ext-password.patch - ./multiple-configs.patch (fetchpatch { name = "suppress-ctrl-event-signal-change.patch"; url = "https://w1.fi/cgit/hostap/patch/?id=c330b5820eefa8e703dbce7278c2a62d9c69166a"; @@ -45,7 +44,10 @@ stdenv.mkDerivation rec { url = "https://git.w1.fi/cgit/hostap/patch/?id=1ce37105da371c8b9cf3f349f78f5aac77d40836"; hash = "sha256-leCk0oexNBZyVK5Q5gR4ZcgWxa0/xt/aU+DssTa0UwE="; }) - ]; + ./unsurprising-ext-password.patch + ./multiple-configs.patch + ] + ++ lib.optional unprivileged ./unprivileged-daemon.patch; # TODO: Patch epoll so that the dbus actually responds # TODO: Figure out how to get privsep working, currently getting SIGBUS diff --git a/pkgs/os-specific/linux/wpa_supplicant/unprivileged-daemon.patch b/pkgs/os-specific/linux/wpa_supplicant/unprivileged-daemon.patch new file mode 100644 index 0000000000000..93dceff753cd6 --- /dev/null +++ b/pkgs/os-specific/linux/wpa_supplicant/unprivileged-daemon.patch @@ -0,0 +1,96 @@ +commit 24e932357ee3041763135b931206dfc0bbe0441e +Author: rnhmjoj +Date: Wed Jul 23 10:18:55 2025 +0200 + + Fixes for running wpa_supplicant unprivileged + + 1. Change the dbus service user to "wpa_supplicant" + + 2. Ensure appropriate group ownership and permissions on the client sockets. + Motivation: clients communicate with the daemon by creating "client" + sockets; by default this is owned by the user running the client, + so it may be inaccessible by the daemon. + + 3. Move the "control" sockets under a subdirectory of /run/wpa_supplicant. + Motivation: wpa_supplicant will try to adjust the ownership of the + sockets directory, even if they are fine, and fail. + + 4. Move the "client" under a subdirectory of /run/wpa_supplicant instead + of tmp. Motivation: this allows to unshare /tmp + +diff --git a/src/common/wpa_ctrl.c b/src/common/wpa_ctrl.c +index 7e197f0..6bfb091 100644 +--- a/src/common/wpa_ctrl.c ++++ b/src/common/wpa_ctrl.c +@@ -15,6 +15,8 @@ + #include + #include + #include ++#include ++#include + #include + #endif /* CONFIG_CTRL_IFACE_UNIX */ + #ifdef CONFIG_CTRL_IFACE_UDP_REMOTE +@@ -165,6 +167,14 @@ try_again: + return NULL; + } + ++ /* Set the client socket owner group to "wpa_supplicant" ++ * and ensure group and user permissions are the same */ ++ struct group *grp = getgrnam("wpa_supplicant"); ++ if (grp != NULL) { ++ lchown(ctrl->local.sun_path, -1, grp->gr_gid); ++ chmod(ctrl->local.sun_path, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP); ++ } ++ + #ifdef ANDROID + /* Set group even if we do not have privileges to change owner */ + lchown(ctrl->local.sun_path, -1, AID_WIFI); +--- a/wpa_supplicant/dbus/dbus-wpa_supplicant.conf ++++ b/wpa_supplicant/dbus/dbus-wpa_supplicant.conf +@@ -2,9 +2,15 @@ + "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" + "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd"> + +- ++ + +- ++ ++ ++ ++ ++ ++ ++ + + + +diff --git a/wpa_supplicant/dbus/fi.w1.wpa_supplicant1.service.in b/wpa_supplicant/dbus/fi.w1.wpa_supplicant1.service.in +index d97ff39..367a7c6 100644 +--- a/wpa_supplicant/dbus/fi.w1.wpa_supplicant1.service.in ++++ b/wpa_supplicant/dbus/fi.w1.wpa_supplicant1.service.in +@@ -1,5 +1,5 @@ + [D-BUS Service] + Name=fi.w1.wpa_supplicant1 + Exec=@BINDIR@/wpa_supplicant -u +-User=root ++User=wpa_supplicant + SystemdService=wpa_supplicant.service +diff --git a/wpa_supplicant/wpa_cli.c b/wpa_supplicant/wpa_cli.c +index af00e79..840b307 100644 +--- a/wpa_supplicant/wpa_cli.c ++++ b/wpa_supplicant/wpa_cli.c +@@ -44,10 +44,10 @@ static int wpa_cli_attached = 0; + static int wpa_cli_connected = -1; + static int wpa_cli_last_id = 0; + #ifndef CONFIG_CTRL_IFACE_DIR +-#define CONFIG_CTRL_IFACE_DIR "/var/run/wpa_supplicant" ++#define CONFIG_CTRL_IFACE_DIR "/run/wpa_supplicant/control" + #endif /* CONFIG_CTRL_IFACE_DIR */ + static const char *ctrl_iface_dir = CONFIG_CTRL_IFACE_DIR; +-static const char *client_socket_dir = NULL; ++static const char *client_socket_dir = "/run/wpa_supplicant/client"; + static char *ctrl_ifname = NULL; + static const char *global = NULL; + static const char *pid_file = NULL;