Mein modernes OpenPGP-Setup auf macOS mit zwei YubiKeys

Dieses Tutorial ist sehr lang. Ich habe versucht es in sinnvolle Blöcke aufzuteilen, die jeweils als Lesezeichen gespeichert werden können. Auf einem Tablet / Desktop steht ein Inhaltsverzeichns bereit.

Ich wollte mein bisheriges PGP-Setup einmal grundsätzlich neu aufsetzen. Nicht einfach nur einen neuen Schlüssel erzeugen, sondern eine Architektur bauen, die ich auch in einigen Jahren noch guten Gewissens verwenden kann:

Das wichtigste vorab: Die Firmware von Yubikeys lässt sich nicht aktualisieren. Die Vorraussetzung für Ellyptic-Curve Algorithmen in den OpenPGP Slots des YubiKey ist Firmware 5.23 oder höher.

Das Ergebnis ist dieses Setup:

Wichtigster Kernpunkt dieses Setup:

Der private Primary Key befindet sich nicht in meinem normalen GnuPG-Keyring. Die drei Subkeys liegen dagegen auf beiden YubiKeys.

Dieser Blogbeitragt ist der (hoffentlich) komplette Weg dorthin – inklusive einiger Fehler, die ich während der Einrichtung gemacht habe und die man sich bei einem sauberen Neuaufbau sparen kann.

Hinweis: „State of the Art“ bedeutet hier für mich: modern, praktisch, interoperabel und mit einem YubiKey 5 nutzbar. Das Setup ist nicht post-quantum-sicher. Neuere GnuPG-Versionen besitzen inzwischen auch PQC-/Hybrid-Verfahren, die OpenPGP-Anwendung eines YubiKey 5 unterstützt diese jedoch nicht. Für ein hardwaregestütztes YubiKey-Setup sind Ed25519 und X25519 daher weiterhin eine sinnvolle Wahl.

Die Schlüsselarchitektur

Ich habe bewusst nicht einen einzelnen Schlüssel mit allen Fähigkeiten erzeugt.

Mein Primary Key besitzt ausschließlich:

Die täglichen Operationen laufen über drei Subkeys:

Konkret:

ZweckAlgorithmusLaufzeit
Primary / CertificationEd255195 Jahre
SigningEd255192 Jahre
Encryptioncv25519 / X255192 Jahre
AuthenticationEd255192 Jahre

Der Primary Key ist damit meine eigentliche kryptografische Identität. Mit ihm kann ich unter anderem neue Subkeys zertifizieren, Ablaufzeiten verlängern oder Identitäten ändern.

Im Alltag brauche ich ihn nicht.

Software

Ich verwende Homebrew, mit diesem Befehl lassen sich alle benötigen Tools installieren:

brew install gnupg pinentry-mac ykman openssh

Für die normale GnuPG-Installation:

mkdir -p ~/.gnupg
chmod 700 ~/.gnupg

Warum ~/.gnupg? Es wäre doch sinnvoll, die meisten Arbeitsschritte direkt auf einem externen Datenträger abzubilden? Das ist richtig. Allerdings hatte ich vielerlei Probleme dieses Setup nutzbar unter macOS zum Laufen zu bekommen. Ich habe es schließlich aufgegeben. Dazu später mehr.

pinentry-mac habe ich als PIN-/Passphrase-Dialog konfiguriert:

echo "pinentry-program $(brew --prefix)/bin/pinentry-mac" >> ~/.gnupg/gpg-agent.conf

gpgconf --kill gpg-agent
gpg-connect-agent /bye

Testen lässt sich das Ganze mit:

gpg-connect-agent 'GETINFO version' /bye

Erwartete Ausgabe (ungefähr):
D 2.x.x
OK

Wichtig: YubiKeys prüfen

Damit dieses Setup funktioniert, brauchen die YubiKeys mindestens Firmware 5.2.3, damit ECC-Unterstüzung vorhanden ist. Nicht jeder YubiKey hat diese Version, wodurch sich insbesondere ältere Keys nicht für dieses Setup eignen. Ich verwende zwei YubiKey 5C NFC. Informationen über diese Keys lassen sich mit ykman info und ykman openpgp info anzeigen.

Ein wichtiger Hinweis, denn es gibt direkt die eine kleine Ernüchterung: Die Firmware eines YubiKey 5 lässt sich nicht aktualisieren.

Sollte also einer oder mehrere Keys eine zu niedrige Firmware aufweisen, ist dieser Key leider nicht für dieses Setup verwendbar.

Den Primary Key erzeugen - ein Kampf gegen MacOS

Ich wollte meinen Primary Key ursprünglich direkt auf einer verschlüsselten externen SSD erzeugen: /Volumes/T7Shield/pgp. Dafür hatte ich GNUPGHOME auf dieses Volume gesetzt.

Das führte unter macOS allerdings zu:

gpg-connect-agent: Kein aktiver gpg-agent
...
can't connect to the gpg-agent
IPC "connect" Aufruf fehlgeschlagen

Ein Versuch mit:

gpgconf --create-socketdir

endete mit:

gpgconf: no /run/user dir
gpgconf: using homedir as fallback
gpgconf: error creating socket directory

Das ist auf macOS nicht überraschend: gpgconf --create-socketdir ist für Socket-Verzeichnisse unter /run/user beziehungsweise /var/run/user gedacht – eine Linux-typische Struktur.

Die robustere macOS-Lösung

Ich verwende daher für Key-Management einen separaten lokalen GnuPG-Keyring:

OFFLINE="$HOME/.gnupg-pgp-root"

mkdir -p "$OFFLINE"
chmod 700 "$OFFLINE"

Pinentry muss auch für dieses separate Home konfiguriert werden:

printf 'pinentry-program %s\n' "$(brew --prefix)/bin/pinentry-mac" > "$OFFLINE/gpg-agent.conf"

