secrets overhaul: use tpm for laptop (need to migrate desktop later)

This commit is contained in:
2026-04-23 14:05:43 -04:00
parent 22282691e7
commit e019f2d4fb
17 changed files with 218 additions and 25 deletions

View File

@@ -36,10 +36,11 @@ lib/
overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts
patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds
secrets/ 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) home/ # git-crypt: per-user HM secrets (api keys, steam id)
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys (muffin)
usb-secrets/ # USB-resident agenix identity key (git-crypt inside the repo) 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`. **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` | | `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` | | `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` | | `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). 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 ## 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). - **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. - **agenix** decrypts `*.age` into `/run/agenix/` at activation on every host:
- **USB identity**: `/mnt/usb-secrets/usb-secrets-key` on muffin; the age identity path is wired in `modules/usb-secrets.nix`. - **muffin**: identity is `/mnt/usb-secrets/usb-secrets-key` (ssh-ed25519 on a physical USB). Wired in `modules/usb-secrets.nix`.
- **Encrypting a new agenix secret** uses the SSH public key directly with `age -R`: - **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 ```sh
age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \ age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \
-o secrets/server/<name>.age \ -o secrets/server/<name>.age \
/path/to/plaintext /path/to/plaintext
``` ```
For desktop secrets, prefer `agenix -e secrets/desktop/<name>.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. - **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. - 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. - **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`). - **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`). - **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. - **Disks**: disko.
- **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`. - **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`.

View File

@@ -19,7 +19,7 @@
../../modules/zfs.nix ../../modules/zfs.nix
../../modules/server-impermanence.nix ../../modules/server-impermanence.nix
../../modules/usb-secrets.nix ../../modules/usb-secrets.nix
../../modules/age-secrets.nix ../../modules/server-age-secrets.nix
../../modules/server-lanzaboote-agenix.nix ../../modules/server-lanzaboote-agenix.nix
../../modules/no-rgb.nix ../../modules/no-rgb.nix
../../modules/server-security.nix ../../modules/server-security.nix

View File

@@ -12,6 +12,14 @@
"/var/lib/systemd/coredump" "/var/lib/systemd/coredump"
"/var/lib/nixos" "/var/lib/nixos"
"/var/lib/systemd/timers" "/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 = [ files = [

View File

@@ -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";
};
};
}

View File

@@ -17,9 +17,10 @@
./desktop-vm.nix ./desktop-vm.nix
./desktop-steam.nix ./desktop-steam.nix
./desktop-networkmanager.nix ./desktop-networkmanager.nix
./desktop-age-secrets.nix
./desktop-lanzaboote-agenix.nix
inputs.disko.nixosModules.disko inputs.disko.nixosModules.disko
inputs.lanzaboote.nixosModules.lanzaboote
inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate
inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower
@@ -50,16 +51,6 @@
mkdir -p /nix/var/nix/profiles/per-user/root/channels 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 = [ ]; swapDevices = [ ];
@@ -71,7 +62,7 @@
trusted-public-keys = [ trusted-public-keys = [
site_config.binary_cache.public_key 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 # cachyos kernel overlay
@@ -896,8 +887,7 @@
"camera" "camera"
"adbusers" "adbusers"
]; ];
# TODO! this is really bad :( I should really figure out how to do proper secrets management hashedPasswordFile = config.age.secrets.password-hash.path;
hashedPasswordFile = "${../secrets/desktop/password-hash}";
}; };
services.gvfs.enable = true; services.gvfs.enable = true;

View File

@@ -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}
)
'';
};
};
}

View File

@@ -1,4 +1,4 @@
{ hostname, site_config, ... }: { hostname, ... }:
{ {
# speed up boot times (by about three seconds) # speed up boot times (by about three seconds)
systemd.services.NetworkManager-wait-online.enable = false; systemd.services.NetworkManager-wait-online.enable = false;
@@ -9,7 +9,10 @@
networkmanager = { networkmanager = {
enable = true; enable = true;
appendNameservers = site_config.dns_servers; appendNameservers = [
"1.1.1.1"
"9.9.9.9"
];
wifi = { wifi = {
scanRandMacAddress = true; scanRandMacAddress = true;

View File

@@ -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… <hostname>"
# 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 <<EOF
recipient for $host:
"$recipient $host"
next steps (run on a workstation with git-crypt unlocked):
1. edit secrets/secrets.nix and append the line above inside the \`tpm\` list.
2. nix run nixpkgs#agenix -- -r # re-encrypts every .age file.
3. git commit + ./deploy.sh switch
EOF

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
secrets/secrets.nix Normal file

Binary file not shown.