From 61ce9cc1be48545b0b4c9e19b01a3139c2c55071 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 19 Jul 2026 23:53:01 -0400 Subject: [PATCH 1/5] 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. --- .claude/tasks/0011-neogaia-ssh-host-keys.md | 47 ++++++++++++++++++--- .sops.yaml | 9 ++++ hosts/neogaia/default.nix | 32 +++++++++++++- hosts/neogaia/ssh_host_ed25519_key.pub | 1 + hosts/neogaia/ssh_host_rsa_key.pub | 1 + secrets/neogaia.yaml | 26 ++++++++++++ 6 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 hosts/neogaia/ssh_host_ed25519_key.pub create mode 100644 hosts/neogaia/ssh_host_rsa_key.pub create mode 100644 secrets/neogaia.yaml diff --git a/.claude/tasks/0011-neogaia-ssh-host-keys.md b/.claude/tasks/0011-neogaia-ssh-host-keys.md index b621ec3..d338eee 100644 --- a/.claude/tasks/0011-neogaia-ssh-host-keys.md +++ b/.claude/tasks/0011-neogaia-ssh-host-keys.md @@ -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..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. diff --git a/.sops.yaml b/.sops.yaml index d91b7b5..024fa58 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -9,6 +9,15 @@ keys: - &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe creation_rules: + # Material belonging to one machine. + # No machine other than the one named is a recipient, so a host that is + # compromised cannot decrypt another's material. + - path_regex: secrets/neogaia\.yaml$ + key_groups: + - age: + - *admin + - *neogaia + # Material common to every machine, so it is stored once rather than per host. - path_regex: secrets/shared\.yaml$ key_groups: diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index cd6e22f..a7d6293 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -1,6 +1,19 @@ -{ inputs, pkgs, ... }: +{ + config, + inputs, + pkgs, + ... +}: # neogaia — Dell XPS 13 9380 laptop. # Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here. +let + # Read by the daemon at startup, so a re-key has to restart it to take effect. + sshHostKey = { + sopsFile = ../../secrets/neogaia.yaml; + mode = "0400"; + restartUnits = [ "sshd.service" ]; + }; +in { imports = [ inputs.nixos-hardware.nixosModules.dell-xps-13-9380 @@ -29,6 +42,23 @@ # So setup can be driven over the network. services.openssh.enable = true; + # This host's SSH identity is restored from secrets. + # Reimaging the machine therefore keeps its fingerprint, and every client's + # `known_hosts` entry stays valid. + # The matching public halves are committed in plaintext, since publishing + # them is their purpose. + sops.secrets = { + ssh-host-ed25519-key = sshHostKey; + ssh-host-rsa-key = sshHostKey; + }; + + # An empty list is what stops the daemon generating keys of its own. + services.openssh.hostKeys = [ ]; + services.openssh.extraConfig = '' + HostKey ${config.sops.secrets.ssh-host-ed25519-key.path} + HostKey ${config.sops.secrets.ssh-host-rsa-key.path} + ''; + # fish as the login shell. modules.fish.enable = true; modules.fish.defaultShell = true; diff --git a/hosts/neogaia/ssh_host_ed25519_key.pub b/hosts/neogaia/ssh_host_ed25519_key.pub new file mode 100644 index 0000000..2f54b18 --- /dev/null +++ b/hosts/neogaia/ssh_host_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p root@neogaia diff --git a/hosts/neogaia/ssh_host_rsa_key.pub b/hosts/neogaia/ssh_host_rsa_key.pub new file mode 100644 index 0000000..b313779 --- /dev/null +++ b/hosts/neogaia/ssh_host_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== root@neogaia diff --git a/secrets/neogaia.yaml b/secrets/neogaia.yaml new file mode 100644 index 0000000..ee57300 --- /dev/null +++ b/secrets/neogaia.yaml @@ -0,0 +1,26 @@ +ssh-host-ed25519-key: ENC[AES256_GCM,data:K6F9JrmmnK0EKqe9wsjGKOM01ygHvKRUj/a65IdWQsnR8MHNKR90xxviwZa8BdJOjhfOnZYua66h/folPYFT8jiR4HRXRTcZ25LNrGK+oOoxnvN2ES/9X0NDRhTbfxRUfBYb6gf38cyJUoCxoVps+NQ19eCO+kbj/4vfrYyiiZRdL6Bf7xMrftzkMZ4Fxr2xK0NmARvh39Q8tYWJX+57H0bNsRdTQkZkRsLl8l8kQyD8U1g5OsMkNCYZchHZyp6YIzaSoZyAZRBGNxKJmSwzC7ewP/JA7S5nqErtrsZIlS4s27yn1ERT7VfJwhx4xGZgnCF5HQDz/eW6/EhdF8MOTA2HkZqz8u0QpamtgGi0X/K/Ip+ntYIyAa4VtmKcgghPrftZ4xc0TeeOVPU1KgbW7HBnqeDiNQd5k/878FDmsoKWt3CrdL1RszeUCJH1DY+kN44cMlbKwo8P3vp7oNTV1Sn5NUuWv6JxCYX4HTYlW92Wt//mqjtX2gWrMB0R63aPlJNmcIVjDN2mNGXn/Uyn,iv:q6chCkDdccg6pyH7fr8yMvNDcGjl5ZhO4CN25M7ydkk=,tag:JSuTMuG+0fSEjvG8uLG56A==,type:str] +ssh-host-rsa-key: ENC[AES256_GCM,data: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,iv:xbUR7YIZKQd6qlzsEZT4aur5xZuRBgQvmSSTdpc05ag=,tag:UIuoHsgMYWmhdJR/GDB0iA==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzM29VSk83QzZFV3h0Q0Rl + bGhiVytqN1VWZVpacWIvdVZUU2pHUWVhK1RZCmtEeGNMVXEvLzZEWmFOR1Z3dDQ4 + N0FCZFZPeUlIN0s1dXBxcDBSemZvQnMKLS0tIEFjS0RNVG5rZDhhVkV3ak92T2dG + WEd4cXRzZHp5VWRqOStwRkc0VUZ1MzQKV9qG7NT1T3IsZT3i7Qurf8+7Uc7wRya7 + Cx5A/EWyiHqkWB5/m66TAk0VA4Yd0Qc++uqQCRbxFdliBO++RVjg2A== + -----END AGE ENCRYPTED FILE----- + recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvSjVTdzNlenpvcHZvZFZH + UHB2OTZPNzZERDFRRkpEZ09sOW5ZSEoyWm5RCnpIWlloOXdXVk5jalRRK1BmV0gr + bi9UaGhneGJBeko5K2hWWGROdE1qMGsKLS0tIDRYdXp5Unp4cWZtUTZ5bVNQVlY5 + S2RxYldqa2w3QUp5SjRkdFlDVXA0T1kKslKZMI4PwbdD6T+hV6KDlmEsBAg9AaDO + cbQ5nwrYjI7FapucJQQFebB7FrLKCDgZRyw+/nhCmEJSWFz7iZKLQA== + -----END AGE ENCRYPTED FILE----- + recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe + lastmodified: "2026-07-20T03:45:30Z" + mac: ENC[AES256_GCM,data:ThhDV+a054Z32fFkgkJ40I/Wr9EY9gMa/Wc8uToMaqGGdMT87xJH1QEJxdKHuzgWAXFLH9+4Rl/t2uRFC6AVyOGyBou/NDy0G3WzIfrJn1raTYwIvNm4I/0vycRvLyOi4ERPpDERqgoDczeE9i7U7Qves/KdLFPqOlGHDBI/QWE=,iv:oBNu3jeM7S41+mhKYzpYUzyynrJ8hQ7uA8Msd0UrIcA=,tag:HWV32TmxfKZlLcDWo4iTiA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.2 -- 2.47.3 From 60738f65c2bd20982a766a7b4b01009c506422cc Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 19 Jul 2026 23:54:08 -0400 Subject: [PATCH 2/5] docs: correct the forge CLI gotcha, a tea login does exist --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e991708..e85339c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,9 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays. - The remote is self-hosted Gitea (`git.alexion.dev`), and the intended CLI is `gitea-axi` rather than `tea`. `gitea-axi` resolves the repository from the `origin` remote and takes credentials from the `axi` tea login, so both are implicit inside a checkout. - **None of it is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh`, no tea login under `~/.config/tea`, and no `GITEA_*` environment — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not on `PATH`. - Pull requests therefore cannot be opened from this machine until a module provides the tool and its credentials; branches can only be pushed. - The earlier claim that `tea` remains installed described the machine while it still ran CachyOS with these tools installed by hand. + **No forge CLI is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh` on `PATH` — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not there. + Credentials, however, do exist: `~/.config/tea/config.yml` holds a token-bearing login named `alexion` (not `axi`), so pull requests **can** be opened with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head ...`. + The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches. - `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store. Editing a skill in place fails; its source is `modules/claude-code/skills//` here, applied by a rebuild. Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine. -- 2.47.3 From 42ff195556b46d68193c5830acf5fa51acfe4492 Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 20 Jul 2026 07:40:47 -0400 Subject: [PATCH 3/5] refactor(ssh): extract the host key wiring into a module 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. --- CLAUDE.md | 9 ++++++ hosts/neogaia/default.nix | 37 ++++-------------------- modules/ssh/ssh.nix | 61 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 modules/ssh/ssh.nix diff --git a/CLAUDE.md b/CLAUDE.md index e85339c..c251336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,15 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla **No forge CLI is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh` on `PATH` — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not there. Credentials, however, do exist: `~/.config/tea/config.yml` holds a token-bearing login named `alexion` (not `axi`), so pull requests **can** be opened with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head ...`. The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches. + The same token reads PR discussion, which `tea` itself does poorly: `tea pr --comments` prints only the body, and `-f comments` returns no comments field at all. + Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`. + Review comments are **not** at `/issues//comments` — that endpoint holds only top-level discussion and is usually empty. + Inline comments need two calls: `/pulls//reviews` for the review ids, then `/pulls//reviews//comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to. + A review row with an empty `body` is the normal shape when the operator left only inline comments. +- SSH **host** keys (`ssh_host__key`, served by the daemon from `/etc/ssh` or a secret) are not user authentication keys (`~/.ssh/id_ed25519`, offered to a remote server). + The `ssh_host_` prefix is OpenSSH's own name for the former, and the `root@` trailing field in a `.pub` is a free-text comment stamped by `ssh-keygen` at generation time, not a claim about which account uses the key. + On this machine the two are provably distinct: the daemon presents `SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM`, while pushes to `git.alexion.dev` authenticate with `SHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA`. + Renaming host keys after user keys, or vice versa, is therefore always wrong. - `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store. Editing a skill in place fails; its source is `modules/claude-code/skills//` here, applied by a rebuild. Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine. diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index a7d6293..1c04a4e 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -1,19 +1,6 @@ -{ - config, - inputs, - pkgs, - ... -}: +{ inputs, pkgs, ... }: # neogaia — Dell XPS 13 9380 laptop. # Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here. -let - # Read by the daemon at startup, so a re-key has to restart it to take effect. - sshHostKey = { - sopsFile = ../../secrets/neogaia.yaml; - mode = "0400"; - restartUnits = [ "sshd.service" ]; - }; -in { imports = [ inputs.nixos-hardware.nixosModules.dell-xps-13-9380 @@ -40,24 +27,10 @@ in networking.networkmanager.enable = true; # So setup can be driven over the network. - services.openssh.enable = true; - - # This host's SSH identity is restored from secrets. - # Reimaging the machine therefore keeps its fingerprint, and every client's - # `known_hosts` entry stays valid. - # The matching public halves are committed in plaintext, since publishing - # them is their purpose. - sops.secrets = { - ssh-host-ed25519-key = sshHostKey; - ssh-host-rsa-key = sshHostKey; - }; - - # An empty list is what stops the daemon generating keys of its own. - services.openssh.hostKeys = [ ]; - services.openssh.extraConfig = '' - HostKey ${config.sops.secrets.ssh-host-ed25519-key.path} - HostKey ${config.sops.secrets.ssh-host-rsa-key.path} - ''; + # The matching host public keys sit beside this file in plaintext, since + # publishing them is their purpose. + modules.ssh.enable = true; + modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; # fish as the login shell. modules.fish.enable = true; diff --git a/modules/ssh/ssh.nix b/modules/ssh/ssh.nix new file mode 100644 index 0000000..8a62c4c --- /dev/null +++ b/modules/ssh/ssh.nix @@ -0,0 +1,61 @@ +{ + config, + lib, + ... +}: +# The OpenSSH daemon, serving host keys restored from secrets. +let + cfg = config.modules.ssh; + + secretName = type: "ssh-host-${type}-key"; +in +{ + options.modules.ssh = { + enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; + + hostKeys.sopsFile = lib.mkOption { + type = lib.types.path; + description = '' + Encrypted file holding this host's SSH host private keys, one entry per + key type, named `ssh-host--key`. + + These are the keys the daemon presents to identify itself to connecting + clients, not keys used to authenticate anyone to a remote server. + Restoring them from secrets rather than generating them keeps the host's + fingerprint across a reimage, so every client's `known_hosts` entry + stays valid. + ''; + }; + + hostKeys.types = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "ed25519" + "rsa" + ]; + description = '' + Key types to restore, naming both the entries read from the encrypted + file and the algorithms the daemon offers. Dropping a type a client has + already pinned makes the host unrecognisable to it. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + services.openssh.enable = true; + + # The daemon reads its host keys once at startup, so a re-key has to restart + # it to take effect. + sops.secrets = lib.genAttrs (map secretName cfg.hostKeys.types) (_: { + inherit (cfg.hostKeys) sopsFile; + mode = "0400"; + restartUnits = [ "sshd.service" ]; + }); + + # An empty list is what stops the daemon generating keys of its own. + services.openssh.hostKeys = [ ]; + services.openssh.extraConfig = lib.concatMapStrings ( + type: "HostKey ${config.sops.secrets.${secretName type}.path}\n" + ) cfg.hostKeys.types; + }; +} -- 2.47.3 From a6ada9dac38f52674ff7bc6daa57d1ecb9d73b2c Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 20 Jul 2026 07:57:40 -0400 Subject: [PATCH 4/5] chore(neogaia): comment the host public keys as the machine, not root 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. --- hosts/neogaia/ssh_host_ed25519_key.pub | 2 +- hosts/neogaia/ssh_host_rsa_key.pub | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hosts/neogaia/ssh_host_ed25519_key.pub b/hosts/neogaia/ssh_host_ed25519_key.pub index 2f54b18..3ef7287 100644 --- a/hosts/neogaia/ssh_host_ed25519_key.pub +++ b/hosts/neogaia/ssh_host_ed25519_key.pub @@ -1 +1 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p root@neogaia +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia diff --git a/hosts/neogaia/ssh_host_rsa_key.pub b/hosts/neogaia/ssh_host_rsa_key.pub index b313779..9e7ab9b 100644 --- a/hosts/neogaia/ssh_host_rsa_key.pub +++ b/hosts/neogaia/ssh_host_rsa_key.pub @@ -1 +1 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== root@neogaia +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia -- 2.47.3 From ec4089256010fceb9e30829369aee1f8bde37d28 Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 20 Jul 2026 09:29:13 -0400 Subject: [PATCH 5/5] refactor(ssh): flatten the module to a single file Every module folder here carries companion files alongside its .nix. This one has none, so the folder added a level for nothing. --- modules/{ssh => }/ssh.nix | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename modules/{ssh => }/ssh.nix (100%) diff --git a/modules/ssh/ssh.nix b/modules/ssh.nix similarity index 100% rename from modules/ssh/ssh.nix rename to modules/ssh.nix -- 2.47.3