diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f361163ca63..8c199f0fc25 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -193,6 +193,7 @@ ./security/lock-kernel-modules.nix ./security/misc.nix ./security/oath.nix + ./security/gnupg.nix ./security/pam.nix ./security/pam_usb.nix ./security/pam_mount.nix diff --git a/nixos/modules/security/gnupg.nix b/nixos/modules/security/gnupg.nix new file mode 100644 index 00000000000..a7a3c752267 --- /dev/null +++ b/nixos/modules/security/gnupg.nix @@ -0,0 +1,252 @@ +{ config, lib, pkgs, ... }: +let + inherit (builtins) dirOf match split; + inherit (lib) types; + cfg = config.security.gnupg; + gnupgHome = "/var/lib/gnupg"; + escapeUnitName = name: + lib.concatMapStrings (s: if lib.isList s then "-" else s) + (split "[^a-zA-Z0-9_.\\-]+" name); +in +{ +options.security.gnupg = { + store = lib.mkOption { + type = types.path; + default = "/root/.password-store"; + description = '' + Default base path for the gpg option + of the secrets. + Note that you may set it up with something like: + builtins.getEnv "PASSWORD_STORE_DIR" + "/machines/example". + ''; + # Does not copy the entire password-store into the Nix store, + # only the keys actually used will be. + apply = toString; + }; + secrets = lib.mkOption { + description = "Available secrets."; + default = {}; + example = { + "/root/.ssh/id_ed25519" = {}; + "knot/tsig/example.org/acme.conf" = { + user = "knot"; + }; + "lego/example.org/rfc2136" = { + pipe = " + cat - + cat /var/lib/gnupg/. + Defaults to the name of the secret, + prefixed by the path of the store + and suffixed by .gpg. + ''; + }; + mode = lib.mkOption { + type = types.str; + default = "400"; + description = '' + Permission mode of the secret path. + ''; + }; + user = lib.mkOption { + type = types.str; + default = "root"; + description = '' + Owner of the secret path. + ''; + }; + group = lib.mkOption { + type = types.str; + default = "root"; + description = '' + Group of the secret path. + ''; + }; + pipe = lib.mkOption { + type = types.nullOr types.str; + default = null; + apply = x: if x == null then null else pkgs.writeShellScript "pipe" x; + description = '' + Shell script taking the deciphered secret on its standard input + and which must put on its standard output + the actual secret material to be installed. + This allows to decorate the secret with non-secret bits. + ''; + }; + path = lib.mkOption { + type = types.str; + default = name; + apply = p: if match "^/.*" p == null then "/run/keys/gnupg/"+p+"/file" else p; + description = '' + The path on the target system where the secret is installed to. + Default to the name of the secret, + prefixed by /run/keys/gnupg/ + and suffixed by /file, + if non-absolute. + ''; + }; + service = lib.mkOption { + type = types.str; + default = "secret-" + escapeUnitName name + ".service"; + description = '' + The name of the systemd service. + Useful to put constraints like after or wants + into services requiring this secret. + ''; + }; + postStart = lib.mkOption { + type = types.lines; + default = ""; + example = "systemctl reload nginx.service"; + description = '' + Commands to run after new secrets go live. + Typically the web server and other servers using secrets + need to be reloaded. + ''; + }; + postStop = lib.mkOption { + type = types.lines; + default = ""; + example = ''shred -u "$secret_file"''; + description = '' + Commands to run after stopping the service. + Typically removing a persistent secret. + For convenience the path of the secret + is provided in the shell variable secret_file. + ''; + }; + }; + })); + }; + enableGpgAgent = lib.mkEnableOption ''gpg-agent for decrypting secrets. + + Otherwise, you'll have to forward an agent-extra-socket: + + $ ssh -nNT root@example.org -o StreamLocalBindUnlink=yes -R /var/lib/gnupg/S.gpg-agent:$(gpgconf --list-dirs agent-extra-socket) + + '' // {default = true; example = false;}; + gpgAgentFlags = lib.mkOption { + type = types.listOf types.str; + default = [ + "--default-cache-ttl" "600" + "--max-cache-ttl" "7200" + ]; + description = '' + Extra flags passed to the gpg-agent + used to decrypt secrets. + ''; + }; +}; +config = lib.mkIf (cfg.secrets != {}) { + systemd.sockets."gpg-agent" = lib.optionalAttrs cfg.enableGpgAgent { + description = "Socket for gpg-agent"; + wantedBy = ["sockets.target"]; + socketConfig.ListenStream = "${gnupgHome}/S.gpg-agent"; + socketConfig.SocketMode = "0600"; + }; + environment.systemPackages = + # TODO: maybe this would be better to do that directly in pkgs.gnupg? + let gpgPresetPassphrase = pkgs.runCommand "gpg-preset-passphrase" + { preferLocalBuild = true; + allowSubstitutes = false; + } '' + mkdir -p $out/bin + ln -s -t $out/bin ${pkgs.gnupg}/libexec/gpg-preset-passphrase + ''; in + [ pkgs.gnupg gpgPresetPassphrase ]; + systemd.services = + lib.optionalAttrs cfg.enableGpgAgent { + gpg-agent = { + description = "gpg-agent for decrypting GnuPG-protected secrets"; + requires = ["gpg-agent.socket"]; + serviceConfig = { + Type = "simple"; + # Because /run/user/0 is wiped out by pam_systemd when root logouts, + # systemd.services.gpg-agent cannot put its socket in + # the path expected by gpg: + # /run/user/0/gnupg/d.6qoenf9br6fajbkknuz1i6ts + # derived from ${gnupgHome}. + # + # But GPG_AGENT_INFO is ignored with gpg >= 2.1, + # hence this hack makes gpg connect to ${gnupgHome}/S.gpg-agent + # by relaxing permissions of the expected socket directory. + # With this hack, uploading the passphrase should now be doable with just: + # ssh root@example.org 'gpg-preset-passphrase --homedir /var/lib/gnupg --preset $keygrip' + ExecStartPre = "${pkgs.coreutils}/bin/install -D -d -m 640 /run/user/0/gnupg/d.6qoenf9br6fajbkknuz1i6ts"; + ExecStart = ''${pkgs.gnupg}/bin/gpg-agent \ + --supervised \ + --homedir '${gnupgHome}' \ + --allow-loopback-pinentry \ + --allow-preset-passphrase \ + ${lib.escapeShellArgs cfg.gpgAgentFlags} + ''; + Restart = "on-failure"; + RestartSec = 5; + StateDirectory = ["gnupg"]; + StateDirectoryMode = "700"; + }; + }; + } // + lib.mapAttrs' (target: secret: + lib.nameValuePair (lib.removeSuffix ".service" secret.service) { + description = "Install secret ${secret.path}"; + after = ["gpg-agent.service"]; + wants = ["gpg-agent.service"]; + script = '' + set -o pipefail + set -eux + decrypt() { + # In case /run/user/0 has been cleaned up by pam_systemd + install -D -m 640 -d /run/user/0/gnupg/d.6qoenf9br6fajbkknuz1i6ts + ${pkgs.gnupg}/bin/gpg --homedir '${gnupgHome}' --no-autostart --batch --decrypt '${secret.gpg}' | + ${lib.optionalString (secret.pipe != null) (secret.pipe+" |")} \ + install -D -m '${secret.mode}' -o '${secret.user}' -g '${secret.group}' /dev/stdin '${secret.path}' + } + while ! decrypt; do sleep $((1 + ($RANDOM % 12))); done + ''; + postStart = lib.optionalString (secret.postStart != "") '' + set -eux + ${secret.postStart} + ''; + postStop = lib.optionalString (secret.postStop != "") '' + secret_file='${secret.path}' + set -eux + ${secret.postStop} + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + RemainAfterExit = true; + } // lib.optionalAttrs (match "^/.*" target == null) { + RuntimeDirectory = lib.removePrefix "/run/" (dirOf secret.path); + RuntimeDirectoryMode = "711"; + RuntimeDirectoryPreserve = false; + }; + } + ) cfg.secrets; +}; +meta.maintainers = with lib.maintainers; [ julm ]; +}