nixos/wpa_supplicant: harden and run as unprivileged user

This commit is contained in:
rnhmjoj
2025-12-17 10:31:15 +01:00
parent 83e544acc8
commit abb06beb97
4 changed files with 283 additions and 85 deletions
@@ -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 =
+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;