chmod 600 "$OFFLINE/gpg-agent.conf"

Danach sollte der gpg-agent gekilled werden, um die neuen Konfigurationen zu laden:

gpgconf --homedir "$OFFLINE" --kill gpg-agent
gpgconf --homedir "$OFFLINE" --launch gpg-agent

Diese Änderungen sollten unbedingt getestet werden, etwa mit:

gpg-connect-agent --homedir "$OFFLINE" 'GETINFO version' /bye

Alternativ reicht meistens bereits der erste GPG-Aufruf, da gpg-agent automatisch gestartet wird.

Sicherheitsrelevante Einschränkung

Ein lokales temporäres Key-Verzeichnis ist auf macOS funktional deutlich robuster als ein GNUPGHOME auf einem externen Volume. Es ist aber wichtig, sich nichts vorzumachen, rm -rf ~/.gnupg-pgp-root ist auf einer modernen SSD mit APFS keine garantierte forensische sichere Löschung.

Für ein besonders hohes Bedrohungsmodell würde ich die Root-Key-Erzeugung daher auf einem dedizierten Offline-System durchführen.

Für mein Setup habe ich zumindest:

Die Schlüssel erzeugen

Den Primary Key erzeugen

Hier ist ein Detail extrem wichtig, das mir später tatsächlich Probleme gemacht hat: die OpenPGP-UID. Für einen Primary-Key wird diese in einer korrekten Form benötigt, damit beispielsweise GitHub diese auch korrekt zuordnen kann. Eine UID benötigt NAME und EMAIL.

Sie ist beispielsweise so aufgebaut:

NAME="Max Mustermann"
EMAIL="info@maxmustermann.de"
UID="$NAME <$EMAIL>"

Also am Ende Max Mustermann <info@maxmustermann.de. Die spitzen Klammern sind nicht kosmetisch. Sie werden explizit benötigt. Daher hier noch einmal zum Vergleich:

Korrekt:

Max Mustermann <info@maxmustermann.de>

Nicht:

Max Mustermann info@maxmustermann.de

Ich habe anfangs den schweren Fehler begangen, darauf nicht zu achten, was dazu führte, das ich manuell die UIDs in meinem primären Cert-Key ändern musste, was natürlich einen Rückruf des Keys zur Folge hatte. Seid nicht wie ich.

Wenn alles erledigt und geprüft ist, jetzt den Certification-Key erzeugen:

gpg --homedir "$OFFLINE" --quick-generate-key "$UID" ed25519 cert 5y

cert sorgt dafür, dass der Primary ausschließlich die Certification-Fähigkeit bekommt.

Den Fingerprint des Primary-Keys bestimmen

gpg --homedir "$OFFLINE" --list-secret-keys --keyid-format long --with-subkey-fingerprint

Mein Primary-Fingerprint lautet:

F0EE915E82B3E9CBCFA15121091DEF5773661ED3

Fingerprints sind nicht geheim. Ich speichere ihn für die weiteren Befehle, damit er nicht dauernd kopiert werden muss:

export FPR="F0EE915E82B3E9CBCFA15121091DEF5773661ED3"

Wer diesem Tutorial folgt, ersetzt ihn natürlich durch den eigenen Fingerprint.

Den Signing-Subkey erzeugen

gpg --homedir "$OFFLINE" --quick-add-key "$FPR" ed25519 sign 2y

Bitte den Fingerprint des Signing-Subkeys notieren.

Den Encryption-Subkey erzeugen

gpg --homedir "$OFFLINE" --quick-add-key "$FPR" cv25519 encr 2y

Bitte den Fingerprint des Encryption-Subkeys notieren.

cv25519 ist die von GnuPG verwendete OpenPGP-Bezeichnung für den Curve25519-basierten Encryption-Key; auf dem YubiKey landet er als X25519-Decipher-Key.

Den Authentication-Subkey erzeugen

gpg --homedir "$OFFLINE" --quick-add-key "$FPR" ed25519 auth 2y

Bitte den Fingerprint des Authentication-Subkeys notieren.

Die gesamte Struktur kontrollieren

Mit folgendem Befehl lassen sich die erzeugten Keys auflisten:

gpg --homedir "$OFFLINE" --list-secret-keys --keyid-format long --with-subkey-fingerprint

In meinem Fall:

sec   ed25519/... [C]
      F0EE915E82B3E9CBCFA15121091DEF5773661ED3

uid   Florian Schuttkowski <mail@florianschuttkowski.com>

ssb   ed25519/... [S]
      7A86ADC8D74BE03EE37F085E7F3A023218DB3673

ssb   cv25519/... [E]
      805E415E16810EA90718A68E15745B3844922823

ssb   ed25519/... [A]
      84471B2E9D08B5569531F85AC025415EB4BCF1C2

Genau diese Struktur ist das Ziel.

Was ich bezüglich meiner UID verbockt hatte, im Detail

Bei meinem ersten Versuch hatte ich versehentlich diese UID erzeugt:

Florian Schuttkowski mail@florianschuttkowski.com

statt:

Florian Schuttkowski <mail@florianschuttkowski.com>

GnuPG selbst konnte damit arbeiten. GitHub später nicht. GitHub meldete bei signierten Commits:

The email in this signature doesn’t match the committer email.

Der Schlüssel war kryptografisch korrekt – die Mailadresse war in OpenPGP aber nicht als E-Mail-Feld der UID hinterlegt. Deshalb im Tutorial unbedingt von Anfang an UID="$NAME <$EMAIL>" verwenden.

Das Backup – bevor ein YubiKey beschrieben wird

