Files
mw-pfeddersheim-workstation/docs/plans/2026-06-24-yubikey-gpg-github-signing.md
T
mw 5a714e6256 docs(plans): add YubiKey+GPG GitHub commit-signing plan
Baby-step plan for hardware-backed OpenPGP commit/tag signing on YubiKey 5
NFC #30836069 (air-gapped keygen, ed25519/cv25519 subkeys, 2y expiry, touch
required). LUKS-encrypted recovery backup on the Type-C USB stick; non-secret
config to be folded into ansible/. HTTPS+GPG-sign (lowest risk); 2nd YubiKey
scheduled as Phase 9 follow-up.
2026-06-24 16:54:08 +02:00

12 KiB

description, tags, last_updated
description tags last_updated
Baby-step plan to sign GitHub/Git commits and tags with a hardware-backed OpenPGP key on YubiKey 5 NFC #30836069, generated in a dedicated air-gapped Debian-Live environment, with LUKS-encrypted recovery backups on the Type-C USB stick. Non-secret config folded into the ansible/ role; secrets via Bitwarden.
plan
security
yubikey
gpg
github
2026-06-24

YubiKey + GPG signing for GitHub commits - 2026-06-24

Sign all Git commits/tags with a hardware-backed OpenPGP key whose secret material lives on the YubiKey, and whose full recovery backup is stored LUKS-encrypted on the Type-C USB stick.

Goal

  1. Hardware-backed commit/tag signing for GitHub (and GitLab, PGP email) using the YubiKey OpenPGP smartcard.
  2. A complete, offline recovery path: secret keys on a LUKS-encrypted USB stick.
  3. Non-secret configuration managed through the repository's ansible/ role.

Decisions (confirmed 2026-06-24)

Decision Choice Rationale
Approach A - GPG/OpenPGP (not SSH ed25519-sk) Secret keys are exportable, so a true recovery backup on USB is possible. Matches the "pgp" requirement.
Keygen env Dedicated air-gapped Debian-Live (internal disk disconnected) drduh gold standard; the Certify key never touches a networked machine.
Key identity Michael Wegener <mw@satware.com> Matches git config user.email/name.
Algorithms Certify ed25519; Subkeys ed25519 (S), cv25519 (E), ed25519 (A) 2026 modern; avoids RSA-deprecation for the SSH auth subkey. RSA-4096 is the fallback.
Touch policy Required for sign + auth Defeats malware key use; YubiKey blinks for a physical tap.
Subkey expiry 2 years (Certify never expires) drduh lifecycle; renewable offline.
Push transport HTTPS + GPG-sign (unchanged) Lowest risk; signing is transport-independent. SSH-via-auth-subkey deferred.
Config mgmt Fold non-secret config into ansible/ Repo convention; reproducible.
Backup target Type-C USB /dev/sdc (wipe confirmed acceptable) + a 2nd USB later Requirement #3.
2nd YubiKey Scheduled as Phase 9 follow-up (after this key works) Redundancy + travel spare.

Architecture - two environments

  • Air-gapped (Debian-Live, no internal disk): generate Certify + Subkeys, move Subkeys onto the YubiKey, LUKS-encrypt secrets to the USB, export the public key. The Certify key never leaves this environment.
  • This workstation (Manjaro mw-manjaro-pf): import the public key only, configure git/gpg-agent, sign. Private material stays on the YubiKey.

Pre-flight facts (current state, verified 2026-06-24)

  • YubiKey 5 NFC, serial 30836069, FW 5.7.1, OpenPGP app empty, sign-PIN blocked (3 0 3), PIV factory-defaults/empty, OATH empty.
  • FIDO2 has 4 credentials (incl. github.com -> satwareAG-ironMike).
  • gh auth token invalid -> re-login required.
  • USB /dev/sdc1 = dirty FAT32 (errors=remount-ro) -> fsck, then repartition.
  • Tools present: ykman, ykinfo, gpg, pcscd; gpg-agent.conf already has enable-ssh-support.

Phase 0 - Prep on this workstation (read/config only; no key material yet)

  1. Fix GitHub auth + confirm tooling:
    gh auth login -h github.com            # re-auth (browser/token)
    gh auth status
    git --version                          # >=2.34 fine; GPG works on any
    pacman -Q gnupg pcscd yubikey-manager  # all present (verified earlier)
    
  2. Check the USB stick holds nothing needed, then dry-run a repair:
    ls "/run/media/mw/Type-C USB"
    sudo fsck.vfat -n /dev/sdc1            # review output; do NOT -a yet
    
  3. Identify which spare USB will carry the Debian-Live image (Phase 1) and which 2nd USB will later hold the mirrored backup.

