Make a Raspberry Pi Act Like an OpenPGP Smartcard

Make a Raspberry Pi act like an OpenPGP smartcard

A Raspberry Pi OpenPGP smartcard build is for people who want token-style signing without dragging secret key files around. You plug in the Pi, the host sees a CCID smartcard device, and PC/SC routes APDUs to it. The Pi answers SELECT and GET DATA with OpenPGP Card data objects plus SW1/SW2 status words. GnuPG scdaemon talks through pcscd, so gpg --card-status can read slots for sign, decrypt, and authenticate.

What to expect going in: PIN prompts, retry counters, and an ATR that makes the host treat the Pi like a smartcard. Start by proving USB device mode works before touching OpenPGP behavior. If lsusb works but pcsc_scan does not, you do not have a real CCID interface yet. Test each layer in order and do not skip ahead.

Key Takeaways

  • Prove USB device mode works before touching OpenPGP behavior
  • If lsusb works but pcsc_scan does not, you do not have a real CCID interface yet
  • If pcsc_scan works but gpg --card-status hangs, the OpenPGP APDU responses are the next suspect
  • For a token-style workflow, keep the primary key off the device and move only subkeys into slots
  • If you care about theft and cloning, prefer the hardware-backed path over software-only
  • A bad cable causes more “CCID problems” than bad software. Swap it first.

Choose Your Build Path

There is not one right way to get YubiKey-like smartcard behavior on a Raspberry Pi. There are two main paths and they trade convenience for key safety.

Path A: Software OpenPGP card on the Pi

The Pi acts like a CCID smartcard device over USB. A software service on the Pi answers APDUs including SELECT, VERIFY, and GET DATA. Private keys live in Pi storage or RAM, protected by Linux permissions and encryption. The host PC/SC stack sends APDUs to the CCID interface, scdaemon requests signatures from the OpenPGP application, and the Pi responds using an OpenPGP card service.

Pick this path when you want the workflow fast and you are okay with protection that is good enough for a home lab or low-risk signing setup.

Path B: Pi as USB front-end, keys on real secure hardware

The Pi still presents CCID to the host. The cryptographic private key stays inside a real smartcard or secure element wired to the Pi. The Pi becomes a bridge that passes requests to hardware that is harder to copy than a microSD card. The secure element stores private keys inside tamper-resistant memory, the Pi bridge forwards APDU intent to the secure chip, and the secure chip returns signature bytes to the host via the Pi.

Pick this path when theft and offline copying risk matter more than having the simplest setup.

Quick comparison

ChoiceWhat feels YubiKey-likeMain riskBest for
Software-onlyPlug in, enter PIN, signKeys can be copied if Pi storage gets clonedGit signing, testing, learning
Hardware-backedCloser to real token behaviorMore wiring, more moving partsHigher-stakes SSH and signing
Openpgp build path

Host Setup

The host OS must see a CCID smartcard device and GnuPG must talk to it through PC/SC. If that chain breaks, nothing else matters.

Install PC/SC on the host

Debian, Ubuntu, or Raspberry Pi OS (as a host machine):

sudo apt update
sudo apt install pcscd pcsc-tools
sudo systemctl enable --now pcscd

Fedora:

sudo dnf install pcsc-lite pcsc-lite-tools
sudo systemctl enable --now pcscd

macOS with Homebrew:

brew install pcsc-lite
brew services start pcsc-lite

Windows: make sure the Smart Card service is running in the Services app. Most CCID devices bind without extra drivers, but Windows can be picky about descriptors.

Confirm the reader shows up

# Linux and macOS
pcsc_scan

# Quick USB sanity check (Linux)
lsusb

You want to see a reader name and activity when the device is plugged in. If lsusb shows the gadget but pcsc_scan shows nothing, the host is not binding a CCID driver.

Confirm GnuPG can see OpenPGP

gpg --card-status

If GnuPG is wired up correctly you will see card details and key slots for sign, decrypt, and authenticate. If it hangs, PC/SC is likely fine but scdaemon is stuck. Two more checks that catch simple problems fast:

gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd apdu 00A4040000" /bye

That last command sends a basic APDU. If you get any response, even an error status word, the pipe is alive.

Common host problems

SymptomLikely causeWhat to do
pcsc_scan shows nothingBad cable or no USB device modeTry a known data cable, try a different port
pcsc_scan sees reader, gpg hangsscdaemon confusion or stale stateRun gpgconf –kill scdaemon then retry
Works once, then stopsService not running or power glitchCheck systemctl status pcscd
Windows sees unknown deviceDescriptor mismatchRecheck gadget VID/PID and CCID function setup

Raspberry Pi Setup

Pick the right Pi

Use a Pi that can do USB device mode on its OTG port. Pi Zero and Zero 2 W are the usual picks. Most full-size Pi boards only act as USB hosts and will never show up as a smartcard when plugged into a laptop. If /sys/class/udc is empty on the Pi after enabling gadget mode, you are on the wrong hardware or the wrong port.

Enable OTG gadget mode on Raspberry Pi OS Bookworm

Raspberry Pi OS Bookworm keeps boot config under /boot/firmware/. Edit /boot/firmware/config.txt and add:

dtoverlay=dwc2

Some platforms need an explicit peripheral mode:

dtoverlay=dwc2,dr_mode=peripheral

Edit /boot/firmware/cmdline.txt (this is one long line, do not add a line break) and add after rootwait:

modules-load=dwc2

Reboot:

sudo reboot

Confirm the Pi can act as a USB device

ls /sys/class/udc

If you see a controller name, the Pi has a usable UDC and gadget mode is in play. If it is empty, you are fighting the wrong board or the wrong port.

Mount configfs and load the composite framework

sudo modprobe libcomposite
mount | grep configfs || sudo mount -t configfs none /sys/kernel/config

Sanity check with a known-good gadget first

Before trying CCID, prove the pipe works with USB Ethernet. Add this to cmdline.txt after rootwait:

modules-load=dwc2,g_ether

If the host sees a new USB network adapter, your cable, port, and OTG setup are working. This saves a lot of time before you start chasing CCID problems that are actually cable problems.

Build the USB Gadget with configfs

Create the gadget skeleton

sudo modprobe libcomposite
sudo mount -t configfs none /sys/kernel/config 2>/dev/null || true

cd /sys/kernel/config/usb_gadget
sudo mkdir -p openpgpccid
cd openpgpccid

Set USB identity

Use your own values. Do not copy a real vendor’s IDs.

echo 0x1d6b | sudo tee idVendor   >/dev/null
echo 0x0104 | sudo tee idProduct  >/dev/null
echo 0x0200 | sudo tee bcdUSB     >/dev/null
echo 0x0100 | sudo tee bcdDevice  >/dev/null

Add readable strings and a configuration

sudo mkdir -p strings/0x409
echo "0001"                 | sudo tee strings/0x409/serialnumber >/dev/null
echo "YourOrg"              | sudo tee strings/0x409/manufacturer >/dev/null
echo "OpenPGP CCID Gadget"  | sudo tee strings/0x409/product      >/dev/null

sudo mkdir -p configs/c.1
sudo mkdir -p configs/c.1/strings/0x409
echo "CCID" | sudo tee configs/c.1/strings/0x409/configuration >/dev/null
echo 120    | sudo tee configs/c.1/MaxPower >/dev/null

Check for CCID Gadget Support in Your Kernel

Many people get OTG working and then hit a wall because CCID gadget support is not always built into the kernel. Check before assuming it is there:

modinfo usb_f_ccid 2>/dev/null || true
ls /lib/modules/$(uname -r)/kernel/drivers/usb/gadget/function/ 2>/dev/null | grep -i ccid || true
sudo modprobe usb_f_ccid 2>/dev/null || echo "usb_f_ccid not available"

Add the CCID function if it exists

cd /sys/kernel/config/usb_gadget/openpgpccid

sudo mkdir -p functions/ccid.usb0
sudo ln -s functions/ccid.usb0 configs/c.1/

