Skip to content

gpg-yubikey-agent-forwarding

Modules

A NixOS module that configures a hardened GnuPG agent (tuned for a YubiKey / smartcard) and makes a host able to receive a gpg-agent forwarded to it over SSH — so the key material never leaves the machine the YubiKey is plugged into.

The problem

You keep your signing/decryption key on a YubiKey plugged into your laptop. You want a shell on a remote server (a build box, a workstation you SSH into) to be able to sign commits or decrypt secrets using that key — without copying the private key anywhere. SSH can forward the gpg-agent socket, but making the receiving host accept the forward reliably is where people get stuck.

The key insight / trap

Two things must be true on the host that receives the forwarded agent, and neither is obvious:

  1. The receiving host must not run its own gpg-agent. A locally started agent creates/owns the socket path and shadows the tunneled one — gpg on the remote host silently talks to the wrong (local, keyless) agent. This module forces enableAgent and enableSSHSupport off whenever receiveForwardedAgent = true.

  2. The runtime directory must exist, mode 0700, before the forward binds. SSH's RemoteForward binds the socket path (/run/user/<uid>/gnupg/S.gpg-agent) at connection time — which is before your login shell would normally create /run/user/<uid>/gnupg. If the directory is missing, or exists with loose permissions, the forward fails silently: no error, the socket just isn't there. A oneshot systemd user service (gpg-forward-dir) pre-creates the directory with mode 0700 so the bind always has a home.

A third, related detail lives on the SSH layer rather than in this module: the socket-pair string. forwardRemoteOption is a read-only option that publishes the exact RemoteForward value the sender must use. It is read-only on purpose — the sender's SSH config and this module must agree on the exact socket paths, so you read the string from here instead of hand-copying it and letting the two drift out of sync.

Usage

On the receiving host:

{
  imports = [ ./gpg-yubikey-agent-forwarding ];

  modules.gpg = {
    enable = true;
    uid = 1000;                 # must match the receiving user's real uid
    receiveForwardedAgent = true;
  };
}

On the sending host (the one with the YubiKey), point SSH at the read-only socket-pair string so the two never drift:

programs.ssh.extraConfig = ''
  Host your-host
    RemoteForward ${nodes.your-host.config.modules.gpg.forwardRemoteOption}
'';

Plain ~/.ssh/config equivalent (the value forwardRemoteOption computes for uid 1000):

Host your-host
    RemoteForward /run/user/1000/gnupg/S.gpg-agent /run/user/1000/gnupg/S.gpg-agent.extra

On a normal host that just wants a hardened local agent (no forwarding):

modules.gpg = {
  enable = true;
  pinentryPackage = pkgs.pinentry-qt;   # graphical desktop; use pinentry-curses headless
  configureHomeManager = true;          # requires the Home Manager NixOS module
  user = "alice";
  publicKeys = [ { source = ./keys/yubikey.asc; trust = 5; } ];
};

Options

Option Default Purpose
enable false Turn the module on.
user "youruser" Login user whose Home Manager gpg config is written.
uid 1000 Numeric uid used to build the /run/user/<uid>/gnupg socket paths. Must match the real uid.
receiveForwardedAgent false This host receives a forwarded agent: disables the local agent + SSH support, enables the dir-precreate service.
enableAgent true Run a local gpg-agent (auto-forced off when receiving a forward).
enableSSHSupport true Use gpg-agent as the SSH agent (auto-forced off when receiving a forward).
pinentryPackage pkgs.pinentry-curses pinentry used for PIN prompts. Graphical on desktops, curses headless.
keyId null Optional key id exported as the KEYID env var for scripts.
publicKeys [] Public keys to import + trust (Home Manager). Point source at your own .asc.
configureHomeManager false Also write a hardened programs.gpg config via Home Manager.
forwardRemoteOption (read-only) The RemoteForward value the sender must use; derived from uid.

What "hardened" means here

