Unified GPG/SSH Strategy (Hardware Token Edition)
Hardware-backed authentication using Nitrokey 3 tokens with automatic stub management
This document outlines the hardware-first GPG/SSH configuration where all secret keys reside exclusively on Nitrokey 3 hardware tokens, and hosts use lightweight "stubs" that reference keys on the hardware.
Overview
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ USER APPLICATIONS │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐│
│ │ GPG Client │ │ SSH Client │ │ Git (signed)││
│ └─────────┬───────┘ └─────────┬───────┘ └──────┬──────┘│
│ │ │ │ │
│ └──────────────────────┼───────────────────┘ │
│ │ │
│ ┌─────────────▼──────────────┐ │
│ │ GPG Agent │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ Stub Management │ │ │
│ │ │ SSH Auth Bridge │ │ │
│ │ │ Smart Card Daemon │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────┬──────────────┘ │
└──────────────────────────────────┼────────────────────────────┘
│
┌──────────────▼───────────────┐
│ HARDWARE TOKEN │
│ ┌──────────────────────┐ │
│ │ Nitrokey 3 │ │
│ │ ├─ Signing Subkey │ │
│ │ ├─ Encryption Subkey │ │
│ │ └─ Auth Subkey (SSH) │ │
│ └──────────────────────┘ │
└───────────────────────────────┘
Key Principles
- Hardware-First: All secret keys live exclusively on Nitrokey 3 tokens
- Stub Model: Hosts have lightweight references (stubs), not actual keys
- Automatic Discovery: Stubs created automatically when hardware key is used
- Cross-Platform: Identical workflow on Linux (powerhouse/capacitor) and macOS (turbine)
- Manual Provisioning: No automated scripts - fully documented procedures
What Changed from Per-Host Model
| Aspect | Old Model | New Model |
|---|---|---|
| Key Storage | Per-host keys in SOPS | Single hardware key pair |
| Secret Material | Filesystem + SOPS | Hardware token only |
| Host Keys | Different per host | Same keys everywhere |
| Provisioning | Import from SOPS | Stubs from hardware |
| Backup Strategy | SOPS backups | Identical backup token |
How Stubs Work
What is a Stub?
A stub is a lightweight reference file stored in ~/.gnupg/private-keys-v1.d/ that:
- Points to a key on the hardware token (by serial number)
- Contains key metadata (algorithm, keygrip)
- Does NOT contain secret key material
Stub vs. Full Key
Full Key (NOT used in this model):
~/.gnupg/private-keys-v1.d/XXXXXXX.key:
- Secret key material (encrypted)
- Can decrypt/sign without hardware
- Size: ~1-2 KB
Stub (what we use):
~/.gnupg/private-keys-v1.d/XXXXXXX.key:
- Keygrip reference
- Hardware token serial
- Algorithm info
- No secret material
- Size: ~200 bytes
- Points to hardware for actual operations
Creating Stubs
Automatic (Preferred):
# 1. Insert Nitrokey
# 2. Any GPG operation creates stubs
gpg --card-status # View token info (creates stubs)
ssh-add -L # View SSH key (creates auth stub)
git commit -m "test" # Sign (creates signing stub)
# 3. Stubs now exist
gpg --list-secret-keys
# Shows: 'ssb>' notation (secret subkey stub)
Manual (if automatic fails):
# Fetch public key from keyserver
gpg --card-edit
# gpg/card> fetch
# gpg/card> quit
# Force stub creation
gpg-connect-agent "scd serialno" "learn --force" /bye
Magic Recovery (New Machine Setup)
Overview
Setting up GPG/SSH on a new machine with existing hardware keys:
Before: Needed to import secret keys from backup/SOPS
After: Just plug in token and create stubs
Procedure
Step 1: Install System
# Install NixOS or home-manager as normal
# See docs/DEPLOYMENT.md for full procedure
Step 2: Insert Nitrokey
# Physically insert token into USB port
# Wait for LED to stabilize (steady light)
Step 3: Fetch Public Key
# Download from keyserver
gpg --card-edit
# gpg/card> fetch # Automatically fetches from keys.openpgp.org
# gpg/card> quit
# Alternative: Import from local copy
gpg --import keys/brancen-gregory-public.asc
Step 4: Create Stubs
# Link hardware to GPG (creates stubs)
gpg-connect-agent "scd serialno" "learn --force" /bye
# Or simply use any GPG operation
gpg --list-secret-keys # Shows stubs created
Step 5: Verify SSH
# Check SSH key is available
ssh-add -L
# Expected output:
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH... cardno:000F_XXXXXXXX
Step 6: Test
# Test SSH authentication
ssh git@github.com
# Should show: Hi brancengregory! You've successfully authenticated...
# Test Git signing
git commit --allow-empty -m "Test hardware key signing"
git log --show-signature -1
# Should show: Good signature from "Brancen Gregory"
Result
- ✅ No secret key import needed
- ✅ No SOPS secrets for GPG
- ✅ Stubs created automatically
- ✅ Hardware provides all secret operations
- ✅ Both Nitrokeys work identically
Daily Workflow
SSH Authentication
Automatic (once stubs exist):
ssh user@server
# System prompts for PIN (if cache expired)
# LED blinks - touch token
# Authentication complete
With Tmux:
tmux new-session -s work
ssh user@server # Works seamlessly in tmux
# If issues:
refresh_gpg # Updates GPG_TTY for current pane
Git Commit Signing
Automatic (enabled by default):
git commit -m "Update configuration"
# Automatically signed with hardware key
# PIN prompt if cache expired
# Touch token when LED blinks
Verify Signature:
git log --show-signature
# Shows: Good signature from "Brancen Gregory <brancengregory@gmail.com>"
GPG Operations
Encrypt File:
gpg --encrypt --recipient brancengregory@gmail.com file.txt
# PIN prompt
# Touch token
# Encrypted file created
Sign File:
gpg --sign file.txt
# PIN prompt
# Touch token
# Signed file created
Key Management
Your Keys
Hardware Token Keys:
- Fingerprint:
0A8C406B92CEFC33A51EC4933D9E0666449B886D - Key ID:
3D9E0666449B886D - Keyserver: https://keys.openpgp.org
Key Structure:
Master Key (Certify only)
├─ Signing Subkey → Git commit signing
├─ Encryption Subkey → File/email encryption
└─ Authentication Subkey → SSH authentication
Keyserver Integration
Publish Key:
# If you update/extend keys
gpg --keyserver hkps://keys.openpgp.org --send-keys 3D9E0666449B886D
Fetch on New Machine:
gpg --card-edit
# gpg/card> fetch
# gpg/card> quit
# Or directly:
curl https://keys.openpgp.org/vks/v1/by-fingerprint/0A8C406B92CEFC33A51EC4933D9E0666449B886D | gpg --import
Local Backup
Public Key in Repository:
# Located at: keys/brancen-gregory-public.asc
# Use if keyserver unavailable:
gpg --import keys/brancen-gregory-public.asc
SSH Public Key Management
The SSH public key is derived from the GPG authentication subkey on your Nitrokey 3. Unlike traditional SSH keys stored in ~/.ssh/, this key lives exclusively on the hardware token and is exposed through the GPG agent.
How It Works
Authentication Flow:
- SSH client requests authentication
- GPG agent forwards request to Nitrokey
- Nitrokey performs cryptographic operation
- Private key never leaves the hardware token
Key Storage:
- ❌ Not stored in
~/.ssh/id_*files - ❌ No secret key material on filesystem
- ✅ Key provided by GPG agent via
SSH_AUTH_SOCK
Viewing Your SSH Key
From GPG Agent:
# Shows key with cardno notation
ssh-add -L | grep cardno
# Expected output:
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... cardno:000F_9D1F273F0000
From GPG:
# Export SSH public key from GPG
gpg --export-ssh-key 3D9E0666449B886D
# Expected output:
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... openpgp:0xBCF5F8B3
Repository Backup
SSH Public Key File:
- Location:
keys/id_nitrokey.pub - Format: OpenSSH (RFC 4716)
- Source: Exported from Nitrokey authentication subkey
Adding to GitHub/GitLab
Method 1: From Repository:
# Copy to clipboard (macOS)
cat keys/id_nitrokey.pub | pbcopy
# Or display and copy manually
cat keys/id_nitrokey.pub
Method 2: From Agent (Live):
# Copy current key from GPG agent
ssh-add -L | grep cardno | pbcopy
Then:
- Go to GitHub Settings → SSH and GPG keys
- Click "New SSH key"
- Paste the key
- Save
Adding to Servers
Direct from Agent:
# Append to remote authorized_keys
ssh-add -L | grep cardno | ssh user@server "cat >> ~/.ssh/authorized_keys"
Using Exported File:
# Copy key to local .ssh first
gpg --export-ssh-key 3D9E0666449B886D > ~/.ssh/id_nitrokey.pub
chmod 644 ~/.ssh/id_nitrokey.pub
# Then use ssh-copy-id
ssh-copy-id -i ~/.ssh/id_nitrokey.pub user@server
Manual Method:
# On server, edit authorized_keys and add:
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... cardno:000F_9D1F273F0000
No IdentityFile Required
Important: You do NOT need IdentityFile in ~/.ssh/config. The GPG agent provides the key automatically via SSH_AUTH_SOCK.
Correct Configuration:
Host github.com
HostName github.com
User git
PreferredAuthentications publickey
# No IdentityFile - key comes from GPG agent
Why No IdentityFile:
- GPG agent intercepts SSH authentication requests
- Forwards to Nitrokey for cryptographic operations
- Hardware token never exposes private key
- More secure than file-based keys
Testing SSH Authentication
Test with GitHub:
ssh git@github.com
# Expected output:
# Hi brancengregory! You've successfully authenticated...
Test with Any Server:
ssh user@server
# Should prompt for PIN if not cached
# Then prompt for touch confirmation on token
Hardware Token Details
Device Information
Primary Token:
- Model: Nitrokey 3
- Serial: [Check with
gpg --card-status] - Location: [Your secure location]
- Usage: Daily operations
Backup Token:
- Model: Nitrokey 3
- Serial: [Check with
gpg --card-status] - Location: [Your backup secure location]
- Usage: Emergency/backup (identical keys)
PIN Management
User PIN: 6-8 digits
- Daily operations (sign, encrypt, auth)
- 3 attempts before temporary lock
- Unlocked with Admin PIN
Admin PIN: (change from default during setup!)
- Card administration
- Reset User PIN
- Never for daily use
Change PINs:
gpg --card-edit
# gpg/card> admin
# gpg/card> passwd
# Follow prompts
# gpg/card> quit
Touch Confirmation (UIF)
User Interface Flags (UIF):
- Configured: All subkeys require touch
- LED blinks when touch needed
- Press token surface to confirm
Verify:
gpg --card-edit
# gpg/card> uif
# Shows current UIF status
Configuration
Current Configuration Files
GPG Agent (modules/home/gpg.nix):
services.gpg-agent = {
enable = true;
enableSshSupport = true;
enableScDaemon = true; # Smart card daemon for hardware tokens
# Cache settings
defaultCacheTtl = 28800; # 8 hours
defaultCacheTtlSsh = 28800; # 8 hours
maxCacheTtl = 86400; # 24 hours
maxCacheTtlSsh = 86400; # 24 hours
};
Git Signing (modules/home/programs/git.nix):
signing = {
key = "3D9E0666449B886D"; # Hardware token subkey
signByDefault = true;
};
ZSH Integration (modules/home/terminal/zsh.nix):
# Environment setup
export GPG_TTY=$(tty)
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
# Hardware token aliases
nitro-status = "gpg --card-status";
nitro-fetch = "gpg --card-edit";
nitro-learn = "gpg-connect-agent 'scd serialno' 'learn --force' /bye";
Tmux Integration
Configuration (modules/home/terminal/tmux.nix):
set-option -g update-environment "DISPLAY SSH_ASKPASS SSH_AGENT_PID SSH_CONNECTION SSH_AUTH_SOCK WINDOWID XAUTHORITY GPG_TTY"
# Refresh GPG_TTY on session events
set-hook -g session-created 'run-shell "export GPG_TTY=$(tty) && gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1 || true"'
set-hook -g client-attached 'run-shell "export GPG_TTY=$(tty) && gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1 || true"'
Troubleshooting
Hardware Token Not Detected
Symptom: gpg --card-status shows "No such device"
Solutions:
# Check USB
lsusb | grep -i nitro
# Check scdaemon
gpgconf --check-programs
ps aux | grep scdaemon
# Restart
gpgconf --kill scdaemon
gpg-connect-agent /bye
# Try different port
SSH Key Not Available
Symptom: ssh-add -L doesn't show cardno key
Solutions:
# Create stubs
gpg-connect-agent "scd serialno" "learn --force" /bye
# Check GPG agent
echo $SSH_AUTH_SOCK
gpgconf --list-dirs agent-ssh-socket
# Restart agent
gpgconf --kill gpg-agent && gpgconf --launch gpg-agent
Git Signing Fails
Symptom: git commit fails with GPG error
Solutions:
# Check signing key
git config user.signingkey
# Should be: 3D9E0666449B886D
# Test GPG directly
echo "test" | gpg --clearsign
# Check stubs
gpg --list-secret-keys 3D9E0666449B886D
PIN Entry Issues
Symptom: PIN dialog doesn't appear
Solutions:
# Set GPG_TTY
export GPG_TTY=$(tty)
gpg-connect-agent updatestartuptty /bye
# Test pinentry
echo "GETPIN" | pinentry-curses
# In tmux:
refresh_gpg
Security Model
Threat Protection
Hardware Token Provides:
- ✅ Physical possession requirement
- ✅ Keys never leave hardware (even during operations)
- ✅ Protection against key extraction attacks
- ✅ 5th Amendment protection (can't be compelled to reveal PIN)
- ✅ PIN + touch dual authentication
SSH Host Keys Provide:
- ✅ Server identity verification
- ✅ Protection against man-in-the-middle attacks
- ✅ Pre-distributed trust (no TOFU)
SOPS Provides:
- ✅ Encrypted secret distribution
- ✅ Host-specific credentials
- ✅ Declarative configuration
Trust Model
┌─────────────────────────────────────────┐
│ TRUST HIERARCHY │
├─────────────────────────────────────────┤
│ 1. Hardware Token (Root of Trust) │
│ └─ Physical possession + PIN │
│ │
│ 2. SSH Host Keys (Server Identity) │
│ └─ Pre-verified in SOPS │
│ │
│ 3. SOPS + Age (Secret Distribution) │
│ └─ Host-specific decryption │
└─────────────────────────────────────────┘
Best Practices
Daily Use
- Insert token when starting work
- Remove when done (optional but good practice)
- Verify LED behavior:
- Steady = ready
- Blinking = touch needed
- Use aliases for common operations
Security
- Never export secret keys (they stay on hardware)
- Backup token kept offline (emergency only)
- Public key published (keyserver + repo backup)
- PIN not written down (memorize only)
- Touch required (UIF enabled for all operations)
Maintenance
- Monthly: Test backup token
- Quarterly: Verify keyserver publication
- Annually: Consider subkey rotation
Migration from Old Model
For Existing Installations
If you have per-host GPG keys in SOPS:
-
Backup existing GPG directory:
cp -r ~/.gnupg ~/.gnupg.backup.$(date +%Y%m%d) -
Remove old keys:
rm -rf ~/.gnupg/private-keys-v1.d/* rm ~/.gnupg/pubring.kbx* -
Provision with hardware token:
gpg --card-edit # fetch # quit gpg-connect-agent "scd serialno" "learn --force" /bye -
Verify:
gpg --list-secret-keys # Should show stubs ssh-add -L # Should show cardno git commit --allow-empty -m "Test" -
Clean SOPS:
# Remove GPG sections from secrets/secrets.yaml # See docs/DEPLOYMENT.md
Related Documentation
- Hardware Tokens:
docs/HARDWARE-KEYS.md- Detailed token management - Deployment:
docs/DEPLOYMENT.md- New machine provisioning - Secret Management:
docs/SECRET_MANAGEMENT.md- SOPS and age keys - Security:
docs/SECURITY.md- Threat model and practices - FIDO2 (Future):
docs/FIDO2-RESIDENT-KEYS.md- Alternative SSH method - GPG Public Key:
keys/brancen-gregory-public.asc- Local backup - SSH Public Key:
keys/id_nitrokey.pub- SSH authentication key
Quick Reference Commands
# Check token
gpg --card-status
# Create stubs
gpg-connect-agent "scd serialno" "learn --force" /bye
# List keys with stubs
gpg --list-secret-keys
# Show SSH key
ssh-add -L | grep cardno
# Export SSH key
gpg --export-ssh-key 3D9E0666449B886D
# Copy SSH key to clipboard (macOS)
gpg --export-ssh-key 3D9E0666449B886D | pbcopy
# Fetch from keyserver
gpg --card-edit -> fetch -> quit
# Restart agent
gpgconf --kill gpg-agent && gpgconf --launch gpg-agent
# Refresh in tmux
refresh_gpg
# Hardware token aliases
nitro-status # gpg --card-status
nitro-fetch # gpg --card-edit
nitro-learn # Create stubs
Last Updated: 2026-03-04
Hardware Token Model - No Per-Host Keys