--- description: >- 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. tags: - plan - security - yubikey - gpg - github last_updated: '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 ` | 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: ```bash 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: ```bash 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**: ```bash 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): ```bash 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**: ```bash 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): ```bash CERTIFY_PASS=$(LC_ALL=C tr -dc "A-Z2-9" /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): ```bash 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: ```bash 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 " ``` 3. Move subkeys onto the card, then require touch: ```bash 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: ```bash 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**): ```bash 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): ```bash 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 ```bash 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-.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).