Phase 1 - Build the air-gapped keygen USB (drduh method)

  1. Download Debian-Live + signature, verify before trusting:
    IMG=https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid
    curl -fLO "$IMG/SHA512SUMS" -O "$IMG/SHA512SUMS.sign"
    curl -fLO "$IMG/$(awk '/xfce.iso$/ {print $2}' SHA512SUMS)"
    gpg --keyserver hkps://keyserver.ubuntu.com:443 \
        --recv DF9B9C49EAA9298432589D76DA87E80D6294BE9B
    gpg --verify SHA512SUMS.sign SHA512SUMS         # MUST say "Good signature"
    grep "$(sha512sum debian-live-*-amd64-xfce.iso)" SHA512SUMS
    
  2. Write to a spare USB (not the backup stick):
    sudo dd if=debian-live-*-amd64-xfce.iso of=/dev/sdX bs=4M status=progress; sync
    

Phase 2 - Boot air-gapped, generate keys

  1. Power off, disconnect the internal NVMe, boot the Debian-Live USB. Network can stay off.
  2. Install tools + hardened config in a tmpfs GNUPGHOME:
    sudo apt update && sudo apt -y install gnupg2 scdaemon pcscd yubikey-manager
    export GNUPGHOME=$(mktemp -d -t $(date +%Y.%m.%d)-XXXX)
    cd "$GNUPGHOME"
    wget https://raw.githubusercontent.com/drduh/YubiKey-Guide/master/config/gpg.conf
    
  3. Create a strong Certify passphrase (write it down; store it apart from the USB; record in Bitwarden):
    CERTIFY_PASS=$(LC_ALL=C tr -dc "A-Z2-9" </dev/urandom | tr -d "IOUS5" \
      | fold -w4 | paste -sd- - | head -c29); printf "\n$CERTIFY_PASS\n\n"
    
  4. Certify key (never expires):
    IDENTITY="Michael Wegener <mw@satware.com>"
    echo "$CERTIFY_PASS" | gpg --batch --passphrase-fd 0 \
      --quick-generate-key "$IDENTITY" ed25519 cert never
    KEYFP=$(gpg -k --with-colons "$IDENTITY" | awk -F: '/^fpr:/{print $10; exit}')
    
  5. Subkeys (2-year expiry): sign ed25519, encrypt cv25519, auth ed25519:
    echo "$CERTIFY_PASS" | gpg --batch --pinentry-mode=loopback --passphrase-fd 0 --quick-add-key "$KEYFP" ed25519 sign 2y
    echo "$CERTIFY_PASS" | gpg --batch --pinentry-mode=loopback --passphrase-fd 0 --quick-add-key "$KEYFP" cv25519 encrypt 2y
    echo "$CERTIFY_PASS" | gpg --batch --pinentry-mode=loopback --passphrase-fd 0 --quick-add-key "$KEYFP" ed25519 auth 2y
    gpg -K      # expect [C] + ssb [S] [E] [A]
    

