DEV Community

Cover image for YubiKey + PGP: Offline Primary, Subkeys, Backups, and Git Signing
Danylo Mikula
Danylo Mikula

Posted on

YubiKey + PGP: Offline Primary, Subkeys, Backups, and Git Signing

What we’ll build

In this guide we’ll set up a modern PGP workflow anchored by a YubiKey. We’ll generate an offline, certification-only primary key and three subkeys for sign, encrypt, and auth.

We’ll also create secure backups before moving anything to hardware, set expiration dates on the subkeys, load the subkeys onto our primary YubiKey with touch policies, and then clone those subkeys to a second YubiKey as a spare. Finally, we’ll integrate the setup with Git commit signing.

This guide is written for macOS, but the steps translate easily to Linux.


Prerequisites

  • A YubiKey with the OpenPGP applet (e.g., YubiKey 5 series).
  • macOS with Homebrew installed.
  • Basic terminal familiarity.

Install the tools (macOS)

brew install gnupg ykman pinentry-mac
Enter fullscreen mode Exit fullscreen mode

Basic GnuPG setup

We’ll work inside a temporary GnuPG home so we don’t touch any existing setup.

1) Create a temporary GNUPGHOME

export GNUPGHOME="$(mktemp -d -t gpg-$(date +%Y.%m.%d))"
chmod 700 "$GNUPGHOME"
Enter fullscreen mode Exit fullscreen mode

2) Create gpg.conf

cat > "$GNUPGHOME/gpg.conf" <<'EOF'
# === Crypto preferences ===
personal-cipher-preferences AES256 AES192 AES
personal-digest-preferences SHA512 SHA384 SHA256
personal-compress-preferences ZLIB BZIP2 ZIP Uncompressed
default-preference-list SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed

# === Strong digests & s2k ===
cert-digest-algo SHA512
s2k-digest-algo SHA512
s2k-cipher-algo AES256

# === Output / UX ===
charset utf-8
no-comments
no-emit-version
no-greeting
keyid-format 0xlong
list-options show-uid-validity
verify-options show-uid-validity
with-fingerprint
use-agent
armor

# === Security hardening ===
require-cross-certification
require-secmem
no-symkey-cache

# === New keys default ===
default-new-key-algo ed25519/cert,sign+cv25519/encr+ed25519/auth
EOF
Enter fullscreen mode Exit fullscreen mode

3) Create gpg-agent.conf

cat > "$GNUPGHOME/gpg-agent.conf" <<'EOF'
# === Path to pinentry on macOS/Homebrew (Apple Silicon) ===
pinentry-program /opt/homebrew/bin/pinentry-mac

# === SSH Support ===
enable-ssh-support

# === Cache TTLs ===
default-cache-ttl 86400
max-cache-ttl 86400
EOF
Enter fullscreen mode Exit fullscreen mode

4) Reload agent to pick up the config:

gpgconf --kill gpg-agent
Enter fullscreen mode Exit fullscreen mode

Prepare the YubiKey

If you keep a spare YubiKey, run all steps below on both devices (one at a time).

Insert only one YubiKey at a time to avoid confusion.

1) Enable KDF

KDF (Key Derivation Function) makes the YubiKey store a hash of the PIN and derive it locally, rather than accepting the PIN as plain text.

Older/legacy OpenPGP clients (especially some mobile apps) may not support KDF; those clients will fail PIN checks if KDF is enabled.

Order of operations: Enable KDF before changing PINs or moving subkeys to the card, otherwise you may hit:

gpg: error for setup KDF: Conditions of use not satisfied
Enter fullscreen mode Exit fullscreen mode

Enable KDF:

gpg --card-edit
gpg/card> admin
gpg/card> kdf-setup
# pinentry will prompt for the Admin PIN (default is 12345678 on a fresh card)
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

Verify:

gpg --card-status | grep 'KDF setting'
# KDF setting ......: on
Enter fullscreen mode Exit fullscreen mode

Do this on each YubiKey (primary and spare) before proceeding to change PINs, set touch policies, or move subkeys.

2) Change the default PINs

Generate random numeric PINs and store these securely:

export ADMIN_PIN=$(LC_ALL=C tr -dc '0-9' </dev/urandom | fold -w8 | head -1)
export USER_PIN=$(LC_ALL=C  tr -dc '0-9' </dev/urandom | fold -w6 | head -1)
printf "\nAdmin PIN: %12s\nUser  PIN: %12s\n\n" "$ADMIN_PIN" "$USER_PIN"
Enter fullscreen mode Exit fullscreen mode

