feat(ssh): adopt the client key as a secret and declare who may reach each machine (task 0019) #9

Merged
alexion merged 3 commits from task-0019-user-ssh-keys-and-access-policy into main 2026-07-20 13:07:08 -04:00
Owner

Task: .claude/tasks/0019-user-ssh-keys-and-access-policy.md

Makes the operator's SSH client key a secret and declares which machines may reach which.

The key that authenticates pushes to the remote existed only as a file created by hand on one laptop, so a reimage would have destroyed it and locked the operator out. It now lives in secrets/neogaia.yaml, encrypted to the admin identity and neogaia alone, decrypting at activation as 0400 owned by the primary user. The client is pointed at the decrypted path, so there is no second copy to drift.

Access is three options on the ssh module: the client keys of the machines the operator works from, the client keys of the machines that serve, and the set this machine admits. A host declares what it draws from in its own file:

modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;

A server draws on both, so servers reach each other while a compromised one reaches no machine the operator works from.

Deviations

  • The policy was first built as a separate fleet/default.nix mapping each machine to a role and a key, which the module looked up by hostname, and then as two lists behind a role enum. Both were more machinery than the problem has: authorizing a key needs the key text and nothing else, so the per-machine names, the hostname lookup and the role only reconstructed a grouping the two lists already are. A host now states what it admits rather than a category something else maps to keys. This also removed a single-file directory from the repository root.
  • authorizedKeys defaults to the workstation keys. An mkOption of a list type is not mandatory the way a scalar one is — undeclared, it yields [ ] rather than failing, and a machine admitting no key is unreachable over SSH. The default makes the safe case the silent one. Recorded in CLAUDE.md, since the empty value is dangerous here specifically.
  • The committed public key carries the comment alexion@neogaia rather than the adopted key's 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.
  • Home-manager's matchBlocks is deprecated in this pin, so the client uses settings. enableDefaultConfig = false drops home-manager's own default directives, leaving the generated ~/.ssh/config at two lines and everything else at OpenSSH's own defaults.
  • Only neogaia exists, so the server half has nothing to act on. Verified by temporarily adding a synthetic server key and declaring both lists on the host, then reverting: drawing on the workstation keys alone excluded the server key, drawing on both admitted it, and omitting the declaration fell back to the workstation keys rather than to none.

Manual confirmation ran against the deployed machine after nixos-rebuild switch, and the reviewed tree builds bit-for-bit to the running system. The hand-placed key was moved aside for the test so both directions were exercised against the 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.

Unrelated tidy-up

The last commit flattens lib/default.nix and system/default.nix to lib.nix and system.nix. Each directory held one file, so it added a level to descend without grouping anything, and removing this task's own fleet/ left these as the last of that shape. The shared base config's path to the shared secrets file was relative to the directory it sat in, so it moved with the file. Verified to build the same system toplevel as before the move.

Review

Overall: HIGH

  • Blast radius: Medium — the module owns authorized keys and the client identity; today one host consumes it.
  • Reversibility: Medium — the Nix side reverts by git revert, but a bad authorized-keys set on a live machine needs console rollback rather than a push.
  • Test coverage: High — nix flake check only builds the toplevel; nothing in-repo asserts the admitted set or the secret's mode.
  • Sensitive domain: High — purely auth: private key material, secret ownership, and who may log in.
  • Size & complexity: Low — the policy is two lists and a concatenation.
  • Runtime criticality: High — sshd config and authorized keys are the machine's remote-access path.

The review ran against the earlier role-based design. Its two unaddressed findings are resolved or moot:

  • Standards — roles were unvalidated strings rather than a typed enum. Moot: there are no roles left to validate.
  • Spec — the superseded ~/.ssh/id_ed25519 is still on the machine. Still outstanding: deleting it was refused by the agent's permission layer. It is redundant rather than needed, since the same key is in secrets/neogaia.yaml and the client is pointed at the decrypted path, and it is recoverable from that file if ever wanted back. Removing the two files is a manual one-liner.

— Claude

