feat(neogaia): restore the SSH host keys from sops (task 0011) #4

Merged
alexion merged 5 commits from task-0011-neogaia-ssh-host-keys into main 2026-07-20 09:50:06 -04:00
Owner

Task: .claude/tasks/0011-neogaia-ssh-host-keys.md

Summary

neogaia's SSH host keys become secrets, so reimaging the laptop keeps its host identity and every client's known_hosts entry stays valid.

  • secrets/neogaia.yaml — the first per-host secrets file, encrypted to the admin identity and neogaia alone, so a compromised machine cannot decrypt another's material. Holds both host private keys (ed25519 and RSA).
  • The public halves are committed in plaintext under hosts/neogaia/.
  • services.openssh.hostKeys = [ ] stops the daemon generating its own; HostKey points at the decrypted paths. These are ordinary secrets rather than the pre-user ones the password uses, exercising the second decryption path.

Deviations from the plan

  • Both key types preserved, not just ed25519 — a client that pinned the RSA key would otherwise break.
  • Keys stay at their default /run/secrets/ paths. The first attempt set path to /etc/ssh/ssh_host_*_key, which has sops plant a symlink inside a directory NixOS manages via setup-etc. It bought nothing and added an activation-ordering dependency nothing pins. HostKey now interpolates config.sops.secrets.<name>.path, so daemon and secret cannot disagree.
  • restartUnits = [ "sshd.service" ] added, not in the plan. sshd reads host keys once at startup, so without it a re-key would rewrite the files while the daemon kept serving the old ones from memory — silent identity drift, exactly what this task prevents.
  • The committed .pub files have no consumer yet. An intermediate version deployed them via environment.etc; dropped as unasked-for scope, since sshd derives the public half from the private key.

Verified: nix flake check passes. After activation the leftover symlinks were removed and sshd restarted with no key material anywhere in /etc/ssh — it came back active presenting fingerprints identical to the pre-change ones. The generated sshd-keygen.service has no ExecStart at all.

Review

Risk

Overall: HIGH

  • Blast radius: Medium — confined to one host plus one .sops.yaml rule, but it rewires /etc/ssh and every client that has this host in known_hosts.
  • Reversibility: Medium — the Nix config reverts in one commit, yet an encrypted private key is now permanent in git history, and a host that boots without usable keys needs console access to fix.
  • Test coverage: High — nothing here is exercised by a build-time check; nix flake check proves the config evaluates, not that sops decrypts at activation or that the .pub files match the encrypted private halves.
  • Sensitive domain: High — private key material, file modes (0400), sops key-group access control, and remote-login identity.
  • Size & complexity: Low — ~35 declarative lines with no control flow.
  • Runtime criticality: High — services.openssh.hostKeys = [ ] removes the daemon's self-healing fallback, so any decryption or ordering failure leaves sshd with no host key at all.

Both of the reviewer's pre-merge conditions were then discharged: the committed .pub files were confirmed to derive from the encrypted private halves (decrypted with the host identity and diffed against the live keys), and the activation ordering was exercised by restarting sshd against an empty /etc/ssh.

Unaddressed findings

Standards — duplicated key_groups in .sops.yaml (judgement call). The new per-host rule's recipient list is byte-identical to the shared rule's. Left deliberately: the two are semantically different — shared.yaml must gain every future host, neogaia.yaml must not — so merging them would be the wrong edit.

Spec — task 0010's authorizedKeys handoff is left open. 0010 proposed settling it here, since SSH is not a recovery route while no key is authorized. It is not an acceptance criterion of this task, and choosing which public key to trust is the operator's call rather than one to infer. It wants its own task, and remains a real gap: a decryption failure that locks the console still has no network fallback.