Bind the gadget to the UDC

ls /sys/class/udc
UDC_NAME="$(ls /sys/class/udc | head -n 1)"
echo "$UDC_NAME" | sudo tee UDC >/dev/null
# Unbind when needed
echo "" | sudo tee /sys/kernel/config/usb_gadget/openpgpccid/UDC >/dev/null

If CCID gadget support is missing

Option 1 is using a kernel that includes CCID gadget support. This is the cleanest path when available and keeps the USB side in-kernel, which behaves better with picky host stacks. Option 2 is using FunctionFS and implementing the CCID protocol in userspace. This avoids depending on a missing gadget function but requires your service to handle CCID framing and APDU forwarding directly. Option 1 is the least painful path for most people. Option 2 is what you do when you need full control.

Run the Gadget Automatically on Boot

Create the setup script

Create /usr/local/sbin/openpgp-ccid-gadget.sh:

#!/bin/bash
set -euo pipefail

G=/sys/kernel/config/usb_gadget/openpgpccid

modprobe libcomposite
mount -t configfs none /sys/kernel/config 2>/dev/null || true

mkdir -p "$G"
cd "$G"

echo 0x1d6b > idVendor
echo 0x0104 > idProduct
echo 0x0200 > bcdUSB
echo 0x0100 > bcdDevice

mkdir -p strings/0x409
echo "0001"                > strings/0x409/serialnumber
echo "YourOrg"             > strings/0x409/manufacturer
echo "OpenPGP CCID Gadget" > strings/0x409/product

mkdir -p configs/c.1/strings/0x409
echo "CCID" > configs/c.1/strings/0x409/configuration
echo 120    > configs/c.1/MaxPower

if modprobe usb_f_ccid 2>/dev/null; then
  mkdir -p functions/ccid.usb0
  ln -sf functions/ccid.usb0 configs/c.1/
else
  echo "usb_f_ccid not available" >&2
fi

UDC_NAME="$(ls /sys/class/udc | head -n 1)"
echo "$UDC_NAME" > UDC
sudo chmod +x /usr/local/sbin/openpgp-ccid-gadget.sh

Create the systemd service

Create /etc/systemd/system/openpgp-ccid-gadget.service:

[Unit]
Description=OpenPGP CCID USB Gadget
After=systemd-modules-load.service
Before=pcscd.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/openpgp-ccid-gadget.sh
ExecStop=/bin/sh -c 'echo "" > /sys/kernel/config/usb_gadget/openpgpccid/UDC || true'

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now openpgp-ccid-gadget.service

OpenPGP Behavior Setup

A CCID gadget only gives the host a smartcard pipe. To feel like a YubiKey, the Pi also needs an OpenPGP card personality on the other end of that pipe that can answer OpenPGP APDUs with the right data objects and crypto results.

Minimum feature set to act like a token

  • SELECT OpenPGP application (AID) so tools know what they are talking to
  • GET DATA for core data objects including card info, key fingerprints, and key attributes
  • VERIFY for a user PIN with a retry counter
  • At least one private-key operation: PSO COMPUTE DIGITAL SIGNATURE for signing, INTERNAL AUTHENTICATE for auth, or DECIPHER for decryption

Key slots and what they are for

SlotTypical jobWhat you will feel on the host
SignatureGit commits, file signinggpg prompts for PIN then signs
DecryptionEmail and file decryptiongpg prompts for PIN when decrypting
AuthenticationSSH loginssh uses the key via agent

Start with the Signature slot. It is the easiest to test and hardest to confuse with other tooling.

PIN rules that match real token behavior

  • User PIN (PW1): 6 to 12 digits
  • Admin PIN (PW3): 8 to 20 digits
  • Retry counter: 3 attempts per PIN
  • After a wrong PIN, decrement the counter. After it hits zero, block private-key operations until reset rules are satisfied.
  • Keep PIN verified state short-lived to reduce drive-by signing on a compromised host

Test the OpenPGP personality

gpg --card-status
gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd learn --force" /bye