Change the PINs:

gpg --card-edit
gpg/card> admin
gpg/card> passwd
# 1 - Change PIN          (default 123456)     -> enter $USER_PIN
# 3 - Change Admin PIN    (default 12345678)   -> enter $ADMIN_PIN
# Q - quit
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

Default User PIN = 123456, default Admin PIN = 12345678

3) Set touch policies

# Require touch for every signature
ykman openpgp keys set-touch sig on

# Cache touch after PIN entry for encryption and auth
ykman openpgp keys set-touch enc cached
ykman openpgp keys set-touch aut cached
Enter fullscreen mode Exit fullscreen mode

Verify:

ykman openpgp info
Enter fullscreen mode Exit fullscreen mode

Touch policy modes:

  • off — no touch required
  • on — touch required for every operation
  • fixed — like on, but cannot be changed without resetting the OpenPGP applet
  • cached — touch once per PIN session (until PIN cache expires or card is removed)
  • cached-fixed — like cached, but cannot be changed without reset

4) Set key attributes (Ed25519 / cv25519 / Ed25519)

gpg --card-edit
gpg/card> admin
gpg/card> key-attr
# Signature : ECC -> Curve 25519 (Ed25519)
# Encryption: ECC -> Curve 25519 (cv25519 / X25519)
# Authentication: ECC -> Curve 25519 (Ed25519)
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

Set key attributes (Ed25519 / cv25519 / Ed25519)

5) Cardholder info

gpg --card-edit
gpg/card> admin
gpg/card> name   # Lastname, Firstname
gpg/card> lang   # en
gpg/card> login  # your.email@example.com
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

Check card status:

gpg --card-status
Enter fullscreen mode Exit fullscreen mode

When finished, repeat the same steps on your spare (if you have one).


Generate the offline Master (Certify) key (Ed25519)

The primary (master) key is the Certify key. It’s used only to issue subkeys for sign, encrypt, and auth.
We keep this key offline at all times and use it only in a dedicated, secure environment to create or revoke subkeys.

Do not set an expiration date on the Certify key.

1) Generate a strong passphrase for the master key

export CERTIFY_PASS=$(LC_ALL=C tr -dc "A-Z2-9" < /dev/urandom | tr -d "IOUS5" | fold -w ${PASS_GROUPSIZE:-4} | paste -sd ${PASS_DELIMITER:--} - | head -c ${PASS_LENGTH:-29})
printf "\n$CERTIFY_PASS\n\n"
Enter fullscreen mode Exit fullscreen mode

We will use it when GnuPG prompts during key creation.

2) Create the Certify-only primary key

gpg --expert --full-generate-key
Enter fullscreen mode Exit fullscreen mode

When prompted:

  1. Key type: choose ECC (set your own capabilities).
  2. Capabilities: leave only “Certify (C)” enabled. Disable Sign/Encrypt/Auth if shown.
  3. Curve: choose Curve 25519 (Ed25519).
  4. Expiration: choose no expiration (enter 0).
  5. User ID: enter your real name and email (comment optional).
  6. Passphrase: enter the passphrase you generated in step 1.

Offline Master (Certify) key

After creation, note the key ID:

export KEYID=$(gpg --list-keys --keyid-format 0xlong --with-colons | awk -F: '/^pub:/ {print $5; exit}')
echo "Primary KEYID: $KEYID"
Enter fullscreen mode Exit fullscreen mode

3) Verify the primary key is Certify-only

gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

You should see something like:

sec   ed25519/XXXXXXXXXX YYYY-MM-DD [C]
      Key fingerprint = XXXXXXXXXXXXXXXXX
uid   [ultimate] Your Name <you@example.com>
Enter fullscreen mode Exit fullscreen mode

Only [C] should appear on the sec line (no [S], [E], or [A]).


Create subkeys

We’ll add three subkeys to the offline primary key: Sign (Ed25519), Encrypt (cv25519/X25519), and Auth (Ed25519).

Open the key editor:

gpg --expert --edit-key "$KEYID"
Enter fullscreen mode Exit fullscreen mode

1) Sign subkey

gpg> addkey
# Choose: ECC (set your own capabilities)   # number may be (11)
# Curve: 25519 (Ed25519)
# Capabilities: leave ONLY [S] Sign (toggle others off)
# Expiration: 5y (or your preference)
Enter fullscreen mode Exit fullscreen mode

2) Encrypt subkey

gpg> addkey
# Choose: ECC (encrypt only)                # number may be (12)
# Curve: 25519
# Expiration: 5y
Enter fullscreen mode Exit fullscreen mode