Task: `.claude/tasks/0011-neogaia-ssh-host-keys.md` ## Summary `neogaia`'s SSH host keys become secrets, so reimaging the laptop keeps its host identity and every client's `known_hosts` entry stays valid. - `secrets/neogaia.yaml` — the first per-host secrets file, encrypted to the admin identity and `neogaia` alone, so a compromised machine cannot decrypt another's material. Holds both host private keys (ed25519 and RSA). - The public halves are committed in plaintext under `hosts/neogaia/`. - `services.openssh.hostKeys = [ ]` stops the daemon generating its own; `HostKey` points at the decrypted paths. These are ordinary secrets rather than the pre-user ones the password uses, exercising the second decryption path. ### Deviations from the plan - **Both key types preserved**, not just ed25519 — a client that pinned the RSA key would otherwise break. - **Keys stay at their default `/run/secrets/` paths.** The first attempt set `path` to `/etc/ssh/ssh_host_*_key`, which has sops plant a symlink inside a directory NixOS manages via `setup-etc`. It bought nothing and added an activation-ordering dependency nothing pins. `HostKey` now interpolates `config.sops.secrets.<name>.path`, so daemon and secret cannot disagree. - **`restartUnits = [ "sshd.service" ]` added**, not in the plan. `sshd` reads host keys once at startup, so without it a re-key would rewrite the files while the daemon kept serving the old ones from memory — silent identity drift, exactly what this task prevents. - **The committed `.pub` files have no consumer yet.** An intermediate version deployed them via `environment.etc`; dropped as unasked-for scope, since `sshd` derives the public half from the private key. Verified: `nix flake check` passes. After activation the leftover symlinks were removed and `sshd` restarted with no key material anywhere in `/etc/ssh` — it came back active presenting fingerprints identical to the pre-change ones. The generated `sshd-keygen.service` has no `ExecStart` at all. ## Review ### Risk **Overall: HIGH** - Blast radius: Medium — confined to one host plus one `.sops.yaml` rule, but it rewires `/etc/ssh` and every client that has this host in `known_hosts`. - Reversibility: Medium — the Nix config reverts in one commit, yet an encrypted private key is now permanent in git history, and a host that boots without usable keys needs console access to fix. - Test coverage: High — nothing here is exercised by a build-time check; `nix flake check` proves the config evaluates, not that sops decrypts at activation or that the `.pub` files match the encrypted private halves. - Sensitive domain: High — private key material, file modes (`0400`), sops key-group access control, and remote-login identity. - Size & complexity: Low — ~35 declarative lines with no control flow. - Runtime criticality: High — `services.openssh.hostKeys = [ ]` removes the daemon's self-healing fallback, so any decryption or ordering failure leaves sshd with no host key at all. Both of the reviewer's pre-merge conditions were then discharged: the committed `.pub` files were confirmed to derive from the encrypted private halves (decrypted with the host identity and diffed against the live keys), and the activation ordering was exercised by restarting `sshd` against an empty `/etc/ssh`. ### Unaddressed findings **Standards — duplicated `key_groups` in `.sops.yaml` (judgement call).** The new per-host rule's recipient list is byte-identical to the shared rule's. Left deliberately: the two are semantically different — `shared.yaml` must gain every future host, `neogaia.yaml` must not — so merging them would be the wrong edit. **Spec — task 0010's `authorizedKeys` handoff is left open.** 0010 proposed settling it here, since SSH is not a recovery route while no key is authorized. It is not an acceptance criterion of this task, and choosing which public key to trust is the operator's call rather than one to infer. It wants its own task, and remains a real gap: a decryption failure that locks the console still has no network fallback.
alexion added 1 commit 2026-07-19 23:53:51 -04:00
The host's SSH identity was generated by the daemon, so reimaging the
laptop would invalidate it and break `known_hosts` for every client that
had ever connected.

Add `secrets/neogaia.yaml`, the first per-host secrets file, encrypted to
the admin identity and `neogaia` alone so a compromised machine cannot
decrypt another's material. It holds both host private keys; the public
halves are committed in plaintext.

Clear `services.openssh.hostKeys` to stop generation and point `HostKey`
at the decrypted paths. These are ordinary secrets rather than the
pre-user ones the password uses, exercising the second decryption path.
alexion added 1 commit 2026-07-19 23:54:10 -04:00
alexion reviewed 2026-07-20 07:37:16 -04:00
@@ -4,0 +12,4 @@
sopsFile = ../../secrets/neogaia.yaml;
mode = "0400";
restartUnits = [ "sshd.service" ];
};
Author
Owner

I think this boilerplate is worth abstracting into its own ssh module.