Das ist einer der wichtigsten Punkte des gesamten Setups: Vor keytocard muss ein vollständiges Backup existieren. Denn keytocard kopiert den privaten Schlüssel nicht einfach zusätzlich auf die Smartcard. GnuPG ersetzt das lokale Secret-Key-Material im verwendeten Keyring durch einen Smartcard-Stub.

Ein Backup der Keys

Ich verwende meine verschlüsselte T7 Shield:

BACKUP="/Volumes/T7Shield/pgp-backup"

mkdir -p "$BACKUP"
chmod 700 "$BACKUP"

Vollständiges Secret-Key-Backup:

gpg --homedir "$OFFLINE" --armor --output "$BACKUP/full-secret-key.asc" --export-secret-keys "$FPR"

Public Key:

gpg --homedir "$OFFLINE" --armor --output "$BACKUP/public-key.asc" --export "$FPR"

Das Revocation Certificate erzeugen

Zusätzlich erstelle ich, obwohl GnuPG das bereits tut, bewusst ein separates Widerrufszertifikat:

gpg --homedir "$OFFLINE" --armor --output "$BACKUP/revoke.asc" --generate-revocation "$FPR"

Dieses Zertifikat darf nicht veröffentlicht oder versehentlich importiert werden. Wer es besitzt, kann den Key zwar nicht übernehmen, aber widerrufen.

Wie erwähnt, GnuPG erzeugt bei normalen Key-Generierungsverfahren zusätzlich automatisch Revocation-Material unter openpgp-revocs.d/. Das separate revoke.asc macht meinen Recovery-Satz aber deutlich verständlicher.

Die YubiKeys provisionieren

YubiKey #1 vorbereiten

Ich verwende den YubiKey mit Firmware 5.8.0 als Daily Driver. Es sollte dabei immer nur ein YubiKey gleichzeitig eingesteckt sein. Vor dem Reset unbedingt sicherstellen, dass sich keine wichtigen OpenPGP-Keys auf dem Gerät befinden. Das lässt sich mit ykman openpgp info anzeigen.

Anschließend:

ykman openpgp reset

Dieser Reset betrifft die OpenPGP-Anwendung des YubiKeys. FIDO2, PIV, OATH usw. werden dadurch nicht zurückgesetzt. Danach gelten wieder die OpenPGP-Defaults die YubiCo für die Keys vergibt:

User PIN:  123456
Admin PIN: 12345678

Wichtig: KDF vor einer PIN-Änderung aktivieren

KDF steht für Key Derivation Function und wird in OpenPGP smart cards wie den YubiKeys dafür genutzt, PINs nicht als Klartext sondern als sichere Hashes zu hinterlegen. Dieses Vorgehen schützt die Keys gegen brute-force-Angriffe und Offenlegung. Hier hatte ich einen weiteren Stolperstein. Die saubere Reihenfolge ist:

  1. OpenPGP-App resetten
  2. KDF aktivieren
  3. PINs ändern
  4. Key Attributes setzen
  5. Keys übertragen

Nicht KDF erst aktivieren, nachdem bereits Keys auf der Karte liegen. Das hat mich Stunden gekostet. grmll. KDF lässt sich auf folgende Weise aktivieren:

gpg --card-edit

Dann:

gpg/card> admin
gpg/card> kdf-setup

Die PINs der OpenPGP Smartcard ändern

gpg/card> passwd

Dort ändern:

Die Admin-PIN sollte mindestens acht Zeichen lang sein. Ich verwende deutlich mehr.

Warum KDF zuerst?

Bei meinem ersten Versuch wurde die Admin-PIN nach Aktivierung von KDF plötzlich nicht mehr akzeptiert. Das ist eine historisch bekannte Fehlerklasse bei GnuPG/OpenPGP-Karten, insbesondere bei problematischen PIN-Längen. Da noch keine Keys auf dem YubiKey lagen, war die Lösung einfach:

ykman openpgp reset

Leider bedeutet das die Provisionierung sauber neu beginnen zu müssen.

Darum:

KDF zuerst, danach eine ausreichend lange Admin-PIN.

Kontrolle:

gpg --card-status

Dort sollte stehen:

KDF setting ......: on

Key Attributes auf Curve25519 umstellen

Ein frisch zurückgesetzter YubiKey zeigt bei mir:

Key attributes ...: rsa2048 rsa2048 rsa2048

Wir wollen aber ed25519 cv25519 ed25519.

Dafür:

gpg --card-edit
gpg/card> admin
gpg/card> key-attr

GnuPG/YubiKey interpretieren das anschließend passend zum jeweiligen Slot:

Signature      → Ed25519
Encryption     → X25519/cv25519
Authentication → Ed25519

Kontrolle:

gpg --card-status

Erwartete Ausgabe:

Key attributes ...: ed25519 cv25519 ed25519

Wichtig: Die Algorithm Attributes sollte man vor dem Import der Keys festlegen. Ein späteres inkompatibles Ändern der Attributes kann vorhandenes Key-Material auf der Karte unbrauchbar machen beziehungsweise löschen.

Nicht aus dem Root-Keyring direkt auf den YubiKey übertragen

Das ist eine weitere wichtige Designentscheidung.

Auf keinen Fall ~/.gnupg-pgp-root für keytocard verwenden. Stattdessen erstelle ich einen temporären Transfer-Keyring.

Warum?

Weil keytocard die lokalen privaten Schlüssel im aktuellen GnuPG-Keyring durch Smartcard-Stubs ersetzt.

Der Root-Keyring soll aber vollständig erhalten bleiben, bis Backup und Recovery final verifiziert sind.

Der Transfer-Keyring für YubiKey #1

Als erstes ein neuen, leeren Keyring erzeugen, der für den Transfer auf den YubiKey genutzt werden soll.

TRANSFER="$HOME/.gnupg-yubikey-transfer-1"

mkdir -p "$TRANSFER"
chmod 700 "$TRANSFER"