The agent runs with short cache TTLs (default-cache-ttl = 60, max-cache-ttl = 120) so the PIN is re-requested frequently. The Home Manager gpg.conf enforces strong cipher/digest preferences (AES256, SHA512), throw-keyids (recipients aren't leaked in encrypted output), require-cross-certification, no-symkey-cache, and friends. disable-ccid routes smartcard access around flaky CCID drivers that many YubiKeys trip over.

Caveats

  • uid must be correct. The socket paths are built from it; a wrong uid puts the forwarded socket in a directory nothing reads.
  • sshd on the receiver: set StreamLocalBindUnlink yes in sshd_config so a stale forwarded socket is unlinked and re-bound on reconnect — otherwise the sender's RemoteForward fails with "address already in use" after a dropped connection. (NixOS: services.openssh.extraConfig or settings.StreamLocalBindUnlink = "yes";.)
  • configureHomeManager needs the Home Manager NixOS module imported. With it off, this module only manages the system agent; bring your own gpg.conf.
  • The forwarding path assumes a systemd-logind runtime dir (/run/user/<uid>).
  • A forwarded agent only exposes what the physical card can do — the private key never traverses the tunnel, only signing/decryption requests do.

Source

modules/gpg-yubikey-agent-forwarding/default.nix
# gpg-yubikey-agent-forwarding
#
# A NixOS module that configures a hardened GnuPG agent (tuned for a YubiKey /
# smartcard) and, crucially, makes a host able to *receive* a gpg-agent that is
# forwarded to it over SSH.
#
# The forwarding trap this solves:
#   When another machine forwards its gpg-agent socket with SSH's
#   `RemoteForward`, sshd binds the socket path on this host *before* your login
#   shell would normally create `/run/user/<uid>/gnupg`. If that directory does
#   not already exist (or exists with loose permissions), the forward fails —
#   and it fails *silently*. This module pre-creates the directory with mode
#   0700 via a oneshot user service, and forces the host's own gpg-agent off so
#   it can't shadow the tunneled socket.
#
# Drop this file in as a module (e.g. `imports = [ ./gpg-yubikey-agent-forwarding ];`)
# and set `modules.gpg.enable = true;`.
{
  pkgs,
  config,
  options,
  lib,
  ...
}:
let
  inherit (lib) mkEnableOption mkIf mkMerge mkOption optionalAttrs types;
  cfg = config.modules.gpg;
  runDir = "/run/user/${toString cfg.uid}/gnupg";
in
{
  options.modules.gpg = {
    enable = mkEnableOption "hardened GnuPG + YubiKey agent support";

    user = mkOption {
      type = types.str;
      default = "youruser";
      example = "alice";
      description = "Login user whose Home Manager gpg config is written (when configureHomeManager is set).";
    };

    uid = mkOption {
      type = types.int;
      default = 1000;
      description = ''
        Numeric uid of `user`. Used to build the runtime socket paths
        (`/run/user/<uid>/gnupg/...`). Must match the receiving user's real uid,
        otherwise the forwarded socket lands in a directory nothing reads.
      '';
    };

    enableAgent = mkOption {
      type = types.bool;
      default = true;
      description = "Run a local gpg-agent (forced off on hosts that receive a forwarded agent).";
    };

    enableSSHSupport = mkOption {
      type = types.bool;
      default = true;
      description = "Use the GPG agent as the SSH agent (forced off on hosts that receive a forwarded agent).";
    };

    receiveForwardedAgent = mkOption {
      type = types.bool;
      default = false;
      description = ''
        This host receives a gpg-agent forwarded over SSH. Disables the local
        agent and its SSH support (so they can't shadow the tunnel) and enables
        the oneshot service that pre-creates the runtime gnupg directory.
      '';
    };

    pinentryPackage = mkOption {
      type = types.package;
      default = pkgs.pinentry-curses;
      example = lib.literalExpression "pkgs.pinentry-qt";
      description = ''
        pinentry program the agent uses to prompt for the PIN. Use a graphical
        pinentry (e.g. pinentry-qt / pinentry-gnome3) on desktops and
        pinentry-curses on headless hosts.
      '';
    };

    keyId = mkOption {
      type = types.nullOr types.str;
      default = null;
      example = "0x0000000000000000";
      description = ''
        Optional GPG key id exported as the `KEYID` session variable for
        convenience in scripts. Left unset by default.
      '';
    };

    publicKeys = mkOption {
      type = types.listOf types.attrs;
      default = [ ];
      example = lib.literalExpression ''
        [ { source = ./keys/yubikey.asc; trust = 5; } ]
      '';
      description = ''
        Public keys to import + trust via Home Manager's `programs.gpg.publicKeys`.
        Point `source` at your own exported `.asc` file. Only applied when
        `configureHomeManager` is true and a local agent runs.
      '';
    };

    configureHomeManager = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Also write a hardened `programs.gpg` config for `user` via Home Manager.
        Requires the Home Manager NixOS module to be imported. Leave off if you
        manage gpg.conf yourself.
      '';
    };

    forwardRemoteOption = mkOption {
      type = types.str;
      readOnly = true;
      default = "${runDir}/S.gpg-agent ${runDir}/S.gpg-agent.extra";
      description = ''
        Read-only. The exact `RemoteForward` value (remote-socket local-socket)
        the *sending* host must use to tunnel its agent to this host. Read-only
        so this string stays in lockstep with the consuming SSH config — read it
        from here instead of hand-copying the socket pair.

        On the sender, roughly:
          programs.ssh.extraConfig = '''
            Host your-host
              RemoteForward ''${nodes.your-host.config.modules.gpg.forwardRemoteOption}
          ''';
      '';
    };
  };

  config = mkIf cfg.enable (mkMerge [
    {
      # A host receiving a forwarded agent must not run its own — a local agent
      # would shadow the tunneled socket.
      modules.gpg.enableAgent = mkIf cfg.receiveForwardedAgent false;
      modules.gpg.enableSSHSupport = mkIf cfg.receiveForwardedAgent false;

      # Pre-create /run/user/<uid>/gnupg with mode 0700 BEFORE any SSH
      # RemoteForward tries to bind a socket inside it. Without this the forward
      # fails silently: sshd binds the path before login creates the directory,
      # and a missing / loose-perm dir kills the forward with no visible error.
      systemd.user.services.gpg-forward-dir = mkIf cfg.receiveForwardedAgent {
        description = "Pre-create GPG agent forwarding directory";
        wantedBy = [ "default.target" ];
        serviceConfig = {
          Type = "oneshot";
          # %t expands to the user's XDG_RUNTIME_DIR (/run/user/<uid>).
          ExecStart = "${pkgs.coreutils}/bin/mkdir -p %t/gnupg";
          ExecStartPost = "${pkgs.coreutils}/bin/chmod 700 %t/gnupg";
          RemainAfterExit = true;
        };
      };

      environment.sessionVariables = mkIf (cfg.keyId != null) {
        KEYID = cfg.keyId;
      };

      programs.gnupg.agent = mkIf cfg.enableAgent {
        enable = true;
        enableSSHSupport = cfg.enableSSHSupport;
        enableExtraSocket = true;
        settings = {
          default-cache-ttl = 60;
          max-cache-ttl = 120;
        };
        pinentryPackage = cfg.pinentryPackage;
      };
    }

    # Home Manager gpg config, written to `home-manager.users.<user>`.
    #
    # Two-level gate, and the ordering of the two conditions matters:
    #
    #   * The outer `optionalAttrs (options ? home-manager)` decides whether the
    #     `home-manager` attribute *name* appears in config at all. It must be
    #     keyed on `options` (which modules are imported) — a value that does
    #     NOT depend on config — because the module system has to know every
    #     definition's attribute names before it can evaluate any option value.
    #     Keying this on a config value (e.g. `cfg.configureHomeManager`) would
    #     infinite-recurse; keying it on a bare `mkIf` would instead abort with
    #     "The option `home-manager' does not exist" on hosts without Home
    #     Manager, because the option-existence check ignores the mkIf condition.
    #     `options ? home-manager` is static, so it is safe: the attribute simply
    #     vanishes when the Home Manager NixOS module is not imported, and the
    #     base agent/forwarding config keeps working standalone.
    #
    #   * The inner `mkIf cfg.configureHomeManager` gates the actual value — safe
    #     to key on a config value here, since the attribute name is already
    #     present (Home Manager declares it) whenever this branch is reachable.
    (optionalAttrs (options ? home-manager) {
      home-manager.users.${cfg.user} = mkIf cfg.configureHomeManager {
        programs.gpg = {
          enable = true;
          publicKeys = mkIf cfg.enableAgent cfg.publicKeys;
          settings = {
            personal-cipher-preferences = "AES256 AES192 AES";
            personal-digest-preferences = "SHA512 SHA384 SHA256";
            personal-compress-preferences = "ZLIB BZIP2 ZIP Uncompressed";
            default-preference-list = "SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed";
            cert-digest-algo = "SHA512";
            s2k-digest-algo = "SHA512";
            s2k-cipher-algo = "AES256";
            charset = "utf-8";
            keyid-format = "0xlong";
            list-options = "show-uid-validity";
            verify-options = "show-uid-validity";
            throw-keyids = true;
            no-comments = true;
            no-emit-version = true;
            no-greeting = true;
            with-fingerprint = true;
            require-cross-certification = true;
            no-symkey-cache = true;
            armor = true;
            use-agent = true;
          };
          # disable-ccid works around flaky CCID drivers for many YubiKeys;
          # gpg then talks to the card via its internal PC/SC path.
          scdaemonSettings = {
            disable-ccid = true;
          };
        };
      };
    })
  ]);
}