Phase 3 - Backup to the USB stick (requirement #3) - LUKS

Use the Type-C USB stick (/dev/sdc), repartitioned: LUKS partition for secrets + small plaintext partition for the public key.

  1. Export everything:
    KEYID=${KEYFP: -16}
    echo "$CERTIFY_PASS" | gpg -o "$GNUPGHOME/$KEYID-Certify.key" --batch --pinentry-mode=loopback --passphrase-fd 0 --armor --export-secret-keys "$KEYID"
    echo "$CERTIFY_PASS" | gpg -o "$GNUPGHOME/$KEYID-Subkeys.key" --batch --pinentry-mode=loopback --passphrase-fd 0 --armor --export-secret-subkeys "$KEYID"
    gpg -o "$GNUPGHOME/$KEYID-$(date +%F).asc" --armor --export "$KEYID"
    
  2. Partition + LUKS-encrypt (confirm /dev/sdc is the right device - destructive):
    LUKS_PASS=$(LC_ALL=C tr -dc "A-Z2-9" </dev/urandom | tr -d "IOUS5" | fold -w4 | paste -sd- - | head -c29)
    sudo dd if=/dev/zero of=/dev/sdc bs=4M count=1
    sudo fdisk /dev/sdc <<EOF
    g
    n
    1
    
    +50M
    n
    2
    
    
    w
    EOF
    echo "$LUKS_PASS" | sudo cryptsetup -q luksFormat /dev/sdc1
    echo "$LUKS_PASS" | sudo cryptsetup luksOpen /dev/sdc1 gnupg-secrets
    sudo mkfs.ext2 /dev/mapper/gnupg-secrets
    sudo mkfs.vfat /dev/sdc2
    sudo mkdir -p /mnt/sec /mnt/pub
    sudo mount /dev/mapper/gnupg-secrets /mnt/sec
    sudo mount /dev/sdc2 /mnt/pub
    sudo cp -av "$GNUPGHOME" /mnt/sec/                       # encrypted secrets
    gpg --armor --export "$KEYID" | sudo tee "/mnt/pub/$KEYID-$(date +%F).asc" >/dev/null
    sudo umount /mnt/sec /mnt/pub
    sudo cryptsetup luksClose gnupg-secrets
    
  3. Record LUKS_PASS in Bitwarden (separate from the Certify passphrase).
  4. Repeat Phase 3 on a 2nd USB and store both offline in separate locations.

Phase 4 - Transfer Subkeys to the YubiKey

  1. Reset the OpenPGP app (sign-PIN is blocked; app is empty, so nothing is lost):
    ykman openpgp reset -f        # confirm when ready
    gpg --card-status             # retry counters should read 3 3 3
    
  2. Change PINs from defaults and set cardholder identity. Generate your own ADMIN_PIN (8 digits) and USER_PIN (6 digits); store in Bitwarden:
    gpg --change-pin      # menu: 3 = admin PIN (12345678 -> $ADMIN_PIN)
    gpg --change-pin      # menu: 1 = user PIN  (123456   -> $USER_PIN)
    gpg --edit-card       # admin -> login -> "Michael Wegener <mw@satware.com>"
    
  3. Move subkeys onto the card, then require touch:
    gpg --edit-key "$KEYID"        # key 1 -> keytocard (sig=1)
                                   # key 2 -> keytocard (enc=2)
                                   # key 3 -> keytocard (auth=3)
                                   # save
    ykman openpgp keys set-touch sig on -f -a "$ADMIN_PIN"
    ykman openpgp keys set-touch dec on -f -a "$ADMIN_PIN"
    ykman openpgp keys set-touch aut on -f -a "$ADMIN_PIN"
    gpg -K                         # ssb> markers confirm subkeys are on the card
    

Phase 5 - Configure this workstation (public key only)

  1. Copy the public .asc from the USB plaintext partition, import, trust:
    sudo mkdir -p /mnt/pub && sudo mount /dev/sdc2 /mnt/pub
    gpg --import /mnt/pub/*.asc
    gpg --edit-key "$KEYID"        # trust -> 5 (ultimate) -> y -> save
    
  2. Git signing (global; uses the sign subkey):
    SIGNID=$(gpg -k --with-colons "$KEYID" | awk -F: '/^sub:/ && /s/ {split($0,a,":"); print a[5]}')
    git config --global gpg.format openpgp
    git config --global user.signingkey "$SIGNID!"
    git config --global commit.gpgsign true
    git config --global tag.gpgsign true
    
  3. Agent/scdaemon (the disable-ccid line prevents the scdaemon-vs-pcscd lock observed during detection):
    echo "disable-ccid" >> ~/.gnupg/scdaemon.conf
    # ~/.gnupg/gpg-agent.conf already has: enable-ssh-support, cache-ttls
    gpgconf --kill gpg-agent
    

Phase 6 - Register the public key with GitHub

gpg --armor --export "$KEYID" | gh gpg-key add -     # after `gh auth login`
# or paste at github.com/settings/keys

Verify end-to-end: create a test commit, git push, the commit shows Verified on GitHub.

Phase 7 - Fold non-secret config into Ansible (repo convention)

  • New role ansible/roles/gpg_signing/ templating:
    • ~/.gnupg/gpg.conf, ~/.gnupg/gpg-agent.conf, ~/.gnupg/scdaemon.conf
    • the four git config --global signing lines from Phase 5.2
  • Commit the public key to ansible/files/mw-satware-<KEYID>.asc (public, safe to track).
  • PINs, Certify and LUKS passphrases -> Bitwarden only, never the repo (zero-trust rule). Use Ansible Vault vars if a task must consume them.
  • Add the role to ansible/workstation.yml; verify with ansible-playbook ansible/workstation.yml --check.

Phase 8 - Recovery & verification

  • Recovery: plug a backup USB -> cryptsetup luksOpen /dev/sdc1 gnupg-secrets -> restore $KEYID-Subkeys.key onto a new YubiKey via keytocard. The Certify key is needed only to rotate/renew subkeys.
  • Final verification checklist:
    • gpg --card-status shows the three subkeys with correct fingerprints.
    • git commit -S --allow-empty -m "test" prompts for a YubiKey tap and succeeds.
    • git push; GitHub shows the commit as Verified.
    • git tag -s v-test && git verify-tag v-test succeeds.

Phase 9 - Second YubiKey (follow-up, after this key works)

  • Mirror the same Subkeys onto a backup YubiKey from the LUKS backup ($KEYID-Subkeys.key -> keytocard), set identical PINs and touch policy.
  • Register it on GitHub as an additional GPG signing key.
  • Re-register its FIDO2/WebAuthn handles for the relying parties already on the primary key (github.com, auth.satware.ai, git.g-b-c.de, google.com).
  • Create a 2nd backup USB mirroring Phase 3 and store it off-site.

Secrets handling (per repo zero-trust rule)

Item Stored where
Public key ansible/files/*.asc (committed) + USB plaintext partition
Certify key + Subkeys (encrypted) LUKS partition on USB (offline, 2 copies)
Certify passphrase, LUKS passphrase, User PIN, Admin PIN Bitwarden only (bitwarden.jantec.xyz), cached locally in gnome-keyring

References

  • GitHub: About commit signature verification (GPG/SSH/S-MIME).
  • GitHub: Generating a new SSH key (incl. ed25519-sk security-key variant).
  • drduh/YubiKey-Guide (canonical GPG-on-YubiKey walkthrough; LUKS backup model).