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.
This commit is contained in:
mw
2026-06-24 16:54:08 +02:00
parent d9392447f9
commit 5a714e6256
@@ -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 <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:
```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/urandom | tr -d "IOUS5" \
| fold -w4 | paste -sd- - | head -c29); printf "\n$CERTIFY_PASS\n\n"
```
4. **Certify key** (never expires):
```bash
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`:
```bash
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:
```bash
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):
```bash
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):
```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 <mw@satware.com>"
```
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-<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).