feat(ssh): adopt the client key as a secret and declare who may reach each machine (task 0019) #9
@@ -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.
|
||||||
|
|||||||
@@ -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} = { ... }`.
|
||||||
|
|||||||
@@ -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";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user