about summary refs log tree commit diff
diff options
context:
space:
mode:
authoraszlig <aszlig@redmoonstudios.org>2016-04-02 11:13:27 +0200
committeraszlig <aszlig@redmoonstudios.org>2016-04-02 14:08:51 +0200
commit8db1803b5d9865b2355fabdb6bb974d879ce57cc (patch)
tree7bac4a2bcf3fa1773df040fb113901e64d2625b7
parent157f16889edab556dd54cf9f367a021119019ed4 (diff)
Add a new module and test for gpg-agent
Since NixOS/nixpkgs@5391882 there no longer is the option to start the
agent during X session startup, which prompted me to write this module.

I was unhappy how GnuPG is handled in NixOS since a long time and wanted
to OCD all the configuration files directly into the module.

Unfortunately, this is something I eventually gave up because GnuPG's
design makes it very hard to preseed configuration. My first attempt was
to provide default configuration files in /etc/gnupg, but that wasn't
properly picked up by GnuPG.

Another way would have been to change the default configuration files,
but that would have the downside that we could only override those
configurations using command line options for each individual GnuPG
component.

The approach I tried to go for was to patch GnuPG so that all the
defaults are directly set in the source code using a giant sed
expression. It turned out that this approach doesn't work very well,
because every component has implemented its own ways how to handle
commandline arguments versus (default) configuration files.

In the end I gave up trying to OCD anything related to GnuPG
configuration and concentrated just on the agent.

And that's another beast, which unfortunately doesn't work very well
with systemd.

While searching the net for existing patches I stumbled upon one done by
@shlevy:

https://lists.gnupg.org/pipermail/gnupg-devel/2014-November/029092.html

Unfortunately, the upstream author seems to be quite anti-systemd and
didn't want to accept that into the upstream project.

Because of this I went for using LD_PRELOAD to pick up the file
descriptors provided by the systemd sockets, because in the end I don't
want to constantly catch up with upstream and rebase the patch on every
new release.

Apart from just wrapping the agent to be socket activated, we also wrap
the pinentry program, so that we can inject a _CLIENT_PID environment
variable from the LD_PRELOAD wrapper that is picked up by the pinentry
wrapper to determine the TTY and/or display of the client communicating
with the agent.

The wrapper uses the proc filesystem to get all the relevant information
and passes it to the real pinentry.

The advantage of this is that we don't need to do things such as
"gpg-connect-agent updatestartuptty /bye" or any other workarounds and
even if we connect via SSH the agent should be able to correctly pick up
the TTY and/or display.

Signed-off-by: aszlig <aszlig@redmoonstudios.org>
-rw-r--r--modules/module-list.nix1
-rw-r--r--modules/programs/gpg-agent/agent-wrapper.c214
-rw-r--r--modules/programs/gpg-agent/default.nix164
-rw-r--r--modules/programs/gpg-agent/pinentry-wrapper.c281
-rw-r--r--modules/programs/gpg-agent/test.nix11
-rw-r--r--tests/default.nix3
-rw-r--r--tests/programs/gpg-agent/default.nix127
-rw-r--r--tests/programs/gpg-agent/snakeoil.asc59
8 files changed, 860 insertions, 0 deletions
diff --git a/modules/module-list.nix b/modules/module-list.nix
index 9935021f..7a489c55 100644
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -5,6 +5,7 @@
   ./hardware/thinkpad.nix
   ./profiles/common.nix
   ./profiles/tests.nix
+  ./programs/gpg-agent
   ./services/multipath-vpn.nix
   ./services/postfix
   ./services/starbound.nix