Pinentry muss ebenfalls angepasst werden:

printf 'pinentry-program %s\n' "$(brew --prefix)/bin/pinentry-mac" > "$TRANSFER/gpg-agent.conf"

chmod 600 "$TRANSFER/gpg-agent.conf"

Jetzt importiere ich das Backup, nicht den Root-Keyring:

gpg --homedir "$TRANSFER" --import "$BACKUP/full-secret-key.asc"

Kontrollieren lässt sich das mit:

gpg --homedir "$TRANSFER" --list-secret-keys --keyid-format long --with-subkey-fingerprint

Es müssen wieder alle vier privaten Keys vorhanden sein: [C], [S], [E] und [A].

Signing-Key auf YubiKey #1 übertragen

gpg --homedir "$TRANSFER" --edit-key "$FPR"

Dann prüfen, welche Keys, welche Nummer erhalten haben:

gpg> list

Die nachfolgenden Nummern sollte man nicht blind übernehmen, sondern anhand der angezeigten Usage-Flags kontrollieren und entsprechend anpassen.

  1. Signing auswählen: gpg> key 1
  2. gpg> keytocard, Auswahl: Signature key
  3. Danach wieder abwählen: gpg> key 1

Encryption-Key auf YubiKey #1 übertragen

Die nachfolgenden Nummern sollte man nicht blind übernehmen, sondern anhand der angezeigten Usage-Flags kontrollieren und entsprechend anpassen.

  1. Signing auswählen: gpg> key 2
  2. gpg> keytocard, Auswahl: Encryption key
  3. Danach wieder abwählen: gpg> key 2

Authentication-Key auf YubiKey #1 übertragen

Die nachfolgenden Nummern sollte man nicht blind übernehmen, sondern anhand der angezeigten Usage-Flags kontrollieren und entsprechend anpassen.

  1. Signing auswählen: gpg> key 3
  2. gpg> keytocard, Auswahl: Authentication key
  3. Danach wieder abwählen: gpg> key 3

Wurden alle Keys übertragen, abschließend die Änderungen auf die Smartcard schreiben:

gpg> save

Fingerprints auf dem YubiKey kontrollieren

gpg --card-status

Diese Fingerprints müssen exakt mit den drei Subkeys übereinstimmen.

Touch für jede kryptografische Operation aktivieren

Ich möchte nicht, dass Malware im Hintergrund beliebig Signaturen oder Entschlüsselungen über meinen YubiKey durchführen kann. Deshalb muss der YubiKey so konfiguriert werden, dass eine Verwendung eine physische Berührung erfordert:

ykman openpgp keys set-touch sig on
ykman openpgp keys set-touch enc on
ykman openpgp keys set-touch aut on

Kontrolle:

gpg --card-status

Bei UIF setting sollte sinngemäß stehen:

Sign=on Decrypt=on Auth=on

Ich verwende bewusst on, nicht fixed. fixed kann später nicht einfach wieder deaktiviert werden, ohne das jeweilige private Key-Material auf der Karte zu ersetzen.

Prüfen, ob alles funktioniert

Signing testen

Um sicherzustellen, dass alles ordnungsgemäß funktioniert, sollten alle Funktionalitäten einmal geprüft werden. Dafür brauchen wir eine beliebige Testdatei:

echo "OpenPGP YubiKey Test" > /tmp/pgp-test.txt

Diese Datei anschließend mit OpenPGP + YubiKey signieren:

gpg --homedir "$TRANSFER" --armor --detach-sign /tmp/pgp-test.txt

Um zu prüfen ob alles funktioniert hat:

gpg --homedir "$TRANSFER" --verify /tmp/pgp-test.txt.asc /tmp/pgp-test.txt

Idealerweise:

Korrekte Signatur von "Florian Schuttkowski ..."

Im temporären Transfer-Keyring erscheint zunächst zusätzlich:

WARNUNG: Dieser Schlüssel trägt keine vertrauenswürdige Signatur

Das bedeutete nicht, dass die Signatur falsch war. Dem neuen Transfer-Keyring fehlte lediglich das lokale Ownertrust. Das beheben wir später.

Encryption und Decryption testen

AUch dafür brauchen wir eine beliebige Datei:

echo "Secret YubiKey Test" > /tmp/pgp-secret.txt

Verschlüsseln mit:

gpg --homedir "$TRANSFER" --armor --output /tmp/pgp-secret.txt.asc --encrypt --recipient "$FPR" /tmp/pgp-secret.txt

Ascnhließend das Original entfernen:

rm /tmp/pgp-secret.txt

Und nun wieder entschlüsseln:

gpg --homedir "$TRANSFER" --output /tmp/pgp-secret.txt --decrypt /tmp/pgp-secret.txt.asc

Die Datei öffnen und verifizieren, dass ihr Inhalt korrekt ist.

Den Authentication-Subkey testen

Dieses Verhalten lässt sich am einfachsten über SSH testen. Der [A]-Subkey kann von GnuPG als SSH-Key verwendet werden.

Public Key exportieren:

gpg --homedir "$TRANSFER" --export-ssh-key "$FPR"

Um ihn testweise über gpg-agent als SSH-Agent sichtbar zu machen:

echo "enable-ssh-support" >> "$TRANSFER/gpg-agent.conf"

Agent neu starten:

gpgconf --homedir "$TRANSFER" --kill gpg-agent
gpgconf --homedir "$TRANSFER" --launch gpg-agent

SSH-Socket setzen:

export SSH_AUTH_SOCK="$(
  gpgconf --homedir "$TRANSFER" --list-dirs agent-ssh-socket
)"

Dann:

ssh-add -L

Damit konnte ich verifizieren, dass mein [A]-Subkey grundsätzlich als SSH-Authentication-Key funktioniert. Für meinen normalen SSH-Betrieb habe ich mich später allerdings bewusst für FIDO2 statt OpenPGP-Auth entschieden.