3) Auth subkey

gpg> addkey
# Choose: ECC (set your own capabilities)   # number may be (11)
# Capabilities: leave ONLY [A] Authenticate
# Curve: 25519
# Expiration: 5y
gpg> save
Enter fullscreen mode Exit fullscreen mode

4) Verify

gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

You should see your primary key with [C] only, and three ssb lines:

sec   ed25519/XXXXXXXXXX YYYY-MM-DD [C]
uid   Your Name <you@example.com>
ssb   ed25519/XXXXXXXXXX YYYY-MM-DD [S] [expires: YYYY-MM-DD]
ssb   cv25519/XXXXXXXXXX YYYY-MM-DD [E] [expires: YYYY-MM-DD]
ssb   ed25519/XXXXXXXXXX YYYY-MM-DD [A] [expires: YYYY-MM-DD]
Enter fullscreen mode Exit fullscreen mode

Export backups (master, subkeys, public)

Do this before moving any keys to the YubiKey.

1) Create a private backups folder:

mkdir -p gnupg-backups
chmod 700 gnupg-backups
Enter fullscreen mode Exit fullscreen mode

2) Export the keys:

# Master (primary + subkeys): full secret material
gpg --output "gnupg-backups/${KEYID}-certify-$(date +%F).key" --armor --export-secret-keys "${KEYID}"

# Secret subkeys only (no primary secret) — use for loading to YubiKey / cloning to spare
gpg --output "gnupg-backups/${KEYID}-subkeys-$(date +%F).key" --armor --export-secret-subkeys "${KEYID}"

# Public key (safe to share)
gpg --output "gnupg-backups/${KEYID}-public-$(date +%F).asc" --armor --export "${KEYID}"
Enter fullscreen mode Exit fullscreen mode

Store copies in two separate offline locations (e.g., two encrypted USB drives kept in different places).

3) (Optional) Encrypt the backup files

Symmetric (passphrase-based):

# Encrypt each file with AES256; you’ll be prompted for a passphrase
for f in gnupg-backups/${KEYID}-{certify,subkeys}-*.key gnupg-backups/${KEYID}-public-*.asc; do
  gpg --symmetric --cipher-algo AES256 "$f"
done
Enter fullscreen mode Exit fullscreen mode

(Optional) Quick integrity check of the backups

Spin up a throwaway keyring, import, and list:

TMPVERIFY="$(mktemp -d -t gpg-verify-XXXX)"
chmod 700 "$TMPVERIFY"

GNUPGHOME="$TMPVERIFY" gpg --import gnupg-backups/${KEYID}-public-*.asc
GNUPGHOME="$TMPVERIFY" gpg --import gnupg-backups/${KEYID}-subkeys-*.key
GNUPGHOME="$TMPVERIFY" gpg --list-secret-keys --keyid-format 0xlong
Enter fullscreen mode Exit fullscreen mode

You should see sec# (stub for the primary) and ssb lines for subkeys after the subkeys import.

4) Clean up the temporary directory

rm -rf "$TMPVERIFY"
Enter fullscreen mode Exit fullscreen mode

Transfer subkeys to the YubiKey

Important: Moving subkeys to a YubiKey turns their on-disk copies into stubs (ssb#ssb>), so they can’t be moved again unless we re-import the secret-subkeys backup. Make sure backups are done.

We’ll need the Certify key passphrase (for decrypting the secret material) and the YubiKey Admin PIN (to write keys to the card).

Keep only one YubiKey inserted at a time.

1) Load the subkeys onto the primary YubiKey

Insert the primary YubiKey, then:

gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 1
keytocard
1
save
EOF
Enter fullscreen mode Exit fullscreen mode
gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 2
keytocard
2
save
EOF
Enter fullscreen mode Exit fullscreen mode
gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 3
keytocard
3
save
EOF
Enter fullscreen mode Exit fullscreen mode

During these steps GnuPG will prompt for:

  • your master (Certify) passphrase (to unlock the secret subkeys on disk), and
  • the YubiKey Admin PIN (to write into the OpenPGP applet).

2) Verify subkeys are on the YubiKey

gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

Each subkey should display as ssb> (stored on smartcard), for example:

sec   ed25519 YYYY-MM-DD [C]
uid   Your Name <you@example.com>
ssb>  ed25519 YYYY-MM-DD [S] [expires: …]
ssb>  cv25519 YYYY-MM-DD [E] [expires: …]
ssb>  ed25519 YYYY-MM-DD [A] [expires: …]
Enter fullscreen mode Exit fullscreen mode

