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 a514b75..0b6b11b 100644 --- a/.claude/tasks/0019-user-ssh-keys-and-access-policy.md +++ b/.claude/tasks/0019-user-ssh-keys-and-access-policy.md @@ -52,22 +52,31 @@ Note that the fallback only becomes real once a second machine exists to connect ## 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 whole policy is three options on the ssh module. +Two are the lists of client public keys, one for the machines the operator works from and one for the machines that serve. +The third is the set a machine admits, which a host declares in its own file by naming the lists it draws from. -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 earlier designs were discarded as more machinery than the problem has. +The first was a separate fleet declaration mapping each machine to a role and a key, which the module looked up by hostname. +The second kept the two lists but derived the admitted set from a role enum. +Authorizing a key needs the key text and nothing else, so the per-machine names, the hostname lookup and the role all existed to reconstruct a grouping that the two lists simply are. +A host now states what it admits rather than stating a category that something else maps to keys. -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. +`authorizedKeys` defaults to the workstation keys. +An option of a list type is not required in the way a scalar one is: leaving it undeclared yields the empty list rather than an evaluation error, and a machine admitting no key is unreachable over SSH. +The default makes the safe case the silent one. + +Only `neogaia` exists, so the server half has nothing to act on. +It was verified by temporarily adding a synthetic server key and declaring both lists on the host, then reverting. +A host drawing on the workstation keys alone excluded the server key, and one drawing on both admitted it. +Omitting the declaration entirely was confirmed to fall back to the workstation keys rather than to none. 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. +The committed public key carries the comment `alexion@neogaia` rather than the adopted key's own `contact@alexion.dev`, so the list says which machine each key belongs to. +An authorized-keys comment is free text and independent of the private key. + 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. diff --git a/CLAUDE.md b/CLAUDE.md index 6a2a405..a7468a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,10 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla Warming it with `sudo -v` through the agent's own shell — including the `!` prefix — never works: that shell has no controlling terminal, and sudo reports `a terminal is required to read the password`. It has to be a separate terminal. A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling; a failure *without* that message is the sandbox, not the cache. +- An `mkOption` of a list or attribute-set type is **not** mandatory the way a scalar one is. + Those types carry an `emptyValue`, so an option declared with no `default` and never set evaluates to `[ ]` or `{ }` instead of failing with "option used but not defined". + A declaration that is genuinely required cannot be expressed by omitting the default — it needs an assertion, or a default chosen so that the silent case is the safe one. + This bites hardest where the empty value is itself dangerous, such as a list of authorized SSH keys, where it means a machine nobody can reach. - `home-manager.users.` cannot be assigned twice at the same level in one module: `home-manager.users.${user}.home.packages` alongside `home-manager.users.${user}.programs.x` fails with `error: dynamic attribute 'alexion' already defined`. The interpolated key makes it a dynamic attribute, which nix will not merge the way it merges static paths. Nest both under a single `home-manager.users.${user} = { ... }`. diff --git a/fleet/default.nix b/fleet/default.nix deleted file mode 100644 index 8b3ff14..0000000 --- a/fleet/default.nix +++ /dev/null @@ -1,8 +0,0 @@ -# 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 fa5bed6..3e3d037 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -1,4 +1,9 @@ -{ 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. { @@ -33,6 +38,9 @@ modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml; + # A machine the operator works from, so it admits the workstation keys alone. + modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys; + # fish as the login shell. modules.fish.enable = true; modules.fish.defaultShell = true; diff --git a/modules/ssh.nix b/modules/ssh.nix index c62872a..84e3734 100644 --- a/modules/ssh.nix +++ b/modules/ssh.nix @@ -8,40 +8,51 @@ let cfg = config.modules.ssh; user = config.user.name; - 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 = { enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; + workstationKeys = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia" + ]; + description = '' + Client public keys of the machines the operator works from. + + Every machine admits these, so any of them reaches the whole fleet. + ''; + }; + + serverKeys = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Client public keys of the machines that serve. + + Only other servers admit these, so one that is compromised reaches no + machine the operator works from. + ''; + }; + + authorizedKeys = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = cfg.workstationKeys; + defaultText = lib.literalExpression "config.modules.ssh.workstationKeys"; + description = '' + Client public keys this machine admits for the primary user, drawn from + the lists above. + + A machine the operator works from takes the workstation keys. One that + serves takes both, so servers reach each other. The default admits the + workstation keys, since a machine admitting none is unreachable. + ''; + }; + hostKeys.sopsFile = lib.mkOption { type = lib.types.path; description = '' @@ -84,24 +95,6 @@ in }; 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; sops.secrets = @@ -130,7 +123,7 @@ in ) cfg.hostKeys.types; # The primary user is the only account reachable over SSH. - users.users.${user}.openssh.authorizedKeys.keys = authorizedKeys; + users.users.${user}.openssh.authorizedKeys.keys = cfg.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.