From aa818f26680e06a4188d730b28a6f768e83b79c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 3 Jul 2026 19:12:23 -0400 Subject: [PATCH 1/2] Sandbox the nvpn mesh daemon's filesystem access (security) --- nixos/mesh.nix | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/nixos/mesh.nix b/nixos/mesh.nix index 44c0297..521deec 100644 --- a/nixos/mesh.nix +++ b/nixos/mesh.nix @@ -69,8 +69,9 @@ in # Deliberately NOT wantedBy multi-user.target in this increment: the mesh identity (`nvpn init`) # and the peer roster/endpoints (`nvpn set`) are provisioned first (by onboarding, or by the # test), then this is started. A later increment provisions them declaratively and enables it at - # boot; it will also fully confine the daemon behind a dedicated `User=` (this still runs as root - # for now to reach /dev/net/tun and to read the root-provisioned config). + # boot. The daemon still runs as root (opening /dev/net/tun needs it here), but its filesystem + # blast radius is locked down below; dropping to a dedicated non-root User= is the remaining + # hardening step (blocked on making /dev/net/tun reachable without uid 0). serviceConfig = { ExecStart = "${lib.getExe cfg.package} connect"; # nvpn reads $HOME/.config/nvpn/config.toml. @@ -84,6 +85,17 @@ in CapabilityBoundingSet = [ "CAP_NET_ADMIN" ]; AmbientCapabilities = [ "CAP_NET_ADMIN" ]; NoNewPrivileges = true; + # Filesystem confinement: the daemon parses hostile WireGuard/Nostr traffic, so a memory-safety + # bug in boringtun must not be able to read the box's other secrets or write arbitrary files. + # ProtectSystem=strict makes the whole fs read-only except ReadWritePaths (only the mesh + # identity dir); ProtectHome hides /root and /home; PrivateTmp isolates /tmp. The tun device is + # allow-listed explicitly (DevicePolicy=closed denies all other device nodes). + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ReadWritePaths = [ cfg.stateDir ]; + DevicePolicy = "closed"; + DeviceAllow = [ "/dev/net/tun rw" ]; }; }; }; From 7e15a22821fb07d19e9c046871eba3b6b4099093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 3 Jul 2026 20:09:57 -0400 Subject: [PATCH 2/2] Harden the nvpn mesh daemon's syscall and kernel surface (security) --- nixos/mesh.nix | 37 +++++++++++++++++++++++++++++++++++-- tests/mesh.nix | 6 ++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/nixos/mesh.nix b/nixos/mesh.nix index 521deec..1dcef45 100644 --- a/nixos/mesh.nix +++ b/nixos/mesh.nix @@ -86,16 +86,49 @@ in AmbientCapabilities = [ "CAP_NET_ADMIN" ]; NoNewPrivileges = true; # Filesystem confinement: the daemon parses hostile WireGuard/Nostr traffic, so a memory-safety - # bug in boringtun must not be able to read the box's other secrets or write arbitrary files. + # bug in boringtun must not be able to write arbitrary files or tamper with the rest of the box. # ProtectSystem=strict makes the whole fs read-only except ReadWritePaths (only the mesh # identity dir); ProtectHome hides /root and /home; PrivateTmp isolates /tmp. The tun device is - # allow-listed explicitly (DevicePolicy=closed denies all other device nodes). + # allow-listed explicitly (DevicePolicy=closed denies all other device nodes). This is + # write/integrity confinement, NOT confidentiality: while still running as root the daemon can + # READ any other root-readable secret on disk (ProtectSystem only remounts read-only, it hides + # nothing beyond /root and /home) -- closing that read gap is the job of the deferred non-root + # User= drop, under which DAC alone denies a compromised daemon those root-owned key files. A + # later host-DNS increment (nvpn's `.fips` resolver writes /etc/systemd/resolved.conf.d) will + # also need those /etc paths added here; the static relay-less mesh in this increment does not. ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; ReadWritePaths = [ cfg.stateDir ]; DevicePolicy = "closed"; DeviceAllow = [ "/dev/net/tun rw" ]; + # Kernel- and syscall-surface confinement against that same boringtun-RCE threat, mirroring the + # Rust relay client hardened in frost-gate.nix. Address families: AF_INET/AF_INET6 for the UDP + # underlay, AF_NETLINK for the `ip` interface/route setup, AF_UNIX for local libc lookups; every + # other family is denied (no AF_PACKET raw sniffing, no AF_ALG). @system-service is the standard + # service syscall allow-set; boringtun is pure-Rust with no JIT, so MemoryDenyWriteExecute blocks + # injected shellcode without breaking it. ProtectKernelModules is safe because `tun` is loaded at + # boot (boot.kernelModules above), so the daemon never needs to load a module itself. + SystemCallFilter = [ "@system-service" ]; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_NETLINK" + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; }; }; }; diff --git a/tests/mesh.nix b/tests/mesh.nix index 75cf990..5cb7e71 100644 --- a/tests/mesh.nix +++ b/tests/mesh.nix @@ -96,6 +96,12 @@ timeout=90, ) + # The confinement is the whole point of this unit; assert the load-bearing directives are actually + # in effect (and the daemon still peered above WHILE confined) so a refactor can't silently drop + # them -- the daemon would keep forming the mesh either way, so the peering check alone won't catch it. + for prop in ["ProtectSystem=strict", "DevicePolicy=closed", "MemoryDenyWriteExecute=yes"]: + nodeA.succeed(f"systemctl show keep-node-mesh.service | grep -qx '{prop}'") + # 6. Reach the peer over the TUNNEL (its deterministic 10.44.x.y mesh IP), not the underlay. meshB = nodeA.succeed(f"{H} nvpn ip --peer --discover-secs 0").strip().splitlines()[0].strip() meshA = nodeB.succeed(f"{H} nvpn ip --peer --discover-secs 0").strip().splitlines()[0].strip()