If gpg --card-status hangs, the APDU exchange is not completing. If it shows a reader but no OpenPGP application, the OpenPGP personality is not answering the expected SELECT/GET DATA pattern. If it shows an app but no key slots, the slots are empty and waiting for keys.

First real win: test signing

echo "card test" > msg.txt
gpg --clearsign msg.txt

Expected behavior: pinentry asks for the user PIN, the output is a signed text block, and repeating the command soon after may not ask again if the agent cached the PIN state.

Optional: add a presence button

If you want touch-to-sign behavior without copying YubiKey branding, wire a GPIO button as a presence check. Require the button press before returning a signature APDU response, with a 10-second approval window. This does not make the Pi tamper-proof, but it stops silent signing while it is plugged in and unattended.

GnuPG Key Setup for a Card-Style Workflow

The clean setup: one primary key that stays off the Pi, and three subkeys that match the OpenPGP slots for sign, encrypt, and authenticate. This keeps day-to-day use simple and limits how much key material ends up near a microSD card.

Create primary key and subkeys on the host

# Create the primary key
gpg --full-generate-key
# List keys and note the key ID
gpg --list-secret-keys --keyid-format LONG
# Add subkeys
gpg --edit-key KEYID

Inside the prompt: use addkey to create a signing subkey, addkey for an encryption subkey, addkey for an authentication subkey, then save.

Move subkeys to the card

# Confirm the gadget shows up
gpg --card-status

# Move keys into slots
gpg --edit-key KEYID

Inside the prompt: use key 1 to select the signing subkey, then keytocard and choose the Signature slot. Repeat for the other subkeys choosing the Encryption and Authentication slots. After moving all three:

gpg --card-status

You should see fingerprints tied to the card slots.

Keep a backup that will not ruin your week

# Export public key
gpg --armor --export KEYID > publickey.asc

# Generate revocation certificate and store it offline
gpg --output revoke.asc --gen-revoke KEYID

SSH Agent Setup

Enable SSH support in gpg-agent

Edit ~/.gnupg/gpg-agent.conf and add:

enable-ssh-support
default-cache-ttl 300
max-cache-ttl 600
gpgconf --kill gpg-agent
gpgconf --launch gpg-agent

Point SSH at gpg-agent

Add to your shell profile on Linux:

export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"

Check what SSH sees

ssh-add -L

If it prints a public key, the auth slot is being exposed through the agent. If it prints no identities, the usual causes are wrong SSH_AUTH_SOCK, gpg-agent not restarted, no authentication subkey moved to the card slot, or scdaemon stuck. Quick reset that fixes most of these:

gpgconf --kill scdaemon
gpg --card-status
ssh-add -L

Testing Checklist: Prove Each Layer Works

Test this like a stack. Do not jump ahead. If one layer fails, the layers above it are not real progress.

Openpgp testing layers

Layer 1: USB: Does the Host See a Device

lsusb
dmesg -w

You want a new USB device entry when the Pi is plugged in and no constant disconnect/reconnect loop, which is usually a power or cable problem.

Layer 2: Gadget: Is the Pi Bound to the UDC

ls /sys/class/udc
cat /sys/kernel/config/usb_gadget/openpgpccid/UDC

You want ls /sys/class/udc to return a controller name and the UDC file to contain that name, not be blank. If UDC is blank, the gadget is not active.

Layer 3: CCID and PCSC: Does the Host See a Smartcard Reader

pcsc_scan

You want a reader to appear and status changes when the device is plugged or unplugged. If pcsc_scan sees nothing but lsusb does, the USB interface is probably not presenting as CCID.

Layer 4: APDU: Can scdaemon Exchange Commands

gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd apdu 00A4040000" /bye

Any response, even an error status word, means the pipe is alive. If this hangs, kill scdaemon and retry:

gpgconf --kill scdaemon
gpg-connect-agent "scd serialno" /bye

Layer 5: OpenPGP Application: Does It Look Like a Card

gpg --card-status

You want card and application details, and slot fingerprints even if empty at first. If it says it cannot find a card, the OpenPGP application is not answering the expected SELECT/GET DATA pattern over PC/SC.

