Introduction
Raspberry Pi OpenPGP CCID gadget builds are for people who want token-style signing without dragging secret key files around like it’s 2009. You plug in the Pi, the host sees a CCID smartcard device, and PC/SC routes APDUs to it.
What you’re building
- Linux configfs defines USB descriptors (VID/PID, endpoints) as a gadget.
- The device answers SELECT and GET DATA with OpenPGP Card data objects plus SW1/SW2 status words.
- scdaemon talks through pcscd, so
gpg --card-statuscan read slots for sign, decrypt, and authenticate.
What you should expect
PIN prompts, retry counters, and an ATR that makes the host treat the Pi like a smartcard.
Key takeaways
- Start by proving USB device mode works before you touch OpenPGP behavior.
- If
lsusbworks butpcsc_scandoesn’t, you likely don’t 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.
Choose your build path
Spoiler alert: there isn’t 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 like SELECT, VERIFY, and GET DATA.
- Private keys usually live in Pi storage or RAM, protected by Linux permissions and encryption.
- Raspberry Pi responds to APDUs using an OpenPGP card service
- Host PC/SC stack sends APDUs to the CCID interface
- GnuPG scdaemon requests signatures from the OpenPGP application
Why you’d pick it: you want the workflow fast, and you’re okay with “good enough” protection for a home lab or low-risk signing.
Path B: Pi as the USB front-end, keys stay on real secure hardware
- The Pi still presents CCID to the host.
- The cryptographic private key stays inside a real smartcard or secure element that’s wired to the Pi.
- The Pi becomes a bridge that passes requests to hardware that’s harder to copy than a microSD card.
- Secure element stores private keys inside tamper-resistant memory
- Pi bridge forwards APDU intent to the secure chip
- Secure chip returns signature bytes to the host via Pi
Why you’d pick it: you care about theft and offline copying risk 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 the 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
You want one thing here: 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
- Debian/Ubuntu/Raspberry Pi OS (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 (Homebrew):
brew install pcsc-lite brew services start pcsc-lite - Windows:
- Make sure the Smart Card service is running (Services app).
- Most CCID devices bind without extra drivers, but Windows can be picky about descriptors.
Confirm the “reader” shows up
- Linux/macOS:
pcsc_scanWhat you want to see: a reader name, then activity when the device is plugged in. - Quick USB sanity check (Linux):
lsusb
Confirm GnuPG can see OpenPGP
Run these in a terminal on the host:
gpg --card-status
If GnuPG is wired up right, you’ll see card details and key slots (sign, decrypt, authenticate). If it hangs, that usually means PC/SC is fine but scdaemon is stuck talking to the device.
Two more checks that catch dumb problems fast:
gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd apdu 00A4040000" /bye
That last one sends a basic APDU. If you get a clean response (even an error status word), the pipe is alive.
Common problems that waste your night
| 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 --card-status hangs | scdaemon confusion or stale state | Restart the agent: gpgconf --kill scdaemon then retry |
| Works once, then stops | Service not running or power glitch | Check systemctl status pcscd (Linux) |
| Windows sees “unknown device” | Descriptor mismatch | Recheck gadget VID/PID and CCID function setup |
Raspberry Pi setup
Before you touch config files
- Use a Pi that can do USB device mode on its OTG port. Pi Zero and Zero 2 W are the usual picks. A lot of full-size Pi boards only act as USB hosts, so they will never show up as a “smartcard” when you plug them into a laptop.
Enable OTG gadget mode (Raspberry Pi OS Bookworm)
- On the Pi, edit the real boot config file location:
- Raspberry Pi OS Bookworm keeps boot config under
/boot/firmware/.
- Add the OTG overlay:
- Edit
/boot/firmware/config.txtand add:dtoverlay=dwc2 - Some platforms use an explicit peripheral mode, like:
dtoverlay=dwc2,dr_mode=peripheralThat pattern shows up in Raspberry Pi’s OTG whitepaper for OTG-capable boards.
- Load the USB controller module at boot:
- Edit
/boot/firmware/cmdline.txt(this is one long line) and add afterrootwait:modules-load=dwc2The Raspberry Pi forums show the samemodules-load=dwc2,...approach for gadget mode on Bookworm.
- Reboot:
sudo reboot
Confirm the Pi can act like a USB device
After reboot, run:
ls /sys/class/udc
- If you see a controller name, the Pi has a usable UDC and gadget mode is in play.
- If it’s empty, you’re fighting the wrong board or the wrong port.
Mount configfs and load the composite framework
Run:
sudo modprobe libcomposite
mount | grep configfs || sudo mount -t configfs none /sys/kernel/config
Linux’s configfs gadget interface is the normal way to build a USB composite device in userspace.
Sanity check with a known-good gadget (quick win)
Before you try CCID, prove the pipe works with a basic gadget like USB Ethernet:
- Add this to
cmdline.txtafterrootwait:modules-load=dwc2,g_ether
This exact pattern is commonly used to confirm gadget mode is alive on Pi Zero class boards.
If the host sees a new USB network adapter, your cable, port, and OTG setup are fine.
What this means for CCID
Configfs builds the USB “shape”, but CCID smartcard needs a matching gadget function. The kernel has lots of standard gadget functions, but CCID support is not as universal as serial or Ethernet, so you may need a kernel that includes a CCID gadget function, or a FunctionFS based design where userspace handles the protocol.
Build the USB gadget shape with configfs
Goal: make the Pi present itself as a USB device, with the right identity strings, then add a CCID function if your kernel supports it.
Create a 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
Pick your own values. Don’t copy a real vendor’s IDs.
echo 0x1d6b | sudo tee idVendor >/dev/null # Linux Foundation example
echo 0x0104 | sudo tee idProduct >/dev/null # example product
echo 0x0200 | sudo tee bcdUSB >/dev/null
echo 0x0100 | sudo tee bcdDevice >/dev/null
Add readable strings
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
Create one configuration
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 if a CCID gadget function exists on your kernel
Here’s the blunt truth: many people get OTG working, then hit a wall because CCID gadget support is not always built in.
Look for a CCID function driver
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
Try loading it
sudo modprobe usb_f_ccid 2>/dev/null || echo "usb_f_ccid not available"
If it exists, you’ll be able to create a function
You’ll see something like functions/ccid.* become possible.
Add the CCID function (if available)
Create the function and link it into the config
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
# pick the name you see, then:
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
You’ve got two realistic options:
Option 1: Use a kernel that includes CCID gadget support
- This is the cleanest path when it’s available for your Pi model.
- It keeps the USB side in-kernel, which usually behaves better with picky host stacks.
Option 2: Use FunctionFS and implement the CCID protocol in userspace
- This is more work, but it avoids depending on a missing gadget function.
- The Pi still presents a USB interface, but your service must handle CCID framing and APDU forwarding.
If you want the “YubiKey-like” experience, Option 1 is usually the least painful. Option 2 is what you do when you enjoy pain or you need full control.
Make it start on boot with systemd
Put the gadget setup into a 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
# CCID function, only if present
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
Enable it:
sudo chmod +x /usr/local/sbin/openpgp-ccid-gadget.sh
Create a service/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
Enable it:
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 “it acts like a token” feature set
- SELECT OpenPGP application (AID) so tools know what they’re talking to
- GET DATA for core data objects (card info, key fingerprints, key attributes)
- VERIFY for a user PIN, with a retry counter
- At least one private-key operation:
- PSO: COMPUTE DIGITAL SIGNATURE for signing, or
- INTERNAL AUTHENTICATE for auth, or
- DECIPHER for decryption
- OpenPGP application returns data objects after SELECT
- VERIFY command checks user PIN before signature operations
- PSO: COMPUTE DIGITAL SIGNATURE outputs signature bytes from a hash
Key slots and what they’re for
| Slot | Typical job | What you’ll feel on the host |
|---|---|---|
| Signature | Git commits, file signing | gpg prompts for PIN, then signs |
| Decryption | Email/file decryption | gpg prompts for PIN when decrypting |
| Authentication | SSH login | ssh uses the key via agent |
If the goal is “works today,” start with Signature first. It’s the easiest to test and hardest to confuse with other tooling.
PIN rules that match real token behavior
Suggested baseline
- User PIN (PW1): 6 to 12 digits
- Admin PIN (PW3): 8 to 20 digits
- Retry counter: 3 attempts for each PIN
- PIN prompt style: host-side prompt via pinentry, not a custom prompt on the Pi
What matters in practice
- After a wrong PIN, decrement the retry counter.
- After the counter 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.
What gpg --card-status expects to see
When gpg --card-status runs, it tries to read card metadata and key slot state. If the OpenPGP personality is incomplete, the common failure modes look like this:
- It hangs (the APDU exchange never completes)
- It shows a reader but no OpenPGP application
- It shows an OpenPGP app but no usable key slots
Sanity checks on the host
gpg --card-status
gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd learn --force" /bye
Fast path for a first “real” win: signing
Once the OpenPGP personality can do VERIFY and PSO: COMPUTE DIGITAL SIGNATURE, test signing from the host:
echo "card test" > msg.txt
gpg --clearsign msg.txt
Expected behavior:
- pinentry asks for the user PIN
- the output is a signed text block
- repeating the command soon after may not ask again if the agent cached the PIN state
Optional: make it feel more like a physical token
If you want “touch to sign” style behavior without copying YubiKey branding:
- Add a GPIO button as a “presence check”
- Require the button press before returning a signature APDU response
- Time out the approval window (example: 10 seconds)
This doesn’t make the Pi tamper-proof, but it does stop silent signing while it’s plugged in and unattended.
GnuPG key setup for a “card-style” workflow
The clean setup
- One primary key that stays off the Pi
- Three subkeys that match the OpenPGP slots:
- sign
- encrypt
- authenticate
This keeps day-to-day use simple, and it limits how much key material ends up near a microSD card.
Make a primary key and subkeys (host machine)
- Create the primary key:
gpg --full-generate-key
Pick what you actually use. If you want fewer weird edge cases, pick a normal option like RSA 3072 or RSA 4096.
- List keys and grab the key ID:
gpg --list-secret-keys --keyid-format LONG
- Add subkeys:
gpg --edit-key KEYID
Inside the prompt:
addkeyand make a signing subkeyaddkeyand make an encryption subkeyaddkeyand make an authentication subkeysave
Move subkeys to the “card”
This is the part that makes it feel like a token.
- Plug in the Pi gadget, confirm it shows:
gpg --card-status
- Move keys into slots:
gpg --edit-key KEYID
Inside:
key 1(select a subkey)keytocard- choose Signature slot
Repeat for the other subkeys, choosing Encryption and Authentication slots.
After that:
gpg --card-status
You should see fingerprints tied to the card slots.
Keep a backup that won’t ruin your week
- Export your public key:
gpg --armor --export KEYID > publickey.asc
- Make a revocation certificate and store it offline:
gpg --output revoke.asc --gen-revoke KEYID
SSH agent settings that usually trip people
Turn on gpg-agent SSH support
Edit:~/.gnupg/gpg-agent.conf
Add:
enable-ssh-support
default-cache-ttl 300
max-cache-ttl 600
Restart the agent:
gpgconf --kill gpg-agent
gpgconf --launch gpg-agent
Point SSH at gpg-agent
On Linux, add to your shell profile:
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
- scdaemon stuck
Quick reset that fixes a lot:
gpgconf --kill scdaemon
gpg --card-status
ssh-add -L
Testing checklist that proves each layer works
You’re going to test this like a stack of bricks. Don’t jump ahead. If one layer fails, the layers above it are fake “success.”
USB layer: “does the host even see a device”
On the host (Linux):
lsusb
dmesg -w
What you want:
- A new USB device entry when you plug in the Pi
- No constant disconnect/reconnect loop (that’s usually power or cable)
On the host (macOS):
- System Information → USB (confirm the device shows)
On Windows:
- Device Manager → look under Smart card readers / USB devices
Gadget layer: “is the Pi actually bound to the UDC”
On the Pi:
ls /sys/class/udc
cat /sys/kernel/config/usb_gadget/openpgpccid/UDC
What you want:
ls /sys/class/udcreturns a controller nameUDCcontains that name (not blank)
If UDC is blank, the gadget isn’t active.
CCID/PCSC layer: “does the host see a smartcard reader”
On the host (Linux/macOS):
pcsc_scan
What you want:
- A reader appears
- You see status changes when the device is plugged/unplugged
If pcsc_scan sees nothing but lsusb does, you likely don’t have a CCID function working (or the USB interface descriptors aren’t CCID).
APDU layer: “can scdaemon exchange commands”
On the host:
gpg-connect-agent "scd serialno" /bye
gpg-connect-agent "scd apdu 00A4040000" /bye
What you want:
- A response, even if it’s an error status word
- No hanging
If this hangs:
gpgconf --kill scdaemon
gpg-connect-agent "scd serialno" /bye
OpenPGP application layer: “does it look like an OpenPGP card”
On the host:
gpg --card-status
What you want:
- Card/application details
- Slot fingerprints (even if empty at first)
If it says it can’t find a card:
- It’s not seeing an OpenPGP application over PC/SC
- Either CCID isn’t real yet, or the OpenPGP personality isn’t answering the expected SELECT/GET DATA pattern
Crypto layer: “can it actually sign”
On the host:
echo "card test" > msg.txt
gpg --clearsign msg.txt
What you want:
- pinentry prompts for the user PIN
- a signed output is produced
SSH layer: “does it authenticate”
On the host:
ssh-add -L
What you want:
- A public key line is printed
Then test:
ssh -v user@host
What you want:
- The log shows it offering the card-backed key
Failure map (fast diagnosis)
| What works | What fails | Most likely issue |
|---|---|---|
lsusb | pcsc_scan | Not actually presenting CCID |
pcsc_scan | gpg --card-status | OpenPGP app/APDU behavior missing or scdaemon stuck |
gpg --card-status | signing | PIN verify flow or signature command not implemented/allowed |
| signing | SSH | auth subkey not in auth slot, or SSH_AUTH_SOCK wrong |
Gotchas that waste your weekend
Bad cable, fake progress
You’d be shocked how many “CCID problems” are actually “this cable only charges.” If the host never sees a new USB device, stop blaming software. Swap the cable first. Yes, even if it “looks nice.”
Wrong Pi port, wrong day
Some Pi models and ports will never behave like a USB device. If /sys/class/udc is empty on the Pi, you’re not “almost there.” You’re on the wrong hardware path.
pcsc_scan sees nothing
If lsusb shows the gadget but pcsc_scan shows no reader, the host isn’t binding a CCID driver. That usually means the USB interface is not really CCID, or the CCID gadget function is missing.
gpg --card-status hangs
This is the classic “scdaemon is stuck” moment.
- Kill and retry:
gpgconf --kill scdaemon gpg --card-status
If it still hangs, the OpenPGP APDU behavior probably isn’t responding the way GnuPG expects.
Windows gets fussy
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 surprises
You enter the PIN once, then it keeps signing for a while. That’s usually the agent cache doing what it does. Tighten cache TTLs if that makes you nervous.
Hardening without getting dramatic
Turn off what you don’t need
If the Pi is only a USB token, Wi-Fi and Bluetooth are just extra ways to have a bad day.
- Disable radios if they’re not needed.
- Keep services minimal.
Treat the microSD card like a key theft magnet
If private key material ever lands on disk unencrypted, it’s copyable. That’s the part people don’t want to hear, but it’s true.
- Encrypt anything that must be stored.
- Restrict file permissions.
- Keep logs from leaking sensitive details.
Make resets boring
Have a simple wipe plan that does not depend on memory.
- A known-good OS image you can reflash
- A written checklist for “reset, re-enroll, retest”
Keep revocation ready
If the device disappears, you want a revocation certificate already made and stored offline. Future-you will be annoying about this. Let future-you win.
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’s not a moral judgment. That’s 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’s usually setup, not magic.
If the host has malware, does the PIN save me
A PIN helps, but a compromised host can still ask for signatures while the token is plugged in and you’re distracted. A presence button helps cut down silent use.
What’s the easiest “first win” test
Signing a small file:
echo "test" > msg.txt
gpg --clearsign msg.txt
If that works reliably, you’ve got a real pipeline.
References
- OpenPGP Smart Card Application (spec PDF)
- Linux USB gadget configfs documentation
- USB-IF CCID Class Specification Rev 1.1 (PDF)
- Raspberry Pi: Using OTG mode on Raspberry Pi SBCs (PDF)
- GnuPG scdaemon documentation