Mehr dazu weiter unten.

YubiKey #2 als echten Cold Spare aufbauen

Für Redundanz wollte ich nicht einfach einen zweiten eigenen Satz Subkeys erzeugen. Insbesondere bei Encryption wäre das problematisch. Wenn jemand heute an meinen Encryption-Key E1 verschlüsselt, kann nur der passende private E1 diese Daten später entschlüsseln. Deshalb besitzen beide YubiKeys dieselben drei OpenPGP-Subkeys.

YubiKey #1 → S1 / E1 / A1
YubiKey #2 → S1 / E1 / A1

Das ist anders als bei FIDO2, wo ich später zwei unterschiedliche Credentials erzeuge.

Den zweiten YubiKey vorbereiten

Ersten YubiKey abziehen. Zweiten einstecken. Dann dieselbe Vorbereitung:

ykman openpgp reset

Danach:

gpg --card-edit
gpg/card> admin
gpg/card> kdf-setup
gpg/card> passwd
gpg/card> key-attr

Wieder:

Signature      → ECC / Curve25519
Encryption     → ECC / Curve25519
Authentication → ECC / Curve25519

Kontrolle:

gpg --card-status
KDF setting ......: on
Key attributes ...: ed25519 cv25519 ed25519

Frischen Transfer-Keyring für YubiKey #2 verwenden

Den ersten Transfer-Keyring darf man nicht einfach weiterverwenden, weil keytocard dort bereits Smartcard-Stubs erzeugt hat.

Deshalb:

TRANSFER2="$HOME/.gnupg-yubikey-transfer-2"

mkdir -p "$TRANSFER2"
chmod 700 "$TRANSFER2"

Pinentry:

printf 'pinentry-program %s\n' "$(brew --prefix)/bin/pinentry-mac" \
  > "$TRANSFER2/gpg-agent.conf"

chmod 600 "$TRANSFER2/gpg-agent.conf"

Dann erneut das vollständige Backup importieren:

gpg --homedir "$TRANSFER2" \
  --import "$BACKUP/full-secret-key.asc"

Und den Key editieren:

gpg --homedir "$TRANSFER2" \
  --edit-key "$FPR"

Und wieder:

→ key 1 → keytocard → Signature → key 1
→ key 2 → keytocard → Encryption → key 2
→ key 3 → keytocard → Authentication → key 3
→ save

Fingerprints beider YubiKeys vergleichen und Touch-Policy aktivieren

gpg --card-status

Die drei Fingerprints müssen exakt mit YubiKey #1 übereinstimmen. Danach auch auf YubiKey #2:

ykman openpgp keys set-touch sig on
ykman openpgp keys set-touch enc on
ykman openpgp keys set-touch aut on

Der entscheidende Backup-Test

Die bereits mit YubiKey #1 getestete verschlüsselte Datei muss auch YubiKey #2 entschlüsseln können:

gpg --homedir "$TRANSFER2" \
  --decrypt /tmp/pgp-secret.txt.asc

Das funktionierte. Zusätzlich habe ich mit YubiKey #2 noch einmal signiert. Damit ist er nicht nur theoretisch ein Backup, sondern tatsächlich ein getesteter Cold Spare.

Ein wichtiger GnuPG-Haken bei identischen Smartcards

GnuPG speichert in seinen lokalen Smartcard-Stubs auch Informationen darüber, auf welcher Karte sich der jeweilige Schlüssel befindet. Deshalb sollte man zwei identische YubiKeys nicht wie zwei völlig transparente RAID-Platten betrachten. Mein Modell lautet:

Wenn der Daily Driver kaputtgeht und GnuPG beim Backup-Key weiterhin nach der alten Karten-Seriennummer fragt, muss der lokale Smartcard-Stub neu gelernt werden.

Dazu zuerst:

gpg -K --with-keygrip "$FPR"

Die Keygrips der drei Subkeys notieren. Im normalen ~/.gnupg sind für diese Identität nur Smartcard-Stubs vorhanden. Falls ein Wechsel auf die Backup-Karte nötig wird, können die entsprechenden Stub-Dateien unter ~/.gnupg/private-keys-v1.d/ entfernt werden.

Danach:

gpgconf --kill gpg-agent

Backup-YubiKey einstecken und:

gpg --card-status

ausführen. Dabei sollte GnuPG die Karte neu lernen.

Hierbei sollte man äußerst vorsichtig arbeiten und niemals wahllos Dateien aus private-keys-v1.d löschen.

Aufräum-Arbeiten

Den normalen macOS-Keyring einrichten

Nach erfolgreicher Provisionierung wechsel ich zurück auf mein default Verzeichnis ~/.gnupg. Falls GNUPGHOME gesetzt ist:

unset GNUPGHOME
gpgconf --list-dirs homedir

Jetzt nur den Public Key importieren:

gpg --import "$BACKUP/public-key.asc"

Daily-Driver-YubiKey einstecken:

gpg --card-status
gpg --list-secret-keys --keyid-format long --with-subkey-fingerprint

Sinngemäß muss hier stehen:

sec#  ed25519 ... [C]
ssb>  ed25519 ... [S]
ssb>  cv25519 ... [E]
ssb>  ed25519 ... [A]

Wichtig dabei: Der private Primary Key ist nicht im normalen Alltags-Keyring vorhanden. Das lässt sich am # vor dem primären Key erkennen.

Backup wirklich wiederherstellen

Jetzt kommt der Test, den ich für wichtiger halte als jede ls-Ausgabe. Einen komplett frischen Restore-Keyring erzeugen:

RESTORE="$HOME/.gnupg-pgp-restore-test"

mkdir -p "$RESTORE"
chmod 700 "$RESTORE"

