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..cbf9aad3eae
--- /dev/null
+++ b/nixos/modules/security/gnupg.nix
@@ -0,0 +1,271 @@
+{ 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 != {}) {
+ # 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}, since this removal would kill gpg-agent.
+ #
+ # Unfortunately, for reaching such a persistent gpg-agent,
+ # GPG_AGENT_INFO can no longer be used as it is ignored with gpg >= 2.1,
+ # hence three different hacks are done here to make gpg connect
+ # to ${gnupgHome}/S.gpg-agent depending on the concern:
+ # - For gpg-agent, --supervised mode is used to pass it a socket
+ # in the persistent directory gnupgHome.
+ # - For the root user, on its login pam_systemd is mounting a fresh tmpfs on /run/user/0
+ # so wrong perms are set on /run/user/0/gnupg/d.6qoenf9br6fajbkknuz1i6ts
+ # when /run/user/0 is mounted, by overriding user-runtime-dir@.service
+ # - For secret decrypting services, /run/user/0/gnupg
+ # is emptied and keept empty by privately mounting
+ # an empty directory on it, using BindReadOnlyPaths=
+ 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.packages = [
+ # Here, passing by systemd.packages is kind of a hack to be able to
+ # write this file which is neither writable using environment.etc
+ # (because environment.etc."systemd/system".source is set)
+ # nor using systemd.services (because systemd.services."user-runtime-dir@0"
+ # does not exist, and should not to keep using systemd's upstream template
+ # and systemd.services."user-runtime-dir@").
+ (pkgs.writeTextDir "etc/systemd/system/user-runtime-dir@0.service.d/override.conf" ''
+ [Unit]
+ [Service]
+ ExecStartPost=${pkgs.writeShellScript "redirect-gpg-agent-run-socket" ''
+ install -D -d -m 640 /run/user/0/gnupg/d.6qoenf9br6fajbkknuz1i6ts
+ ''}
+ '')
+ ];
+ systemd.services =
+ lib.optionalAttrs cfg.enableGpgAgent {
+ gpg-agent = {
+ description = "gpg-agent for decrypting GnuPG-protected secrets";
+ requires = ["gpg-agent.socket"];
+ serviceConfig = {
+ Type = "simple";
+ 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" "gnupg/empty"];
+ 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() {
+ ${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";
+ RemainAfterExit = true;
+ PrivateTmp = true;
+ BindReadOnlyPaths = [ "/var/lib/gnupg/empty:/run/user/0/gnupg" ];
+ } // lib.optionalAttrs (match "^/.*" target == null) {
+ RuntimeDirectory = lib.removePrefix "/run/" (dirOf secret.path);
+ RuntimeDirectoryMode = "711";
+ RuntimeDirectoryPreserve = false;
+ };
+ }
+ ) cfg.secrets;
+};
+meta.maintainers = with lib.maintainers; [ julm ];
+}