Layer 6: Crypto: Can It Actually Sign

echo "card test" > msg.txt
gpg --clearsign msg.txt

You want pinentry to prompt for the user PIN and a signed output to be produced.

Layer 7: SSH: Does It Authenticate

ssh-add -L
ssh -v user@host

You want a public key printed from ssh-add -L and the verbose SSH log to show it offering the card-backed key.

Failure Map

What worksWhat failsMost likely issue
lsusbpcsc_scanNot actually presenting CCID
pcsc_scangpg –card-statusOpenPGP APDU behavior missing or scdaemon stuck
gpg –card-statussigningPIN verify flow or signature command not implemented
signingSSHAuth subkey not in auth slot, or SSH_AUTH_SOCK wrong

Gotchas That Waste Your Weekend

You would be shocked how many CCID problems are actually cable problems. If the host never sees a new USB device, stop blaming software. Swap the cable first. Yes, even if it looks fine.

Some Pi models and ports will never behave as a USB device. If /sys/class/udc is empty on the Pi, you are not almost there. You are on the wrong hardware path.

If lsusb shows the gadget but pcsc_scan shows no reader, the host is not binding a CCID driver. That usually means the USB interface is not really CCID, or the CCID gadget function is missing from the kernel.

If gpg --card-status hangs, that is the classic scdaemon stuck moment. Kill and retry:

gpgconf --kill scdaemon
gpg --card-status

If it still hangs, the OpenPGP APDU behavior is probably not responding the way GnuPG expects. Windows can be picky about descriptors and composite devices. If it shows Unknown USB Device, focus on the gadget identity and interfaces first, not GnuPG. PIN caching means you enter the PIN once and the agent keeps signing for a while after. That is the cache doing its job. Tighten cache TTLs in gpg-agent.conf if that makes you uncomfortable.

Hardening

Turn off what you do not need

If the Pi is only a USB token, Wi-Fi and Bluetooth are extra ways to have a bad day. Disable radios if they are not needed. Keep services minimal. A dedicated signing token does not need a web server, a print daemon, or anything else.

Treat the microSD card like a key theft magnet

If private key material ever lands on disk unencrypted, it is copyable. That is the part people do not want to hear, but it is true. Encrypt anything that must be stored. Restrict file permissions tightly. Keep logs from leaking sensitive details. For managing write pressure on the microSD card in a device that stays plugged in continuously, see Preventing SD Card Corruption on Raspberry Pi. Your Raspberry Pi OpenPGP smartcard build is only as secure as the storage holding the keys.

Keep revocation ready

If the device disappears, you want a revocation certificate already made and stored offline. Generate it when you create the key, store it somewhere that is not on the Pi, and make sure you can find it. Future-you will thank current-you for this.

Have a simple wipe plan

Keep a known-good OS image you can reflash and a written checklist for reset, re-enroll, and retest. A wipe plan that depends on memory is not a wipe plan.

FAQ

Can this replace a YubiKey for daily signing?

For low-to-medium risk signing it can feel similar if the OpenPGP side is solid. For high-stakes use, a Pi with a microSD card is not the same class as a secure element. That is not a moral judgment. That is physics and storage.

Does it work for SSH?

Yes, if you have an authentication subkey in the auth slot and gpg-agent is exposing the SSH socket. If ssh-add -L shows nothing, it is almost always a setup issue rather than a deeper problem. Check SSH_AUTH_SOCK, confirm gpg-agent was restarted after editing gpg-agent.conf, and confirm the auth subkey was moved to the card.

If the host has malware, does the PIN protect me?

A PIN helps, but a compromised host can still request signatures while the token is plugged in. A presence button reduces silent use, but it does not make the Pi equivalent to a secure element. Know the threat model you are operating in.

What is the easiest first win?

Signing a small file:

echo "test" > msg.txt
gpg --clearsign msg.txt

If that works reliably, you have a real pipeline from key to signature. Everything else builds on that.

References

Was this helpful?

Yes
No
Thanks for your feedback!