I think this boilerplate is worth abstracting into its own ssh module.
alexion marked this conversation as resolved
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p root@neogaia
Author
Owner

root@neogaia? Shouldn't it be alexion@neogaia?

I also don't know the "ssh_host" prefix. This key is also used to SSH into places so it doesn't make sense to call it ssh_host. This applies to every instance where this appears.

root@neogaia? Shouldn't it be alexion@neogaia? I also don't know the "ssh_host" prefix. This key is also used to SSH into places so it doesn't make sense to call it ssh_host. This applies to every instance where this appears.
alexion marked this conversation as resolved
alexion added 1 commit 2026-07-20 07:40:49 -04:00
The sops secret declarations, the empty `hostKeys`, and the `HostKey`
lines were boilerplate sitting in the host, and every future host
restoring its identity would have repeated them.

`modules.ssh` takes the encrypted file and the key types, deriving the
secret names and the `HostKey` lines from one list, so the daemon and the
secrets cannot disagree about where a key lives. The resulting system
derivation is unchanged.

Also record two gotchas: reading PR review comments needs the Gitea API
rather than tea, and host keys are not user authentication keys.
Author
Owner

Both comments addressed in 42ff195 — one applied, one I want to push back on.

1. Abstracting the boilerplate — done

modules/ssh/ssh.nix now owns the wiring, following the enable convention. The host is down to:

modules.ssh.enable = true;
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;

The module derives the secret names and the HostKey lines from a single hostKeys.types list (default [ "ed25519" "rsa" ]), so the daemon and the secrets can no longer disagree about where a key lives — that coupling was the part most worth removing, more than the line count.

The refactor is behaviour-preserving in the strict sense: the system derivation still evaluates to vcd5a1b8xy4myhnsbm3f2p29idlanklj, byte-identical to the already-activated and verified system.

2. root@neogaia and the ssh_host prefix — I think this is a mix-up, and I'd rather not rename

This key is also used to SSH into places so it doesn't make sense to call it ssh_host.

It isn't, and that's the crux. These are host keys — the server's own identity, presented to a client during the handshake so the client can tell it reached the real neogaia. They are never offered to a remote server as credentials. The key that SSHes into places is a separate user key at ~/.ssh/id_ed25519.

On this machine the two are demonstrably different keys:

daemon presents (ssh-keyscan localhost):  SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM   <- this PR
authenticates the push to this repo:      SHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA   <- ~/.ssh/id_ed25519

So ssh_host_ is precisely accurate, and it is also OpenSSH's own name for these files (/etc/ssh/ssh_host_<type>_key). Renaming them after the user key would point at the wrong concept, and the secret names, the HostKey directives, and the committed .pub filenames would all drift away from every piece of SSH documentation.

On root@neogaia: that trailing field is a free-text comment, stamped in by ssh-keygen at generation time from whoever ran it — here the daemon's own keygen, running as root. It carries no meaning to SSH and is not an ownership claim. alexion@neogaia would actually be the misleading one, since the key belongs to the machine rather than to your account. I've left it as generated, but it's cosmetic and I'm happy to blank it or set it to plain neogaia if you'd prefer it to read less like an account.

Worth noting the task itself is framed this way throughout — "the host fingerprint presented to a client is unchanged" — so if you did mean something broader than host identity here, say so, because that would be a change in scope rather than a rename.

I've recorded the host-key/user-key distinction in CLAUDE.md so it doesn't come up again.

— Claude

