diff --git a/docs/superpowers/plans/2026-07-25-hardened-nixos-module.md b/docs/superpowers/plans/2026-07-25-hardened-nixos-module.md new file mode 100644 index 0000000..6d6e848 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-hardened-nixos-module.md @@ -0,0 +1,363 @@ +# Hardened NixOS Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a reusable NixOS module for security hardening with impermanence, secureboot, LUKS, AppArmor, and TPM-backed secrets, plus a `harden/` folder for VM/ISO testing. + +**Architecture:** Modular NixOS modules under `modules/` with toggleable features. `harden/` folder uses these modules with disko for declarative partitioning, native NixOS image building, and just for build commands. + +**Tech Stack:** NixOS, Lanzaboote, disko, agenix, just, btrfs, LUKS2, TPM2, AppArmor + +## Global Constraints + +- NixOS unstable channel +- x86_64-linux architecture +- Lanzaboote latest (v1.0.0 is incompatible with current nixpkgs — see below) +- disko latest +- agenix with age-plugin-tpm +- Follow ed209.nix-config patterns for impermanence and secureboot + +--- + +## File Structure + +``` +modules/ +├── hardened.nix # Main module, imports submodules based on options +├── impermanence.nix # Btrfs rollback, persistent partition +├── secureboot.nix # Lanzaboote UKI signing +├── luks.nix # LUKS2 + TPM2 +├── kernel.nix # Hardened sysctl + params +├── apparmor.nix # AppArmor MAC +├── agenix.nix # TPM-backed secrets +└── user.nix # Root disabled, SSH key auth + +harden/ +├── flake.nix # Inputs: nixpkgs, impermanence, lanzaboote, disko, agenix +├── configuration.nix # Imports all modules, enables features +├── hardware-vm.nix # QEMU guest config for VM testing +├── disko.nix # Declarative partition layout +├── justfile # vm, iso, qcow, verify, clean +└── tests/ + └── verify.sh # Automated security checks +``` + +--- + +## Changes from Original Plan + +The following changes were made during implementation and testing. Each explains **what** changed and **why**. + +### 1. Impermanence import path (Task 11: `configuration.nix`) + +- **Plan:** `"${inputs.impermanence}/nixos-modules/impermanence.nix"` +- **Actual:** `"${inputs.impermanence}/nixos.nix"` +- **Why:** The impermanence flake provides its NixOS module at `nixos.nix`, not `nixos-modules/impermanence.nix`. Verified via `nix eval github:nix-community/impermanence#nixosModules`. + +### 2. `` removed from `hardware-vm.nix` (Task 10) + +- **Plan:** `"${toString /nixos/modules/profiles/qemu-guest.nix}"` +- **Actual:** `"${nixpkgsPath}/nixos/modules/profiles/qemu-guest.nix"` via `specialArgs` +- **Why:** Flakes evaluate in pure mode. `` is an impure channel reference that fails with `cannot look up '' in pure evaluation mode`. The flake passes `nixpkgsPath = nixpkgs.outPath` through `specialArgs`. + +### 3. Lanzaboote NixOS module import (Task 11: `configuration.nix`) + +- **Plan:** Not imported (assumed auto-loaded by flake input) +- **Actual:** Added `inputs.lanzaboote.nixosModules.lanzaboote` to imports +- **Why:** Flake inputs provide packages, not NixOS modules. The lanzaboote module must be explicitly imported for `boot.lanzaboote.*` options to exist. Without it: `The option 'boot.lanzaboote' does not exist`. + +### 4. `boot.systemd-boot` → `boot.loader.systemd-boot` (Task 3: `secureboot.nix`) + +- **Plan:** `boot.systemd-boot = { enable = lib.mkForce false; ... }` +- **Actual:** `boot.loader.systemd-boot = { enable = lib.mkForce false; ... }` +- **Why:** The correct NixOS option path is `boot.loader.systemd-boot`, not `boot.systemd-boot`. The plan had a typo. + +### 5. Lanzaboote version: latest instead of v1.0.0 (Task 9: `flake.nix`) + +- **Plan:** `url = "github:nix-community/lanzaboote/v1.0.0"` +- **Actual:** `url = "github:nix-community/lanzaboote"` (latest) +- **Why:** Lanzaboote v1.0.0 sets `boot.bootspec.enable` which was removed from current nixpkgs unstable. This causes: `The option 'boot.bootspec.enable' no longer has any effect`. The latest version fixed this compatibility issue. + +### 6. AppArmor options corrected (Task 6: `apparmor.nix`) + +- **Plan:** `killUnconfined = true; confineInitial = true;` + kernel params `apparmor=1 lsm=apparmor` +- **Actual:** `killUnconfinedConfinables = true;` (no kernel params needed) +- **Why:** In current nixpkgs, `confineInitial` and `killUnconfined` were renamed/removed. The correct option is `killUnconfinedConfinables`. The `apparmor=1` kernel param and `lsm=apparmor` are now handled internally by the AppArmor NixOS module (`security.lsm = [ "apparmor" ]`), so manually setting them causes conflicts. + +### 7. LUKS `tpms2` → removed (Task 4: `luks.nix`) + +- **Plan:** `tpms2 = lib.mkIf cfg.luks.tpm { enable = true; }` +- **Actual:** TPM2 support removed; module only declares the `tpm` option for future use +- **Why:** Neither `tpms2` nor `tpm2` exist as options under `boot.initrd.luks.devices.*` in current nixpkgs. The available sub-options are `fido2`, `name`, `device` etc. TPM2 LUKS binding would need a different mechanism (likely systemd-cryptenroll, not NixOS module options). + +### 8. LUKS device path conflict removed (Task 4: `luks.nix`) + +- **Plan:** `device = "/dev/disk/by-label/cryptroot"` hardcoded +- **Actual:** No device path set; disko provides it +- **Why:** disko already declares `boot.initrd.luks.devices.cryptroot.device` from the partition layout. A second definition causes: `The option 'boot.initrd.luks.devices.cryptroot.device' has conflicting definition values`. + +### 9. `virtualisation.cores` removed (Task 10: `hardware-vm.nix`, Task 9: `flake.nix`) + +- **Plan:** `virtualisation = { diskSize = 20480; memorySize = 4096; cores = 2; }` +- **Actual:** `virtualisation = { diskSize = 20480; memorySize = 4096; }` +- **Why:** `virtualisation.cores` is not provided by the `qemu-guest.nix` profile. It's only available when the `qemu-vm.nix` module is loaded (which happens implicitly with `nixos-rebuild build-vm` but not during `nix eval`). + +### 10. Impermanence `neededForBoot` (Task 2: `impermanence.nix`) + +- **Plan:** Not set +- **Actual:** Added `fileSystems."/persistent".neededForBoot = true;` +- **Why:** The `environment.persistence` module requires all persistent filesystems to have `neededForBoot = true`. Without it: `All filesystems used for persistent storage must have the option "neededForBoot" set to true`. + +### 11. Duplicate `/etc/secureboot` in agenix (Task 7: `agenix.nix`) + +- **Plan:** `directories = [ "/etc/secureboot" ];` in agenix persistence +- **Actual:** `directories = [ "/var/lib/agenix" ];` +- **Why:** `/etc/secureboot` was already declared in `secureboot.nix`'s persistence block. Duplicating it causes: `The following directories were specified two or more times: /etc/secureboot`. + +### 12. Duplicate `luks.tpm` option (Task 1: `hardened.nix`) + +- **Plan:** `luks.tpm = lib.mkEnableOption "TPM-based LUKS unlock";` in main module +- **Actual:** Removed from `hardened.nix`; only declared in `luks.nix` +- **Why:** NixOS options must be declared exactly once. Declaring `hardened.luks.tpm` in both files causes: `The option 'hardened.luks.tpm' is already declared in ...`. + +### 13. Impermanence disabled in VM config (Task 11: `configuration.nix`) + +- **Plan:** `impermanence.enable = true;` +- **Actual:** `impermanence.enable = false; # Requires disko-installed disk layout` +- **Why:** `nixos-rebuild build-vm` creates a `vmVariant` that doesn't include disko's filesystem definitions. The impermanence assertions check for btrfs root and `/persistent` mount, which don't exist in the VM variant. Only testable after a real `disko-install`. + +### 14. `sysrq` sysctl path (Task 5: `kernel.nix`) + +- **Plan:** `"sysrq" = 0;` +- **Actual:** `"kernel.sysrq" = 0;` +- **Why:** NixOS sysctl options under `boot.kernel.sysctl` require the full kernel path prefix. Without `kernel.` prefix, the value resolves to the wrong sysctl and `cat /proc/sys/kernel/sysrq` returns 16 instead of 0. Caught during runtime VM testing. + +--- + +## Verification Results + +### Static Verification (nix eval / build) + +| Module | Eval | Build | Notes | +|--------|------|-------|-------| +| kernel.hardened | ✅ | ✅ | sysctl values verified: kptr_restrict=2, dmesg_restrict=1, sysrq=0 | +| apparmor | ✅ | ✅ | enable=true, killUnconfinedConfinables=true | +| secureboot | ✅ | ✅ | lanzaboote.enable=true, sbctl in packages | +| user | ✅ | ✅ | mutableUsers=true, root hashedPassword="!" | +| luks | ✅ | ✅ | device from disko, allowDiscards=true | +| agenix | ✅ | ✅ | ageBin with age-plugin-tpm, identityPaths=[/persistent/age-tpm.txt] | +| impermanence | ✅ | ❌ (VM) | Assertions require disko-installed disk; disabled for VM testing | +| **Full config** | ✅ | ✅ | `nixos-rebuild build-vm` produces bootable VM | + +### Runtime Verification (SSH into running VM) + +| Check | Expected | Actual | Status | +|-------|----------|--------|--------| +| `lockdown=confidentiality` | in cmdline | present | ✅ | +| `module.sig_enforce=1` | in cmdline | present | ✅ | +| `slab_nomerge` | in cmdline | present | ✅ | +| `init_on_alloc=1` | in cmdline | present | ✅ | +| `init_on_free=1` | in cmdline | present | ✅ | +| `apparmor=1` | in cmdline | present | ✅ | +| `kernel.kptr_restrict` | 2 | 2 | ✅ | +| `kernel.dmesg_restrict` | 1 | 1 | ✅ | +| `kernel.sysrq` | 0 | 0 | ✅ | +| `kernel.modules_disabled` | 0 | 0 | ✅ | +| AppArmor module | loaded | loaded | ✅ | +| Failed services | none (or expected SB only) | generate-sb-keys, prepare-sb-auto-enroll (expected in QEMU VM) | ✅ | +| User `nixos` | exists, uid=1000 | uid=1000(nixos) | ✅ | +| User in `wheel` | yes | yes | ✅ | +| `PasswordAuthentication` | no | no | ✅ | +| `PermitRootLogin` | prohibit-password | prohibit-password | ✅ | + +--- + +### Task 1: Create main hardened module + +**Files:** +- Create: `modules/hardened.nix` + +**Interfaces:** +- Consumes: Nothing (entry point) +- Produces: `options.hardened.*` options, imports submodules + +- [x] **Step 1: Create the main module** + +```nix +{ + config, + lib, + ... +}: let + cfg = config.hardened; +in { + options.hardened = { + impermanence.enable = lib.mkEnableOption "tmpfs-as-root impermanence"; + secureboot.enable = lib.mkEnableOption "Lanzaboote secure boot"; + luks.enable = lib.mkEnableOption "LUKS full-disk encryption"; + apparmor.enable = lib.mkEnableOption "AppArmor MAC"; + kernel.hardened = lib.mkEnableOption "hardened kernel sysctl + params"; + agenix.enable = lib.mkEnableOption "TPM-backed agenix secrets"; + user.enable = lib.mkEnableOption "hardened user config"; + }; + + imports = [ + ./impermanence.nix + ./secureboot.nix + ./luks.nix + ./kernel.nix + ./apparmor.nix + ./agenix.nix + ./user.nix + ]; +} +``` + +- [x] **Step 2: Verify module loads** + +- [x] **Step 3: Commit** + +--- + +### Task 2: Create impermanence module + +**Files:** +- Create: `modules/impermanence.nix` + +**Interfaces:** +- Consumes: `config.hardened.impermanence.enable` +- Produces: `boot.initrd.systemd.services.rollback`, `environment.persistence."/persistent"` + +- [x] **Step 1: Create the impermanence module** (with `neededForBoot` fix) + +- [x] **Step 2: Commit** + +--- + +### Task 3: Create secureboot module + +**Files:** +- Create: `modules/secureboot.nix` + +**Interfaces:** +- Consumes: `config.hardened.secureboot.enable`, `config.hardened.impermanence.enable` +- Produces: `boot.lanzaboote.*`, `environment.persistence."/persistent".directories` + +- [x] **Step 1: Create the secureboot module** (with `boot.loader.systemd-boot` fix) + +- [x] **Step 2: Commit** + +--- + +### Task 4: Create LUKS module + +**Files:** +- Create: `modules/luks.nix` + +**Interfaces:** +- Consumes: `config.hardened.luks.enable`, `config.hardened.luks.tpm` +- Produces: Boot-time LUKS configuration + +- [x] **Step 1: Create the LUKS module** (TPM2 removed — option doesn't exist in nixpkgs) + +- [x] **Step 2: Commit** + +--- + +### Task 5: Create kernel hardening module + +**Files:** +- Create: `modules/kernel.nix` + +- [x] **Step 1-2: Create and commit** (no changes needed from plan) + +--- + +### Task 6: Create AppArmor module + +**Files:** +- Create: `modules/apparmor.nix` + +- [x] **Step 1: Create the AppArmor module** (fixed options: `killUnconfinedConfinables`, removed kernel params) + +- [x] **Step 2: Commit** + +--- + +### Task 7: Create agenix module + +**Files:** +- Create: `modules/agenix.nix` + +- [x] **Step 1: Create the agenix module** (fixed persistence path to `/var/lib/agenix`) + +- [x] **Step 2: Commit** + +--- + +### Task 8: Create user module + +**Files:** +- Create: `modules/user.nix` + +- [x] **Step 1-2: Create and commit** (no changes needed from plan) + +--- + +### Task 9: Create harden flake + +**Files:** +- Create: `harden/flake.nix` + +- [x] **Step 1: Create the flake** (lanzaboote latest, removed virtualisation.cores, added nixpkgsPath) + +- [x] **Step 2: Commit** + +--- + +### Task 10: Create hardware-vm.nix + +**Files:** +- Create: `harden/hardware-vm.nix` + +- [x] **Step 1: Create the hardware config** (pure-mode nixpkgsPath, removed virtualisation block) + +- [x] **Step 2: Commit** + +--- + +### Task 11: Create configuration.nix + +**Files:** +- Create: `harden/configuration.nix` + +- [x] **Step 1: Create the main config** (fixed impermanence path, added lanzaboote import, disabled impermanence for VM) + +- [x] **Step 2: Commit** + +--- + +### Task 12: Create disko.nix + +**Files:** +- Create: `harden/disko.nix` + +- [x] **Step 1-2: Create and commit** (no changes needed from plan) + +--- + +### Task 13: Create justfile + +**Files:** +- Create: `harden/justfile` + +- [x] **Step 1-2: Create and commit** (no changes needed from plan) + +--- + +### Task 14: Create verify.sh + +**Files:** +- Create: `harden/tests/verify.sh` + +- [x] **Step 1-3: Create, chmod, and commit** (no changes needed from plan) diff --git a/docs/superpowers/specs/2026-07-25-hardened-nixos-module-design.md b/docs/superpowers/specs/2026-07-25-hardened-nixos-module-design.md new file mode 100644 index 0000000..3a0de7b --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-hardened-nixos-module-design.md @@ -0,0 +1,192 @@ +# Hardened NixOS Module + +Reusable NixOS module for security hardening: impermanence, secureboot, LUKS, hardened kernel, AppArmor, and TPM-backed secrets. Includes a `harden/` folder for VM/ISO testing using `just` and native NixOS image building. + +## Goals + +- Single `modules/hardened.nix` that any NixOS config can import +- Toggle features via options (`hardened.impermanence.enable`, etc.) +- VM/ISO testing folder with `just` commands +- Automated verification tests +- Declarative disk partitioning with disko + +## Module: `modules/hardened.nix` + +### Options + +```nix +options.hardened = { + impermanence.enable = lib.mkEnableOption "tmpfs-as-root impermanence"; + secureboot.enable = lib.mkEnableOption "Lanzaboote secure boot"; + luks.enable = lib.mkEnableOption "LUKS full-disk encryption"; + luks.tpm = lib.mkEnableOption "TPM-based LUKS unlock"; + apparmor.enable = lib.mkEnableOption "AppArmor MAC"; + kernel.hardened = lib.mkEnableOption "hardened kernel sysctl + params"; + agenix.enable = lib.mkEnableOption "TPM-backed agenix secrets"; + user.enable = lib.mkEnableOption "hardened user config (disable root, SSH keys)"; +}; +``` + +### Impermanence + +Based on [ed209 template](https://tangled.org/ed209.dev/nix-config/blob/main/modules/nixos/system/impermanence/default.nix): + +- tmpfs as root (`/`) +- Btrfs rollback on boot via `boot.initrd.systemd.services.rollback` +- Persistent `/persistent` partition for: + - Directories: `/var/lib/nixos`, `/var/lib/NetworkManager`, `/var/lib/systemd`, `/root`, etc. + - Files: `/etc/machine-id`, SSH host keys, `/etc/adjtime` +- Requires btrfs root and `/persistent` partition +- LUKS device detection tries `/dev/mapper/enc` and `/dev/mapper/cryptroot` + +### Secure Boot + +Based on [ed209 template](https://tangled.org/ed209.dev/nix-config/blob/main/modules/nixos/system/boot/default.nix): + +- Lanzaboote UKI signing +- `autoGenerateKeys.enable = true` +- `autoEnrollKeys` with `includeMicrosoftKeys = false` +- `sbctl` package for manual key management +- Persists `/etc/secureboot` when impermanence is enabled +- Disables `systemd-boot` when secure boot is enabled + +### LUKS + +- Full-disk encryption via disko declarative partitioning +- TPM2-based unlock when `luks.tpm = true` +- Passphrase fallback +- Device naming: `/dev/mapper/cryptroot` + +### Disko (Declarative Disk) + +Based on [ed209 pattern](https://tangled.org/ed209.dev/nix-config/blob/main/flake.nix): + +- Declarative partition layout +- LUKS container with btrfs subvolumes +- `disko-install` for ISO installation +- Supports both VM (`vda`) and bare metal (`nvme0n1`) device names + +### Kernel Hardening + +- `security.lockdown = "confidentiality"` +- `module.sig_enforce = 1` +- `sysrq = 0` +- `kernel.kptr_restrict = 2` +- `kernel.dmesg_restrict = 1` +- `fs.suid_dumpable = 0` +- `kernel.unprivileged_bpf_disabled = 1` +- `net.core.bpf_jit_harden = 2` + +### AppArmor + +- `security.apparmor.enable = true` +- `boot.kernelParams = ["apparmor=1", "lsm=apparmor"]` +- `security.apparmor.killUnconfined = true` +- `security.apparmor.confineInitial = true` + +### Agenix + TPM + +Based on [ed209 template](https://tangled.org/ed209.dev/nix-config/blob/main/modules/nixos/system/agenix/default.nix): + +- TPM kernel modules: `tpm_crb`, `tpm_tis` +- `age-plugin-tpm` for TPM-backed secret decryption +- Identity path: `/persistent/age-tpm.txt` +- Persists TPM key in `/persistent` when impermanence is enabled + +### User Module + +Based on [ed209 template](https://tangled.org/ed209.dev/nix-config/blob/main/modules/nixos/user/default.nix): + +- Disable root login (`hashedPassword = "!"`) +- SSH key-based authentication only +- Fish shell as default +- Groups: `networkmanager`, `wheel`, `kvm`, `libvirtd` + +## Folder: `harden/` + +``` +harden/ +├── flake.nix +├── configuration.nix +├── hardware-vm.nix +├── justfile +├── modules/ +│ ├── impermanence.nix +│ ├── secureboot.nix +│ ├── luks.nix +│ ├── kernel.nix +│ ├── apparmor.nix +│ ├── agenix.nix +│ └── user.nix +└── tests/ + └── verify.sh +``` + +### flake.nix + +Inputs: +- `nixpkgs` (nixos-unstable) +- `impermanence` (github:nix-community/impermanence) +- `lanzaboote` (github:nix-community/lanzaboote/v1.0.0) +- `disko` (github:nix-community/disko/latest) +- `agenix` (github:ryantm/agenix) + +Outputs: +- `nixosConfigurations.vm` — VM config with QEMU guest module +- `nixosConfigurations.iso` — Bootable ISO image +- `packages.x86_64-linux.qcow` — qcow2 VM image via native NixOS builder +- `packages.x86_64-linux.default` — Dev tools (just, alejandra, etc.) + +### justfile + +```just +default: vm + +vm: + nixos-rebuild build-vm --flake .#vm + +iso: + nix build .#iso + +qcow: + nix build .#qcow + +verify: + ./tests/verify.sh + +clean: + rm -rf result result-vm +``` + +### tests/verify.sh + +Checks: +1. Impermanence — `/` is tmpfs, `/persistent` exists +2. Secure boot — `sbctl status` shows enabled +3. LUKS — `cryptsetup status cryptroot` shows active +4. AppArmor — `aa-status` shows profiles loaded +5. Kernel — sysctl values match hardened config +6. User — root login disabled, SSH key auth works +7. Agenix — TPM modules loaded, age secrets accessible + +## Reuse + +Any NixOS config can import the module: + +```nix +# In your configuration.nix +imports = [ + ./modules/hardened.nix +]; + +hardened = { + impermanence.enable = true; + secureboot.enable = true; + luks.enable = true; + luks.tpm = true; + apparmor.enable = true; + kernel.hardened = true; + agenix.enable = true; + user.enable = true; +}; +```