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
lsusbworks butpcsc_scandoes not, you do not have a real CCID interface yet - If
pcsc_scanworks butgpg --card-statushangs, 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
| Choice | What feels YubiKey-like | Main risk | Best for |
|---|---|---|---|
| Software-only | Plug in, enter PIN, sign | Keys can be copied if Pi storage gets cloned | Git signing, testing, learning |
| Hardware-backed | Closer to real token behavior | More wiring, more moving parts | Higher-stakes SSH and signing |

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
| Symptom | Likely cause | What to do |
|---|---|---|
| pcsc_scan shows nothing | Bad cable or no USB device mode | Try a known data cable, try a different port |
| pcsc_scan sees reader, gpg hangs | scdaemon confusion or stale state | Run gpgconf –kill scdaemon then retry |
| Works once, then stops | Service not running or power glitch | Check systemctl status pcscd |
| Windows sees unknown device | Descriptor mismatch | Recheck 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
| Slot | Typical job | What you will feel on the host |
|---|---|---|
| Signature | Git commits, file signing | gpg prompts for PIN then signs |
| Decryption | Email and file decryption | gpg prompts for PIN when decrypting |
| Authentication | SSH login | ssh 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.

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 works | What fails | Most likely issue |
|---|---|---|
| lsusb | pcsc_scan | Not actually presenting CCID |
| pcsc_scan | gpg –card-status | OpenPGP APDU behavior missing or scdaemon stuck |
| gpg –card-status | signing | PIN verify flow or signature command not implemented |
| signing | SSH | Auth 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
- https://gnupg.org/ftp/specs/OpenPGP-smart-card-application-3.4.1.pdf
- https://docs.kernel.org/usb/gadget_configfs.html
- https://www.usb.org/sites/default/files/DWG_Smart-Card_CCID_Rev110.pdf
- https://pip.raspberrypi.com/categories/685-app-notes-guides-whitepapers/documents/RP-009276-WP/Using-OTG-mode-on-Raspberry-Pi-SBCs.pdf

