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 df1753f..fa5bed6 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