diff --git a/AGENTS.md b/AGENTS.md index 57ee10d..92effa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,11 @@ lib/ overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds secrets/ - desktop/ # git-crypt: mreow + yarn share these (wifi, nix-cache-netrc, secureboot.tar, password-hash, disk-password) + secrets.nix # agenix recipients (who can decrypt each .age) + desktop/ # agenix *.age (mreow + yarn) + disk-password (install-time only, git-crypt) home/ # git-crypt: per-user HM secrets (api keys, steam id) - server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys - usb-secrets/ # USB-resident agenix identity key (git-crypt inside the repo) + server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys (muffin) + usb-secrets/ # USB-resident agenix identity for muffin (git-crypt inside the repo) ``` **Never read or write files under `secrets/`.** They are encrypted at rest (git-crypt for plaintext, agenix for `.age`). The git-crypt key is delivered to `muffin` at runtime as `/run/agenix/git-crypt-key-nixos.age`. @@ -89,7 +90,7 @@ If Nix complains about a missing file, `git add` it first — flakes only see tr | `common-` | imported by ALL hosts | `common-doas.nix`, `common-nix.nix`, `common-shell-fish.nix` | | `desktop-` | imported by mreow + yarn only | `desktop-common.nix`, `desktop-steam.nix`, `desktop-networkmanager.nix` | | `server-` | imported by muffin only | `server-security.nix`, `server-power.nix`, `server-impermanence.nix`, `server-lanzaboote-agenix.nix` | -| *(none)* | host-specific filename-scoped; see file contents | `age-secrets.nix`, `zfs.nix`, `no-rgb.nix` (yarn + muffin) | +| *(none)* | host-specific filename-scoped; see file contents | `zfs.nix`, `no-rgb.nix` (yarn + muffin) | New modules: pick the narrowest prefix that's true, then add the import explicitly in the host's `default.nix` (there is no auto-discovery). @@ -117,14 +118,18 @@ New modules: pick the narrowest prefix that's true, then add the import explicit ## Secrets - **git-crypt** covers `secrets/**` per the root `.gitattributes`. Initialized with a single symmetric key checked into `secrets/server/git-crypt-key-nixos.age` (agenix-encrypted to the USB SSH identity). -- **agenix** decrypts `secrets/server/*.age` at activation into `/run/agenix/` on muffin. -- **USB identity**: `/mnt/usb-secrets/usb-secrets-key` on muffin; the age identity path is wired in `modules/usb-secrets.nix`. -- **Encrypting a new agenix secret** uses the SSH public key directly with `age -R`: +- **agenix** decrypts `*.age` into `/run/agenix/` at activation on every host: + - **muffin**: identity is `/mnt/usb-secrets/usb-secrets-key` (ssh-ed25519 on a physical USB). Wired in `modules/usb-secrets.nix`. + - **mreow + yarn**: identity is `/var/lib/agenix/tpm-identity` (an `age-plugin-tpm` handle sealed by the host's TPM 2.0). Wired in `modules/desktop-age-secrets.nix`; yarn persists `/var/lib/agenix` through impermanence. +- **Recipients** are declared in `secrets/secrets.nix`. Desktop secrets are encrypted to the admin SSH key + each host's TPM recipient; server secrets stay encrypted to the muffin USB key. +- **Bootstrap a new desktop**: run `doas scripts/bootstrap-desktop-tpm.sh` on the host. It generates a TPM-sealed identity at `/var/lib/agenix/tpm-identity` and prints an `age1tpm1…` recipient. Append it to the `tpm` list in `secrets/secrets.nix`, run `agenix -r` to re-encrypt, commit, `./deploy.sh switch`. +- **Encrypting a new server secret** uses the SSH public key directly with `age -R`: ```sh age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \ -o secrets/server/.age \ /path/to/plaintext ``` + For desktop secrets, prefer `agenix -e secrets/desktop/.age` from a shell with `age-plugin-tpm` on PATH — it reads `secrets/secrets.nix` and encrypts to every recipient listed there. - **DO NOT use `ssh-to-age`**. It produces `X25519` recipient stanzas, which the SSH private key on muffin cannot decrypt (it only decrypts `ssh-ed25519` stanzas produced by `age -R` against the SSH pubkey). Mismatched stanzas show up as `age: error: no identity matched any of the recipients` at deploy time. - Never read or commit plaintext secrets. Never log secret values. @@ -210,7 +215,7 @@ Prior art: the 3-path `{kernel,initrd,kernel-modules}` diff is lifted from nixpk - **Privilege escalation**: `doas` everywhere; `sudo` is disabled on every host. - **Shell**: fish. `bash` login shells re-exec into fish via `programs.bash.interactiveShellInit` (see `modules/common-shell-fish.nix`). -- **Secure boot**: lanzaboote. Desktops extract keys from `secrets/desktop/secureboot.tar`; muffin extracts from an agenix-decrypted tar (see `modules/server-lanzaboote-agenix.nix`). +- **Secure boot**: lanzaboote. Every host extracts keys from an agenix-decrypted tar at activation — desktops via `modules/desktop-lanzaboote-agenix.nix`, muffin via `modules/server-lanzaboote-agenix.nix`. - **Impermanence**: muffin is tmpfs-root with `/persistent` surviving reboots (`modules/server-impermanence.nix`); yarn binds `/home/primary` from `/persistent` (`hosts/yarn/impermanence.nix`). - **Disks**: disko. - **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`. diff --git a/hosts/muffin/default.nix b/hosts/muffin/default.nix index a853561..5401583 100644 --- a/hosts/muffin/default.nix +++ b/hosts/muffin/default.nix @@ -19,7 +19,7 @@ ../../modules/zfs.nix ../../modules/server-impermanence.nix ../../modules/usb-secrets.nix - ../../modules/age-secrets.nix + ../../modules/server-age-secrets.nix ../../modules/server-lanzaboote-agenix.nix ../../modules/no-rgb.nix ../../modules/server-security.nix diff --git a/hosts/yarn/impermanence.nix b/hosts/yarn/impermanence.nix index b22eab9..26c8056 100644 --- a/hosts/yarn/impermanence.nix +++ b/hosts/yarn/impermanence.nix @@ -12,6 +12,14 @@ "/var/lib/systemd/coredump" "/var/lib/nixos" "/var/lib/systemd/timers" + # agenix identity sealed by the TPM. Must survive the tmpfs root + # wipe so decryption at activation finds the right handle. + { + directory = "/var/lib/agenix"; + mode = "0700"; + user = "root"; + group = "root"; + } ]; files = [ diff --git a/modules/desktop-age-secrets.nix b/modules/desktop-age-secrets.nix new file mode 100644 index 0000000..12dcca9 --- /dev/null +++ b/modules/desktop-age-secrets.nix @@ -0,0 +1,70 @@ +{ + pkgs, + inputs, + ... +}: +let + # rage cannot invoke age-plugin-tpm unless the plugin binary is on PATH at + # activation time. Wrap rage so the activation scripts (and anything else + # that picks up `age.ageBin`) get age-plugin-tpm for free. + rageWithTpm = pkgs.writeShellScriptBin "rage" '' + export PATH="${pkgs.age-plugin-tpm}/bin:$PATH" + exec ${pkgs.rage}/bin/rage "$@" + ''; +in +{ + imports = [ + inputs.agenix.nixosModules.default + ]; + + # Expose the plugin + agenix CLI for interactive edits (`agenix -e …`). + environment.systemPackages = [ + inputs.agenix.packages.${pkgs.system}.default + pkgs.age-plugin-tpm + ]; + + age.ageBin = "${rageWithTpm}/bin/rage"; + + # Primary identity: TPM-sealed key, generated by scripts/bootstrap-desktop-tpm.sh. + # Fallback identity: admin SSH key. age tries paths in order, so if the TPM + # is wiped or the board is replaced the SSH key keeps secrets accessible until + # the TPM is re-bootstrapped. Both are encrypted recipients on every .age file. + age.identityPaths = [ + "/var/lib/agenix/tpm-identity" + "/home/primary/.ssh/id_ed25519" + ]; + + # Ensure the identity directory exists before agenix activation so a fresh + # bootstrap doesn't race the directory creation. + systemd.tmpfiles.rules = [ + "d /var/lib/agenix 0700 root root -" + ]; + + age.secrets = { + # Secureboot PKI bundle (db/KEK/PK keys + certs) consumed by lanzaboote + # via desktop-lanzaboote-agenix.nix at activation time. + secureboot-tar = { + file = ../secrets/desktop/secureboot.tar.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + # netrc for the private nix binary cache. + nix-cache-netrc = { + file = ../secrets/desktop/nix-cache-netrc.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + # yescrypt hash for the primary user. + password-hash = { + file = ../secrets/desktop/password-hash.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + }; +} diff --git a/modules/desktop-common.nix b/modules/desktop-common.nix index e103b1e..1012012 100644 --- a/modules/desktop-common.nix +++ b/modules/desktop-common.nix @@ -17,9 +17,10 @@ ./desktop-vm.nix ./desktop-steam.nix ./desktop-networkmanager.nix + ./desktop-age-secrets.nix + ./desktop-lanzaboote-agenix.nix inputs.disko.nixosModules.disko - inputs.lanzaboote.nixosModules.lanzaboote inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower @@ -50,16 +51,6 @@ mkdir -p /nix/var/nix/profiles/per-user/root/channels ''; - # extract all my secureboot keys - # TODO! proper secrets management - "secureboot-keys".text = '' - #!/usr/bin/env sh - rm -fr ${config.boot.lanzaboote.pkiBundle} || true - mkdir -p ${config.boot.lanzaboote.pkiBundle} - ${lib.getExe pkgs.gnutar} xf ${../secrets/desktop/secureboot.tar} -C ${config.boot.lanzaboote.pkiBundle} - chown -R root:wheel ${config.boot.lanzaboote.pkiBundle} - chmod -R 500 ${config.boot.lanzaboote.pkiBundle} - ''; }; swapDevices = [ ]; @@ -71,7 +62,7 @@ trusted-public-keys = [ site_config.binary_cache.public_key ]; - netrc-file = "${../secrets/desktop/nix-cache-netrc}"; + netrc-file = config.age.secrets.nix-cache-netrc.path; }; # cachyos kernel overlay @@ -896,8 +887,7 @@ "camera" "adbusers" ]; - # TODO! this is really bad :( I should really figure out how to do proper secrets management - hashedPasswordFile = "${../secrets/desktop/password-hash}"; + hashedPasswordFile = config.age.secrets.password-hash.path; }; services.gvfs.enable = true; diff --git a/modules/desktop-lanzaboote-agenix.nix b/modules/desktop-lanzaboote-agenix.nix new file mode 100644 index 0000000..3c3aa56 --- /dev/null +++ b/modules/desktop-lanzaboote-agenix.nix @@ -0,0 +1,49 @@ +{ + config, + lib, + pkgs, + inputs, + ... +}: +{ + imports = [ + inputs.lanzaboote.nixosModules.lanzaboote + ]; + + boot = { + loader.systemd-boot.enable = lib.mkForce false; + + lanzaboote = { + enable = true; + # sbctl expects the bundle at /var/lib/sbctl; muffin uses /etc/secureboot + # because it is wiped on every activation there (impermanence) — desktops + # extract to the historical sbctl path so existing tooling keeps working. + pkiBundle = "/var/lib/sbctl"; + }; + }; + + system.activationScripts = { + # Extract the secureboot PKI bundle from the agenix-decrypted tar. Mirrors + # modules/server-lanzaboote-agenix.nix; skip when keys are already present + # (e.g., disko-install staged them via --extra-files). + "secureboot-keys" = { + deps = [ "agenix" ]; + text = '' + #!/bin/sh + ( + umask 077 + if [[ -d ${config.boot.lanzaboote.pkiBundle} && -f ${config.boot.lanzaboote.pkiBundle}/db.key ]]; then + echo "secureboot keys already present, skipping extraction" + else + echo "extracting secureboot keys from agenix" + rm -fr ${config.boot.lanzaboote.pkiBundle} || true + install -d -o root -g wheel -m 0500 ${config.boot.lanzaboote.pkiBundle} + ${pkgs.gnutar}/bin/tar xf ${config.age.secrets.secureboot-tar.path} -C ${config.boot.lanzaboote.pkiBundle} + fi + chown -R root:wheel ${config.boot.lanzaboote.pkiBundle} + chmod -R 500 ${config.boot.lanzaboote.pkiBundle} + ) + ''; + }; + }; +} diff --git a/modules/desktop-networkmanager.nix b/modules/desktop-networkmanager.nix index 7cb6265..428783e 100644 --- a/modules/desktop-networkmanager.nix +++ b/modules/desktop-networkmanager.nix @@ -1,4 +1,4 @@ -{ hostname, site_config, ... }: +{ hostname, ... }: { # speed up boot times (by about three seconds) systemd.services.NetworkManager-wait-online.enable = false; @@ -9,7 +9,10 @@ networkmanager = { enable = true; - appendNameservers = site_config.dns_servers; + appendNameservers = [ + "1.1.1.1" + "9.9.9.9" + ]; wifi = { scanRandMacAddress = true; diff --git a/modules/age-secrets.nix b/modules/server-age-secrets.nix similarity index 100% rename from modules/age-secrets.nix rename to modules/server-age-secrets.nix diff --git a/scripts/bootstrap-desktop-tpm.sh b/scripts/bootstrap-desktop-tpm.sh new file mode 100755 index 0000000..00ee456 --- /dev/null +++ b/scripts/bootstrap-desktop-tpm.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Bootstrap the age-plugin-tpm identity for a desktop host (mreow / yarn). +# +# Produces a TPM-sealed age identity at /var/lib/agenix/tpm-identity and +# prints the legacy `age1tpm1…` recipient. The identity file is a TPM +# handle, not key material — the actual key never leaves the TPM. +# +# --tpm-recipient is required: nixpkgs only ships `age-plugin-tpm`, not the +# `age-plugin-tag` binary that rage looks up when it sees the new p256tag +# `age1tag1…` format. Until a packaged age-plugin-tag lands, every recipient +# stays in the legacy form so encryption works with off-the-shelf nixpkgs. +# +# Usage: +# doas scripts/bootstrap-desktop-tpm.sh +# +# After running: +# 1. Append the printed recipient to `tpm` in secrets/secrets.nix: +# "age1tpm1… " +# 2. `agenix -r` (from a shell with age-plugin-tpm on PATH) to re-encrypt +# every desktop secret with the new recipient list. +# 3. Commit + `./deploy.sh switch`. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "this script must run as root (access to /dev/tpmrm0 + /var/lib/agenix)" >&2 + exit 1 +fi + +host=$(hostname -s) +id_file=/var/lib/agenix/tpm-identity + +install -d -m 0700 -o root -g root /var/lib/agenix + +if [[ -f "$id_file" ]]; then + echo "existing identity found at $id_file — preserving" +else + echo "generating TPM-sealed age identity..." + nix run nixpkgs#age-plugin-tpm -- --generate --tpm-recipient --output "$id_file" + chmod 0400 "$id_file" + chown root:root "$id_file" +fi + +# Always derive the legacy age1tpm1… recipient, even if the identity file +# was generated with the newer p256tag comment (Recipient line starts with +# age1tag1…). `--convert --tpm-recipient` uses the same TPM object and just +# serializes the public key point in the old format. +recipient=$(nix run nixpkgs#age-plugin-tpm -- --convert --tpm-recipient < "$id_file" 2>/dev/null | grep -o 'age1tpm1[0-9a-z]*' | head -n1) +if [[ -z "$recipient" ]]; then + # fallback to parsing the header comment (only works when the identity was + # already generated with --tpm-recipient). + recipient=$(grep '^# Recipient:' "$id_file" | awk '{print $3}') +fi +if [[ -z "$recipient" ]]; then + echo "failed to derive recipient for $id_file" >&2 + exit 1 +fi + +cat <