Security Hardening
Security & Privacy Hardening
Defense-in-depth architecture: hardware token, secure boot, disk encryption, kernel hardening, application sandboxing, network isolation.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Layer 7: Application Sandbox (nixpak + bubblewrap) │
│ Discord, Telegram, Zoom, WeMeet │
├─────────────────────────────────────────────────────────────┤
│ Layer 6: MAC (AppArmor) │
│ killUnconfinedConfinables = true │
├─────────────────────────────────────────────────────────────┤
│ Layer 5: Network (Firewall + Fail2Ban + Tailscale) │
│ LAN-scoped rules, exponential bans, WireGuard mesh │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Secrets (Agenix) │
│ Per-host scoping, runtime decryption, SSH key identity │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: User Auth (YubiKey PAM U2F + SSH ed25519-sk) │
│ Auto-lock on removal, touch-required sudo │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Boot (Lanzaboote Secure Boot + LUKS2 + FIDO2) │
│ Signed kernel, encrypted root, hardware-unlocked LUKS │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: Kernel (sysctl hardening) │
│ kptr_restrict, dmesg_restrict, BPF hardening, ASLR │
├─────────────────────────────────────────────────────────────┤
│ Layer 0: Hardware (YubiKey, USBGuard, TPM2) │
│ Physical token, USB policy, measured boot │
└─────────────────────────────────────────────────────────────┘
Layer 0: Hardware Security
YubiKey (modules/profiles/hardware/yubikey.nix)
Your YubiKey serves multiple roles in the security stack:
| Function | Mechanism | Module |
|---|---|---|
| sudo/login auth | PAM U2F | security.pam.u2f |
| LUKS unlock | FIDO2 | systemd-cryptenroll --fido2-device=auto |
| SSH keys | ed25519-sk (non-exportable) | ssh-keygen -t ed25519-sk |
| Git commit signing | SSH signing | git config gpg.format ssh |
| Auto-lock on removal | udev rule | Triggers hyprlock/i3lock |
Auto-lock behavior: When the YubiKey is physically removed, a udev rule triggers the lock screen immediately. This protects against physical access attacks when you walk away from your workstation.
Touch requirement: sudo operations require physical touch on the YubiKey, preventing remote attackers from escalating privileges even with SSH access.
USBGuard (modules/profiles/hardware/usbguard.nix)
USBGuard blocks unauthorized USB devices by default:
implicitPolicyTarget = "block"; # Deny unknown devices
Status: Rules are placeholder stubs. To populate with real device IDs:
# After plugging in all your trusted devices:
usbguard generate-policy > /etc/usbguard/rules.conf
# Or use the NixOS option:
modules.hardware.usbguard.rules = [
"allow id 046d:c539 # Logitech receiver"
"allow id 1050:0407 # YubiKey"
];
TPM2 (Trusted Platform Module)
TPM2 can be used for measured boot and LUKS key sealing:
# Enroll TPM2 for LUKS (seals key to PCRs — tamper-evident):
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 /dev/nvme0n1p2
When sealed to PCRs, the LUKS key only releases if the boot chain (firmware → bootloader → kernel) hasn’t been tampered with.
Layer 1: Kernel Hardening (modules/security.nix)
The kernel is hardened via sysctl parameters:
Memory & Address Space
| Parameter | Value | Effect |
|---|---|---|
kernel.kptr_restrict | 2 | Hide kernel pointers from unprivileged users |
kernel.dmesg_restrict | 1 | Restrict dmesg to root only |
vm.mmap_rnd_bits | 32 | ASLR entropy for mmap |
vm.mmap_rnd_compat_bits | 16 | ASLR entropy for compat mode |
kernel.unprivileged_bpf_disabled | 1 | Disable BPF for unprivileged users |
net.core.bpf_jit_harden | 2 | Full BPF JIT hardening |
Network Hardening
| Parameter | Value | Effect |
|---|---|---|
net.ipv4.tcp_syncookies | 1 | SYN flood protection |
net.ipv4.tcp_rfc1337 | 1 | Protect against TIME-WAIT assassination |
net.ipv4.conf.all.rp_filter | 1 | Strict reverse path filtering |
net.ipv4.conf.all.accept_redirects | 0 | Ignore ICMP redirects |
net.ipv6.conf.all.accept_redirects | 0 | Ignore ICMPv6 redirects |
net.ipv4.conf.all.send_redirects | 0 | Don’t send ICMP redirects |
net.ipv4.icmp_echo_ignore_broadcasts | 1 | Ignore broadcast pings |
Blacklisted Kernel Modules
Dangerous/uncommon protocols and known-vulnerable modules are blacklisted:
boot.blacklistedKernelModules = [
"dccp" "sctp" "rds" "tipc" # rare protocols
"esp4" "esp6" "rxrpc" # Dirty Frag LPE mitigation
];
Vulnerability-driven blacklists are managed separately from static hardening — see docs/vulnerability-response.md for the active list, how to add new entries, and how to verify mitigations on a running host.
Coredumps
Disabled system-wide to prevent information leakage:
systemd.coredump.enable = false;
Performance (not hardening, but co-located)
net.core.default_qdisc = "cake"; # Buffer bloat protection
net.ipv4.tcp_congestion_control = "bbr"; # Google's congestion control
Layer 2: Secure Boot & Disk Encryption
Lanzaboote Secure Boot (modules/profiles/role/workstation.nix)
Lanzaboote signs the kernel and initrd with your own keys, preventing bootkit attacks:
boot.lanzaboote = {
enable = true;
pkiBundle = "/etc/secureboot"; # Keys persisted via impermanence
};
Key management: sbctl is installed for key creation and enrollment:
sbctl create-keys
sbctl enroll-keys --microsoft # Include Microsoft keys for dual-boot
sbctl verify # Check signing status
Active on: bio-smart host (boot = "lanzaboote")
LUKS2 Disk Encryption
Full-disk encryption with multiple unlock methods:
| Host | Encryption | Unlock Method |
|---|---|---|
id3-eniac | LUKS2 (2 partitions) | Keyfile + FIDO2 |
bio-smart | LUKS2 (root) | FIDO2 + password fallback |
sbc-opi5p | None (server) | N/A |
FIDO2 enrollment (one-time setup):
# Add FIDO2 as a LUKS unlock method:
sudo systemd-cryptenroll --fido2-device=auto /dev/nvme0n1p2
# Verify enrolled methods:
sudo systemd-cryptenroll /dev/nvme0n1p2
Agenix-managed keyfiles: LUKS keyfiles are stored as agenix secrets (*.luks-keyfile), decrypted at boot to /run/agenix/.
Layer 3: User Authentication
PAM U2F (modules/profiles/hardware/yubikey.nix)
All interactive authentication requires YubiKey touch:
security.pam.u2f = {
enable = true;
cue = true; # "Please touch the device"
authFile = config.age.secrets.yubikey-auth.path;
};
Protected services: sudo, login, greetd, hyprlock
SSH Authentication (modules/services/net/ssh.nix)
| Setting | Value | Effect |
|---|---|---|
PasswordAuthentication | false | No password login |
KbdInteractiveAuthentication | false | No keyboard-interactive |
PermitRootLogin | prohibit-password | Root only via keys |
X11Forwarding | false | No X11 tunneling |
MaxAuthTries | 3 | Limit attempts |
LoginGraceTime | 30 | 30s timeout |
SSH keys: ed25519-sk (YubiKey-backed, non-exportable) for signing and authentication.
Fail2Ban (modules/services/monitoring/fail2ban.nix)
Exponential ban times with a 7-day cap:
| Offense | Ban Duration |
|---|---|
| 1st | 1 hour |
| 2nd | 2 hours |
| 3rd | 4 hours |
| … | … |
| Max | 168 hours (7 days) |
Tailscale IP ranges are whitelisted to prevent self-lockout.
Vaultwarden has dedicated jails for login and admin endpoints.
Layer 4: Secrets Management (Agenix)
Architecture (modules/agenix.nix)
Agenix decrypts .age secrets at build time using two SSH identity keys:
nix-secrets/secrets/secrets.nix ← Secret definitions (encrypted)
│
▼
agenix decrypts using:
- Host key: /persist/etc/ssh/ssh_host_ed25519_key (per-host, Nix-generated)
- Global key: /persist/etc/ssh/global_ed25519 (optional, shared across hosts)
│
▼
/run/agenix/ ← Runtime (tmpfs, not persisted)
The host key is generated by NixOS and persisted via environment.persistence bind-mount in ssh.nix. The global key is placed directly on the /persist btrfs subvolume (no Nix config needed — files on /persist survive reboots by nature of being on a persistent filesystem). Agenix tries both keys; any missing key is silently skipped.
For the full bootstrap workflow (how keys get onto the machine in the first place), see docs/security.md.
Per-Host Scoping
Secrets can declare which hosts should receive them:
# In nix-secrets/secrets/secrets.nix:
"smtp-password".publicKeys = [id3-eniac bio-smart];
"wireguard-key".publicKeys = [vps-pacman];
# Secrets without 'nodes' are shared across all hosts
The filterByNodes logic in modules/agenix.nix filters out secrets not belonging to the current host, preventing unnecessary decryption.
Registered Secrets
| Secret | Purpose |
|---|---|
user-password | User login password |
root-password | Root password (emergency) |
smtp-password | Email relay auth |
wireguard-*.conf | VPN configs |
*-luks-keyfile | Disk encryption keys |
singbox-*.json | Proxy configs |
syncthing-*.pem | Syncthing certs |
wifi-psk | WiFi passwords |
Layer 5: Network Security
Firewall
Enabled by default with reverse path filtering:
networking.firewall = {
enable = true;
checkReversePath = "loose"; # Needed for Tailscale/WireGuard
};
LAN-scoped rules: Services like Spotify Connect, Steam Remote Play, and printers only accept connections from RFC1918 ranges:
iptables -A INPUT -p tcp --dport 57621 -s 192.168.0.0/16 -j ACCEPT
iptables -A INPUT -p tcp --dport 57621 -s 10.0.0.0/8 -j ACCEPT
Tailscale / WireGuard
| Network | Module | Use Case |
|---|---|---|
Tailscale (ts0) | modules/profiles/network/ts0.nix | Mesh VPN, MagicDNS, zero-config |
WireGuard (wg0) | modules/profiles/network/wg0.nix | Site-to-site VPN, manual config |
Tailscale ranges are whitelisted in fail2ban to prevent self-lockout.
Proxy (modules/services/net/sing-box.nix)
Sing-box handles traffic routing with protocol-level isolation. Workstation TUN configs should use encrypted DNS, strict routing, and explicit local-network exclusions:
- Remote DoH goes through the proxy path; China/private DNS goes through local DoH.
- TUN uses
auto_route,auto_redirect, andstrict_routeon Linux. - Tailscale, LAN, container bridge, libvirt, Waydroid, and MicroVM private ranges stay local.
- Public internet traffic from browsers, host services, and containers can still route through Sing-box.
- VLESS/REALITY multiplex stays disabled by default to reduce correlation and avoid server-compatibility surprises.
See docs/networking-proxy.md for the detailed Sing-box policy.
Layer 6: Mandatory Access Control (AppArmor)
security.apparmor.enable = true;
security.apparmor.killUnconfinedConfinables = true;
AppArmor confines processes to predefined profiles. killUnconfinedConfinables terminates any process that isn’t confined by an AppArmor profile — a strict policy that ensures all running processes are under MAC control.
Layer 7: Application Sandbox (Nixpak)
What is Nixpak?
Nixpak wraps GUI applications in bubblewrap-based sandboxes using a Flatpak-like model. It provides per-app DBus filtering via xdg-dbus-proxy, GPU passthrough, filesystem isolation, and Flatpak-compatible metadata — all built from native Nix derivations without ostree or runtimes.
Our integration lives in modules/sandbox.nix, which defines three shared nixpak modules (gui-base, network, common) and a mkNixPakApp helper. Per-app configs in the apps attrset import the shared modules and add app-specific bind mounts, sockets, and environment variables.
Sandbox Architecture
┌──────────────────────────┐
│ wrapperScript │
│ HOME=$XDG_FAKE_HOME │
│ XAUTHORITY fallback │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ nixpak launch script │
│ exports BWRAP_EXE, │
│ NIXPAK_APP_EXE, etc. │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ nixpak launcher (Go) │
│ Reads bwrap-args.json │
│ Starts xdg-dbus-proxy │
│ Creates .var dirs │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ bubblewrap (bwrap) │
│ --unshare-user/pid/net │
│ --ro-bind /nix/store │
│ --bind .var/app/<appId> │
│ --setenv HOME ... │
└──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ Sandboxed Application │
└──────────────────────────┘
What the Sandbox Provides
| Protection | Mechanism | Effect |
|---|---|---|
| HOME isolation | Wrapper sets HOME=~/.local/user before launcher | .var dirs and app data jailed under fake home |
| DBus filtering | xdg-dbus-proxy with per-app policies | Only whitelisted services visible |
| Filesystem whitelist | --ro-bind for specific paths | App can’t wander the filesystem |
| /nix/store access | --ro-bind /nix/store | Libraries and assets readable |
| Namespace isolation | --unshare-user/pid/net/uts | Process/network isolation |
| GPU passthrough | gpu.provider = "nixos" | Mesa drivers bundled, /dev/dri exposed |
| Wayland/X11 sockets | Conditional socket bindings | Apps get the display protocol they need |
| PipeWire audio | --ro-bind pipewire socket | Audio/video streams work |
| Child cleanup | --die-with-parent | No orphan processes |
| Theme consistency | XCURSOR_PATH, XDG_DATA_DIRS injected | Cursor and icon themes match the host |
Data Isolation Model
Nixpak apps follow a Flatpak-style data layout under $HOME/.var/app/<appId>/:
~/.local/user/.var/app/<appId>/
├── data/ → bind-mounted to $XDG_DATA_HOME
├── config/ → bind-mounted to $XDG_CONFIG_HOME
└── cache/ → bind-mounted to $XDG_CACHE_HOME
Because our wrapper sets HOME=~/.local/user before the nixpak launcher runs, the entire .var tree lives under the fake home. The configuration bridge (~/.local/user/.config → ~/.config) still applies for host config files exposed via bind.ro.
Shared Nixpak Modules
Three reusable modules in modules/sandbox.nix define the sandbox baseline:
| Module | Purpose |
|---|---|
gui-base | GPU passthrough, cursor/icon themes, locale, fonts, /etc bind mounts |
network | SSL certificates, /etc/resolv.conf, enables networking |
common | DBus policies (30+ SNI slots, portals, MPRIS, notifications), XDG dirs, PipeWire, Wayland |
Per-app configs import these via sharedModules and add app-specific bind.rw, sockets, and env.
Sandboxed Applications
| App | Category | AppId | Display | Why Sandboxed |
|---|---|---|---|---|
| Discord | Messaging | com.discord.Discord | Wayland + PipeWire | Proprietary, telemetry-heavy |
| Telegram | Messaging | org.telegram.desktop | Wayland + PipeWire | Official Telegram client |
| Zoom | Meeting | us.zoom.Zoom | X11 + PipeWire | Proprietary, past security issues |
| WeMeet | Meeting | com.tencent.wemeet | X11 + PipeWire | Tencent, camera/mic access |
Note: QQ (com.qq.QQ) works via nixpak after clearing stale config data. WeChat (com.tencent.WeChat) uses a dedicated bubblewrap wrapper in packages/wechat/ — nixpak’s --unshare-user conflicts with CEF’s internal GPU sandbox, so the custom wrapper provides PID/IPC/cgroup/tmpfs isolation without unsharing the user namespace. Data is stored under $XDG_FAKE_HOME/WeChat_Data/. Both are disabled by default; set modules.desktop.apps.messaging.{qq,wechat}.enable = true to enable.
Non-sandboxed messaging apps (Element, Fractal) are installed directly without wrapping since they’re open source.
How to Sandbox a New App
- Add a nixpak definition in
modules/sandbox.nix:
myapp = mkNixPakApp {
package = pkgs.myapp;
binPath = "bin/myapp"; # binary inside the package
appId = "com.example.MyApp"; # Flatpak-style app ID
extraConfig = {sloth, ...}: {
bubblewrap = {
bind.rw = [
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
];
sockets = {
x11 = false;
wayland = true;
pipewire = true;
};
};
};
};
- Add a toggle in the consuming module (e.g.
modules/desktop/apps/myapp.nix):
options.modules.desktop.apps.myapp = {
enable = mkBoolOpt false;
sandbox = mkBoolOpt true;
};
config = mkIf cfg.enable {
user.packages = [
(if cfg.sandbox
then pkgs.nixpaks.myapp
else wrapFakeHome pkgs.myapp "myapp")
];
};
The overlay at the bottom of modules/sandbox.nix automatically exposes all apps as pkgs.nixpaks.*.
When NOT to Use Nixpak
| Situation | Why Skip |
|---|---|
| Apps needing full filesystem access | --ro-bind whitelist is too restrictive |
| Apps that are DBus services themselves | xdg-dbus-proxy filtering may break them |
| Development tools (compilers, build systems) | Need full /nix/store write access |
| Open source apps with no telemetry | Unnecessary overhead; use wrapFakeHome instead |
| NixOS modules that manage their own binary (Spicetify) | Can’t wrap module-managed binaries |
Verifying the Sandbox
# Check if an app is running inside bwrap:
ps aux | grep bwrap
# Verify HOME isolation:
# Inside the sandbox:
echo $HOME # Should be ~/.local/user, not ~
ls ~/.var/app/ # Should show app data dirs
# Check DBus proxy:
ls /run/user/$(id -u)/nixpak-bus* # nixpak DBus sockets
# Verify process isolation:
# Inside the sandbox:
ls /proc/ # Only sandbox processes visible (unshare-pid)
App-by-App Security Posture
Browsers
| Browser | Sandbox | Extensions | Telemetry |
|---|---|---|---|
| LibreWolf | wrapFakeHome | uBlock, ClearURLs, LocalCDN, Facebook Container | Disabled (Betterfox) |
| Chromium | mkWrapper (flags only) | — | Ungoogled (no Google services) |
| Qutebrowser | None | Brave adblock | Minimal |
Recommendation: LibreWolf is the most hardened browser. Use it for untrusted browsing. Chromium for sites requiring Chrome compatibility.
Messaging
| App | Sandbox | Data Access | Network |
|---|---|---|---|
| Discord | nixpak | Fake HOME + .var | Full |
| Telegram | nixpak | Fake HOME + .var | Full |
| (disabled) | — | — | |
| bwrap (dedicated) | $XDG_FAKE_HOME/WeChat_Data/ | Full | |
| Element | None | Full (open source) | Full |
Meeting
| App | Sandbox | Data Access | Network |
|---|---|---|---|
| Zoom | nixpak | Fake HOME + .var | Full |
| WeMeet | nixpak | Fake HOME + .var | Full |
Editors
| Editor | Sandbox | Telemetry | Notes |
|---|---|---|---|
| Neovim | None | None | Open source, local only |
| VS Code | mkWrapper (HOME) | Microsoft (can disable) | Extensions run in-process |
| Cursor | mkWrapper (HOME) | Proprietary AI | Sends code to cloud |
| JetBrains | wrapFakeHome | JetBrains (can disable) | Phoning home for updates |
Security Checklist for New Hosts
When commissioning a new host, verify:
- LUKS2 encryption enabled on root partition
- FIDO2 enrolled:
sudo systemd-cryptenroll --fido2-device=auto /dev/... - YubiKey PAM configured:
modules.profiles.hardware = ["yubikey"] - SSH key-only auth:
modules.services.net.ssh.enable = true - Firewall enabled:
networking.firewall.enable = true - Fail2Ban active:
modules.services.monitoring.fail2ban.enable = true - Agenix secrets scoped: check
nodesattribute insecrets.nix - USBGuard configured: populate device ID rules
- AppArmor active:
security.apparmor.enable = true - Secure boot (workstations):
modules.profiles.boot = "lanzaboote" - Mutable users disabled:
users.mutableUsers = false
Related Documentation
| Doc | Coverage |
|---|---|
docs/security.md | Lanzaboote, DNS, LUKS/FIDO2/TPM2, YubiKey, Agenix, kernel hardening |
docs/vulnerability-response.md | Live vulnerability tracking, module blacklisting, verification |
docs/security-auth-logic.md | Authentication flow and PAM configuration |
docs/sso-identity.md | Kanidm, OAuth2 Proxy, Nginx auth_request |
docs/networking-vpn.md | Tailscale, WireGuard, Headscale, MagicDNS |
docs/networking-proxy.md | Sing-box, SSH tunnels, traffic routing |
docs/storage-disko.md | Disko, Btrfs+LUKS2, impermanence |