From e1eb0cb5c9f27d6e877a8926792d310f1f9d881f Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 20 Jul 2026 12:41:17 -0400 Subject: [PATCH] feat(ssh): adopt the client key as a secret and derive access from roles (task 0019) The operator's SSH client key existed only as a file created by hand on one laptop, so a reimage would destroy it and lock the operator out of the remote. It now lives in neogaia's own secrets file, encrypted to the admin identity and neogaia alone, and the client is pointed at the decrypted path rather than a copy in the user's home. Access becomes a policy over roles instead of a per-host list of keys. A new fleet declaration names each machine's role and client public key, and every machine derives what it authorizes from that: a workstation admits workstations alone, a server admits both, so a compromised server reaches no machine of the operator's own. Registering a machine is an entry in that one file. Only neogaia exists, so the server half of the policy is built rather than exercised. Two assertions reject a machine missing from the fleet and any entry whose role no policy defines. --- .../0019-user-ssh-keys-and-access-policy.md | 49 +++++++-- fleet/default.nix | 8 ++ hosts/neogaia/default.nix | 1 + modules/ssh.nix | 104 ++++++++++++++++-- secrets/neogaia.yaml | 5 +- 5 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 fleet/default.nix diff --git a/.claude/tasks/0019-user-ssh-keys-and-access-policy.md b/.claude/tasks/0019-user-ssh-keys-and-access-policy.md index e652846..a514b75 100644 --- a/.claude/tasks/0019-user-ssh-keys-and-access-policy.md +++ b/.claude/tasks/0019-user-ssh-keys-and-access-policy.md @@ -38,14 +38,41 @@ Note that the fallback only becomes real once a second machine exists to connect ## Acceptance criteria -- [ ] `neogaia` has its own client SSH identity, distinct from its host keys, adopted from the key already on the machine -- [ ] Its private half is stored in `neogaia`'s own secrets file, encrypted to the admin identity and `neogaia` alone -- [ ] Its public half is committed in plaintext -- [ ] The private half decrypts at activation, readable only by the primary user and not by other accounts -- [ ] The SSH client uses the decrypted key with no hand-placed copy in the user's home directory -- [ ] Each machine declares a role, and the keys it authorizes follow from that role rather than from a per-host list -- [ ] Workstation keys are authorized on every machine -- [ ] Server keys are authorized on servers only, and on no workstation -- [ ] Registering a new machine is a role declaration in one place, requiring no edit to any other host -- [ ] `nix flake check` builds the `neogaia` toplevel -- [ ] Manual confirmation: the key materializes with the declared ownership and mode, an authenticated push to the remote still succeeds, and `neogaia` accepts an SSH connection offering the adopted key +- [x] `neogaia` has its own client SSH identity, distinct from its host keys, adopted from the key already on the machine +- [x] Its private half is stored in `neogaia`'s own secrets file, encrypted to the admin identity and `neogaia` alone +- [x] Its public half is committed in plaintext +- [x] The private half decrypts at activation, readable only by the primary user and not by other accounts +- [x] The SSH client uses the decrypted key with no hand-placed copy in the user's home directory +- [x] Each machine declares a role, and the keys it authorizes follow from that role rather than from a per-host list +- [x] Workstation keys are authorized on every machine +- [x] Server keys are authorized on servers only, and on no workstation +- [x] Registering a new machine is a role declaration in one place, requiring no edit to any other host +- [x] `nix flake check` builds the `neogaia` toplevel +- [x] Manual confirmation: the key materializes with the declared ownership and mode, an authenticated push to the remote still succeeds, and `neogaia` accepts an SSH connection offering the adopted key + +## Implementation Notes + +The fleet is a plain data file, `fleet/default.nix`, mapping each machine to its role and client public key. +The ssh module reads it, looks this machine up by hostname, and derives the authorized set, so a machine's own configuration never names another's key. + +The role policy is one attribute set: a workstation authorizes workstation keys alone, a server authorizes both. +Only `neogaia` exists, so the server half cannot be exercised by the real fleet. +It was verified by temporarily adding a synthetic server and a second workstation, evaluating the authorized set with `neogaia` as a workstation and again as a server, and reverting. +A workstation excluded the server's key; a server admitted all three. + +Two assertions guard the derivation. +One rejects a machine absent from the fleet or carrying a role no policy defines. +The other rejects any fleet entry whose role is undefined, because such an entry matches no policy and would lose its access everywhere without failing anything. +Both were confirmed to fire. + +Home-manager's `matchBlocks` is deprecated in favour of `settings`, so the client uses the latter. +`enableDefaultConfig = false` drops home-manager's own default directives, leaving the generated `~/.ssh/config` at two lines and every other directive at the value OpenSSH itself ships. + +Manual confirmation was performed after a `nixos-rebuild switch`. +The secret materialized as `-r--------` owned by the primary user, and the public half derived from it matches the committed fleet entry. +The hand-placed `~/.ssh/id_ed25519` was moved aside for the test, so both directions were exercised against the decrypted secret alone: `ssh -v` to the remote reported `Server accepts key: /run/secrets/ssh-user-ed25519-key`, and an inbound connection to `neogaia` authenticated and returned a shell. + +One follow-up is outstanding. +Deleting the now-redundant `~/.ssh/id_ed25519` and its public half was refused by the agent's permission layer, so both files remain on the machine. +They are superseded rather than needed: the same key is in `secrets/neogaia.yaml`, and the client is pointed at the decrypted path. +Removing them is a one-line manual step, and the key is recoverable from the secrets file if it is ever wanted back. diff --git a/fleet/default.nix b/fleet/default.nix new file mode 100644 index 0000000..8b3ff14 --- /dev/null +++ b/fleet/default.nix @@ -0,0 +1,8 @@ +# Every machine, its role, and the public half of the client key it +# authenticates with. +{ + neogaia = { + role = "workstation"; + sshPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE contact@alexion.dev"; + }; +} diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index 1c04a4e..a75d500 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -31,6 +31,7 @@ # publishing them is their purpose. modules.ssh.enable = true; modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; + modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml; # fish as the login shell. modules.fish.enable = true; diff --git a/modules/ssh.nix b/modules/ssh.nix index 8a62c4c..c62872a 100644 --- a/modules/ssh.nix +++ b/modules/ssh.nix @@ -3,11 +3,40 @@ lib, ... }: -# The OpenSSH daemon, serving host keys restored from secrets. +# SSH on this machine, in both directions. let cfg = config.modules.ssh; + user = config.user.name; - secretName = type: "ssh-host-${type}-key"; + fleet = import ../fleet; + + hostKeySecret = type: "ssh-host-${type}-key"; + userKeySecret = "ssh-user-ed25519-key"; + + # The roles whose keys a machine of the given role authorizes. + # A workstation admits workstations alone, so a server that is compromised + # reaches no machine of the operator's own. + authorizedRoles = { + workstation = [ "workstation" ]; + server = [ + "workstation" + "server" + ]; + }; + + machine = fleet.${config.networking.hostName} or null; + + # Guarded so that an unregistered machine fails the assertion below with a + # readable message, rather than on a missing attribute here. + registered = machine != null && authorizedRoles ? ${machine.role}; + + authorizedKeys = lib.optionals registered ( + lib.mapAttrsToList (_name: m: m.sshPublicKey) ( + lib.filterAttrs (_name: m: lib.elem m.role authorizedRoles.${machine.role}) fleet + ) + ); + + undefinedRoles = lib.attrNames (lib.filterAttrs (_name: m: !(authorizedRoles ? ${m.role})) fleet); in { options.modules.ssh = { @@ -39,23 +68,78 @@ in already pinned makes the host unrecognisable to it. ''; }; + + userKey.sopsFile = lib.mkOption { + type = lib.types.path; + description = '' + Encrypted file holding this machine's SSH client private key, under the + entry `ssh-user-ed25519-key`. + + This is the key the primary user offers to authenticate to a remote + server, not a key the daemon presents to identify this machine. + It belongs to this machine alone, so withdrawing its access does not + re-key any other. + ''; + }; }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = registered; + message = '' + modules.ssh: ${config.networking.hostName} is not in the fleet under a + defined role, so the keys it authorizes cannot be derived. + ''; + } + { + # An undefined role matches no policy, which would drop that machine's + # access everywhere without failing anything. + assertion = undefinedRoles == [ ]; + message = '' + modules.ssh: fleet entries carry a role no policy defines: ${lib.concatStringsSep ", " undefinedRoles}. + ''; + } + ]; + 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" ]; - }); + sops.secrets = + # The daemon reads its host keys once at startup, so a re-key has to + # restart it to take effect. + lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: { + inherit (cfg.hostKeys) sopsFile; + mode = "0400"; + restartUnits = [ "sshd.service" ]; + }) + // { + # The primary user is the only account that authenticates with this key, + # and the mode admits no other. + # The client rereads it per connection, so no unit restarts on a re-key. + ${userKeySecret} = { + inherit (cfg.userKey) sopsFile; + mode = "0400"; + owner = user; + }; + }; # 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" + type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n" ) cfg.hostKeys.types; + + # The primary user is the only account reachable over SSH. + users.users.${user}.openssh.authorizedKeys.keys = authorizedKeys; + + # The client reads the decrypted key where it is written, so no copy of it + # lives in the user's home to drift from the secret. + # Declaring no defaults of home-manager's own leaves every other directive + # at the one OpenSSH itself ships. + home-manager.users.${user}.programs.ssh = { + enable = true; + enableDefaultConfig = false; + settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path; + }; }; } diff --git a/secrets/neogaia.yaml b/secrets/neogaia.yaml index ee57300..e978087 100644 --- a/secrets/neogaia.yaml +++ b/secrets/neogaia.yaml @@ -1,5 +1,6 @@ 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] +ssh-user-ed25519-key: ENC[AES256_GCM,data:p8HGPQrlLLj57Zlkbe/g4HzMRnEucrrl/Aeh/iG/3nSM8I73cCEC8lZXcQgU9BtBP3rrY5F0BvT8MKVlkgCVqJ63EVPj/xjZqmmyKQvf9VH7tx8IoqAA3K/eSt9F7HhUPR3f2NQtce4xeOf7qYHFvP1LNEtv0UgmgROhUfYPaHb7JZ3p4kZ8V8LhVttmIJi9k45P182TtZJkgt36Ys6gyBp7A6UOT7Ha/HdhB6fkqt3H5HWqSq786NXUhqXgzPSDFvH4Iy+aBczAehjYRVNPJ8LTnDovmr8fMzuTJ1t8WCEnljkDWen645ToCObfJTmCIphGfD/VkST0ZphvmV8MY4/z+Ch+CUxI2O2XgJYQVsD7qBtKKxlAS4P4U28JgLt01Y4tPK6Qs+VTXEsBqKxYS99AUVjNAfxE2d7c0IpQSNHqm8mw0d41OKiphIAxgJ14RtUQDxsytAlEl8giQKeJ3yFf8xVYKJ0NTvKyf2X0+d7k3ZiEP0FGbjBwA484kCUzfYuTX+bRoKLd7EcaJkdHSuqQprzUfYpAGgS/,iv:f8YPwuXpP94FMhCIgkLpR1PVufl3PPQVnNaEn56WXPE=,tag:8YnJjtOCEleEZdKQu0bBfA==,type:str] sops: age: - enc: | @@ -20,7 +21,7 @@ sops: 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] + lastmodified: "2026-07-20T16:29:45Z" + mac: ENC[AES256_GCM,data:XLrro73Z4hA4ntA2uFlBj+eNJJXlYw3YUFFLI/it9KvvEbxlvAVka++HQpby2rRZm+VjSXa7BDtzSnLA4Jpue2csUQUMVpKCGvWBNxTBnZpkKpi7fJd2AOFcMA+mXk19OS9YdnfZy9VLpr4+XiM8e+zxNEijZmHXn0gsGUotd8I=,iv:6FWSG86Ix764vD/BMlnEWDdQX74yIg0wkmz5Zz343VQ=,tag:tIeODybScPRVuLBpj7gzVw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.2