Backup importieren:

gpg --homedir "$RESTORE" --import "$BACKUP/full-secret-key.asc"
gpg --homedir "$RESTORE" --list-secret-keys --keyid-format long --with-subkey-fingerprint

Jetzt beide YubiKeys abziehen.

Dann:

echo "Offline restore test" > /tmp/offline-restore-test.txt

Und mit dem restaurierten Software-Key signieren:

gpg --homedir "$RESTORE" --armor --detach-sign /tmp/offline-restore-test.txt

Wenn das ohne YubiKey funktioniert, ist bewiesen:

Das Backup enthält echtes privates Schlüsselmaterial und nicht nur Smartcard-Stubs.

Anschließend noch die Signatur prüfen:

gpg --homedir "$RESTORE" --verify /tmp/offline-restore-test.txt.asc /tmp/offline-restore-test.txt

Erst danach den Test-Keyring entfernen:

rm -rf "$RESTORE"

Transfer-Keyrings entfernen

Nachdem beide YubiKeys und das Backup erfolgreich getestet wurden:

rm -rf ~/.gnupg-yubikey-transfer-1
rm -rf ~/.gnupg-yubikey-transfer-2

Auch den temporären Root-Keyring brauche ich im Alltag nicht mehr. Aber erst nachdem mindestens ein vollständiges Backup zuverlässig getestet wurde. Noch besser: Zwei physisch getrennte Offline-Backups besitzen.

Der Alltag

Ownertrust setzen

Im normalen ~/.gnupg kann die eigene UID nach dem Import zunächst als nicht vertrauenswürdig gelten.

Deshalb:

gpg --edit-key "$FPR"
gpg> trust
5 = ultimatives Vertrauen
gpg> quit

Das verändert nur den lokalen Trust-Store. Es veröffentlicht nichts.

Default-Key konfigurieren

~/.gnupg/gpg.conf mit einem Texteditor öffnen und folgendes hinzufügen:

default-key F0EE915E82B3E9CBCFA15121091DEF5773661ED3

Dabei den Fingerprint durch den eigenen Fingerprint des Primary-Keys ersetzen. Damit kann man anschließend einfach:

gpg --armor --detach-sign datei.txt

verwenden. GnuPG wählt automatisch den gültigen [S]-Subkey.

Git-Commits mit dem YubiKey signieren

Git konfigurieren

Jetzt konfigurieren wir Git so, dass Commits standardmäßig OpenPGP-signiert werden.

git config --global gpg.format openpgp
git config --global user.signingkey "$FPR"
git config --global commit.gpgsign true
git config --global tag.gpgSign true
git config --global gpg.program "$(which gpg)"

# Für zsh:
echo 'export GPG_TTY=$(tty)' >> ~/.zshrc
source ~/.zshrc

Kontrolle:

git config --global --get gpg.format
git config --global --get user.signingkey
git config --global --get commit.gpgsign
git config --global --get tag.gpgSign
git config --global --get gpg.program

Git-Signing lokal testen

Am Besten in einem neuen Repository testen:

mkdir /tmp/gpg-git-test
cd /tmp/gpg-git-test
git init
echo "YubiKey Git signing test" > README.md
git add README.md
git commit -m "Test signed commit"

Der YubiKey sollte blinken und einen Touch verlangen.

Kontrolle:

git log --show-signature -1

Bei mir wurde mit dem tatsächlichen [S]-Subkey signiert:

7A86ADC8D74BE03EE37F085E7F3A023218DB3673

GitHub „Verified“

Damit GitHub den Commit als Verified markiert, muss mein Public Key im Account hinterlegt sein.

Export:

gpg --armor --export "$FPR"

Den gesamten Block, der ungefähr so aussieht kopieren:

-----BEGIN PGP PUBLIC KEY BLOCK-----
...
-----END PGP PUBLIC KEY BLOCK-----

habe ich bei GitHub unter Settings → SSH and GPG keys → New GPG key eingetragen.

Danach muss außerdem die Committer-Mailadresse stimmen:

git config --global user.email

Bei mir:

mail@florianschuttkowski.com

Diese Adresse muss:

  1. als echte E-Mail-Adresse in einer UID des OpenPGP-Keys stehen
  2. im GitHub-Account verifiziert sein
  3. mit der Committer-E-Mail des Commits übereinstimmen

Genau hier fiel mein früherer UID-Fehler auf.

Bonus: Eine falsche UID nachträglich reparieren

Wer bereits versehentlich eine falsche UID konfiguriert hat, hat ein Problem, aber auch Glück, denn er muss keinen komplett neuen Schlüssel bauen. Dafür wird der Primary Key aus dem Backup temporär wiederhergestellt:

OFFLINE="$HOME/.gnupg-pgp-maintenance"

mkdir -p "$OFFLINE"
chmod 700 "$OFFLINE"
gpg --homedir "$OFFLINE" --import "$BACKUP/full-secret-key.asc"

Korrekte UID hinzufügen:

gpg --homedir "$OFFLINE" --quick-add-uid "$FPR" 'Max Mustermann <mail@maxmustermann.de>'

Danach muss die alte UID widerrufen werden:

gpg --homedir "$OFFLINE" \
  --edit-key "$FPR"

Mit gpg> list die alte falsche UID identifizieren.

Dann beispielsweise:

gpg> uid 1
gpg> revuid
gpg> save

Unbedingt kontrollieren, dass wirklich die falsche UID ausgewählt wurde. Danach Public und Secret Backup neu exportieren:

gpg --homedir "$OFFLINE" --armor --output "$BACKUP/public-key-new.asc" --export "$FPR"
gpg --homedir "$OFFLINE" --armor --output "$BACKUP/full-secret-key-new.asc" --export-secret-keys "$FPR"

