
I have kept my SSH and signing keys on hardware for years. The private key lives on an OpenPGP card or a Nitrokey, it never touches the disk, and a stolen laptop is a stolen laptop rather than a stolen identity. What changed over the last year is the software I use to talk to those cards. For SSH authentication and Git signing I no longer go through GnuPG at all. I use three small Rust programs from the OpenPGP Card project instead.
I use them daily on Fedora and on my FreeBSD T480. On FreeBSD I had been running them from ~/.cargo/bin, which works but is not how software should arrive on a FreeBSD machine. So I wrote ports for them, and all three have now been committed to the FreeBSD ports tree:
| Port | Binary | Job | Upstream |
|---|---|---|---|
security/openpgp-card-tools |
oct |
Inspect and administer cards | openpgp-card-tools |
security/openpgp-card-tools-git |
oct-git |
Sign and verify Git commits and tags | oct-git |
security/openpgp-card-ssh-agent |
openpgp-card-ssh-agent |
SSH agent backed by the card’s authentication key | ssh-agent |
At the time of writing, openpgp-card-tools is already available as a binary package in the latest repository. The other two will follow once the package builders have picked them up. Since the ports were added this quarter, none of them are in quarterly yet.
This article explains what the three tools do, how they fit together, and how I set them up on both systems.
Table of Contents
Why Not Just Use GnuPG?
GnuPG can do all of this. gpg-agent can act as an SSH agent, scdaemon talks to the card, and Git calls gpg to sign commits. Plenty of people run exactly that setup, and I did for a long time.
My problem with it was never the cryptography. It was the moving parts. A Git signature goes through git, gpg, gpg-agent and scdaemon, each with its own configuration and its own idea of which card is plugged in. When something breaks, the error tends to surface two processes away from the cause. scdaemon also likes to take exclusive control of the card, which gets in the way of every other program that wants to use it. On a laptop that suspends, resumes and sees cards come and go, I spent more time than I would like restarting agents.
The OpenPGP Card tools take a different approach:
- Each tool does one job.
octmanages cards.oct-gitsigns for Git. The SSH agent serves SSH. None of them tries to be a general-purpose OpenPGP suite. - No long-lived card connection.
oct-gitruns only when Git calls it and holds no permanent connection to the card. The SSH agent is a process, but it opens the card per operation and picks up cards that were plugged in after it started. - They talk PC/SC directly. Underneath is
pcscd, the same smart-card daemon every other card application on the system uses. There is no second card daemon in the middle. - Shared PIN handling. All of them get the User PIN through the same small library,
openpgp-card-state, so you configure PIN storage once per card instead of once per program. - Pure Rust. The tools are built on rPGP and RustCrypto, and they do not use any part of GnuPG.
GnuPG is still installed on my machines, and I still use it for other OpenPGP work. It just isn’t in the path for SSH logins and commit signatures anymore.
What You Need
The hardware is any OpenPGP card that your system can reach over PC/SC. That includes Nitrokeys, YubiKeys in OpenPGP mode and classic OpenPGP smart cards in a USB reader. Supported algorithms depend on the card. oct info lists them. On the software side, the tools need the PC/SC daemon.
On FreeBSD
pkg install pcsc-lite libccid
sysrc pcscd_enable="YES"
service pcscd start
If oct list later finds your card as root but not as your normal user, don’t go looking in devfs.rules. pcscd runs as root and opens the reader itself, so device permissions are not the problem. The usual cause is the polkit check in pcscd, which turns away clients that are not in a session polkit considers active. You can allow your user with a polkit rule for the org.debian.pcsc-lite.access_pcsc and org.debian.pcsc-lite.access_card actions in /usr/local/etc/polkit-1/rules.d/. The blunter option, which I use on my single-user laptop, is to start pcscd with --disable-polkit:
sysrc pcscd_flags="--disable-polkit"
service pcscd restart
That lets every local user talk to every card. Think about it before you copy it to a shared machine.
Then install the tools. Right now that means one package and two ports:
pkg install openpgp-card-tools
cd /usr/ports/security/openpgp-card-tools-git && make install clean
cd /usr/ports/security/openpgp-card-ssh-agent && make install clean
Once the other two packages appear in latest, this becomes one line:
pkg install openpgp-card-tools openpgp-card-tools-git openpgp-card-ssh-agent
If you build from ports, install rust and cmake-core as packages first. cmake-core is only needed for openpgp-card-tools-git. Without the packages, the ports framework builds lang/rust from source, and that takes hours.
If you work from a graphical session, also install a graphical pinentry. I’ll come back to why in the PIN section.
pkg install pinentry-gtk2
On Fedora
Fedora needs pcscd running and, for now, the tools themselves from Cargo:
sudo dnf install pcsc-lite pcsc-lite-devel rustc cargo
sudo systemctl enable --now pcscd.socket
cargo install --locked openpgp-card-tools openpgp-card-tools-git openpgp-card-ssh-agent
The binaries land in ~/.cargo/bin, which needs to be on your PATH.
oct: Managing the Card
oct is the administration tool. It is what I reach for when a new Nitrokey arrives, when I want to know which key sits in which slot, or when I need to change a PIN.
The first two commands you will use:
oct list # idents of all connected cards, e.g. 000F:01234567
oct status # cardholder, key fingerprints, algorithms, signature counter, PIN retries
Every card has an ident made of a manufacturer ID and a serial number, like 000F:01234567. When more than one card is connected, you select one with --card. The same ident shows up again later in the PIN configuration and in ssh-add -L, so it is worth knowing where to find it.
oct status -v adds the touch policy and key status for each slot. oct info shows what the card can do, including the supported algorithms.
Setting Up a New Card
A fresh OpenPGP card ships with the User PIN 123456 and the Admin PIN 12345678. Change both first:
oct pin --card 000F:01234567 set-user
oct pin --card 000F:01234567 set-admin
Then give the card a key. You have two options, and they have different trade-offs.
Generate the keys on the card. The private key is created inside the hardware and can never leave it:
oct admin --card 000F:01234567 generate \
--userid "Alice Adams <alice@example.org>" \
--output alice.pub curve25519
--output writes the resulting public certificate. Other algorithm names include rsa4096 and nistp256, if the card supports them. The catch: if the card dies or gets lost, the key is gone. That is acceptable for an SSH authentication key that you can simply replace on your servers. It hurts more for a signing key that people have learned to trust.
Import an existing key. You create the key in software, keep an offline backup, and load it onto one or more cards:
oct admin --card 000F:01234567 import key.priv
If the key has more than one subkey per role, --sig-fp, --dec-fp and --auth-fp choose which subkey goes into which slot. This is how you get two cards with the same identity, one on your keyring and one in a drawer.
Touch Confirmation
Many cards can require a physical touch before they use a key. I strongly recommend turning it on, at least for signing and authentication:
oct admin --card 000F:01234567 touch --key SIG --policy On
oct admin --card 000F:01234567 touch --key AUT --policy On
The available policies depend on the card. Cached lets one touch cover several operations within 15 seconds, which is pleasant for an interactive rebase that signs a dozen commits. The Fixed variants cannot be changed again without a factory reset.
Touch is the part that protects you against a compromised host. More on that below.
The Public Side
Two more commands give you the public half of the card:
oct ssh # SSH public key of the authentication slot, for authorized_keys
oct pubkey # OpenPGP certificate built from the card's keys
oct ssh prints a line you can paste straight into ~/.ssh/authorized_keys or into your Forgejo, Codeberg or GitHub account. oct pubkey reconstructs a certificate from what the card stores. The upstream documentation calls this export approximate, because the card doesn’t store things like expiration dates. If you imported a key, your original certificate is the better one to publish.
Where the PIN Lives
Before the other two tools make sense, you need to know how they get the PIN.
GnuPG keeps the PIN in gpg-agent‘s cache. The OpenPGP Card tools hand that job to a small library called openpgp-card-state, which both oct-git and the SSH agent use. Its configuration lives in ~/.config/openpgp-card-state/config.toml and contains one entry per card, keyed by the ident:
[[cards]]
ident = "000F:01234567"
pin_storage = "Ephemeral"
[[cards]]
ident = "0006:12345678"
pin_storage = "Keyring"
The backend decides where the PIN is kept:
| Backend | Where the PIN lives | Notes |
|---|---|---|
Keyring |
The desktop secret store (Secret Service, macOS Keychain, Windows Credential Manager) | The default. Survives reboots |
Ephemeral |
The Linux kernel keyring, expiring after pin_expiry seconds |
Linux only. Never written to disk |
Pinentry |
Nowhere. You get a prompt for every operation | Works everywhere, no storage at all |
Direct |
In plaintext in config.toml |
Meant for CI and tests |
On Fedora I use Ephemeral for my Nitrokey and Keyring for a second card. On the FreeBSD laptop I use Pinentry, because Ephemeral is Linux-only and I don’t run a Secret Service provider in my Xfce session.
It helps to think carefully about what the PIN protects. The upstream README is admirably honest about it: the User PIN mostly protects against someone who has stolen the card. Against malware running as your user on an unlocked desktop, a cached PIN is at best a weak defense, whichever backend holds it. Touch confirmation is what actually stops a remote attacker, because malware cannot press the button on your Nitrokey. With touch enabled, a cached PIN becomes a reasonable convenience rather than a risk.
The pinentry Trap on FreeBSD
With the Pinentry backend, the tools call an executable named pinentry from PATH. On FreeBSD, /usr/local/bin/pinentry is the curses variant. That works in a terminal, but it cannot prompt from a background agent, or from Git started by an editor. The result is an operation that just fails without a visible prompt.
The fix is to put a graphical pinentry first on PATH for these tools only:
mkdir -p ~/.local/libexec/openpgp-card
ln -s /usr/local/bin/pinentry-gtk-2 ~/.local/libexec/openpgp-card/pinentry
The package is called pinentry-gtk2 and the binary is called pinentry-gtk-2. The hyphen is not a typo. pinentry-qt works just as well if you prefer it.
The agent’s start script and the Git wrapper below both prepend that directory to PATH.
oct-git: Signing Commits With the Card
oct-git is a replacement for gpg in exactly one role: being the program Git calls to create and verify OpenPGP signatures. It implements the small subset of GnuPG’s command-line interface that Git uses, and nothing more.
Setting it up is four lines:
git config --global gpg.program /usr/local/bin/oct-git
git config --global user.signingkey <fingerprint>
git config --global commit.gpgsign true
oct-git --store-card-pin
On Fedora with a Cargo install, the path is ~/.cargo/bin/oct-git instead. <fingerprint> is the fingerprint of your signing key, as shown by oct status, in hex without spaces. tag.gpgsign true does the same for tags.
--store-card-pin walks through all connected cards, checks whether a working PIN is already stored, and asks for one if not. With the Keyring or Ephemeral backends, that means later signatures need no prompt. Press Enter to skip a card. With Pinentry there is nothing to store, so you get the prompt at signing time instead.
From then on, git commit signs with the card. When the card wants a touch, oct-git shows a desktop notification that names oct-git as the program asking. This sounds minor, but it makes a real difference. With a blinking Nitrokey and no explanation, you can’t tell whether Git, SSH or something else wants the key. With the notification, you know what you are confirming.
Switching back to GnuPG is just a matter of pointing gpg.program at gpg again. Your repositories don’t care which program made the signature.
Verifying Signatures
oct-git also verifies, so git log --show-signature and git verify-commit work. It looks up the signer’s certificate in the shared openpgp-cert-d directory. If it is not there, it tries keys.openpgp.org, pgpkeys.eu and keyserver.ubuntu.com. For certificates that are not on any keyserver, import them by hand:
oct-git --import colleague.pub
Two limitations are worth knowing. oct-git cannot yet update certificates that are already in the store. And it has no notion of trust levels, so a valid signature is reported with an “Unverified” trust level. The signature is cryptographically valid and the output shows the issuer’s fingerprint, but deciding whether that fingerprint belongs to the right person is still up to you. For signing my own work, neither limitation matters to me.
The FreeBSD Wrapper
On FreeBSD with the Pinentry backend, Git needs to find the graphical pinentry too. I point gpg.program at a three-line wrapper instead of the binary:
#!/bin/sh
# ~/.local/bin/oct-git-freebsd
PATH="$HOME/.local/libexec/openpgp-card:$PATH"
export PATH
exec /usr/local/bin/oct-git "$@"
chmod 700 ~/.local/bin/oct-git-freebsd
git config --global gpg.program "$HOME/.local/bin/oct-git-freebsd"
openpgp-card-ssh-agent: SSH Keys That Cannot Be Copied
The third tool replaces ssh-agent (or gpg-agent --enable-ssh-support) with an agent that offers the key in each connected card’s authentication slot. When SSH needs a signature, the agent performs it on the card. The private key never exists anywhere else, so there is no ~/.ssh/id_ed25519 to steal, back up by accident or leave on an old laptop.
The agent supports RSA, NIST P-256/384/521 and Curve25519 keys. Apart from the PINs, it needs no configuration and keeps no state. Cards can be plugged in after it starts. It finds them on the next operation, which is exactly what I want on a laptop.
Running It
The agent needs a socket to listen on:
openpgp-card-ssh-agent -H "unix://$XDG_RUNTIME_DIR/openpgp-card/ssh-agent.sock"
Point SSH at it:
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/openpgp-card/ssh-agent.sock"
ssh-add -L
ssh-add -L lists the public key of every connected card, with the card’s ident as the comment. To store a PIN for a card, use ssh-add -s with that ident:
ssh-add -s 000F:01234567
SSH will ask for a “passphrase for PKCS#11”. That wording is hard-coded in OpenSSH and has nothing to do with PKCS#11 here. Enter the card’s User PIN. The agent tests it against the card and only saves it if the card accepts it.
If you don’t want the card agent for everything, select it per host in ~/.ssh/config instead of setting SSH_AUTH_SOCK globally:
Host git.example.org
IdentityAgent /var/run/xdg/<username>/openpgp-card/ssh-agent.sock
That is the runtime directory on my FreeBSD laptop. On Fedora it is /run/user/<uid>/. Use whatever echo $XDG_RUNTIME_DIR prints on your machine.
On Fedora: systemd Socket Activation
On Linux the agent can be socket-activated by systemd --user. The upstream repository ships unit files in its contrib directory. I use them almost unchanged. The socket unit, ~/.config/systemd/user/openpgp-card-ssh-agent.socket:
[Unit]
Description=A simple ssh-agent backed by OpenPGP card authentication keys
[Socket]
ListenStream=%t/openpgp-card/ssh-agent.sock
SocketMode=0600
DirectoryMode=0700
[Install]
WantedBy=sockets.target
And the service, ~/.config/systemd/user/openpgp-card-ssh-agent.service:
[Unit]
Description=A simple ssh-agent backed by OpenPGP card authentication keys
Requires=openpgp-card-ssh-agent.socket
[Service]
ExecStart=%h/.cargo/bin/openpgp-card-ssh-agent -H fd://
-H fd:// tells the agent to take the listening socket that systemd hands over. %t is the runtime directory. Enable the socket and export the path in your shell:
systemctl --user enable --now openpgp-card-ssh-agent.socket
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/openpgp-card/ssh-agent.sock"
The agent now starts the first time something connects to the socket.
If you use the Keyring backend, the agent needs a Secret Service provider to be running. My units also list gnome-keyring-daemon.service in Requires=, because gnome-keyring is my Secret Service provider. Do not copy that line blindly. On a stock KDE Plasma installation, KWallet provides Secret Service and there is no gnome-keyring-daemon.service. A Requires= on a unit that doesn’t exist will stop the agent from starting.
An export in your shell only reaches programs started from that shell. A Git GUI or an editor launched from the desktop menu won’t see it. On Fedora, environment.d sets the variable for the whole user session instead. Put this in ~/.config/environment.d/50-openpgp-card-ssh-agent.conf:
SSH_AUTH_SOCK=${XDG_RUNTIME_DIR}/openpgp-card/ssh-agent.sock
GNOME and Plasma both pick this up at the next login. Fish users who still want it in the shell write set -gx SSH_AUTH_SOCK $XDG_RUNTIME_DIR/openpgp-card/ssh-agent.sock instead of the export line.
If your machine has a second smart card stack that the agent should not touch, I wrote about hiding a reader from the agent with LD_PRELOAD. That drops into this same service as an override.
On FreeBSD: An Xfce Autostart Entry
FreeBSD has no user session manager like systemd --user, so on the T480 the agent starts with the Xfce session. The Exec line of a .desktop file doesn’t expand shell variables, so the autostart entry calls a small script:
#!/bin/sh
# ~/.local/bin/start-openpgp-card-ssh-agent
set -eu
: "${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR must be set by the desktop session}"
socket_dir="$XDG_RUNTIME_DIR/openpgp-card"
socket="$socket_dir/ssh-agent.sock"
umask 077
mkdir -p "$socket_dir"
chmod 700 "$socket_dir"
# The Rust card tools look for an executable named pinentry on PATH.
PATH="$HOME/.local/libexec/openpgp-card:$PATH"
export PATH
# A socket can survive a logout or an agent crash. Only keep it if
# an agent actually answers on it. ssh-add -l exits with 2 when it
# cannot reach an agent, and with 0 or 1 when one is listening.
if [ -S "$socket" ]; then
rc=0
SSH_AUTH_SOCK="$socket" ssh-add -l >/dev/null 2>&1 || rc=$?
if [ "$rc" -ne 2 ]; then
exit 0
fi
rm -f "$socket"
fi
if [ -e "$socket" ]; then
echo "Refusing to replace unexpected path: $socket" >&2
exit 1
fi
exec /usr/local/bin/openpgp-card-ssh-agent -H "unix://$socket"
The autostart entry, ~/.config/autostart/openpgp-card-ssh-agent.desktop:
[Desktop Entry]
Type=Application
Name=OpenPGP Card SSH Agent
Comment=Provide SSH authentication through the OpenPGP card
Exec=/home/<username>/.local/bin/start-openpgp-card-ssh-agent
TryExec=/home/<username>/.local/bin/start-openpgp-card-ssh-agent
Terminal=false
OnlyShowIn=XFCE;
chmod 700 ~/.local/bin/start-openpgp-card-ssh-agent
Other desktops have equivalent autostart mechanisms. The script itself doesn’t depend on Xfce.
A Day With the Stack
Once it is set up, there is very little to see, which is the point. My day on either machine looks like this:
- I log in. The agent socket is there, from systemd on Fedora or from the autostart entry on FreeBSD.
- I plug in the Nitrokey. Nothing happens yet, because nothing needs to.
sshto a server orgit pushto Forgejo. The agent finds the card, the card blinks, I touch it and I’m in.git commit.oct-gitsigns, a notification tells me who is asking, I touch the card, and the commit carries a signature.- I unplug the card and walk away. Without it, nobody signs or logs in as me, including anything already running on the laptop.
The same card, the same keys and nearly the same configuration work on both operating systems. The only real differences are how the agent starts and where the PIN is kept. My dotfiles handle those with a few template conditions in chezmoi.
Troubleshooting
oct list shows no card. Check that pcscd is running. Then check for GnuPG’s scdaemon, which can hold the card exclusively. On my T480, oct list found nothing while scdaemon was running and found the card immediately after this:
gpgconf --kill scdaemon
Killing it is only a temporary fix. If GnuPG is still installed, the next gpg invocation can start scdaemon again and take the card back. That includes an editor plugin quietly running gpg --list-keys. If you never use GnuPG with the card, tell gpg-agent not to start scdaemon at all:
echo disable-scdaemon >> ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent
If you still need GnuPG’s card support now and then, put pcsc-shared in ~/.gnupg/scdaemon.conf instead. Then scdaemon shares the reader with other PC/SC clients and doesn’t lock them out.
Signing or SSH fails without any prompt. On FreeBSD this is almost always the curses pinentry being called from a context that has no terminal. Check that the graphical pinentry is first on the PATH the tool actually sees, not just the one in your shell.
Nothing happens and the card is blinking. It’s waiting for a touch. That’s the system working as intended.
Debug output from the agent. Start it with RUST_LOG=debug to see what it does with each request.
Porting Notes
The ports themselves were not dramatic. These are Rust programs with a single real system dependency, pcsc-lite, and USES=cargo in the FreeBSD ports framework does most of the work. Each port carries a Makefile.crates listing every crate it vendors, so the build is reproducible and runs without network access.
The time-consuming part was everything around the binaries. That meant working out the pinentry behavior and figuring out how to start an agent without systemd --user. The port repository’s README collects those notes.
Credit for the tools goes to Heiko Schaefer and Wiktor Kwapisiewicz and the rest of the OpenPGP Card project. They built a set of small, understandable programs for a job that used to require a large, opaque stack. All I did was make them easy to install on one more operating system.
Conclusion
Hardware-backed keys only work in practice if they are less annoying than the alternative. For me, the Rust OpenPGP Card stack is what got there. oct handles the card, oct-git signs my commits and openpgp-card-ssh-agent replaces my SSH keys. Each tool does one job, none of them fights over the card, and all three share a single PIN configuration.
If you have a Nitrokey or an OpenPGP card in a drawer because the GnuPG setup felt like too much, give these a try. On FreeBSD it now starts with pkg install openpgp-card-tools, and soon the other two packages will be there as well.
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...