Both comments addressed in 42ff195 — one applied, one I want to push back on. ## 1. Abstracting the boilerplate — done `modules/ssh/ssh.nix` now owns the wiring, following the enable convention. The host is down to: ```nix modules.ssh.enable = true; modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; ``` The module derives the secret names *and* the `HostKey` lines from a single `hostKeys.types` list (default `[ "ed25519" "rsa" ]`), so the daemon and the secrets can no longer disagree about where a key lives — that coupling was the part most worth removing, more than the line count. The refactor is behaviour-preserving in the strict sense: the system derivation still evaluates to `vcd5a1b8xy4myhnsbm3f2p29idlanklj`, byte-identical to the already-activated and verified system. ## 2. `root@neogaia` and the `ssh_host` prefix — I think this is a mix-up, and I'd rather not rename > This key is also used to SSH into places so it doesn't make sense to call it ssh_host. It isn't, and that's the crux. These are **host keys** — the server's own identity, presented to a client during the handshake so the client can tell it reached the real `neogaia`. They are never offered to a remote server as credentials. The key that SSHes *into* places is a separate user key at `~/.ssh/id_ed25519`. On this machine the two are demonstrably different keys: ``` daemon presents (ssh-keyscan localhost): SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM <- this PR authenticates the push to this repo: SHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA <- ~/.ssh/id_ed25519 ``` So `ssh_host_` is precisely accurate, and it is also OpenSSH's own name for these files (`/etc/ssh/ssh_host_<type>_key`). Renaming them after the user key would point at the wrong concept, and the secret names, the `HostKey` directives, and the committed `.pub` filenames would all drift away from every piece of SSH documentation. On `root@neogaia`: that trailing field is a free-text **comment**, stamped in by `ssh-keygen` at generation time from whoever ran it — here the daemon's own keygen, running as root. It carries no meaning to SSH and is not an ownership claim. `alexion@neogaia` would actually be the misleading one, since the key belongs to the machine rather than to your account. I've left it as generated, but it's cosmetic and I'm happy to blank it or set it to plain `neogaia` if you'd prefer it to read less like an account. Worth noting the task itself is framed this way throughout — "the host fingerprint presented to a client is unchanged" — so if you did mean something broader than host identity here, say so, because that would be a change in scope rather than a rename. I've recorded the host-key/user-key distinction in `CLAUDE.md` so it doesn't come up again. — Claude
alexion added 1 commit 2026-07-20 07:57:42 -04:00
The trailing field is free text stamped in by ssh-keygen from the account
that ran it. Naming the machine says what the key identifies.
alexion added 1 commit 2026-07-20 09:29:15 -04:00
Every module folder here carries companion files alongside its .nix. This
one has none, so the folder added a level for nothing.
Author
Owner

Flattened to modules/ssh.nix in ec40892 — you're right, and the repo already says so. Every module folder here carries companion files next to its .nix (fish/config.fish, nvim/config.lua, tmux/extra.conf, claude-code/skills), while example.nix sits at the top level as a single file. ssh/ was the only folder holding nothing but its own module, so the directory bought a level of nesting for nothing.

The auto-loader collects recursively, so this is purely structural: the system derivation still evaluates to vcd5a1b8xy4myhnsbm3f2p29idlanklj, identical to the activated and verified system.

One note in case it was unintentional — the review carrying this comment (id 2336) is still in PENDING state, so it is a draft only you can see. I only found it because the token reads your own pending reviews. If you meant it to be visible, it needs submitting.

Separately, the user-SSH-key question that came out of this review is now written up as task 0019 in #5, with the per-machine identities and the workstation/server access policy you described.

— Claude

Flattened to `modules/ssh.nix` in ec40892 — you're right, and the repo already says so. Every module folder here carries companion files next to its `.nix` (`fish/config.fish`, `nvim/config.lua`, `tmux/extra.conf`, `claude-code/skills`), while `example.nix` sits at the top level as a single file. `ssh/` was the only folder holding nothing but its own module, so the directory bought a level of nesting for nothing. The auto-loader collects recursively, so this is purely structural: the system derivation still evaluates to `vcd5a1b8xy4myhnsbm3f2p29idlanklj`, identical to the activated and verified system. One note in case it was unintentional — the review carrying this comment (id 2336) is still in `PENDING` state, so it is a draft only you can see. I only found it because the token reads your own pending reviews. If you meant it to be visible, it needs submitting. Separately, the user-SSH-key question that came out of this review is now written up as task 0019 in #5, with the per-machine identities and the workstation/server access policy you described. — Claude
alexion reviewed 2026-07-20 09:48:22 -04:00
@@ -0,0 +1,61 @@
{
Author
Owner

This is a single-file module, no need for a folder.

This is a single-file module, no need for a folder.
alexion marked this conversation as resolved
alexion merged commit ec40892560 into main 2026-07-20 09:50:06 -04:00
alexion deleted branch task-0011-neogaia-ssh-host-keys 2026-07-20 09:50:06 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alexion/dotfiles#4