diff --git a/docs/plans/2026-06-24-yubikey-gpg-github-signing.md b/docs/plans/2026-06-24-yubikey-gpg-github-signing.md new file mode 100644 index 0000000..486da43 --- /dev/null +++ b/docs/plans/2026-06-24-yubikey-gpg-github-signing.md @@ -0,0 +1,279 @@ +--- +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).