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

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.
This commit is contained in:
2026-07-19 23:53:01 -04:00
parent ce103a7353
commit 61ce9cc1be
6 changed files with 108 additions and 8 deletions

View File

@@ -18,10 +18,43 @@ These secrets decrypt in the ordinary activation stage rather than the early pre
## Acceptance criteria
- [ ] A secrets file for `neogaia` exists, encrypted to the admin identity and `neogaia` only — not to any other recipient
- [ ] `neogaia`'s SSH host private keys are stored in it
- [ ] The corresponding host public keys are committed in plaintext
- [ ] The SSH daemon no longer generates its own host keys and reads the decrypted paths
- [ ] The host key secrets are declared beside the SSH daemon configuration that consumes them
- [ ] `nix flake check` builds the `neogaia` toplevel
- [ ] Manual confirmation: after activation the secrets materialize with the declared ownership and mode, the daemon adopts the restored keys, and the host fingerprint presented to a client is unchanged
- [x] A secrets file for `neogaia` exists, encrypted to the admin identity and `neogaia` only — not to any other recipient
- [x] `neogaia`'s SSH host private keys are stored in it
- [x] The corresponding host public keys are committed in plaintext
- [x] The SSH daemon no longer generates its own host keys and reads the decrypted paths
- [x] The host key secrets are declared beside the SSH daemon configuration that consumes them
- [x] `nix flake check` builds the `neogaia` toplevel
- [x] Manual confirmation: after activation the secrets materialize with the declared ownership and mode, the daemon adopts the restored keys, and the host fingerprint presented to a client is unchanged
## Implementation Notes
**Both key types were preserved, not just ed25519.**
The running daemon served an ed25519 and an RSA host key, and a client that pinned either would break if only one were carried over.
Both private halves are in `secrets/neogaia.yaml`.
**The decrypted keys stay at their default `/run/secrets/` paths.**
The first attempt set each secret's `path` to the conventional `/etc/ssh/ssh_host_*_key`, which has sops plant a symlink inside a directory NixOS otherwise manages through `setup-etc`.
It worked, but it buys nothing: `sshd` reads whatever `HostKey` names, and the extra `/etc` interaction depends on activation ordering that nothing in the config pins.
The `HostKey` lines now interpolate `config.sops.secrets.<name>.path`, so the daemon and the secret cannot disagree about where the key is.
`/etc/ssh` ends up holding no key material at all.
**`restartUnits = [ "sshd.service" ]` is not in the plan and is needed.**
`sshd` reads its host keys once at startup.
Without this, re-keying the host would rewrite the decrypted files while the daemon kept serving the old keys from memory until some unrelated restart — silently, and precisely the identity drift this task exists to prevent.
The plan's manual criterion would not have caught it, since it was verified on a switch where the keys had not changed.
**The committed public keys have no consumer yet.**
An intermediate version deployed them to `/etc/ssh` via `environment.etc`.
That was dropped as scope the task did not ask for: `sshd` derives the public half from the private key at load, so nothing read them.
They are committed, per the criterion, and the task that distributes `known_hosts` to clients is where they acquire a use.
**Verification was stronger than a before/after comparison.**
After activation the leftover `/etc/ssh/ssh_host_*_key` symlinks from the first attempt were removed and `sshd` restarted with no key material anywhere in `/etc/ssh`.
It came back active and presented `SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM` (ed25519) and `SHA256:y6Tl3P/FvfufblfG059BfCsSkMYX8Zk2EpFQvWzCAew` (RSA) — identical to the pre-change fingerprints.
The generated `sshd-keygen.service` has no `ExecStart` at all, which is what confirms generation is off rather than merely idle.
Separately, the encrypted file was decrypted with the host identity and diffed against the live private keys before anything was changed.
**Task 0010's handoff about `authorizedKeys` is deliberately left open.**
That note proposed settling it here, on the grounds that 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.