diff --git a/.agents/skills/nix-config-secrets/SKILL.md b/.agents/skills/nix-config-secrets/SKILL.md index 8f77d82d..b3c34977 100644 --- a/.agents/skills/nix-config-secrets/SKILL.md +++ b/.agents/skills/nix-config-secrets/SKILL.md @@ -9,11 +9,10 @@ description: Secrets are age-encrypted files in the private repository `~/codes/nix-secrets` (`git@github.com:ryan4yin/nix-secrets.git`), pulled in as the `mysecrets` flake input and declared -here in `secrets/nixos.nix` and `secrets/darwin.nix`. No secret value is ever stored in this -repository. +here in `secrets/nixos.nix` and `secrets/darwin.nix`. No secret value or ciphertext is ever stored +in this public repository. -Read [secrets/README.md](../../../secrets/README.md) for the private-repository workflow and the -recipient rule. +Read [secrets/README.md](../../../secrets/README.md) for the concepts and the recipient rule. ## Core rules @@ -45,26 +44,45 @@ recipient rule. | Consumers | modules reading `config.age.secrets."".path` (default `/run/agenix/`) | | Decryption key | `age.identityPaths`: the host's SSH host key; `/persistent/etc/ssh/...` on a preservation host | +The private repository groups the `.age` files: `desktop/` (only desktops decrypt them), `server/` +(any server), `certs/`, and `public/`. + ## 2. Add or change a secret Do the mechanical work yourself: edit `secrets.nix` in `~/codes/nix-secrets`, run the non-interactive steps, bump the lock, and add the declaration and consumer. Hand back only what -needs a human: `sudo agenix -e`/`-r` (interactive, and they handle key material) and anything else -under `sudo`. Commits and pushes follow the global git rules. +needs a human: the `edit`/`rekey` recipes in [`secrets/Justfile`](../../../secrets/Justfile) (they +handle key material and `$EDITOR`) and anything else under `sudo`. Commits and pushes follow the +global git rules. -1. In `~/codes/nix-secrets`, add the file to `secrets.nix` with the recipient set from core rule 3. - Then ask the user to create or edit it on a desktop: - `sudo agenix -e ./xxx.age -i /etc/ssh/ssh_host_ed25519_key`. +1. In `~/codes/nix-secrets`, add the file under `desktop/` or `server/` with a matching + `secrets.nix` entry, keyed by that exact path (e.g. `"./desktop/xxx.age"`), and the recipient set + from core rule 3. Then ask the user to create or edit it on a desktop with the command in §3. 2. Here: `just upp mysecrets` (commits the lock) or `nix flake update mysecrets` (leaves it for you to commit). `git diff flake.lock` should show only `mysecrets` moving. 3. Declare it in `secrets/nixos.nix` or `secrets/darwin.nix` under the right - `modules.secrets.` gate, with a mode/owner preset (step 3). + `modules.secrets.` gate, with a mode/owner preset (§4). 4. Point the consumer at the runtime path, from a module behind the same gate (core rule 4). -5. `just test` and `just build-host `, deploy, then verify as in step 4. +5. `just test` and `just build-host `, deploy, then verify as in §5. Changing only a secret's value is steps 1, 2, and the deploy. -## 3. Modes and ownership +## 3. The private repository + +The agenix operations run against `~/codes/nix-secrets`. Their single source is +[`secrets/Justfile`](../../../secrets/Justfile), which pins the identity and option order; run them +from this repository's root and keep each path identical to its key in `secrets.nix`: + +```bash +just -f secrets/Justfile edit ./desktop/xxx.age # edit or create (interactive) +just -f secrets/Justfile rekey # re-encrypt after a recipient change +``` + +The repository keeps a single amended commit: `git commit --amend -a --no-edit`, +`git reflog expire --expire-unreachable=now --all`, `git gc --prune=now`, then force push. Treat +amend and force push as impactful and get authorization first. + +## 4. Modes and ownership Use one of the presets in `secrets/nixos.nix` (`noaccess`, `high_security`, `user_readable`); the table and the `environment.etc` copy trap are in @@ -72,7 +90,7 @@ table and the `environment.etc` copy trap are in **whenever an `environment.etc` entry sets `mode`, it also sets `user`.** Never widen a mode to make a root-owned copy readable. -## 4. Verify without reading +## 5. Verify without reading ```bash stat -c '%a %U:%G' /run/agenix/ # mode and owner, not content (the default path) @@ -84,7 +102,7 @@ tail -n 100 /Library/Logs/org.nixos.activate-agenix.stderr.log # macOS A successful activation is silent. Check that the _access_ changed as intended (the mode and owner you set, an old copy gone) instead of assuming activation did it. -## 5. Remove or rename a secret +## 6. Remove or rename a secret - Search all references before changing it: @@ -99,11 +117,10 @@ you set, an old copy gone) instead of assuming activation did it. - Remove it from `secrets.nix` and delete the file in the private repository, then bump the lock. - A rename changes the attribute name and every consumer. The `.age` filename is separate. -## 6. Failure modes +## 7. Failure modes - **Decryption fails on one host:** its host key is not a recipient. Add its - `/etc/ssh/ssh_host_ed25519_key.pub` to `secrets.nix`, rekey with - `sudo agenix -r -i /etc/ssh/ssh_host_ed25519_key` on a desktop, push, bump the lock, redeploy. + `/etc/ssh/ssh_host_ed25519_key.pub` to `secrets.nix`, rekey (§3), push, bump the lock, redeploy. - **Eval or activation cannot find the file:** the lock still points at a `mysecrets` revision without it (core rule 2). - **`permission denied` in a user service:** it reads a root-only secret. Fix that secret's owner; diff --git a/AGENTS.md b/AGENTS.md index d73270b2..f27f310d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ deployments. Keep repository guidance here; reusable global rules live in `agent - `hosts/` contains host-specific configuration; `outputs//src/` wires hosts into outputs. - `vars/` and `lib/` provide shared values and helpers. Use `myvars` and existing abstractions instead of hardcoding usernames or paths. -- `secrets/` contains agenix definitions; secret material also comes from a private external repo. +- `secrets/` contains agenix definitions. The encrypted `.age` files live in the private + `~/codes/nix-secrets` repository, never in this public one — never copy a `.age` file here. Use + the `nix-config-secrets` skill. - `overlays/` and `hardening/` hold package overlays and hardened (nixpak/bwrap) wrappers. - Desktop (Niri and Noctalia) config is mostly out-of-store symlinks that hot-reload without a rebuild; see the desktop skill below before changing it. @@ -110,8 +112,8 @@ repository, and cross-link them — no need to be told. networking, VM images, or cluster addons usually change both repositories together. - `~/codes/containers` — container images consumed by k8s-gitops; a tag bump is often a two-repo change. -- `~/codes/nix-secrets` — agenix files and recipients for secrets declared here (see the - `nix-config-secrets` skill). +- `~/codes/nix-secrets` — the private agenix store behind `secrets/` and the `mysecrets` input: + ciphertext and recipient rules for the secrets declared here. - `wallpapers`, `nur-packages`, `pyclipsync`, `nu_scripts` — flake inputs; bump with `just upp ` after their source changes, do not edit them from here. diff --git a/flake.lock b/flake.lock index 81af97dc..e21abdd4 100644 --- a/flake.lock +++ b/flake.lock @@ -484,10 +484,10 @@ "mysecrets": { "flake": false, "locked": { - "lastModified": 1790913443, - "narHash": "sha256-3KvrF3j19qo90q0frvQnU4/Lirh1mb0dk8UA0KUrtog=", + "lastModified": 1790915701, + "narHash": "sha256-ES+PUPSCJBE4pwhTBwgKsoBit3oXjrI/OJt5xxngeT4=", "ref": "refs/heads/main", - "rev": "bc17c93809bc029f2901b86a5e3306a6ef1be4d3", + "rev": "37e3c3ee87ac649fc52f9314db1cdf672a3c62b0", "shallow": true, "type": "git", "url": "ssh://git@github.com/ryan4yin/nix-secrets.git" diff --git a/secrets/Justfile b/secrets/Justfile new file mode 100644 index 00000000..6a3689de --- /dev/null +++ b/secrets/Justfile @@ -0,0 +1,21 @@ +# just is a command runner; Justfile is like Makefile but simpler. +# The agenix operations run against the private secrets repository at ~/codes/nix-secrets. +# Uses nushell: nix shell nixpkgs#just nixpkgs#nushell +set shell := ["nu", "-c"] +set working-directory := "../../codes/nix-secrets" + +identity := "/etc/ssh/ssh_host_ed25519_key" + +# List all recipes +default: + @just --list --justfile {{justfile()}} + +# Edit or create a secret (interactive); pipe the plaintext to write non-interactively +edit file: + sudo -E agenix -i {{identity}} -e {{quote(file)}} + +# Re-encrypt every secret after adding or changing a recipient key +[confirm("Re-encrypt every secret in ~/codes/nix-secrets?")] +rekey: + sudo -E agenix -r -i {{identity}} + sudo chown -R ryan:ryan * diff --git a/secrets/README.md b/secrets/README.md index fa7afa02..1415174a 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -107,16 +107,11 @@ in } ``` -3. Create and edit the secret file `xxx.age` interactively using the following command: +3. Create and edit the secret file `xxx.age` with the [`Justfile`](./Justfile) `edit` recipe + (interactive, from the repository root); pipe the plaintext to write it non-interactively: ```shell -sudo agenix -e ./xxx.age -i /etc/ssh/ssh_host_ed25519_key -``` - -Alternatively, you can encrypt an existing file to `xxx.age` using the following command: - -```shell -cat xxx | sudo agenix -e ./xxx.age -i /etc/ssh/ssh_host_ed25519_key +just -f secrets/Justfile edit ./xxx.age ``` `agenix` will encrypt the file with all the public keys we defined in `secrets.nix`, so all the @@ -214,8 +209,8 @@ the `age.secrets..path` argument, which defaults to `/run/agenix/`. 1. `cat` the system-level public key(`/etc/ssh/ssh_host_ed25519_key.pub`) of the new host, and send it to an old host which has already been configured. 2. On the old host: - 1. Add the public key to `secrets.nix`, and rekey all the secrets via - `sudo agenix -r -i /etc/ssh/ssh_host_ed25519_key`. + 1. Add the public key to `secrets.nix`, then rekey all the secrets: + `just -f secrets/Justfile rekey`. 2. Commit and push the changes to `nix-secrets`. 3. On the new host: 1. Clone this repo and run `nixos-rebuild switch` to deploy it, all the secrets will be decrypted diff --git a/secrets/darwin.nix b/secrets/darwin.nix index d9f42aa0..507aabf5 100644 --- a/secrets/darwin.nix +++ b/secrets/darwin.nix @@ -49,7 +49,7 @@ # .age means the decrypted file is still encrypted by age(via a passphrase) "ryan4yin-gpg-subkeys.priv.age" = { - file = "${mysecrets}/ryan4yin-gpg-subkeys-2024-01-27.priv.age.age"; + file = "${mysecrets}/desktop/ryan4yin-gpg-subkeys-2024-01-27.priv.age.age"; } // noaccess; @@ -58,12 +58,12 @@ # --------------------------------------------- "rclone.conf" = { - file = "${mysecrets}/rclone.conf.age"; + file = "${mysecrets}/desktop/rclone.conf.age"; } // high_security; "nix-access-tokens" = { - file = "${mysecrets}/nix-access-tokens.age"; + file = "${mysecrets}/server/nix-access-tokens.age"; } # access-token needs to be readable by the user running the `nix` command // user_readable; @@ -73,13 +73,13 @@ # --------------------------------------------- "ssh-key-romantic" = { - file = "${mysecrets}/ssh-key-romantic.age"; + file = "${mysecrets}/desktop/ssh-key-romantic.age"; } // user_readable; # decrypted secret: AI agents must not read it, only reference the path "nushell-secrets.nu" = { - file = "${mysecrets}/nushell-secrets.nu.age"; + file = "${mysecrets}/desktop/nushell-secrets.nu.age"; } // user_readable; }; diff --git a/secrets/nixos.nix b/secrets/nixos.nix index 3034c1c5..7fc7b038 100644 --- a/secrets/nixos.nix +++ b/secrets/nixos.nix @@ -72,7 +72,7 @@ in # secrets that are used by all nixos hosts age.secrets = { "nix-access-tokens" = { - file = "${mysecrets}/nix-access-tokens.age"; + file = "${mysecrets}/server/nix-access-tokens.age"; } # access-token needs to be readable by the user running the `nix` command // user_readable; @@ -95,14 +95,14 @@ in # .age means the decrypted file is still encrypted by age(via a passphrase) "ryan4yin-gpg-subkeys.priv.age" = { - file = "${mysecrets}/ryan4yin-gpg-subkeys-2024-01-27.priv.age.age"; + file = "${mysecrets}/desktop/ryan4yin-gpg-subkeys-2024-01-27.priv.age.age"; } // noaccess; # Used only by NixOS Modules "rclone.conf" = { - file = "${mysecrets}/rclone.conf.age"; + file = "${mysecrets}/desktop/rclone.conf.age"; } // high_security; @@ -111,13 +111,13 @@ in # --------------------------------------------- "ssh-key-romantic" = { - file = "${mysecrets}/ssh-key-romantic.age"; + file = "${mysecrets}/desktop/ssh-key-romantic.age"; } // user_readable; # decrypted secret: AI agents must not read it, only reference the path "nushell-secrets.nu" = { - file = "${mysecrets}/nushell-secrets.nu.age"; + file = "${mysecrets}/desktop/nushell-secrets.nu.age"; } // user_readable; }; @@ -232,13 +232,13 @@ in # the backup server holds the homelab one, so a shared password would let # that server decrypt desktop data. age.secrets."restic-password" = { - file = "${mysecrets}/restic-password-desktop.age"; + file = "${mysecrets}/desktop/restic-password-desktop.age"; mode = "0400"; owner = "root"; }; # credentials for the restic REST server that receives this backup age.secrets."restic-rest-credentials" = { - file = "${mysecrets}/restic-rest-credentials.age"; + file = "${mysecrets}/desktop/restic-rest-credentials.age"; mode = "0400"; owner = "root"; }; @@ -248,14 +248,14 @@ in # the homelab's restic repository password, shared by the homelab hosts # (this host holds it, so it can copy/verify homelab repositories) age.secrets."restic-password" = { - file = "${mysecrets}/restic-password-homelab.age"; + file = "${mysecrets}/server/restic-password-homelab.age"; mode = "0400"; owner = "root"; }; # htpasswd for the restic REST server this host runs; the service runs as # the unprivileged `restic` user, which must be able to read it age.secrets."restic-rest-htpasswd" = { - file = "${mysecrets}/restic-rest-htpasswd.age"; + file = "${mysecrets}/server/restic-rest-htpasswd.age"; mode = "0400"; owner = "restic"; }; @@ -264,7 +264,7 @@ in (mkIf cfg.server.storage.enable { age.secrets = { "hdd-luks-crypt-key" = { - file = "${mysecrets}/hdd-luks-crypt-key.age"; + file = "${mysecrets}/server/hdd-luks-crypt-key.age"; mode = "0400"; owner = "root"; };