diff --git a/nixos/modules/services/networking/wpa_supplicant.nix b/nixos/modules/services/networking/wpa_supplicant.nix index 5bcc5e5c38d8..8fa11f9ef87e 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,30 +111,93 @@ 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 '' @@ -503,27 +549,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. + 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. - When using a declarative network specification you cannot persist any - settings via wpa_gui or wpa_cli. - ''; - }; - - 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 +679,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 d7aaaf8a1429..921c111cfc1f 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 204481e0c096..91377303c229 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 000000000000..93dceff753cd --- /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;