From 4909f6357b06887e253bc09d594d217833b52a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Thu, 1 Oct 2026 21:18:34 +0800 Subject: [PATCH] security: scope privileges and stop a world-readable secret copy (#335) --- flake.nix | 21 ------------- modules/base/nix.nix | 29 +++++++++++------- modules/nixos/base/ssh.nix | 5 ++-- modules/nixos/base/user-group.nix | 10 ++++++- .../{base => server}/remote-building.nix | 0 modules/nixos/server/server.nix | 1 + .../security-container-groups/expected.nix | 12 ++++++++ .../tests/security-container-groups/expr.nix | 24 +++++++++++++++ .../security-container-groups/expected.nix | 10 ++++++- .../tests/security-container-groups/expr.nix | 30 ++++++++++++++----- secrets/nixos.nix | 9 ++++-- 11 files changed, 106 insertions(+), 45 deletions(-) rename modules/nixos/{base => server}/remote-building.nix (100%) create mode 100644 outputs/aarch64-linux/tests/security-container-groups/expected.nix create mode 100644 outputs/aarch64-linux/tests/security-container-groups/expr.nix diff --git a/flake.nix b/flake.nix index 09efa40d..b73a905c 100644 --- a/flake.nix +++ b/flake.nix @@ -10,27 +10,6 @@ outputs = inputs: import ./outputs inputs; - # the nixConfig here only affects the flake itself, not the system configuration! - # for more information, see: - # https://nixos-and-flakes.thiscute.world/nix-store/add-binary-cache-servers - nixConfig = { - # substituers will be appended to the default substituters when fetching packages - extra-substituters = [ - "https://cache.numtide.com" - "https://catppuccin.cachix.org" - # "https://nix-gaming.cachix.org" - # "https://nixpkgs-wayland.cachix.org" - # "https://install.determinate.systems" - ]; - extra-trusted-public-keys = [ - "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" - "catppuccin.cachix.org-1:noG/4HkbhJb+lUAdKrph6LaozJvAeEEZj4N732IysmU=" - # "nix-gaming.cachix.org-1:nbjlureqMbRAxR1gJ/f3hxemL9svXaZF/Ees8vCUUs4=" - # "nixpkgs-wayland.cachix.org-1:3lwxaILxMRkVhehr5StQprHdEo4IrE8sRho9R9HOLYA=" - # "cache.flakehub.com-3:hJuILl5sVK4iKm86JzgdXW12Y2Hwd5G07qKtHTOcDCM=" - ]; - }; - # This is the standard format for flake.nix. `inputs` are the dependencies of the flake, # Each item in `inputs` will be passed as a parameter to the `outputs` function after being pulled and built. inputs = { diff --git a/modules/base/nix.nix b/modules/base/nix.nix index f81c4b9e..5550f565 100644 --- a/modules/base/nix.nix +++ b/modules/base/nix.nix @@ -1,7 +1,6 @@ { pkgs, config, - myvars, ... }: { @@ -12,12 +11,19 @@ "flakes" ]; - # given the users in this list the right to specify additional substituters via: - # 1. `nixConfig.substituers` in `flake.nix` - # 2. command line args `--options substituers http://xxx` - trusted-users = [ myvars.username ]; + # NOTE: the normal user is deliberately NOT a trusted-user. That would + # grant the right to inject arbitrary substituters (via `nixConfig` in a + # flake, or `--option substituters ...`), which is a known root-equivalent + # vector through the daemon. Every substituter we want is instead listed in + # the system substituters below, which the daemon applies for every user. - # substituers that will be considered before the official ones(https://cache.nixos.org) + # WARNING: `substituters` + `trusted-public-keys` are a security boundary, + # not a convenience list. Nix downloads prebuilt store paths from a + # substituter, and a trusted key means "trust anything signed by it" — + # together they let a cache inject arbitrary store paths into your system, + # which are then run as root at activation. Only add caches you fully trust. + # The official https://cache.nixos.org is always trusted and is appended + # automatically, so it does not need to be listed here. substituters = [ # cache mirror located in China # status: https://mirrors.ustc.edu.cn/status/ @@ -28,15 +34,18 @@ # "https://mirrors.sustech.edu.cn/nix-channels/store" "https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store" - "https://nix-community.cachix.org" - "https://catppuccin.cachix.org" + # numtide's cache (niks3), which serves the prebuilt packages of the + # numtide/llm-agents.nix flake input used for the AI agent CLIs. + "https://cache.numtide.com" # my own cache server, currently not used. # "https://ryan4yin.cachix.org" ]; + # WARNING: every key here trusts *everything* that key signs. Add one only + # for a cache you trust as much as the official one — a careless key hands + # a third party the ability to ship you arbitrary store paths. trusted-public-keys = [ - "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" - "catppuccin.cachix.org-1:noG/4HkbhJb+lUAdKrph6LaozJvAeEEZj4N732IysmU=" + "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" "ryan4yin.cachix.org-1:Gbk27ZU5AYpGS9i3ssoLlwdvMIh0NxG0w8it/cv9kbU=" ]; builders-use-substitutes = true; diff --git a/modules/nixos/base/ssh.nix b/modules/nixos/base/ssh.nix index 3aaf2900..73ab92f4 100644 --- a/modules/nixos/base/ssh.nix +++ b/modules/nixos/base/ssh.nix @@ -1,7 +1,8 @@ { lib, ... }: { - # Secure by default: firewall ON everywhere unless a host explicitly disables it - # (servers disable it in modules/nixos/server/{server,server-aarch64}.nix). + # Secure by default: firewall ON everywhere. Servers run the same shared + # firewall (modules/nixos/base/networking/firewall.nix) as every other host, + # as defence in depth behind the router. networking.firewall.enable = lib.mkDefault true; # Enable the OpenSSH daemon. services.openssh = { diff --git a/modules/nixos/base/user-group.nix b/modules/nixos/base/user-group.nix index 7f128fd1..7e6642e7 100644 --- a/modules/nixos/base/user-group.nix +++ b/modules/nixos/base/user-group.nix @@ -7,6 +7,15 @@ # Don't allow mutation of users outside the config. users.mutableUsers = false; + # Root-equivalent / over-broad groups are deliberately NOT added to the + # user's `extraGroups` below, e.g.: + # docker / podman -> rootful container socket (~ root) + # libvirtd -> polkit grants it `org.libvirt.unix.manage` (~ root) + # disk -> raw block devices (~ root) + # input -> keylogging + # Membership is a *passwordless* path to root, so run those commands with + # `sudo` instead (e.g. `sudo virsh`). The groups below exist only for the + # services that need them; `security-container-groups` guards the main ones. users.groups = { "${myvars.username}" = { }; podman = { }; @@ -36,7 +45,6 @@ "networkmanager" # for nmtui / nmcli "wireshark" "adbusers" # android debugging - "libvirtd" # virt-viewer / qemu "fileshare" ]; }; diff --git a/modules/nixos/base/remote-building.nix b/modules/nixos/server/remote-building.nix similarity index 100% rename from modules/nixos/base/remote-building.nix rename to modules/nixos/server/remote-building.nix diff --git a/modules/nixos/server/server.nix b/modules/nixos/server/server.nix index c9434748..b108fbce 100644 --- a/modules/nixos/server/server.nix +++ b/modules/nixos/server/server.nix @@ -1,6 +1,7 @@ { ... }: { imports = [ + ./remote-building.nix ../base ../../base ]; diff --git a/outputs/aarch64-linux/tests/security-container-groups/expected.nix b/outputs/aarch64-linux/tests/security-container-groups/expected.nix new file mode 100644 index 00000000..202d771f --- /dev/null +++ b/outputs/aarch64-linux/tests/security-container-groups/expected.nix @@ -0,0 +1,12 @@ +{ + lib, + outputs, + ... +}: +lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) (_: { + userHasDockerAccess = false; + userHasPodmanAccess = false; + userHasDiskAccess = false; + userHasKvmAccess = false; + userHasInputAccess = false; +}) diff --git a/outputs/aarch64-linux/tests/security-container-groups/expr.nix b/outputs/aarch64-linux/tests/security-container-groups/expr.nix new file mode 100644 index 00000000..6ca7c458 --- /dev/null +++ b/outputs/aarch64-linux/tests/security-container-groups/expr.nix @@ -0,0 +1,24 @@ +{ + lib, + outputs, + ... +}: +lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) ( + name: + let + userGroups = outputs.nixosConfigurations.${name}.config.users.users.ryan.extraGroups; + in + { + # Groups that grant root-equivalent or otherwise over-broad access. The + # normal user must never be a member of any of them: + # docker / podman -> the rootful container socket (~ root) + # disk -> raw block devices (~ root) + # kvm -> /dev/kvm + # input -> read/forge input events (keylogging) + userHasDockerAccess = builtins.elem "docker" userGroups; + userHasPodmanAccess = builtins.elem "podman" userGroups; + userHasDiskAccess = builtins.elem "disk" userGroups; + userHasKvmAccess = builtins.elem "kvm" userGroups; + userHasInputAccess = builtins.elem "input" userGroups; + } +) diff --git a/outputs/x86_64-linux/tests/security-container-groups/expected.nix b/outputs/x86_64-linux/tests/security-container-groups/expected.nix index 9b900579..202d771f 100644 --- a/outputs/x86_64-linux/tests/security-container-groups/expected.nix +++ b/outputs/x86_64-linux/tests/security-container-groups/expected.nix @@ -1,4 +1,12 @@ { + lib, + outputs, + ... +}: +lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) (_: { userHasDockerAccess = false; userHasPodmanAccess = false; -} + userHasDiskAccess = false; + userHasKvmAccess = false; + userHasInputAccess = false; +}) diff --git a/outputs/x86_64-linux/tests/security-container-groups/expr.nix b/outputs/x86_64-linux/tests/security-container-groups/expr.nix index 25738d8c..6ca7c458 100644 --- a/outputs/x86_64-linux/tests/security-container-groups/expr.nix +++ b/outputs/x86_64-linux/tests/security-container-groups/expr.nix @@ -1,8 +1,24 @@ -{ outputs, ... }: -let - userGroups = outputs.nixosConfigurations.ai-niri.config.users.users.ryan.extraGroups; -in { - userHasDockerAccess = builtins.elem "docker" userGroups; - userHasPodmanAccess = builtins.elem "podman" userGroups; -} + lib, + outputs, + ... +}: +lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) ( + name: + let + userGroups = outputs.nixosConfigurations.${name}.config.users.users.ryan.extraGroups; + in + { + # Groups that grant root-equivalent or otherwise over-broad access. The + # normal user must never be a member of any of them: + # docker / podman -> the rootful container socket (~ root) + # disk -> raw block devices (~ root) + # kvm -> /dev/kvm + # input -> read/forge input events (keylogging) + userHasDockerAccess = builtins.elem "docker" userGroups; + userHasPodmanAccess = builtins.elem "podman" userGroups; + userHasDiskAccess = builtins.elem "disk" userGroups; + userHasKvmAccess = builtins.elem "kvm" userGroups; + userHasInputAccess = builtins.elem "input" userGroups; + } +) diff --git a/secrets/nixos.nix b/secrets/nixos.nix index 5c0e2f49..3034c1c5 100644 --- a/secrets/nixos.nix +++ b/secrets/nixos.nix @@ -139,11 +139,14 @@ in mode = "0000"; }; - # The following secrets are used by home-manager modules - # So we need to make then readable by the user + # Used by a home-manager module, so it must be readable by the user. + # Setting `mode` makes environment.etc copy the file instead of + # symlinking the runtime secret, so the owner must be set as well — + # otherwise it becomes a world-readable, root-owned copy of the secret. "agenix/nushell-secrets.nu" = { source = config.age.secrets."nushell-secrets.nu".path; - mode = "0644"; # both the original file and the symlink should be readable and executable by the user + mode = "0400"; + user = myvars.username; }; }; })