Nach Kontrolle die alten Dateien ersetzen. Den neuen Public Key wieder ins normale GnuPG importieren:

gpg --import "$BACKUP/public-key.asc"

und bei GitHub aktualisieren. Die YubiKeys müssen dafür nicht neu provisioniert werden. Ihre drei Subkeys haben sich ja nicht geändert.

OpenSSH

Warum ich für SSH nicht den OpenPGP-Auth-Key verwende

Der [A]-Key funktioniert grundsätzlich für SSH. Der Datenfluss wäre allerdings:

Moderne OpenSSH-Versionen können FIDO2-Security-Keys direkt verwenden:

Das ist einfacher und trennt SSH sauber von meiner OpenPGP-Infrastruktur. Deshalb nutze ich [A] zwar als vorhandene OpenPGP-Fähigkeit, für meine normalen SSH-Zugänge aber FIDO2.

FIDO2-SSH unter macOS

Hier gibt es eine wichtige macOS-Besonderheit: Apples mitgeliefertes OpenSSH ist nicht mit der benötigten libfido2-Unterstützung gebaut. Deshalb sollte man unbedingt, falls nicht bereits geschehen, Homebrew installieren und openssh aktualisieren.

brew install openssh

Anschließend den Homebrew-Pfad ermitteln:

brew --prefix openssh

Dann beispielsweise:

OPENSSH_PREFIX="$(brew --prefix openssh)"
export PATH="$OPENSSH_PREFIX/bin:$PATH"

Für ein dauerhaftes zsh-Setup kann dieser Pfad entsprechend in .zshrc aufgenommen werden.

Kontrolle:

which ssh
which ssh-keygen
ssh -V

Wichtig ist, dass wirklich das Homebrew-OpenSSH verwendet wird.

FIDO2-PIN setzen

Die FIDO2-PIN ist nicht die OpenPGP-PIN. Der YubiKey hat mehrere voneinander getrennte Anwendungen:

FIDO2 prüfen und setzen / ändern:

ykman fido info
ykman fido access change-pin

Resident FIDO2 SSH-Key auf YubiKey #1

Ich verwende folgenden Befehl:

ssh-keygen \
  -t ed25519-sk \ # Ed25519 auf einem Security Key
  -O resident \ # Credential ist auf dem YubiKey auffindbar
  -O verify-required \ # FIDO2 User Verification / PIN erforderlich
  -O application=ssh:personal \ # Eindeutige Credential-ID
  -C "Florian SSH YubiKey 1" \
  -f ~/.ssh/id_ed25519_sk_yubikey1

Lokalen entstehen dadurch:

Die Datei ohne .pub ist bei einem FIDO2-Security-Key kein klassischer exportierter Private Key. Sie enthält im Wesentlichen den Handle, über den OpenSSH das Hardware-Credential verwendet. Der eigentliche private Schlüssel verlässt den YubiKey nicht.

Resident FIDO2 SSH-Key auf YubiKey #2

Hier mache ich bewusst nicht dasselbe wie bei OpenPGP.

Bei FIDO2 bekommt YubiKey #2 ein eigenes Credential:

ssh-keygen \
  -t ed25519-sk \
  -O resident \
  -O verify-required \
  -O application=ssh:personal-backup \
  -C "Florian SSH YubiKey 2" \
  -f ~/.ssh/id_ed25519_sk_yubikey2

Auf meinen Servern hinterlege ich beide Public Keys in ~/.ssh/authorized_keys. Damit kann jeder der beiden YubiKeys unabhängig authentifizieren.

Resident Key auf neuem Rechner wiederherstellen

Ein Vorteil von resident: Auf einem neuen Rechner kann das Credential vom YubiKey wieder entdeckt werden.

ssh-keygen -K

Das erzeugt wieder die lokalen Handle-/Public-Key-Dateien. Das ist einer der Gründe, warum ich Resident Credentials für diesen Einsatzzweck bevorzuge.


SSH-Config

Beispielsweise:

Host myserver
    HostName example.com
    User florian
    IdentityFile ~/.ssh/id_ed25519_sk_yubikey1
    IdentitiesOnly yes

Für den normalen Betrieb verwende ich den Daily Driver. Der zweite FIDO2-Key bleibt das Backup.

Public Keys über WKD veröffentlichen

Als letzten großen Baustein wollte ich meinen Public Key automatisch über meine E-Mail-Adresse auffindbar machen. Dafür verwende ich WKD – Web Key Directory. Ein OpenPGP-Client kann dadurch aus der Mailadresse automatisch meinen Public Key ermitteln.

Ich verwende die Advanced Method über openpgpkey.florianschuttkowski.com.

WKD-Struktur mit gpg-wks-client erzeugen

Zuerst prüfen ob der wks client installiert ist, falls nicht mit homebrew installieren:

command -v gpg-wks-client

Dann:

WKD="$HOME/wkd"
mkdir -p "$WKD/.well-known/openpgpkey"

Jetzt lasse ich GnuPG die korrekte Hash-/Verzeichnisstruktur erzeugen:

gpg-wks-client --install-key -C "$WKD/.well-known/openpgpkey" "$FPR" "$EMAIL"

Danach:

find "$WKD" -type f

Für die Advanced Method entsteht sinngemäß:

.well-known/
└── openpgpkey/
    └── florianschuttkowski.com/
        ├── policy
        └── hu/
            └── <WKD-HASH>

Die policy-Datei kann leer sein.

Wichtig: Die Datei unter hu/ ist kein ASCII-armored Public Key, sondern das von WKD erwartete binäre Format. Das übernimmt gpg-wks-client für mich.

WKD-URL prüfen

gpg-wks-client --print-wkd-url mail@florianschuttkowski.com

Damit sehe ich exakt, unter welcher URL GnuPG meinen Key später sucht.

WKD mit Hugo

