about summary refs log tree commit diff
diff options
context:
space:
mode:
-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
+
+lQO+BFb12VYBCADBxfyzvHKtc5L2b9tqw5oOgAxnAWnsj5Weapm/zlK+gd32/PIy
+LN++ZBoxJDr5geSU8vdoI6aKAP8zhOlWU9B+vE83cDuCtvLaR7DiWxXpvACr+2pL
+Hd9ZUDVGC8HGJOljpqF04rkyHFvWIksQz2ihGR616kR3Ir2YOnGkiefsREnS/CF3
+1GXYfg4w9YO77GdCMAdXJ1I3PH+axkHjveWDKFD5f31dcolAqChl2zMoFXkPLnrf
+tA91his15YJFTIjt9KIA++J+2VEtOPvUqC6yI+DlS+j3Ie2BPi1yo10PG9TR//WI
+r2jQ36AvON87ZVNsA0YOQiZUbbS8NeUx+Y6NABEBAAH+AwMC8LL9GSjcywXbhmNt
+SMvlVHJwECg1pu/+VD0F+PTg6zXIYTeIoM2QZxxFsN2ugC8d7jfn15qX843c0npu
+hP8OeCv62pyAdSIaE8tLczPHjy613w67S4DSazaGjMA6ED/YyHOimi6Iz7+GYksZ
+DwNRe2jULr15+yVgLDXpL6Z+ROZDK6i8ovR0VZ6ueINISza3TYgsm9j/rCMbtjCh
+Ut6I4e6Ja8nJgTwwN8WezTcpo1QGBS8x0C4SYC3rDLYjlYidOXQX4OfzAYO66ABd
+/g3+NeKEFRT7EBoZgiwYX8jXhJiU14H0ZmJl5donKjZURD+kZEYj0oS6Q8VhHGfP
+eqVj5O09RRYLa9aAln/6C2J/FHDz0FhPkojISKximPN2ATxypBMweyTPuMBYKVcj
+52Dzj2crrZeTfVDmJojuM/enz7jJ2VyUsCF+V6x6Zgj3PYJEsw55C2elLNhQg9No
+GN4QXpiC3bArrEINQpcZy0Nhr56HHIBuIvLY39h0uNJFmtwog9lyyW+iG9snE2rp
+kmwd8aglH2VZhtE5SV4D/Hf9raDrrP4sLNWTeDF9vmJZ/gdnGwVYNaAfDlxyyReR
+ptqnJ8Q3mm4pQ65zKFG89UOw8ZmUVkofgdMOAAGNjVRMPkQAIu2O1oJVfAGJT+gv
+G2tplAFgMbRqpjOlL3Rvh8K3gNeA1iwa4Na9qZfo/GcmvJ150zi7TBWcT2EmOyJc
+xUMMQgXTm5JJkY/fDw75Fv7FogN5VkG2Uf8+kkfW++zkT4kF8yyD4Lw2JUuUn5l5
+JWsXDmN6fK0vhXInubkmyV2DXmsS2YVTPmtZvIYa2nVvdamh6QDwUULmnI1VPqdk
+/i1v8dMkoV5eV91pir9N6JcWng5OKz1DAY1X8fWH9bbCD0Rf/xpCbJoRSchEQqqM
+W7QZQWxpY2UgPGFsaWNlQGV4YW1wbGUub3JnPokBOQQTAQgAIwUCVvXZVgIbAwcL
+CQgHAwIBBhUIAgkKCwQWAgMBAh4BAheAAAoJENWKNpXswV/hbnMH/35mgiPd9prX
+YqRrylVyxSiHdewVeR8nghjo/g2tR9D/A9feGoz3WL24J3NINAVmZZzKnvpT9Ut+
+Nzy5vL111TSSkMdYIrcjMu4/iUoc8w2JFExMeg1JI1EOS7ctd3qOWMYeWHtlzEJS
+DORsR/IGqq8KHNKtJPywpcjSCpXtiqzjjJrE8F2SbYMFY21SBza6QQY+Vlerr+Bo
+fwa8f3z+cyr0ISHHtEI1h8KoCCWTp/YU1FIEYc22CGz80ExMgCbBxWYukn709Wxd
+6QTFqmNtNUHi4xq1zOA/m0JMASdZPzbRcsbUQGWlwW85Dq4jYV08kC4mPLW537Lx
+A3+rzT5aiB+dA74EVvXZVgEIALXQn98p0mzZYki0aFkS5APQ1gpuXcsMRqlGQTd+
+6gZF32yEWMRrQO8gs59T4zZjGa1EhrMStMHdApxYw82oxhUU8krjYkhqOxZyW363
+H+MTYohiwr3Q6YEdVm6E8lcZwHE2d3WD5bdS0JsDjlZXMXjcJ1bivmwGGAWaucxi
+jAOayYTRpSKUFZDiFTln5dmiFFejyhU/jkTYm7VtXOQbNTwsUCkQtxT8Z468x7M6
+GzDdEvRDgN4VMVbJ2IWQdgS1WaAP9GvZgjS5B2yKUA6ONlOQOdF6gZChr2ej6Jue
+P/feNiuF9ZEqzwB1t1RrljGoyL0jjMH7RCJo2iy/OcL/nocAEQEAAf4DAwLwsv0Z
+KNzLBdvWJwkmTkAjVJRk779nD95vjddWFZgT0zy43U7AyiCYITHms0+/TM3qI5Yt
+teLBARbRddHz3+Wp6ed9zFHlCZW89Qa1yfmSsPFdp+UyN+SVHsaQIGZmFDPQ5uEd
+JRMwgnI5k09APCIq5YCE6bDcvcVLEBFT9IsuY6oWB8FLjh4fe+WAZxDlePHCxf7H
+jAfe4RDiN+bKEZQruGIfhwyuehQW/SOzY6L9PnNfouVWq5nUAl4oxGwsJfhyMpte
+MhqXox5uEeLn8S4gWZtD57Ux8CQAtAZccvjWG5jZXa2bNaEpIRBZGL6r0TS0aKTG
+v2n3CThLsYEudMiWzB7+l74ANFggZnMBXsc2nSElg57GjaCygFkpHnGeghiOjL/9
+cj/yHRz1SKH18lI+Uet/i/QFoHCGeZFbtQ8RUSp93meCHzsFKQ2ZG+djK8HqV5T0
+Tfov1RuHD9RyU00Ohc3RJWSTyeMjxAgjhJKnnfEb1w2JMcXbBCakudBAAMa2Sbdw
+a7h1I+IVTLr9SWRLYg1bWR1hCKjrjBGTA09VZF8BAH1yrszKxOPovV/fLNjohDd5
+xUXu96amSVDhq0M1DVFu8gEADN80+FhUYXIZs1HSoXuw8gusd2Bjq12oyaKNEVd0
+gazgrZ83uAT3PTkEtD4UKjCURPXJ/b4IeQlwkehcwGT7cWhgt8waNPSU5+majRXa
+RJZ/nqdk41E+NN2RvkIuyxl3ggosc3g8jtr8h2115JnoRmGzoZThrhceqVa9aLUd
+Cf6EIoXxL5RPRwaAkimuOEflHEx0NetRNVCIqhq7GLyc4LVMGhTi5U+XAg95X6gJ
+LzvVtrx3P7XG/gd74nAAW5MnW9sVXiuZZzfD56Fl7h79wAg7k3refnbERNSP1WEL
+hmUPS9SW/cKUiQEfBBgBCAAJBQJW9dlWAhsMAAoJENWKNpXswV/h5UAH/itFIGwr
+p7taEh9+x23vPdw0IuKl2lRmx4QIIC55AlzU1Tlij3jppz8PgfLArJDBY9cLe2ir
+cxXIEf+/L59832Q1Z09OXTElqpLw82wWjxTN4b4ZQjgkHGwO4RgxQKdvwDpWVt6g
+JaI1d4LAyW/RxF1vvtC4OzoUtjNXxPLHzga0PP9TOhpuPSB0fc4FDU9QaLUemkJZ
+VUICqAOcTQpENMHdDJcizYsahca2bg5gYaV1Tv/sNINNxKqcSGb1iUdJz4hAaRmO
++y4+aKxJkyt+WqmUOa5aZ9D3s9P87IuSNMc51lgiBFKWBrqSQCTfLBxMbSsPZk9h
+75FOlpj5VS82Sl0=
+=3HD3
+-----END PGP PRIVATE KEY BLOCK-----