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:

FunctionMechanismModule
sudo/login authPAM U2Fsecurity.pam.u2f
LUKS unlockFIDO2systemd-cryptenroll --fido2-device=auto
SSH keysed25519-sk (non-exportable)ssh-keygen -t ed25519-sk
Git commit signingSSH signinggit config gpg.format ssh
Auto-lock on removaludev ruleTriggers 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

ParameterValueEffect
kernel.kptr_restrict2Hide kernel pointers from unprivileged users
kernel.dmesg_restrict1Restrict dmesg to root only
vm.mmap_rnd_bits32ASLR entropy for mmap
vm.mmap_rnd_compat_bits16ASLR entropy for compat mode
kernel.unprivileged_bpf_disabled1Disable BPF for unprivileged users
net.core.bpf_jit_harden2Full BPF JIT hardening

Network Hardening

ParameterValueEffect
net.ipv4.tcp_syncookies1SYN flood protection
net.ipv4.tcp_rfc13371Protect against TIME-WAIT assassination
net.ipv4.conf.all.rp_filter1Strict reverse path filtering
net.ipv4.conf.all.accept_redirects0Ignore ICMP redirects
net.ipv6.conf.all.accept_redirects0Ignore ICMPv6 redirects
net.ipv4.conf.all.send_redirects0Don’t send ICMP redirects
net.ipv4.icmp_echo_ignore_broadcasts1Ignore 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:

HostEncryptionUnlock Method
id3-eniacLUKS2 (2 partitions)Keyfile + FIDO2
bio-smartLUKS2 (root)FIDO2 + password fallback
sbc-opi5pNone (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)

SettingValueEffect
PasswordAuthenticationfalseNo password login
KbdInteractiveAuthenticationfalseNo keyboard-interactive
PermitRootLoginprohibit-passwordRoot only via keys
X11ForwardingfalseNo X11 tunneling
MaxAuthTries3Limit attempts
LoginGraceTime3030s 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:

OffenseBan Duration
1st1 hour
2nd2 hours
3rd4 hours
Max168 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

SecretPurpose
user-passwordUser login password
root-passwordRoot password (emergency)
smtp-passwordEmail relay auth
wireguard-*.confVPN configs
*-luks-keyfileDisk encryption keys
singbox-*.jsonProxy configs
syncthing-*.pemSyncthing certs
wifi-pskWiFi 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

NetworkModuleUse Case
Tailscale (ts0)modules/profiles/network/ts0.nixMesh VPN, MagicDNS, zero-config
WireGuard (wg0)modules/profiles/network/wg0.nixSite-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, and strict_route on 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

ProtectionMechanismEffect
HOME isolationWrapper sets HOME=~/.local/user before launcher.var dirs and app data jailed under fake home
DBus filteringxdg-dbus-proxy with per-app policiesOnly whitelisted services visible
Filesystem whitelist--ro-bind for specific pathsApp can’t wander the filesystem
/nix/store access--ro-bind /nix/storeLibraries and assets readable
Namespace isolation--unshare-user/pid/net/utsProcess/network isolation
GPU passthroughgpu.provider = "nixos"Mesa drivers bundled, /dev/dri exposed
Wayland/X11 socketsConditional socket bindingsApps get the display protocol they need
PipeWire audio--ro-bind pipewire socketAudio/video streams work
Child cleanup--die-with-parentNo orphan processes
Theme consistencyXCURSOR_PATH, XDG_DATA_DIRS injectedCursor 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:

ModulePurpose
gui-baseGPU passthrough, cursor/icon themes, locale, fonts, /etc bind mounts
networkSSL certificates, /etc/resolv.conf, enables networking
commonDBus 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

AppCategoryAppIdDisplayWhy Sandboxed
DiscordMessagingcom.discord.DiscordWayland + PipeWireProprietary, telemetry-heavy
TelegramMessagingorg.telegram.desktopWayland + PipeWireOfficial Telegram client
ZoomMeetingus.zoom.ZoomX11 + PipeWireProprietary, past security issues
WeMeetMeetingcom.tencent.wemeetX11 + PipeWireTencent, 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

  1. 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;
      };
    };
  };
};
  1. 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

SituationWhy Skip
Apps needing full filesystem access--ro-bind whitelist is too restrictive
Apps that are DBus services themselvesxdg-dbus-proxy filtering may break them
Development tools (compilers, build systems)Need full /nix/store write access
Open source apps with no telemetryUnnecessary 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

BrowserSandboxExtensionsTelemetry
LibreWolfwrapFakeHomeuBlock, ClearURLs, LocalCDN, Facebook ContainerDisabled (Betterfox)
ChromiummkWrapper (flags only)Ungoogled (no Google services)
QutebrowserNoneBrave adblockMinimal

Recommendation: LibreWolf is the most hardened browser. Use it for untrusted browsing. Chromium for sites requiring Chrome compatibility.

Messaging

AppSandboxData AccessNetwork
DiscordnixpakFake HOME + .varFull
TelegramnixpakFake HOME + .varFull
QQ(disabled)
WeChatbwrap (dedicated)$XDG_FAKE_HOME/WeChat_Data/Full
ElementNoneFull (open source)Full

Meeting

AppSandboxData AccessNetwork
ZoomnixpakFake HOME + .varFull
WeMeetnixpakFake HOME + .varFull

Editors

EditorSandboxTelemetryNotes
NeovimNoneNoneOpen source, local only
VS CodemkWrapper (HOME)Microsoft (can disable)Extensions run in-process
CursormkWrapper (HOME)Proprietary AISends code to cloud
JetBrainswrapFakeHomeJetBrains (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 nodes attribute in secrets.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

DocCoverage
docs/security.mdLanzaboote, DNS, LUKS/FIDO2/TPM2, YubiKey, Agenix, kernel hardening
docs/vulnerability-response.mdLive vulnerability tracking, module blacklisting, verification
docs/security-auth-logic.mdAuthentication flow and PAM configuration
docs/sso-identity.mdKanidm, OAuth2 Proxy, Nginx auth_request
docs/networking-vpn.mdTailscale, WireGuard, Headscale, MagicDNS
docs/networking-proxy.mdSing-box, SSH tunnels, traffic routing
docs/storage-disko.mdDisko, Btrfs+LUKS2, impermanence