feat(ssh): adopt the client key as a secret and derive access from roles (task 0019)
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.
This commit is contained in:
104
modules/ssh.nix
104
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user