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:
-
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
enableAgentandenableSSHSupportoff wheneverreceiveForwardedAgent = true. -
The runtime directory must exist, mode 0700, before the forward binds. SSH's
RemoteForwardbinds 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¶
uidmust 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 yesinsshd_configso a stale forwarded socket is unlinked and re-bound on reconnect — otherwise the sender'sRemoteForwardfails with "address already in use" after a dropped connection. (NixOS:services.openssh.extraConfigorsettings.StreamLocalBindUnlink = "yes";.) configureHomeManagerneeds the Home Manager NixOS module imported. With it off, this module only manages the system agent; bring your owngpg.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;
};
};
};
})
]);
}