secrets overhaul: use tpm for laptop (need to migrate desktop later)
This commit is contained in:
21
AGENTS.md
21
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/<name>.age \
|
||||
/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.
|
||||
- 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
70
modules/desktop-age-secrets.nix
Normal file
70
modules/desktop-age-secrets.nix
Normal 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";
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
49
modules/desktop-lanzaboote-agenix.nix
Normal file
49
modules/desktop-lanzaboote-agenix.nix
Normal 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}
|
||||
)
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
68
scripts/bootstrap-desktop-tpm.sh
Executable file
68
scripts/bootstrap-desktop-tpm.sh
Executable 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.
BIN
secrets/desktop/nix-cache-netrc.age
Normal file
BIN
secrets/desktop/nix-cache-netrc.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/desktop/password-hash.age
Normal file
BIN
secrets/desktop/password-hash.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/desktop/secureboot.tar.age
Normal file
BIN
secrets/desktop/secureboot.tar.age
Normal file
Binary file not shown.
Binary file not shown.
BIN
secrets/secrets.nix
Normal file
BIN
secrets/secrets.nix
Normal file
Binary file not shown.
Reference in New Issue
Block a user