Additionally:

gpg --card-status
Enter fullscreen mode Exit fullscreen mode

Should list the Signature/Encryption/Authentication slots and the touch policies you set earlier.

3) (If you have a spare) Re-import and load to the second YubiKey

Because moving subkeys to the first YubiKey turned local copies into stubs, we’ll clear any existing secret entries and re-import the pre-move secret-subkeys backup.

# Ensure you’re in the same GNUPGHOME you used for the guide
echo "$GNUPGHOME"

# Remove current secret entries (drops stubs)
gpg --delete-secret-keys "$KEYID"

# Re-import secret subkeys
gpg --import gnupg-backups/${KEYID}-subkeys-*.key

# Sanity check — these must be plain `ssb` (not `ssb#` or `ssb>`)
gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

Insert the spare YubiKey (only this one inserted), then run the same transfer flow:

gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 1
keytocard
1
save
EOF
Enter fullscreen mode Exit fullscreen mode
gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 2
keytocard
2
save
EOF
Enter fullscreen mode Exit fullscreen mode
gpg --status-fd=2 --command-fd=0 --expert --edit-key "$KEYID" <<'EOF'
key 3
keytocard
3
save
EOF
Enter fullscreen mode Exit fullscreen mode

Pinentry will prompt for your master (Certify) passphrase and the Admin PIN.

4) Verify:

gpg --card-status
gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

You should see ssb> for all three subkeys again, now on the spare as well.

Done! Both YubiKeys now hold the same Sign/Encrypt/Auth subkeys.


Post-Setup

Publish your public key (keys.openpgp.org) and link it to your YubiKey

Why?
Publishing the public key makes it easy for others (and for your future machines) to discover it.

Upload

1) Go to keys.openpgp.org → Upload Key.
2) Export and upload your public key:

   gpg --armor --export "$KEYID" > pubkey-$KEYID.asc
Enter fullscreen mode Exit fullscreen mode

3) Click Send verification email.
4) Check the inbox for the email on your key and click the verification link.

Put the public key URL on your YubiKey

We’ll store a permalink to your key on the card so any machine can fetch it with one command.

1) Get your full fingerprint and build the permalink:

FPR=$(gpg --with-colons --fingerprint "$KEYID" | awk -F: '/^fpr:/ {print $10; exit}')
echo "Fingerprint: $FPR"
echo "Permalink  : https://keys.openpgp.org/vks/v1/by-fingerprint/$FPR"
Enter fullscreen mode Exit fullscreen mode

2) Write the URL into the card:

gpg --card-edit
gpg/card> admin
gpg/card> url
https://keys.openpgp.org/vks/v1/by-fingerprint/$FPR
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

Do this for each YubiKey you use (primary and spare).

On a new machine: fetch straight from the card

Insert the YubiKey and run:

gpg --card-edit
gpg/card> admin
gpg/card> fetch
gpg/card> quit
Enter fullscreen mode Exit fullscreen mode

This pulls your public key from the URL stored on the card and imports it into the new machine’s keyring.
You can verify with:

gpg --card-status
gpg -K --keyid-format long
Enter fullscreen mode Exit fullscreen mode

GitHub: sign commits with your YubiKey subkey

We’ll upload your public key to GitHub and configure Git to sign with the Sign subkey that lives on your YubiKey.

1) Get the Sign subkey’s long ID (automatically)

# Prints the first subkey with [S] capability (long 0x… ID)
SUBKEY_SIGN=$(
  gpg -K --with-colons --keyid-format 0xlong "$KEYID" |
  awk -F: '/^ssb/ && $12 ~ /s/ {print $5; exit}'
)
echo "Signing subkey: $SUBKEY_SIGN"
Enter fullscreen mode Exit fullscreen mode

2) Add your public key to GitHub

  • Export your public key and copy it:
   gpg --armor --export "$KEYID" | pbcopy   # macOS; use xclip/clipboard on Linux
Enter fullscreen mode Exit fullscreen mode
  • GitHubSettingsSSH and GPG keysNew GPG key → paste → Add GPG key.

Your commit email must match a verified email on GitHub.

3) Configure Git to use that subkey

git config --global gpg.program gpg
git config --global gpg.format openpgp
git config --global user.signingkey "$SUBKEY_SIGN"
git config --global user.email "yubikey@example.com"  # must be a verified GitHub email
git config --global commit.gpgsign true
git config --global tag.gpgSign true
Enter fullscreen mode Exit fullscreen mode

Top comments (0)