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-boot with a signed version.
  • Key Management: Keys are stored in /etc/secureboot (persisted via /persist).
  • Tooling: sbctl is 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 makes resolv.conf management 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 by networking.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 sudo and logins.
  • Locking Hooks: Removing the token triggers hyprlock or i3lock via 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:

  1. security.pam.u2f.control = "sufficient" — YubiKey touch alone grants access.
  2. security.pam.u2f.settings.authfile = "/etc/agenix/u2f_keys" — the mapping of user → registered key handles.
  3. Per-service u2fAuth = true for sudo, 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 / i3lock is already running to avoid double-locking.
  • Iterates Wayland sockets to find the correct WAYLAND_DISPLAY.
  • Falls back to i3lock on 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

SymptomLikely CauseFix
”No U2F device found”YubiKey not plugged in or udev rules missingVerify lsusb | grep Yubico, check services.udev.packages includes yubikey-personalization
”authfile not found”Missing agenix secretVerify /etc/agenix/u2f_keys exists and is readable by your user (mode 0400)
sudo never prompts for U2Fsudo session cachedRun sudo -k first to clear the ticket
U2F prompt appears but touch does nothingDevice not flashed for U2F/FIDO2Run ykman info — ensure “FIDO U2F” or “FIDO2” is listed under applications
hyprlock not launching on key removalWayland socket not foundVerify $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):

KeyPathPersist mechanismScope
Host key/persist/etc/ssh/ssh_host_ed25519_keyenvironment.persistence bind-mount (ssh.nix)One per host
Global key/persist/etc/ssh/global_ed25519Directly on /persist btrfs subvolumeShared 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)

  1. 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'
  1. Add the host’s public key as a recipient in your nix-secrets repo, then rekey all secrets so the new host can decrypt them. See the agenix documentation for the exact rekey workflow (agenix --rekey / agenix -r).

  2. 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 NameModulePurpose
*.HashedPasswordFilesecurity.nixUser/Root password hashes.
cloudflare-api-tokenacme.nixDNS-01 challenge token for SSL.
forgejo-smtp-*forgejo.nixSMTP user/pass for Git notifications.
vaultwarden-smtp-*vaultwarden.nixSMTP credentials for Vaultwarden.
nextcloud-smtp-passnextcloud.nixSMTP password for Nextcloud.
kanidm-smtp-passkanidm.nixSMTP password for Identity service.
oauth2-proxy-envoauth2-proxy.nixOIDC Client ID/Secret & Cookie Secret.
*.wg0PrivateKeywg0.nixWireGuard private keys per host.
wireless.pskFilewifi.nixWiFi SSIDs and Passwords.
*.syncthing.key.pemsyncthing.nixDevice-specific Syncthing identity.
*.singboxConfigFilesing-box.nixEncrypted Sing-box proxy config.
*.luks-keyfilesecrets.nixBinary keyfiles for secondary drives.

Adding a New Secret

  1. Edit the mapping in the private nix-secrets repository or local hosts/<host>/secrets/secrets.nix.
  2. Encrypt the file: agenix -e mysecret.age.
  3. 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_filter and ICMP redirect rejection.
  • User Space: mutableUsers = false (enforces declarative passwords), execWheelOnly = true for 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-ld is our official escape hatch for maintaining productivity with external tools without resorting to messy manual patchelf calls.
  • Config: Libraries are managed in modules/security.nix.