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.
12 KiB
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. |
|
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
- Hardware-backed commit/tag signing for GitHub (and GitLab, PGP email) using the YubiKey OpenPGP smartcard.
- A complete, offline recovery path: secret keys on a LUKS-encrypted USB stick.
- 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, configuregit/gpg-agent, sign. Private material stays on the YubiKey.
Pre-flight facts (current state, verified 2026-06-24)
- YubiKey 5 NFC, serial
30836069, FW5.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 authtoken invalid -> re-login required.- USB
/dev/sdc1= dirty FAT32 (errors=remount-ro) ->fsck, then repartition. - Tools present:
ykman,ykinfo,gpg,pcscd;gpg-agent.confalready hasenable-ssh-support.
Phase 0 - Prep on this workstation (read/config only; no key material yet)
- 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) - 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 - 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)
- 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 - 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
- Power off, disconnect the internal NVMe, boot the Debian-Live USB. Network can stay off.
- 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 - 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" - 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}') - Subkeys (2-year expiry): sign
ed25519, encryptcv25519, authed25519: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.
- 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" - Partition + LUKS-encrypt (confirm
/dev/sdcis 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 - Record
LUKS_PASSin Bitwarden (separate from the Certify passphrase). - Repeat Phase 3 on a 2nd USB and store both offline in separate locations.
Phase 4 - Transfer Subkeys to the YubiKey
- 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 - Change PINs from defaults and set cardholder identity. Generate your own
ADMIN_PIN(8 digits) andUSER_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>" - 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)
- Copy the public
.ascfrom 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 - 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 - Agent/scdaemon (the
disable-ccidline 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 --globalsigning 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 withansible-playbook ansible/workstation.yml --check.
Phase 8 - Recovery & verification
- Recovery: plug a backup USB ->
cryptsetup luksOpen /dev/sdc1 gnupg-secrets-> restore$KEYID-Subkeys.keyonto a new YubiKey viakeytocard. The Certify key is needed only to rotate/renew subkeys. - Final verification checklist:
gpg --card-statusshows 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-testsucceeds.
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-sksecurity-key variant). - drduh/YubiKey-Guide (canonical GPG-on-YubiKey walkthrough; LUKS backup model).