nixos/wpa_supplicant: harden and run as unprivileged user (#427528)

This commit is contained in:
Michele Guerini Rocco
2025-12-27 09:39:02 +00:00
committed by GitHub
7 changed files with 319 additions and 98 deletions
@@ -61,6 +61,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.
@@ -165,6 +165,7 @@ in
wireless = {
enable = lib.mkIf (!enableIwd) true;
dbusControlled = true;
autoDetectInterfaces = false;
iwd = lib.mkIf enableIwd {
enable = true;
};
@@ -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 <literal>unmanaged</literal> 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;
})
@@ -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.
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 +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 =
+38 -17
View File
@@ -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")
'';
};
@@ -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
@@ -0,0 +1,96 @@
commit 24e932357ee3041763135b931206dfc0bbe0441e
Author: rnhmjoj <rnhmjoj@inventati.org>
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 <fcntl.h>
#include <sys/un.h>
#include <unistd.h>
+#include <sys/types.h>
+#include <grp.h>
#include <fcntl.h>
#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">
<busconfig>
- <policy user="root">
+ <policy user="wpa_supplicant">
<allow own="fi.w1.wpa_supplicant1"/>
-
+ </policy>
+ <policy user="root">
+ <allow send_destination="fi.w1.wpa_supplicant1"/>
+ <allow send_interface="fi.w1.wpa_supplicant1"/>
+ <allow receive_sender="fi.w1.wpa_supplicant1" receive_type="signal"/>
+ </policy>
+ <policy group="wpa_supplicant">
<allow send_destination="fi.w1.wpa_supplicant1"/>
<allow send_interface="fi.w1.wpa_supplicant1"/>
<allow receive_sender="fi.w1.wpa_supplicant1" receive_type="signal"/>
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;