Meine Website läuft mit Hugo auf Cloudflare Pages.

Hugo kopiert Dateien unter static/ unverändert in das erzeugte Webroot.

Deshalb kopiere ich:

cp -R "$WKD/.well-known" ./static/

Im Repository sieht es anschließend so aus:

static/
└── .well-known/
    └── openpgpkey/
        └── florianschuttkowski.com/
            ├── policy
            └── hu/
                └── <HASH>

Dann:

git add static/.well-known
git commit -m "Add OpenPGP WKD"
git push

Dank des vorherigen Git-Setups ist natürlich auch dieser Commit wieder GPG-signiert.

Cloudflare-Subdomain einrichten

In Cloudflare Pages: Workers & Pages → mein Pages-Projekt → Custom domains → Set up a domain

Dort openpgpkey.zieldomain.com eintragen.

Da mein DNS ebenfalls bei Cloudflare liegt, kann Cloudflare den passenden DNS-Eintrag selbst erzeugen. Ich würde die Subdomain über Pages als Custom Domain registrieren, nicht einfach vorher manuell irgendeinen CNAME anlegen.

Keine Redirects auf dem WKD-Pfad

Wichtig: openpgpkey.florianschuttkowski.com darf nicht durch eine globale Redirect-Regel wieder auf eine der beiden Hauptdomains umgeleitet werden:

Das gilt insbesondere für /.well-known/openpgpkey/.

Der konkrete WKD-Key muss direkt mit HTTP 200 erreichbar sein.


WKD testen

Ziel-URL ermitteln:

WKD_URL="$(
  gpg-wks-client --print-wkd-url mail@florianschuttkowski.com
)"

Dann:

curl -I "$WKD_URL"
# Erwartet:
HTTP/2 200

Der wichtigere Test erfolgt aber mit einem komplett frischen GnuPG-Keyring:

WKDTEST="$(mktemp -d)"
chmod 700 "$WKDTEST"

Dann:

gpg --homedir "$WKDTEST" --auto-key-locate clear,wkd --locate-keys mail@florianschuttkowski.com

Wenn dein Primary-Fingerprint erscheint funktioniert das WKD Ende-zu-Ende.

Danach:

rm -rf "$WKDTEST"

Wartung und Pflege

Ein solches Setup ist nicht „einmal einrichten und vergessen“. Ich plane folgende Routine.

Alle sechs Monate

Beide YubiKeys testen:

Zusätzlich:

Einmal jährlich

Vollständigen Restore-Test des Secret-Key-Backups durchführen. Also tatsächlich:

Vor Ablauf der Subkeys

Meine Subkeys laufen nach zwei Jahren ab. Den Primary temporär aus dem Offline-Backup wiederherstellen und beispielsweise die Subkeys verlängern:

gpg --homedir "$OFFLINE" \
  --quick-set-expire \
  "$FPR" \
  2y \
  7A86ADC8D74BE03EE37F085E7F3A023218DB3673 \
  805E415E16810EA90718A68E15745B3844922823 \
  84471B2E9D08B5569531F85AC025415EB4BCF1C2

Den Primary selbst kann ich separat verlängern:

gpg --homedir "$OFFLINE" --quick-set-expire "$FPR" 5y

Danach müssen aktualisiert werden:

Die eigentlichen privaten Subkeys auf den YubiKeys ändern sich durch eine reine Verlängerung nicht.

Was tun, wenn ein YubiKey kaputtgeht?

Wenn der Daily Driver defekt, aber nicht kompromittiert ist:

  1. Cold-Spare verwenden
  2. GnuPG gegebenenfalls den Backup-YubiKey neu lernen lassen
  3. Ersatz-YubiKey beschaffen
  4. dieselben Subkeys aus dem Offline-Backup auf das Ersatzgerät übertragen

Was tun, wenn ein YubiKey gestohlen wird?

Das ist etwas anderes. Der gestohlene YubiKey besitzt echte Kopien der Keys. Touch und PIN bieten erheblichen Schutz, trotzdem würde ich bei einem tatsächlich verlorenen oder gestohlenen Gerät die betroffenen Subkeys als potenziell kompromittiert betrachten.

Deshalb:

  1. Offline-Primary wiederherstellen
  2. alte Subkeys widerrufen
  3. neue [S], [E], [A] erzeugen
  4. verbleibenden und neuen YubiKey neu provisionieren
  5. neuen Public Key veröffentlichen
  6. WKD aktualisieren
  7. GitHub aktualisieren

Wichtig: Da beide meiner YubiKeys dieselben OpenPGP-Subkeys tragen, macht ein Widerruf auch die Kopien auf dem Backup-YubiKey ungültig. Der Cold Spare hilft mir also beim unmittelbaren Recovery – bei einem Diebstahl sollte ich anschließend trotzdem auf neue Subkeys rotieren.

Was tun, wenn der Primary Key kompromittiert wird?

Das ist der große Notfall. Dann verwende ich revoke.asc um die komplette OpenPGP-Identität zu widerrufen und erzeuge einen neuen Primary Key. Genau deshalb liegt das Revocation Certificate separat im Recovery-Backup.

Fazit

Der schwierigste Teil dieses Setups war am Ende gar nicht die Kryptografie.

Ed25519, X25519, Subkeys und YubiKeys sind konzeptionell relativ klar.

Die eigentlichen Fallstricke lagen in den Details:

Gerade deshalb bin ich froh, den gesamten Prozess einmal vollständig durchlaufen und – noch wichtiger – die Recovery-Pfade tatsächlich getestet zu haben.

Das Ergebnis ist kein Setup, bei dem ich einfach hoffe, dass mein Backup im Ernstfall funktioniert.

Das Ergebnis ist ein Setup, bei dem ich weiß, dass das Backup im Ernstfall funktioniert.