From e6efc7c131667ca24e8bc23a81d92b900941df3d Mon Sep 17 00:00:00 2001 From: Michael Hoang Date: Tue, 17 Feb 2026 22:03:09 +0100 Subject: [PATCH] services/openssh: manage host keys --- modules/services/openssh.nix | 89 ++++++++++++++++++++++++++- modules/system/activation-scripts.nix | 1 + tests/services-openssh.nix | 29 ++++++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/modules/services/openssh.nix b/modules/services/openssh.nix index 8d782ed..42e347e 100644 --- a/modules/services/openssh.nix +++ b/modules/services/openssh.nix @@ -1,7 +1,72 @@ -{ config, lib, ... }: +{ config, lib, pkgs, ... }: let cfg = config.services.openssh; + + hostKeyOpts = { + options = { + type = lib.mkOption { + type = lib.types.enum [ "dsa" "ecdsa" "ed25519" "rsa" ]; + description = '' + Key type passed to `ssh-keygen -t`. + ''; + }; + + path = lib.mkOption { + type = lib.types.str; + description = '' + Path to the private key file. + ''; + }; + + bits = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + description = '' + Key size in bits. If `null`, `ssh-keygen` uses the default + for the given key type (RSA=3072, ECDSA=256, ED25519=fixed). + ''; + }; + + comment = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Comment for the key, passed to `ssh-keygen -C`. + + Defaults to an empty string to match Apple's built-in host key + generation and avoid leaking the hostname. + ''; + }; + }; + }; + + hostKeysConfig = lib.concatMapStringsSep "\n" + (k: "HostKey ${k.path}") + cfg.hostKeys; + + keygenScript = lib.concatMapStrings (k: + let + escapedPath = lib.escapeShellArg k.path; + in '' + if ! [[ -s ${escapedPath} ]]; then + if ! [[ -L ${escapedPath} ]]; then + rm -f ${escapedPath} + fi + + keygenArgs=( + -t ${lib.escapeShellArg k.type} + ${lib.optionalString (k.bits != null) "-b ${toString k.bits}"} + -C ${lib.escapeShellArg k.comment} + -f ${escapedPath} + -N "" + ) + + mkdir -p "$(dirname ${escapedPath})" + chmod 0755 "$(dirname ${escapedPath})" + ${lib.getExe' pkgs.openssh "ssh-keygen"} "''${keygenArgs[@]}" + fi + '') cfg.hostKeys; in { options = { @@ -24,6 +89,22 @@ in See {manpage}`sshd_config(5)` for help. ''; }; + + hostKeys = lib.mkOption { + type = lib.types.listOf (lib.types.submodule hostKeyOpts); + default = [ + { type = "rsa"; path = "/etc/ssh/ssh_host_rsa_key"; } + { type = "ecdsa"; path = "/etc/ssh/ssh_host_ecdsa_key"; } + { type = "ed25519"; path = "/etc/ssh/ssh_host_ed25519_key"; } + ]; + description = '' + SSH host key declarations. Each entry specifies a key type and path. + `HostKey` directives are written to the sshd configuration for each + entry. + + The default matches the keys that macOS automatically generates. + ''; + }; }; }; @@ -41,6 +122,12 @@ in fi ''); + environment.etc."ssh/sshd_config.d/099-host-keys.conf" = lib.mkIf (cfg.hostKeys != []) { + text = hostKeysConfig; + }; + environment.etc."ssh/sshd_config.d/100-nix-darwin.conf".text = cfg.extraConfig; + + system.activationScripts.openssh.text = lib.mkIf (cfg.hostKeys != []) keygenScript; }; } diff --git a/modules/system/activation-scripts.nix b/modules/system/activation-scripts.nix index 23f1024..df6376a 100644 --- a/modules/system/activation-scripts.nix +++ b/modules/system/activation-scripts.nix @@ -121,6 +121,7 @@ in ${cfg.activationScripts.applications.text} ${cfg.activationScripts.pam.text} ${cfg.activationScripts.patches.text} + ${cfg.activationScripts.openssh.text} ${cfg.activationScripts.etc.text} ${cfg.activationScripts.defaults.text} ${cfg.activationScripts.userDefaults.text} diff --git a/tests/services-openssh.nix b/tests/services-openssh.nix index 5f61481..bb32a3b 100644 --- a/tests/services-openssh.nix +++ b/tests/services-openssh.nix @@ -1,12 +1,37 @@ -{ config, pkgs, ... }: +{ config, ... }: { + services.openssh.enable = true; services.openssh.extraConfig = '' StreamLocalBindUnlink yes ''; + services.openssh.hostKeys = [ + { type = "ed25519"; path = "/etc/ssh/custom_host_ed25519_key"; comment = "my-host"; } + { type = "rsa"; path = "/etc/ssh/custom_host_rsa_key"; bits = 4096; } + ]; test = '' - echo >&2 "checking for StreamLocalBindUnlink in /etc/ssh/ssh_known_hosts" + echo >&2 "checking for StreamLocalBindUnlink in /etc/ssh/sshd_config.d/100-nix-darwin.conf" grep 'StreamLocalBindUnlink yes' ${config.out}/etc/ssh/sshd_config.d/100-nix-darwin.conf + + echo >&2 "checking for HostKey directives in /etc/ssh/sshd_config.d/099-host-keys.conf" + grep 'HostKey /etc/ssh/custom_host_ed25519_key' ${config.out}/etc/ssh/sshd_config.d/099-host-keys.conf + grep 'HostKey /etc/ssh/custom_host_rsa_key' ${config.out}/etc/ssh/sshd_config.d/099-host-keys.conf + + echo >&2 "checking that default keys are absent from config" + (! grep 'HostKey /etc/ssh/ssh_host_rsa_key' ${config.out}/etc/ssh/sshd_config.d/099-host-keys.conf) + (! grep 'HostKey /etc/ssh/ssh_host_ecdsa_key' ${config.out}/etc/ssh/sshd_config.d/099-host-keys.conf) + (! grep 'HostKey /etc/ssh/ssh_host_ed25519_key' ${config.out}/etc/ssh/sshd_config.d/099-host-keys.conf) + + echo >&2 "checking for ssh-keygen commands in activation script" + grep 'ssh-keygen.*keygenArgs' ${config.out}/activate + + echo >&2 "checking for keygenArgs in activation script" + grep '\-t ed25519' ${config.out}/activate + grep '\-C my-host' ${config.out}/activate + grep '\-f /etc/ssh/custom_host_ed25519_key' ${config.out}/activate + grep '\-t rsa' ${config.out}/activate + grep '\-b 4096' ${config.out}/activate + grep '\-f /etc/ssh/custom_host_rsa_key' ${config.out}/activate ''; }