diff --git a/modules/programs/gpg-agent/agent-wrapper.c b/modules/programs/gpg-agent/agent-wrapper.c
new file mode 100644
index 00000000..e969e85b
--- /dev/null
+++ b/modules/programs/gpg-agent/agent-wrapper.c
@@ -0,0 +1,214 @@
+#define _GNU_SOURCE
+#include <dlfcn.h>
+
+#include <stddef.h>
+#include <unistd.h>
+#include <string.h>
+#include <stdlib.h>
+#include <errno.h>
+#include <malloc.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#include <systemd/sd-daemon.h>
+
+int main_fd = 0;
+int ssh_fd = 0;
+int scdaemon_fd = 0;
+
+/* Get the systemd file descriptor for a particular socket file.
+ * Returns -1 if there is an error or -2 if it is an unnamed socket.
+ */
+int get_sd_fd_for(const struct sockaddr_un *addr)
+{
+    if (main_fd == 0 && ssh_fd == 0 && scdaemon_fd == 0) {
+        int num_fds;
+        char **fdmap = NULL;
+        num_fds = sd_listen_fds_with_names(0, &fdmap);
+
+        if (num_fds <= 0) {
+            fputs("No suitable file descriptors in LISTEN_FDS.\n", stderr);
+            if (num_fds == 0)
+                errno = EADDRNOTAVAIL;
+            return -1;
+        }
+
+        if (fdmap != NULL) {
+            for (int i = 0; i < num_fds; i++) {
+                if (strncmp(fdmap[i], "main", 5) == 0)
+                    main_fd = SD_LISTEN_FDS_START + i;
+                else if (strncmp(fdmap[i], "ssh", 4) == 0)
+                    ssh_fd = SD_LISTEN_FDS_START + i;
+                else if (strncmp(fdmap[i], "scdaemon", 9) == 0)
+                    scdaemon_fd = SD_LISTEN_FDS_START + i;
+                free(fdmap[i]);
+            }
+            free(fdmap);
+        }
+    }
+
+    if (addr->sun_path == NULL || *(addr->sun_path) == 0)
+        return -2;
+
+    char *basename = strrchr(addr->sun_path, '/');
+    if (basename == NULL) {
+        fprintf(stderr, "Socket path %s is not absolute.\n",
+                addr->sun_path);
+        errno = EADDRNOTAVAIL;
+        return -1;
+    } else {
+        basename++;
+    }
+
+    if (strncmp(basename, "S.gpg-agent", 12) == 0) {
+        return main_fd;
+    } else if (strncmp(basename, "S.gpg-agent.ssh", 16) == 0) {
+        return ssh_fd;
+    } else if (strncmp(basename, "S.scdaemon", 11) == 0) {
+        return scdaemon_fd;
+    } else {
+        fprintf(stderr, "Socket path %s is unknown.\n", addr->sun_path);
+        errno = EADDRNOTAVAIL;
+        return -1;
+    }
+}
+
+/* Replace the systemd-provided socket FD with the one that is used by the
+ * agent, so that we can later look it up in our accept() wrapper.
+ */
+void record_sockfd(int sysd_fd, int redir_fd)
+{
+    if (sysd_fd == main_fd)
+        main_fd = redir_fd;
+    else if (sysd_fd == ssh_fd)
+        ssh_fd = redir_fd;
+}
+
+/* systemd is already listening on that socket, so we don't need to. */
+int listen(int sockfd, int backlog)
+{
+    return 0;
+}
+
+/* The agent should already have called socket() before and we need to close the
+ * file descriptor that the socket() call has returned and replace it with the
+ * one provided by systemd.
+ */
+int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
+{
+    int new_fd;
+
+    new_fd = get_sd_fd_for((const struct sockaddr_un *)addr);
+
+    switch (new_fd) {
+        case -1: return -1;
+        /* Unnamed socket, just pretend everything is fine */
+        case -2: return 0;
+    }
+
+    if ((new_fd = get_sd_fd_for((const struct sockaddr_un *)addr)) == -1)
+        return -1;
+
+    fprintf(stderr, "bind: Redirecting FD %d to systemd-provided FD %d.\n",
+            sockfd, new_fd);
+
+    if (dup2(new_fd, sockfd) == -1)
+        return -1;
+    else
+        record_sockfd(new_fd, sockfd);
+
+    return 0;
+}
+
+/* Avoid forking for the first time so we can properly track the agent using a
+ * systemd service (without the need to set Type="forking").
+ */
+int first_fork = 1;
+
+pid_t fork(void)
+{
+    static pid_t (*_fork)(void) = NULL;
+    if (_fork == NULL)
+        _fork = dlsym(RTLD_NEXT, "fork");
+
+    /* Unset the LD_PRELOAD environment variable to make sure we don't propagate
+     * it down to things like the pinentry.
+     */
+    if (unsetenv("LD_PRELOAD") == -1)
+        return -1;
+
+    if (first_fork)
+        return first_fork = 0;
+
+    return _fork();
+}
+
+/* Get the PID of the client connected to the given socket FD. */
+pid_t get_socket_pid(int sockfd)
+{
+    struct ucred pcred;
+    socklen_t pcred_len = sizeof(pcred);
+
+    if (getsockopt(sockfd, SOL_SOCKET, SO_PEERCRED, &pcred, &pcred_len) == -1)
+        return -1;
+
+    return pcred.pid;
+}
+
+pid_t last_pid = 0;
+
+/* For the pinentry to work correctly with SSH, we need to record the process ID
+ * of the process communicating with the agent. That way we can get more
+ * information about the PTS/PTY/TTY the user is on and also know whether a
+ * DISPLAY is set for that process, because we will connect the pinentry's TTY
+ * to the TTY of the process on the other end of the socket.
+ */
+int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
+{
+    int retval;
+
+    static int (*_accept)(int, struct sockaddr *, socklen_t *) = NULL;
+    if (_accept == NULL)
+        _accept = dlsym(RTLD_NEXT, "accept");
+
+    retval = _accept(sockfd, addr, addrlen);
+
+    last_pid = 0;
+
+    if (retval != -1 && ssh_fd != 0 && sockfd == ssh_fd) {
+        pid_t client_pid = get_socket_pid(retval);
+        if (client_pid == -1) {
+            close(retval);
+            return -1;
+        }
+        last_pid = client_pid;
+        fprintf(stderr, "Socket endpoint PID for accepted socket %d is %d.\n",
+                retval, client_pid);
+    }
+
+    return retval;
+}
+
+/* Wrap the execv() that calls the pinentry program to include a special
+ * _CLIENT_PID environment variable, which contains the PID we gathered during
+ * accept(). Note that this is potentially racy if we have a lot of concurrent
+ * connections, but the worst that could happen is that we end up having a
+ * pinentry running on the wrong TTY/display.
+ */
+int execv(const char *path, char *const argv[])
+{
+    static int (*_execv)(const char *, char *const[]) = NULL;
+    if (_execv == NULL)
+        _execv = dlsym(RTLD_NEXT, "execv");
+
+    if (last_pid != 0 &&
+        strncmp(path, PINENTRY_WRAPPER, sizeof(PINENTRY_WRAPPER) + 1) == 0) {
+        char env_var[40];
+        if (snprintf(env_var, 40, "_CLIENT_PID=%d", last_pid) < 0)
+            return -1;
+        if (putenv(env_var) < 0)
+            return -1;
+    }
+
+    last_pid = 0;
+    return _execv(path, argv);
+}
diff --git a/modules/programs/gpg-agent/default.nix b/modules/programs/gpg-agent/default.nix
new file mode 100644
index 00000000..81a113f7
--- /dev/null
+++ b/modules/programs/gpg-agent/default.nix
@@ -0,0 +1,164 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.vuizvui.programs.gpg-agent;
+
+  pinentryWrapper = pkgs.runCommand "pinentry-wrapper" {
+    pinentryProgram = cfg.pinentry.program;
+  } ''
+    cc -Wall -std=gnu11 -DPINENTRY_PROGRAM=\"$pinentryProgram\" \
+      "${./pinentry-wrapper.c}" -o "$out"
+  '';
+
+  scdaemonRedirector = pkgs.writeScript "scdaemon-redirector" ''
+    #!${pkgs.stdenv.shell}
+    exec "${pkgs.socat}/bin/socat" - \
+      UNIX-CONNECT:"$HOME/${cfg.homeDir}/S.scdaemon"
+  '';
+
+  agentWrapper = pkgs.runCommand "gpg-agent-wrapper" {
+    buildInputs = with pkgs; [ pkgconfig systemd ];
+    inherit pinentryWrapper;
+  } ''
+    cc -Wall -shared -std=c11 \
+      $(pkg-config --cflags --libs libsystemd) -ldl \
+      -DPINENTRY_WRAPPER=\"$pinentryWrapper\" \
+      "${./agent-wrapper.c}" -o "$out" -fPIC
+  '';
+
+  agentSocketConfig = name: {
+    FileDescriptorName = name;
+    Service = "gpg-agent.service";
+    SocketMode = "0600";
+    DirectoryMode = "0700";
+  };
+
+in {
+  options.vuizvui.programs.gpg-agent = {
+    enable = mkEnableOption "support for GnuPG agent";
+
+    homeDir = mkOption {
+      type = types.addCheck types.str (d: builtins.substring 0 1 d != "/");
+      default = ".gnupg";
+      description = ''
+        The directory where GnuPG keeps its state files and configuration files,
+        relative to the user's home directory.
+      '';
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.gnupg;
+      defaultText = "pkgs.gnupg";
+      example = literalExample "pkgs.gnupg21";
+      description = "The GnuPG package to use for running the agent.";
+    };
+
+    pinentry.program = mkOption {
+      type = types.path;
+      default = "${pkgs.pinentry}/bin/pinentry";
+      defaultText = "\${pkgs.pinentry}/bin/pinentry";
+      example = literalExample "\${pkgs.pinentry_qt5}/bin/pinentry";
+      description = "The pinentry program to use to ask for passphrases.";
+    };
+
+    sshSupport = mkEnableOption "GnuPG agent support for SSH";
+
+    scdaemon = {
+      enable = mkEnableOption "GnuPG agent with Smartcard daemon";
+
+      program = mkOption {
+        type = types.path;
+        default = "${cfg.package}/libexec/scdaemon";
+        defaultText = let
+          configPath = "config.vuizvui.programs.gpg-agent";
+        in "\${${configPath}.package}/libexec/scdaemon";
+        example = literalExample "\${pkgs.my_shiny_scdaemon}/bin/scdaemon";
+        description = "The program to use for the Smartcard daemon";
+      };
+    };
+  };
+
+  config = mkMerge [
+    (mkIf cfg.enable {
+      vuizvui.requiresTests = singleton ["vuizvui" "programs" "gpg-agent"];
+      environment.systemPackages = [ cfg.package ];
+
+      systemd.user.services.gpg-agent = {
+        description = "GnuPG Agent";
+        environment.LD_PRELOAD = agentWrapper;
+        environment.GNUPGHOME = "~/${cfg.homeDir}";
+
+        serviceConfig.ExecStart = toString ([
+          "${cfg.package}/bin/gpg-agent"
+          "--pinentry-program=${pinentryWrapper}"
+          (if cfg.scdaemon.enable
+           then "--scdaemon-program=${scdaemonRedirector}"
+           else "--disable-scdaemon")
+          "--no-detach"
+          "--daemon"
+        ] ++ optional cfg.sshSupport "--enable-ssh-support");
+
+        serviceConfig.ExecReload = toString [
+          "${cfg.package}/bin/gpg-connect-agent"
+          "RELOADAGENT"
+          "/bye"
+        ];
+      };
+
+      systemd.user.sockets.gpg-agent-main = {
+        wantedBy = [ "sockets.target" ];
+        description = "Main Socket For GnuPG Agent";
+        listenStreams = [ "%h/${cfg.homeDir}/S.gpg-agent" ];
+        socketConfig = agentSocketConfig "main";
+      };
+    })
+    (mkIf (cfg.enable && cfg.scdaemon.enable) {
+      systemd.user.sockets.gnupg-scdaemon = {
+        wantedBy = [ "sockets.target" ];
+        description = "GnuPG Smartcard Daemon Socket";
+        listenStreams = [ "%h/${cfg.homeDir}/S.scdaemon" ];
+        socketConfig = {
+          FileDescriptorName = "scdaemon";
+          SocketMode = "0600";
+          DirectoryMode = "0700";
+        };
+      };
+
+      systemd.user.services.gnupg-scdaemon = {
+        description = "GnuPG Smartcard Daemon";
+        environment.LD_PRELOAD = agentWrapper;
+        environment.GNUPGHOME = "~/${cfg.homeDir}";
+
+        serviceConfig.ExecStart = toString [
+          "${cfg.scdaemon.program}"
+          "--no-detach"
+          "--daemon"
+        ];
+      };
+    })
+    (mkIf (cfg.enable && cfg.sshSupport) {
+      environment.variables.SSH_AUTH_SOCK =
+        "$HOME/${cfg.homeDir}/S.gpg-agent.ssh";
+
+      systemd.user.sockets.gpg-agent-ssh = {
+        wantedBy = [ "sockets.target" ];
+        description = "SSH Socket For GnuPG Agent";
+        listenStreams = [ "%h/${cfg.homeDir}/S.gpg-agent.ssh" ];
+        socketConfig = agentSocketConfig "ssh";
+      };
+
+      assertions = singleton {
+        assertion = !config.programs.ssh.startAgent;
+        message = toString [
+          "You cannot use the GnuPG agent with SSH support in addition to the"
+          "SSH agent, please either disable"
+          "`vuizvui.programs.gpg-agent.sshSupport' or disable"
+          "`programs.ssh.startAgent'."
+        ];
+      };
+    })
+  ];
+}
diff --git a/modules/programs/gpg-agent/pinentry-wrapper.c b/modules/programs/gpg-agent/pinentry-wrapper.c
new file mode 100644
index 00000000..12710760
--- /dev/null
+++ b/modules/programs/gpg-agent/pinentry-wrapper.c
@@ -0,0 +1,281 @@
+#include <sys/types.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <fcntl.h>
+
+/* Get the terminal path of the given PID and FD using the /proc file system. */
+char *get_terminal(pid_t pid, int fd)
+{
+    int term, is_term;
+    ssize_t linklen;
+    char fd_path[50];
+    char term_path[100];
+    struct stat st;
+
+    if (snprintf(fd_path, 50, "/proc/%d/fd/%d", pid, fd) < 0) {
+        perror("snprintf proc fd path");
+        return NULL;
+    }
+
+    if (lstat(fd_path, &st) == -1)
+        return NULL;
+
+    if (!S_ISLNK(st.st_mode))
+        return NULL;
+
+    if ((linklen = readlink(fd_path, term_path, sizeof term_path)) == -1) {
+        perror("readlink term path");
+        return NULL;
+    }
+
+    term_path[linklen] = 0;
+
+    if ((term = open(term_path, O_RDONLY | O_NOCTTY)) == -1)
+        return NULL;
+
+    is_term = isatty(term);
+
+    if (close(term) == -1) {
+        perror("close client tty");
+        return NULL;
+    }
+
+    if (!is_term)
+        return NULL;
+
+    return strdup(term_path);
+}
+
+/* Probes FD 0, 1 and 2 for a connected terminal device and return an allocated
+ * string pointing to the filename.
+ */
+char *detect_terminal(pid_t pid)
+{
+    char *term;
+
+    for (int i = 0; i < 3; ++i) {
+        term = get_terminal(pid, i);
+        if (term == NULL)
+            continue;
+
+        return term;
+    }
+
+    return NULL;
+}
+
+/* Fetch the info from /proc/PID/environ and retorn it as an array. */
+char **fetch_environ(pid_t pid)
+{
+    char environ_path[50], **result = NULL;
+    char buf[2048], *envbuf, *environ = NULL;
+    size_t chunklen, envlen = 0;
+    int env_fd;
+
+    if (snprintf(environ_path, 50, "/proc/%d/environ", pid) < 0) {
+        perror("snprintf proc environ path");
+        return NULL;
+    }
+
+    if ((env_fd = open(environ_path, O_RDONLY)) == -1) {
+        perror("open proc environ");
+        return NULL;
+    }
+
+    while ((chunklen = read(env_fd, buf, sizeof buf)) > 0) {
+        if (environ == NULL) {
+            if ((environ = malloc(envlen + chunklen + 1)) == NULL) {
+                perror("malloc proc environ");
+                return NULL;
+            }
+        } else {
+            if ((environ = realloc(environ, envlen + chunklen + 1)) == NULL) {
+                perror("realloc proc environ");
+                free(environ);
+                return NULL;
+            }
+        }
+        memcpy(environ + envlen, buf, chunklen);
+        envlen += chunklen;
+        environ[envlen + 1] = 0;
+        if (chunklen < sizeof buf)
+            break;
+    }
+
+    if (close(env_fd) == -1) {
+        perror("close proc environ");
+        free(environ);
+        return NULL;
+    }
+
+    envbuf = environ;
+
+    if ((result = malloc(sizeof(char*))) == NULL) {
+        perror("malloc environ array");
+        free(environ);
+        return NULL;
+    }
+    result[0] = NULL;
+
+    for (int i = 0; envbuf - environ < envlen; ++i) {
+        if ((result = realloc(result, sizeof(char*) * (i + 2))) == NULL) {
+            perror("realloc environ array");
+            free(environ);
+            free(result);
+            return NULL;
+        }
+
+        result[i] = strndup(envbuf, envlen - (envbuf - environ));
+        result[i + 1] = NULL;
+        envbuf += strlen(envbuf) + 1;
+    }
+
+    free(environ);
+    return result;
+}
+
+void free_environ(char **environ)
+{
+    char **tmp = environ;
+    if (environ == NULL) return;
+    do free(*tmp);
+    while (*(++tmp) != NULL);
+    free(environ);
+    environ = NULL;
+}
+
+struct proc_info {
+    char **environ;
+    char *term;
+};
+
+/* Gather information for the given process ID, like environment or connected
+ * terminals.
+ */
+struct proc_info *open_proc_info(pid_t pid)
+{
+    struct proc_info *pi = NULL;
+
+    if ((pi = malloc(sizeof(struct proc_info *))) == NULL) {
+        perror("malloc proc_info");
+        return NULL;
+    }
+
+    pi->term = detect_terminal(pid);
+    if ((pi->environ = fetch_environ(pid)) == NULL) {
+        free(pi->term);
+        free(pi);
+        return NULL;
+    }
+
+    return pi;
+}
+
+void close_proc_info(struct proc_info *pi)
+{
+    if (pi->term != NULL) free(pi->term);
+    free_environ(pi->environ);
+    free(pi);
+}
+
+/* Fetch an environment variable from the proc_info structure similar to
+ * getenv() but for remote PIDs.
+ */
+char *proc_info_getenv(struct proc_info *pi, const char *name)
+{
+    char **tmp = pi->environ;
+    size_t namelen = strlen(name);
+    do {
+        if (strncmp(*tmp, name, namelen) == 0 &&
+            *(*tmp + namelen) == '=') {
+            return strdup(*tmp + namelen + 1);
+        }
+    } while (*(++tmp) != NULL);
+    return NULL;
+}
+
+#define MAYBE_EXPAND_ARGV(opt, value) \
+    if ((tmp = value) != NULL) { \
+        new_argv = realloc(new_argv, sizeof(char*) * (new_argc + 3)); \
+        if (new_argv == NULL) { \
+            perror("realloc new argv"); \
+            return EXIT_FAILURE; \
+        } \
+        new_argv[new_argc + 0] = "--" opt; \
+        new_argv[new_argc + 1] = tmp; \
+        new_argv[new_argc + 2] = NULL; \
+        new_argc += 2; \
+    }
+
+/* This is our program main routine whenever we get a _CLIENT_PID environment
+ * variable.
+ */
+int wrap(struct proc_info *pi, int argc, char **argv)
+{
+    char *tmp, **new_argv;
+    int new_argc = 1;
+
+    if ((new_argv = malloc(sizeof(char*) * 2)) == NULL) {
+        perror("malloc new argv");
+        return EXIT_FAILURE;
+    }
+
+    new_argv[0] = PINENTRY_PROGRAM;
+    new_argv[1] = NULL;
+
+    MAYBE_EXPAND_ARGV("display", proc_info_getenv(pi, "DISPLAY"));
+    MAYBE_EXPAND_ARGV("ttyname", strdup(pi->term));
+    MAYBE_EXPAND_ARGV("ttytype", proc_info_getenv(pi, "TERM"));
+    MAYBE_EXPAND_ARGV("lc-ctype", proc_info_getenv(pi, "LC_CTYPE"));
+    MAYBE_EXPAND_ARGV("lc-messages", proc_info_getenv(pi, "LC_MESSAGES"));
+
+    close_proc_info(pi);
+
+    /* No DISPLAY/TTY found, so use the arguments provided by the agent. */
+    if (new_argc == 1) {
+        free(new_argv);
+        new_argv = argv;
+    }
+
+    /* Make sure we don't have DISPLAY already in our environment to avoid
+     * starting a pinentry on X while the user is connected via SSH for example.
+     */
+    if (unsetenv("DISPLAY") == -1)
+        return EXIT_FAILURE;
+
+    if (execv(PINENTRY_PROGRAM, new_argv) == -1) {
+        perror("execv real pinentry");
+        return EXIT_FAILURE;
+    }
+
+    /* Not reached because the process should be substituted in execve(). */
+    return EXIT_SUCCESS;
+}
+
+int main(int argc, char **argv)
+{
+    const char *pidstr;
+    struct proc_info *pi = NULL;
+
+    if ((pidstr = getenv("_CLIENT_PID")) != NULL) {
+        if ((pi = open_proc_info(atoi(pidstr))) == NULL)
+            fprintf(stderr, "Client PID %d has vanished before we could"
+                    " retrieve /proc information.\n", atoi(pidstr));
+        else
+            return wrap(pi, argc, argv);
+    }
+
+    argv[0] = PINENTRY_PROGRAM;
+
+    if (execv(PINENTRY_PROGRAM, argv) == -1) {
+        perror("execv real pinentry");
+        return EXIT_FAILURE;
+    }
+
+    /* Not reached because the process should be substituted in execve(). */
+    return EXIT_SUCCESS;
+}
diff --git a/modules/programs/gpg-agent/test.nix b/modules/programs/gpg-agent/test.nix
new file mode 100644
index 00000000..ecfa2737
--- /dev/null
+++ b/modules/programs/gpg-agent/test.nix
@@ -0,0 +1,11 @@
+let
+  pkgs = import <nixpkgs> {};
+in
+
+  pkgs.runCommand "gpg-agent-wrapper" {
+    buildInputs = with pkgs; [ pkgconfig systemd ];
+  } ''
+    cc -Wall -shared -std=c11 \
+      $(pkg-config --cflags --libs libsystemd) \
+      "${./agent-wrapper.c}" -o "$out" -ldl -fPIC
+  ''
diff --git a/tests/default.nix b/tests/default.nix
index 54b130a3..29fdb973 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -12,6 +12,9 @@ in {
   games = {
     starbound = callTest ./games/starbound.nix;
   };
+  programs = {
+    gpg-agent = callTest ./programs/gpg-agent;
+  };
   richi235 = {
     # Currently broken
     #multipath-vpn = callTest ./richi235/multipath-vpn.nix;
diff --git a/tests/programs/gpg-agent/default.nix b/tests/programs/gpg-agent/default.nix
new file mode 100644
index 00000000..d10fdbfe
--- /dev/null
+++ b/tests/programs/gpg-agent/default.nix
@@ -0,0 +1,127 @@
+{ pkgs, ... }:
+
+let
+  mkExpect = expectScript: script: pkgs.writeScript "test-gnupg-cli" ''
+    #!${pkgs.expect}/bin/expect -f
+    set timeout 20
+    spawn ${pkgs.writeScript "cli-testscript.sh" ''
+      #!${pkgs.stdenv.shell} -ex
+      ${script}
+    ''}
+    ${expectScript}
+    set ret [wait]
+    exit [lindex $ret 3]
+  '';
+
+  cliTestWithPassphrase = mkExpect ''
+    expect -regexp ---+.*Please.enter
+    send supersecret\r
+  '';
+
+  cliTest = mkExpect "";
+
+in {
+  name = "gpg-agent";
+
+  enableOCR = true;
+
+  machine = { lib, ... }: {
+    imports = map (what:
+      "${import ../../../nixpkgs-path.nix}/nixos/tests/common/${what}.nix"
+    ) [ "user-account" "x11" ];
+
+    services.openssh.enable = true;
+    services.xserver.displayManager.auto.user = "alice";
+
+    vuizvui.programs.gpg-agent.enable = true;
+    vuizvui.programs.gpg-agent.sshSupport = true;
+    programs.ssh.startAgent = false;
+  };
+
+  testScript = ''
+    $machine->waitForUnit("sshd.service");
+    $machine->succeed("ssh-keygen -t ed25519 -f /root/id_ed25519 -N '''");
+    my $cmd = 'mkdir -p ~/.ssh && cat > ~/.ssh/authorized_keys';
+    $machine->succeed("su -c 'umask 0077; $cmd' alice < /root/id_ed25519.pub");
+
+    $machine->waitForX;
+
+    sub ssh ($) {
+      my $esc = $_[0] =~ s/'/'\\${"'"}'/gr;
+      return "ssh -q -i /root/id_ed25519".
+             " -o StrictHostKeyChecking=no".
+             " alice\@127.0.0.1 -- '$esc'";
+    }
+
+    sub xsu ($) {
+      my $esc = $_[0] =~ s/'/'\\${"'"}'/gr;
+      return "DISPLAY=:0 su alice -c '$esc'";
+    }
+
+    $machine->nest("import snakeoil key", sub {
+      $machine->succeed(ssh "${cliTestWithPassphrase ''
+        gpg2 --import ${./snakeoil.asc}
+      ''}");
+      $machine->succeed(ssh "${mkExpect ''
+        expect gpg>
+        send trust\r
+        expect decision?
+        send 5\r
+        expect "Do you really want"
+        send y\r
+        expect gpg>
+        send save\r
+      '' "gpg2 --edit-key ECC15FE1"}");
+    });
+
+    subtest "test SSH agent support", sub {
+      $machine->succeed(ssh 'ssh-keygen -t ed25519 -f ~/testkey -N ""');
+      $machine->succeed(ssh '${mkExpect ''
+        expect -regexp ---+.*Please.enter
+        send supersecret\r
+        expect -regexp ---+.*Please.re-en
+        send supersecret\r
+      '' "ssh-add ~/testkey"}');
+
+      $machine->succeed("umask 0077; $cmd < ~alice/testkey.pub");
+      $machine->succeed(ssh 'rm ~/testkey*');
+
+      $machine->succeed(ssh 'ssh -o StrictHostKeyChecking=no root@127.0.0.1'.
+                            ' touch /i_have_thu_powarr');
+      $machine->succeed("test -e /i_have_thu_powarr");
+
+      $machine->succeed(ssh "systemctl --user reload gpg-agent");
+
+      $machine->succeed(ssh "${cliTestWithPassphrase ''
+        ssh -o StrictHostKeyChecking=no root@127.0.0.1 \
+          touch /i_still_have_thu_powarr
+      ''}");
+      $machine->succeed("test -e /i_still_have_thu_powarr");
+    };
+
+    subtest "test from SSH", sub {
+      $machine->succeed(ssh "systemctl --user reload gpg-agent");
+      $machine->succeed(ssh "${cliTestWithPassphrase ''
+        echo encrypt me > to_encrypt
+        gpg2 -sea -r ECC15FE1 to_encrypt
+        rm to_encrypt
+      ''}");
+      $machine->succeed(ssh "${cliTest ''
+        [ "$(gpg2 -d to_encrypt.asc)" = "encrypt me" ]
+      ''}");
+    };
+
+    subtest "test from X", sub {
+      $machine->succeed(ssh "systemctl --user reload gpg-agent");
+      my $pid = $machine->succeed(xsu
+        'echo encrypt me | gpg2 -sea -r ECC15FE1 > encrypted_x.asc & echo $!'
+      );
+      chomp $pid;
+      $machine->waitForText(qr/Passphrase/);
+      $machine->screenshot("passphrase_dialog");
+      $machine->sendChars("supersecret\n");
+      $machine->waitUntilFails("kill -0 $pid");
+      $machine->succeed(xsu '[ "$(gpg2 -d encrypted_x.asc)" = "encrypt me" ]');
+    };
+  '';
+}
diff --git a/tests/programs/gpg-agent/snakeoil.asc b/tests/programs/gpg-agent/snakeoil.asc
new file mode 100644
index 00000000..59c07011
--- /dev/null
+++ b/tests/programs/gpg-agent/snakeoil.asc
@@ -0,0 +1,59 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GnuPG v2
+
+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
+75FOlpj5VS82Sl0=
+=3HD3
+-----END PGP PRIVATE KEY BLOCK-----