Task: `.claude/tasks/0019-user-ssh-keys-and-access-policy.md` Makes the operator's SSH client key a secret and declares which machines may reach which. The key that authenticates pushes to the remote existed only as a file created by hand on one laptop, so a reimage would have destroyed it and locked the operator out. It now lives in `secrets/neogaia.yaml`, encrypted to the admin identity and `neogaia` alone, decrypting at activation as `0400` owned by the primary user. The client is pointed at the decrypted path, so there is no second copy to drift. Access is three options on the ssh module: the client keys of the machines the operator works from, the client keys of the machines that serve, and the set this machine admits. A host declares what it draws from in its own file: ```nix modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys; ``` A server draws on both, so servers reach each other while a compromised one reaches no machine the operator works from. ## Deviations - The policy was first built as a separate `fleet/default.nix` mapping each machine to a role and a key, which the module looked up by hostname, and then as two lists behind a role enum. Both were more machinery than the problem has: authorizing a key needs the key text and nothing else, so the per-machine names, the hostname lookup and the role only reconstructed a grouping the two lists already are. A host now states what it admits rather than a category something else maps to keys. This also removed a single-file directory from the repository root. - `authorizedKeys` defaults to the workstation keys. An `mkOption` of a list type is not mandatory the way a scalar one is — undeclared, it yields `[ ]` rather than failing, and a machine admitting no key is unreachable over SSH. The default makes the safe case the silent one. Recorded in `CLAUDE.md`, since the empty value is dangerous here specifically. - The committed public key carries the comment `alexion@neogaia` rather than the adopted key's `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. - Home-manager's `matchBlocks` is deprecated in this pin, so the client uses `settings`. `enableDefaultConfig = false` drops home-manager's own default directives, leaving the generated `~/.ssh/config` at two lines and everything else at OpenSSH's own defaults. - Only `neogaia` exists, so the server half has nothing to act on. Verified by temporarily adding a synthetic server key and declaring both lists on the host, then reverting: drawing on the workstation keys alone excluded the server key, drawing on both admitted it, and omitting the declaration fell back to the workstation keys rather than to none. Manual confirmation ran against the deployed machine after `nixos-rebuild switch`, and the reviewed tree builds bit-for-bit to the running system. The hand-placed key was moved aside for the test so both directions were exercised against the 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. ## Unrelated tidy-up The last commit flattens `lib/default.nix` and `system/default.nix` to `lib.nix` and `system.nix`. Each directory held one file, so it added a level to descend without grouping anything, and removing this task's own `fleet/` left these as the last of that shape. The shared base config's path to the shared secrets file was relative to the directory it sat in, so it moved with the file. Verified to build the same system toplevel as before the move. ## Review **Overall: HIGH** - Blast radius: Medium — the module owns authorized keys and the client identity; today one host consumes it. - Reversibility: Medium — the Nix side reverts by `git revert`, but a bad authorized-keys set on a live machine needs console rollback rather than a push. - Test coverage: High — `nix flake check` only builds the toplevel; nothing in-repo asserts the admitted set or the secret's mode. - Sensitive domain: High — purely auth: private key material, secret ownership, and who may log in. - Size & complexity: Low — the policy is two lists and a concatenation. - Runtime criticality: High — sshd config and authorized keys are the machine's remote-access path. The review ran against the earlier role-based design. Its two unaddressed findings are resolved or moot: - **Standards — roles were unvalidated strings rather than a typed enum.** Moot: there are no roles left to validate. - **Spec — the superseded `~/.ssh/id_ed25519` is still on the machine.** Still outstanding: deleting it was refused by the agent's permission layer. It is redundant rather than needed, since the same key is in `secrets/neogaia.yaml` and the client is pointed at the decrypted path, and it is recoverable from that file if ever wanted back. Removing the two files is a manual one-liner. — Claude
alexion changed title from feat(ssh): adopt the client key as a secret and derive access from roles (task 0019) to feat(ssh): adopt the client key as a secret and declare who may reach each machine (task 0019) 2026-07-20 12:58:18 -04:00
alexion added 3 commits 2026-07-20 13:06:15 -04:00
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.
The access policy was a separate fleet declaration mapping each machine to a
role and a client key, which the module looked up by hostname to derive what to
authorize. Authorizing a key needs the key text and nothing else, so the
per-machine names, the hostname lookup and the role existed only to reconstruct
a grouping that two lists already are.

The module now exposes those two lists and the set a machine admits, and a host
declares what it draws from in its own file. The fleet declaration is gone, and
with it a single-file directory at the repository root.

`authorizedKeys` defaults to the workstation keys. An option of a list type is
not mandatory the way a scalar one is: undeclared, it yields the empty list
rather than failing, and a machine admitting no key is unreachable. The default
makes the safe case the silent one.

Verified to produce the same system as the design it replaces, and against the
running machine in both directions.
Each held one default.nix, so the directory added a level to descend without
grouping anything. They are now lib.nix and system.nix beside flake.nix, which
reads as the flake's own scaffolding.

The shared base config's reference to the shared secrets file was relative to
the directory it sat in, so it moves with the file.

Verified to build the same system toplevel as before the move.
alexion force-pushed task-0019-user-ssh-keys-and-access-policy from 9a860d7715 to 78081143cf 2026-07-20 13:06:15 -04:00 Compare
alexion merged commit 78081143cf into main 2026-07-20 13:07:08 -04:00
alexion deleted branch task-0019-user-ssh-keys-and-access-policy 2026-07-20 13:07:08 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alexion/dotfiles#9