Security
Security Configuration & Infrastructure
Comprehensive system security leveraging hardware tokens, kernel hardening, and declarative secret management.
🛡️ Secure Boot (Lanzaboote)
For workstations requiring verified boot chains, we use Lanzaboote to implement UEFI Secure Boot.
- Status: Enabled on hosts with
role = "workstation/lanzaboote". - Implementation: Replaces standard
systemd-bootwith a signed version. - Key Management: Keys are stored in
/etc/secureboot(persisted via/persist). - Tooling:
sbctlis installed for manual key management and enrollment.
🚀 Secure Boot Workflow (Manual Setup)
If you are setting up Secure Boot for the first time or on a new host, follow these steps:
1. Generate Secure Boot Keys
If /etc/secureboot is empty, generate your persistent keys.
sudo sbctl create-keys
These keys are the root of trust for your system. Since they are located in /etc/secureboot, our persistence module ensures they survive reboots and reinstalls (via /persist/etc/secureboot).
2. Enter UEFI Setup Mode
- Reboot into your BIOS/UEFI settings.
- Navigate to Security -> Secure Boot.
- Select “Reset to Setup Mode” or “Delete all Secure Boot Variables/Keys”. This “unlocks” the firmware to accept new keys.
- Ensure Secure Boot Mode is set to Custom.
- Save and Reboot into NixOS.
3. Verify Setup Mode & Status
Run sbctl status.
- Setup Mode: Should show
✗ Enabled(Green/Ready for keys). - Secure Boot: Should show
✗ Disabled.
4. Enroll Keys with Microsoft Compatibility
Enroll your local keys into the firmware. We include the --microsoft flag to ensure that hardware drivers (like NVIDIA) and other OSes (like Windows) can still boot.
sudo sbctl enroll-keys --microsoft
5. Verify & Sign Binaries (Optional/Manual)
Lanzaboote automatically signs your NixOS kernels and initrd. However, if you have custom EFI binaries (e.g., specialized boot shells), you can sign them manually:
sudo sbctl sign -s /path/to/binary.efi
Check the current list of signed files:
sudo sbctl list-files
6. Final Activation
- Reboot back into BIOS/UEFI.
- Toggle Secure Boot to Enabled.
- Save and Reboot.
- Verify success with
sbctl status. Secure Boot should now show ✓ Enabled.
🌐 Network & DNS Configuration
We prioritize reliability and privacy by using a deterministic DNS configuration, avoiding the complexity of systemd-resolved on workstations.
Disabling systemd-resolved
By default, we disable systemd-resolved in modules/security.nix to prevent it from interfering with complex networking setups (like VPNs or local proxies).
- Why?: It avoids the “stub listener” on
127.0.0.53, which can cause issues with some legacy applications and makesresolv.confmanagement more transparent.
Setting Custom DNS Servers
DNS servers are configured via the modules.security.nameservers option (defaulting to Cloudflare and Google).
modules.security.nameservers = [ "1.1.1.1" "8.8.4.4" ];
This configuration is pushed into /etc/resolv.conf via openresolv. If you use DHCP, openresolv will typically combine your manual nameservers with those provided by the network. This ensures that your choice of private/secure nameservers is respected while still allowing local network resolution.
Configuration Accessibility (resolv.conf & nsswitch.conf)
In a hardened or jailed environment, applications must be able to resolve hostnames.
resolv.conf: Managed bynetworking.resolvconf. It is world-readable (644) by default.nsswitch.conf: Defines the lookup order (files, dns, etc.). It is also world-readable.
You can verify their accessibility by running:
ls -l /etc/resolv.conf /etc/nsswitch.conf
Both should show -rw-r--r-- (or a symlink pointing to a world-readable file). If you encounter “Temporary failure in name resolution” inside a jailed application (like those using wrapFakeHome), ensure that the jail has not restricted access to /etc. Our default wrapFakeHome only modifies the HOME environment variable and does not isolate the network or filesystem, so resolution should work out-of-the-box.
🔐 Encryption at Rest (LUKS2)
Sensitive data is protected via LUKS2 encryption, often integrated with systemd-initrd for modern unlock flows.
FIDO2 / TPM2 Enrollment
Instead of passphrases, we prefer hardware-backed unlocking:
FIDO2 (YubiKey/CanoKey)
sudo systemd-cryptenroll --fido2-device=auto --fido2-with-client-pin=true /dev/nvme0n1p2
TPM2 (Platform-tied)
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs="0+2+7" /dev/nvme0n1p2
🔑 Hardware Token Integration (YubiKey)
Centrally configured via modules/profiles/hardware/yubikey.nix.
- PAM U2F: Enforces hardware touch for
sudoand logins. - Locking Hooks: Removing the token triggers
hyprlockori3lockvia udev rules. - SSH/Git: Uses
ed25519-sk(Security Key) resident keys for non-exportable identities. - LUKS Unlock: FIDO2 can unlock LUKS2-encrypted root at boot (see LUKS2 section above).
How It Works (PAM Flow)
When modules.profiles.hardware = ["yubikey"] is active, yubikey.nix configures:
security.pam.u2f.control = "sufficient"— YubiKey touch alone grants access.security.pam.u2f.settings.authfile = "/etc/agenix/u2f_keys"— the mapping of user → registered key handles.- Per-service
u2fAuth = trueforsudo,login,greetd,hyprlock,i3lock.
The PAM stack tries U2F first. If you touch the key within the timeout, you’re authenticated. If no key is present or you don’t touch it, PAM falls through to the standard Unix password prompt (pam_unix.so).
Initial YubiKey Enrollment
Do this once per token on any secure machine.
1. Enable the YubiKey Profile
# In your host's configuration.nix:
modules.profiles.hardware = ["yubikey"];
Then rebuild:
hey sync
2. Generate the U2F Mapping File
Plug in your YubiKey and generate the auth mapping. The output is a colon-delimited line: <username>:<keyHandle1>,<keyHandle2,...>.
mkdir -p ~/.config/Yubico
pamu2fcfg -u${USER} > ~/.config/Yubico/u2f_keys
If your YubiKey has a FIDO2 PIN set, you’ll be prompted to enter it and then touch the key.
Register a backup key (recommended — append, don’t overwrite):
pamu2fcfg -u${USER} -n >> ~/.config/Yubico/u2f_keys
The -n flag appends the new key handle to the same user line. Without it, pamu2fcfg outputs a separate line for the same user, which would overwrite the first key’s registration.
Verify the file looks correct:
cat ~/.config/Yubico/u2f_keys
# Example output:
# alienzj:Z8xH+f9Km...firstKeyHandle...,R2tL+q7Jw...secondKeyHandle...
3. Encrypt with Agenix
The mapping file must never live in the world-readable /nix/store. Encrypt it:
agenix -e ~/.config/Yubico/u2f_keys.age
Place the .age file in your nix-secrets repo at the path expected by the module:
nix-secrets/secrets/<hostname>.<username>.u2f.age
The module auto-wires it via:
age.secrets."${hostName}.${userName}.u2f" = { mode = "0400"; ... };
environment.etc."agenix/u2f_keys" = { source = <decrypted-secret>; ... };
4. Rebuild and Test
hey sync
Test sudo:
sudo -k # clear cached credentials
sudo echo "U2F test"
# → prompt: "Please touch the device." — touch your YubiKey
Test login: lock your screen (hyprlock / i3lock), then unlock with the YubiKey.
If U2F fails, PAM falls back to your password automatically.
Registering Additional Keys
When you get a new YubiKey, re-run the append command:
pamu2fcfg -u${USER} -n >> ~/.config/Yubico/u2f_keys
Re-encrypt the updated file and rebuild. The old key handles remain valid — all registered keys can authenticate.
Revoking a Lost Key
If a YubiKey is lost, regenerate the file from scratch with only your remaining keys:
pamu2fcfg -u${USER} > ~/.config/Yubico/u2f_keys # first remaining key
pamu2fcfg -u${USER} -n >> ~/.config/Yubico/u2f_keys # second remaining key
Re-encrypt, rebuild. The lost key’s handle is no longer in the authfile and will be rejected.
udev Auto-Lock on Removal
The module includes a udev rule that triggers the screen lock when a YubiKey is physically removed (USB detach event matching ID_VENDOR_ID=1050, ID_MODEL_ID=0407). This protects against someone pulling your key while you’re away.
The lock script:
- Checks if
hyprlock/i3lockis already running to avoid double-locking. - Iterates Wayland sockets to find the correct
WAYLAND_DISPLAY. - Falls back to
i3lockon X11 hosts.
SSH with YubiKey (ed25519-sk)
Generate a non-exportable resident key:
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "$(whoami)@$(hostname)-yubikey"
-O resident: Store the key handle on the YubiKey so it’s portable.-O verify-required: Require PIN before each use.
Use the generated public key (~/.ssh/id_ed25519_sk.pub) wherever you’d use a normal SSH public key. The private key handle never leaves the hardware token.
Git Commit Signing with YubiKey
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_sk.pub
git config --global commit.gpgsign true
Commits are signed with the YubiKey-backed key — GitHub/GitLab will show them as “Verified (SSH)”.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| ”No U2F device found” | YubiKey not plugged in or udev rules missing | Verify lsusb | grep Yubico, check services.udev.packages includes yubikey-personalization |
| ”authfile not found” | Missing agenix secret | Verify /etc/agenix/u2f_keys exists and is readable by your user (mode 0400) |
| sudo never prompts for U2F | sudo session cached | Run sudo -k first to clear the ticket |
| U2F prompt appears but touch does nothing | Device not flashed for U2F/FIDO2 | Run ykman info — ensure “FIDO U2F” or “FIDO2” is listed under applications |
| hyprlock not launching on key removal | Wayland socket not found | Verify $XDG_RUNTIME_DIR/wayland-* sockets exist and hyprlock is installed |
🤐 Secret Management (Agenix)
We use Agenix to encrypt secrets using SSH keys. Secrets are decrypted at runtime into /run/agenix.
How Agenix Unlocks Secrets
Agenix tries two SSH identity keys at build time (modules/agenix.nix):
| Key | Path | Persist mechanism | Scope |
|---|---|---|---|
| Host key | /persist/etc/ssh/ssh_host_ed25519_key | environment.persistence bind-mount (ssh.nix) | One per host |
| Global key | /persist/etc/ssh/global_ed25519 | Directly on /persist btrfs subvolume | Shared across hosts |
Both paths live under /persist so they survive reboots on impermanent systems. Each key is optional — agenix silently skips any that don’t exist:
# modules/agenix.nix
identityPaths = [hostKey globalKey];
The host key alone is sufficient. The global key is a convenience: encrypt a secret once for all hosts instead of per-host.
Bootstrap & Fresh Install: Getting Keys onto the Target
The chicken-and-egg problem: agenix needs a private key to decrypt secrets, but on first boot no key exists yet.
Option A — Upload the global key (persist systems only)
If the target uses modules.persist (impermanence with /persist btrfs subvolume), copy the global key directly onto persistent storage:
# From your local machine:
scp ~/.ssh/global_ed25519 root@<target>:/persist/etc/ssh/global_ed25519
chmod 600 /persist/etc/ssh/global_ed25519
# Then deploy — agenix finds the key and decrypts all shared secrets:
hey ops deploy vps-ultraman vps_ultraman_root
The global key survives reboots because /persist is a persistent subvolume — no Nix config needed. This is the simplest path for shared secrets.
Option B — Use the per-host key (any system, persist or not)
- After first boot (with
password.mode = "bootstrap"for TTY access), get the host’s public key:
ssh root@<target> 'cat /etc/ssh/ssh_host_ed25519_key.pub'
-
Add the host’s public key as a recipient in your
nix-secretsrepo, then rekey all secrets so the new host can decrypt them. See the agenix documentation for the exact rekey workflow (agenix --rekey/agenix -r). -
Deploy — agenix decrypts using the host key:
hey ops deploy vps-ultraman vps_ultraman_root
This works for any system (with or without persist), but requires rekeying per host.
Option C — Bootstrap password mode (temporary bypass)
Set security.password.mode = "bootstrap" in the host config. This sets users.users.root.initialPassword = "nixos" for first-boot TTY access — bypassing agenix entirely until keys are in place. After bootstrap, switch to "deploy" and follow Option A or B.
See docs/hosts/vps-ultraman.md for a real bootstrap case study.
Without Persist: The Global Key Constraint
On systems that don’t use modules.persist, the global key at /persist/etc/ssh/global_ed25519 has nowhere to live — /persist is just a directory on tmpfs root, wiped on reboot. There is no way to make it survive without an out-of-band mechanism (e.g., a provisioning script that copies it on every boot).
For non-persist systems, Option B (per-host key) is the only viable path. The host key is generated by NixOS at /etc/ssh/ssh_host_ed25519_key and used in-place — no persistence needed as long as you don’t rebuild the entire root filesystem.
Secret Registry (Registry of used secrets)
| Secret Name | Module | Purpose |
|---|---|---|
*.HashedPasswordFile | security.nix | User/Root password hashes. |
cloudflare-api-token | acme.nix | DNS-01 challenge token for SSL. |
forgejo-smtp-* | forgejo.nix | SMTP user/pass for Git notifications. |
vaultwarden-smtp-* | vaultwarden.nix | SMTP credentials for Vaultwarden. |
nextcloud-smtp-pass | nextcloud.nix | SMTP password for Nextcloud. |
kanidm-smtp-pass | kanidm.nix | SMTP password for Identity service. |
oauth2-proxy-env | oauth2-proxy.nix | OIDC Client ID/Secret & Cookie Secret. |
*.wg0PrivateKey | wg0.nix | WireGuard private keys per host. |
wireless.pskFile | wifi.nix | WiFi SSIDs and Passwords. |
*.syncthing.key.pem | syncthing.nix | Device-specific Syncthing identity. |
*.singboxConfigFile | sing-box.nix | Encrypted Sing-box proxy config. |
*.luks-keyfile | secrets.nix | Binary keyfiles for secondary drives. |
Adding a New Secret
- Edit the mapping in the private
nix-secretsrepository or localhosts/<host>/secrets/secrets.nix. - Encrypt the file:
agenix -e mysecret.age. - Reference it in your module:
config.age.secrets.mysecret.path.
🏗️ Kernel & System Hardening
Managed via modules/security.nix, implementing:
- AppArmor: Mandatory Access Control enabled for all supported services.
- Sysctl Tweaks:
kernel.kptr_restrict = 2: Hide kernel pointers.kernel.unprivileged_bpf_disabled = 1: Prevent eBPF abuse.net.ipv4.tcp_syncookies = 1: SYN flood protection.net.ipv4.tcp_congestion_control = "bbr": Optimized throughput.
- Network: Spoofing protection via
rp_filterand ICMP redirect rejection. - User Space:
mutableUsers = false(enforces declarative passwords),execWheelOnly = truefor sudo.
🧩 Compatibility Layer (nix-ld)
To support pre-compiled binaries that expect a standard Filesystem Hierarchy (FHS)—such as the VS Code Server, Node.js binaries, or specialized developer tools—we enable nix-ld.
- Purpose: It provides a dynamic loader bridge and a common set of shared libraries (e.g.,
glibc,zlib,libstdc++,openssl). - Policy: While we prefer native Nix derivations,
nix-ldis our official escape hatch for maintaining productivity with external tools without resorting to messy manualpatchelfcalls. - Config: Libraries are managed in
modules/security.nix.