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.
280 lines
12 KiB
Markdown
280 lines
12 KiB
Markdown
---
|
|
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).
|