refactor(ssh): state the admitted keys directly instead of deriving them

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.
This commit is contained in:
2026-07-20 12:57:23 -04:00
parent f18b40091c
commit 6b5729b98a
5 changed files with 70 additions and 64 deletions

View File

@@ -52,22 +52,31 @@ Note that the fallback only becomes real once a second machine exists to connect
## Implementation Notes ## Implementation Notes
The fleet is a plain data file, `fleet/default.nix`, mapping each machine to its role and client public key. The whole policy is three options on the ssh module.
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. 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. Two earlier designs were discarded as more machinery than the problem has.
Only `neogaia` exists, so the server half cannot be exercised by the real fleet. The first was a separate fleet declaration mapping each machine to a role and a key, which the module looked up by hostname.
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. The second kept the two lists but derived the admitted set from a role enum.
A workstation excluded the server's key; a server admitted all three. 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. `authorizedKeys` defaults to the workstation keys.
One rejects a machine absent from the fleet or carrying a role no policy defines. 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 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. The default makes the safe case the silent one.
Both were confirmed to fire.
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. 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. `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`. 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 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. 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.

View File

@@ -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`. 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. 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. 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.<user>` 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`. - `home-manager.users.<user>` 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. 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} = { ... }`. Nest both under a single `home-manager.users.${user} = { ... }`.

View File

@@ -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";
};
}

View File

@@ -1,4 +1,9 @@
{ inputs, pkgs, ... }: {
config,
inputs,
pkgs,
...
}:
# neogaia — Dell XPS 13 9380 laptop. # neogaia — Dell XPS 13 9380 laptop.
# Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here. # 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.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.ssh.userKey.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. # fish as the login shell.
modules.fish.enable = true; modules.fish.enable = true;
modules.fish.defaultShell = true; modules.fish.defaultShell = true;

View File

@@ -8,40 +8,51 @@ let
cfg = config.modules.ssh; cfg = config.modules.ssh;
user = config.user.name; user = config.user.name;
fleet = import ../fleet;
hostKeySecret = type: "ssh-host-${type}-key"; hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-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 in
{ {
options.modules.ssh = { options.modules.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; 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 { hostKeys.sopsFile = lib.mkOption {
type = lib.types.path; type = lib.types.path;
description = '' description = ''
@@ -84,24 +95,6 @@ in
}; };
config = lib.mkIf cfg.enable { 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; services.openssh.enable = true;
sops.secrets = sops.secrets =
@@ -130,7 +123,7 @@ in
) cfg.hostKeys.types; ) cfg.hostKeys.types;
# The primary user is the only account reachable over SSH. # 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 # 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. # lives in the user's home to drift from the secret.