From f3648de497a8dd75b83988cd3009e4e8e64e6db4 Mon Sep 17 00:00:00 2001 From: Philip Taron Date: Wed, 1 Apr 2026 13:51:33 -0700 Subject: [PATCH 1/2] polkit: fix polkit-agent-helper@ sandbox for pam_u2f polkit 127 switched to socket activation with a systemd unit for polkit-agent-helper that uses PrivateDevices=yes and ProtectHome=yes. This breaks pam_u2f (and potentially other PAM modules that interact with hardware), because: - PrivateDevices=yes creates a private /dev without hidraw devices, so FIDO tokens are inaccessible - ProtectHome=yes blocks reading ~/.config/Yubico/u2f_keys - StandardError=inherit causes polkit agents to misinterpret PAM diagnostic output as protocol errors, resulting in tight re-execution loops Fix by overriding these properties in the polkit module: - StandardError=journal unconditionally (benefits all PAM modules) - PrivateDevices/DeviceAllow/ProtectHome when pam_u2f is enabled Co-authored-by: Victor Luft --- nixos/modules/security/polkit.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nixos/modules/security/polkit.nix b/nixos/modules/security/polkit.nix index ca0a88246528..d4a99ba8a7b6 100644 --- a/nixos/modules/security/polkit.nix +++ b/nixos/modules/security/polkit.nix @@ -80,6 +80,28 @@ in systemd.sockets."polkit-agent-helper".wantedBy = [ "sockets.target" ]; + systemd.services."polkit-agent-helper@".serviceConfig = lib.mkMerge [ + # The upstream unit inherits stderr to the polkit agent, which causes + # agent processes to misinterpret diagnostic output from PAM modules + # as protocol errors, resulting in tight re-execution loops. + { StandardError = "journal"; } + + # The upstream unit uses PrivateDevices=yes and ProtectHome=yes, + # which prevents PAM modules from accessing hardware (e.g. FIDO + # tokens via /dev/hidraw*) or reading key files from home directories. + (lib.mkIf config.security.pam.u2f.enable { + # Override upstream PrivateDevices=yes to allow access to /dev/hidraw* + PrivateDevices = false; + DeviceAllow = [ + "/dev/urandom r" + "char-hidraw rw" + ]; + # Override upstream ProtectHome=yes so pam_u2f can read + # ~/.config/Yubico/u2f_keys (the default key file location) + ProtectHome = "read-only"; + }) + ]; + # The polkit daemon reads action/rule files environment.pathsToLink = [ "/share/polkit-1" ]; From 098b000a3680252c8d664811aeb48fce069f03c2 Mon Sep 17 00:00:00 2001 From: Philip Taron Date: Wed, 1 Apr 2026 13:51:47 -0700 Subject: [PATCH 2/2] nixos/tests: add end-to-end pam_u2f and polkit sandbox tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pam-u2f: Registers a U2F credential with pamu2fcfg against a virtual FIDO device (QEMU u2f-emulated), then logs in as alice at a TTY — the device authenticates her automatically with no password. pam-u2f-polkit: Verifies the polkit-agent-helper@ sandbox fix by showing that PrivateDevices=yes blocks hidraw access (the bug), the module overrides are applied, and fido2-token/pamu2fcfg work inside the full polkit sandbox (the fix). --- nixos/tests/all-tests.nix | 1 + nixos/tests/pam/pam-u2f-polkit.nix | 90 ++++++++++++++++++++++++++++++ nixos/tests/pam/pam-u2f.nix | 59 +++++++++++++++++--- 3 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 nixos/tests/pam/pam-u2f-polkit.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 187fdb17a0ad..779cefa572c2 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1220,6 +1220,7 @@ in pam-oath-login = runTest ./pam/pam-oath-login.nix; pam-pgsql = runTest ./pam/pam-pgsql.nix; pam-u2f = runTest ./pam/pam-u2f.nix; + pam-u2f-polkit = runTest ./pam/pam-u2f-polkit.nix; pam-ussh = runTest ./pam/pam-ussh.nix; pam-zfs-key = runTest ./pam/zfs-key.nix; pangolin = runTest ./pangolin.nix; diff --git a/nixos/tests/pam/pam-u2f-polkit.nix b/nixos/tests/pam/pam-u2f-polkit.nix new file mode 100644 index 000000000000..0db4d0b1b578 --- /dev/null +++ b/nixos/tests/pam/pam-u2f-polkit.nix @@ -0,0 +1,90 @@ +{ hostPkgs, ... }: + +{ + name = "pam-u2f-polkit"; + + qemu.package = hostPkgs.qemu_test.override { u2fEmuSupport = true; }; + + nodes.machine = + { pkgs, ... }: + { + virtualisation.qemu.options = [ + "-usb" + "-device u2f-emulated" + ]; + + security.polkit.enable = true; + security.pam.u2f.enable = true; + + environment.systemPackages = with pkgs; [ + libfido2 + pam_u2f + ]; + }; + + testScript = '' + machine.wait_for_unit("multi-user.target") + + # The upstream polkit-agent-helper@.service has PrivateDevices=yes and + # DevicePolicy=strict with only /dev/null allowed. This blocks hidraw. + # Verify that: run a command under the upstream defaults and show it fails. + machine.fail( + "systemd-run --wait --pipe " + "--property=PrivateDevices=yes " + "--property=DevicePolicy=strict " + "--property='DeviceAllow=/dev/null rw' " + "test -c /dev/hidraw0" + ) + + # The PR overrides PrivateDevices=no and adds DeviceAllow for hidraw. + # Verify that the actual polkit-agent-helper@ unit got these overrides. + props = machine.succeed("systemctl show polkit-agent-helper@dummy.service") + assert "PrivateDevices=no" in props, f"Expected PrivateDevices=no, got: {props}" + assert "ProtectHome=read-only" in props, f"Expected ProtectHome=read-only, got: {props}" + + # Run fido2-token under the same constraints as the fixed service. + # This proves the device is not just visible but actually usable + # inside the polkit-agent-helper@ sandbox. + machine.succeed( + "systemd-run --wait --pipe " + "--property=PrivateDevices=no " + "--property=DevicePolicy=strict " + "--property='DeviceAllow=/dev/null rw' " + "--property='DeviceAllow=/dev/urandom r' " + "--property='DeviceAllow=char-hidraw rw' " + "--property=ProtectHome=read-only " + "--property=PrivateNetwork=yes " + "--property=ProtectSystem=strict " + "--property=ProtectKernelModules=yes " + "--property=ProtectKernelLogs=yes " + "--property=ProtectKernelTunables=yes " + "--property=ProtectControlGroups=yes " + "--property=ProtectClock=yes " + "--property=ProtectHostname=yes " + "--property=LockPersonality=yes " + "--property=MemoryDenyWriteExecute=yes " + "--property=NoNewPrivileges=yes " + "--property=PrivateTmp=yes " + "--property=RemoveIPC=yes " + "--property='RestrictAddressFamilies=AF_UNIX' " + "--property=RestrictNamespaces=yes " + "--property=RestrictRealtime=yes " + "--property=RestrictSUIDSGID=yes " + "--property=SystemCallArchitectures=native " + "fido2-token -I /dev/hidraw0" + ) + + # Also verify that pamu2fcfg can register a credential inside the sandbox + # (needs hidraw + urandom access) + machine.succeed( + "systemd-run --wait --pipe " + "--property=PrivateDevices=no " + "--property=DevicePolicy=strict " + "--property='DeviceAllow=/dev/null rw' " + "--property='DeviceAllow=/dev/urandom r' " + "--property='DeviceAllow=char-hidraw rw' " + "--property=ProtectHome=read-only " + "pamu2fcfg" + ) + ''; +} diff --git a/nixos/tests/pam/pam-u2f.nix b/nixos/tests/pam/pam-u2f.nix index 17b9e19e4cc5..65a3ce7c459f 100644 --- a/nixos/tests/pam/pam-u2f.nix +++ b/nixos/tests/pam/pam-u2f.nix @@ -1,29 +1,70 @@ -{ ... }: +{ hostPkgs, ... }: { name = "pam-u2f"; + qemu.package = hostPkgs.qemu_test.override { u2fEmuSupport = true; }; + nodes.machine = - { ... }: + { pkgs, ... }: { + virtualisation.qemu.options = [ + "-usb" + "-device u2f-emulated" + ]; + security.pam.u2f = { enable = true; - control = "required"; + control = "sufficient"; settings = { - cue = true; debug = true; - interactive = true; - origin = "nixos-test"; - # Freeform option - userpresence = 1; + origin = "pam://nixos-test"; }; }; + + users.users.alice = { + isNormalUser = true; + uid = 1000; + }; + + environment.systemPackages = with pkgs; [ + libfido2 + pam_u2f + ]; + + # Allow non-root users to access the virtual U2F device + services.udev.extraRules = '' + KERNEL=="hidraw*", SUBSYSTEM=="hidraw", MODE="0666" + ''; }; testScript = '' machine.wait_for_unit("multi-user.target") + machine.wait_until_succeeds("pgrep -f 'agetty.*tty1'") + + # The virtual U2F device should be recognized + machine.succeed("fido2-token -L | grep -q hidraw") + + # Register a U2F credential for alice + machine.succeed("mkdir -p /home/alice/.config/Yubico") machine.succeed( - 'egrep "auth required .*/lib/security/pam_u2f.so.*cue.*debug.*interactive.*origin=nixos-test.*userpresence=1" /etc/pam.d/ -R' + "pamu2fcfg -u alice -o pam://nixos-test" + " > /home/alice/.config/Yubico/u2f_keys" ) + machine.succeed("chown -R alice:users /home/alice/.config") + + # Log in as alice on tty2. With control=sufficient, pam_u2f runs + # before pam_unix. The emulated device auto-approves user presence, + # so alice is authenticated by her U2F key — no password needed. + machine.send_key("alt-f2") + machine.wait_until_succeeds("[ $(fgconsole) = 2 ]") + machine.wait_for_unit("getty@tty2.service") + machine.wait_until_tty_matches("2", "login: ") + machine.send_chars("alice\n") + + # alice should get a shell without being asked for a password + machine.wait_until_succeeds("pgrep -u alice bash") + machine.send_chars("touch /tmp/u2f-login-success\n") + machine.wait_for_file("/tmp/u2f-login-success") ''; }