# OpenPGP Key Documentation & Ceremonies This document specifies how I maintain my PGP keys. It is re-signed at every ceremony, like so: ```bash ./scripts/sign.sh OPENPGP.md # to produce OPENPGP.md.asc gpg --verify OPENPGP.md.asc OPENPGP.md ``` ## Current Key > [`8B11 40AD B518 5334 E9FA 513B 7568 1C81 6731 2680`](https://keys.openpgp.org/vks/v1/by-fingerprint/8B1140ADB5185334E9FA513B75681C8167312680) on [OpenPGP](https://keys.openpgp.org) | Field | Value | |-------------|-------------------------------------------------------| | Fingerprint | `8B11 40AD B518 5334 E9FA 513B 7568 1C81 6731 2680` | | Key ID | `75681C8167312680` (fingerprint's last 16 chars) | | Primary | ed25519 `[C]` OFFLINE (i.e., `sec#` on all machines) | | Subkey 1 | cv25519 `[E]` | | Subkey 2 | ed25519 `[S]` internal | | Created | 2026-07-08 | | Expires | always **End of January** (next: 2027-01-31) | | Primary UID | `Alexander Hess ` | | Other UID 1 | `Alexander Hess ` | | Other UID 2 | `Alexander Hess ` | ### Subkeys | Key ID | Type | Purpose | Expires | |--------------------|------|-----------------------------------------|------------| | `253A7FFDDF34BE5E` | E | encryption | 2027-01-31 | | `B46EDB4DFC26805D` | S | signing on my own machines | 2027-01-31 | ### Policies - Primary and internal subkeys expire at the **end of January** so the update ceremony in **early January** has a brief grace period - No `[A]` subkey: SSH stays separate in `~/.ssh` because I prefer defaults over cleverness - Internal `[S]` subkey pinned everywhere with bang syntax (`!`) because I never want to rely on GPG's newest-subkey auto-selection - External `[S]` subkeys per client engagement with shorter 6-month expiry living on client machines ### Storage - Secret material: In my **Vaultwarden** as secure notes (because they are offline-synced text, unlike attachments): - "Full Secret Key": Armored export of primary and subkeys - "Deployed Secret Keys": Armored export of the subkeys only - "Revocation Certificate", generated at key creation - "Key Info": All key IDs and fingerprints, as a login entry - Public record: **This repository**: - `KEYS.md` (with all current keys), this file, and `AGE.md`, each with a detached `.asc` signature, refreshed by `scripts/sign.sh` - `public-key.asc`, the armored export of the current PGP key, for convenient usage, refreshed by `scripts/snapshot.sh` - `ceremonies/`, signed records of exceptional events, such as the 2026 supersession and the `age` key introduction - `history/`, armored exports of all retired keys, see [Key History](#key-history) below - `snapshots/-public-YYYY.asc`, refreshed at every ceremony, one file per year, written by `scripts/snapshot.sh` - One commit per ceremony to keep snapshot, updated tables, and re-signed `KEYS.md`, `OPENPGP.md`, and `AGE.md` together - Distribution: - WKD on all three UID domains - Public keyservers (see section below) - My public websites because this repo is the archive and not the channel: - [alexander-hess.com](https://www.alexander-hess.com/KEYS.md) ([Key](https://www.alexander-hess.com/public-key.asc), [Signature](https://www.alexander-hess.com/KEYS.md.asc), [security.txt](https://www.alexander-hess.com/.well-known/security.txt)) - [webartifex.de](https://www.webartifex.de/KEYS.md) ([Key](https://www.webartifex.de/public-key.asc), [Signature](https://www.webartifex.de/KEYS.md.asc), [security.txt](https://www.webartifex.de/.well-known/security.txt)) - Machines: subkeys only via `gpg --export-secret-subkeys ` => Keyring shows `sec#` - Rationale: secrets and public record in independent systems; so, either one alone suffices to recover or verify the other ## Publication to Keyservers ```bash ./scripts/publish.sh ``` The script pushes to these five servers: - `hkps://keys.openpgp.org` - `hkps://keyserver.ubuntu.com` - `hkps://pgpkeys.eu` - `hkps://pgp.mit.edu` - `hkps://keyserver.escomposlinux.org` Note: - `keys.openpgp.org` strips UIDs until each address is verified => Click the links mailed to all addresses - The other four run SKS-style software and are append-only: They merge and never delete => Run after *every* change ## Ceremonies - The `gpg-agent` caches passphrases => Run `gpg-connect-agent reloadagent /bye` before any step that must actually prompt - Over `ssh`, the import may hang, for example, if the agent tries to raise a graphical pinentry => `gpgconf --kill gpg-agent` and `gpg ... --pinentry-mode loopback` (Note: `loopback` asks for a new passphrase only ONCE without confirmation) - Scratch keyrings isolate any step from the main keyring: ```bash # Disposable $GNUPGHOME T=$(mktemp -d) chmod 700 "$T" # To scope any operation to "$T" gpg --homedir "$T" ... # To stop the scratch agent gpgconf --homedir "$T" --kill gpg-agent rm -rf "$T" ``` ### Yearly Update In early January, *before* the primary and internal subkeys expire at the end of the month, I conduct a yearly update ceremony. Because extending an expiry needs the primary secret key, I run it in a scratch keyring (i.e., `T` below), never in the main one: ```bash T=$(mktemp -d) chmod 700 "$T" gpg --homedir "$T" --import public-key.asc gpg --homedir "$T" --import # Paste "Full Secret Key" from Vaultwarden, then Ctrl-D gpg --homedir "$T" --edit-key expire # Primary => At the end of NEXT January key 1 key 2 key ... # Any further internal subkeys; external ones keep their own 6-month expiry expire # Selected subkeys => Same date save gpg --homedir "$T" --armor --export-secret-keys # => Vaultwarden note "Full Secret Key" gpg --homedir "$T" --armor --export | gpg --import # => To make the main keyring carry the new expiry gpgconf --homedir "$T" --kill gpg-agent rm -rf "$T" gpg --list-secret-keys # => Must still show sec# and two ssb (external subkeys, if any, as ssb#) ``` Then, in this repo: 1. `./scripts/snapshot.sh` 2. Update the dates in the two tables above 3. `./scripts/sign.sh` 4. `./scripts/verify.sh` 5. `./scripts/publish.sh` 6. One commit Then, on every other machine: ```bash ./scripts/bootstrap.sh # to merge the new expiry into the local keyrings ``` The deployed secret subkeys stay as they are; only their self-signatures changed. ### To generate a brand-new Key This section documents how we created the current key in the first place. ```bash gpg --expert --full-generate-key # (11) ECC (set your own) => toggle S off => only Certify => (1) Curve 25519 # expiry: end of NEXT January # UID: Alexander Hess # passphrase: 32 random chars from Vaultwarden gpg --edit-key adduid # Alexander Hess adduid # Alexander Hess uid 1 # Select the UID to become the default... primary # ...and flag it addkey # (12) ECC (encrypt only) => Curve 25519 => same expiry addkey # (10) ECC (sign only) => Curve 25519 => same expiry save gpg --list-secret-keys --with-subkey-fingerprints # => Vaultwarden login "Key Info" with all key IDs gpg --gen-revoke # reason 1 = compromised # => Vaultwarden note "Revocation Certificate" gpg --armor --export-secret-keys # => Vaultwarden note "Full Secret Key" ``` Ensure everything goes into **Vaultwarden**. Verify the keys from Vaultwarden *before* the destructive part below. Split the keys so that machines only ever hold the subkeys while the primary secret key stays in Vaultwarden only: ```bash gpg --armor --export-secret-subkeys > /tmp/subkeys.asc gpg --delete-secret-keys gpg --import /tmp/subkeys.asc gpg --list-secret-keys # Must show sec# and two ssb (external subkeys, if any, as ssb#) ``` Re-passphrase the deployed subkeys with a memorable phrase for daily and headless signing: ```bash gpg --edit-key passwd quit gpg --armor --export-secret-subkeys > /tmp/subkeys-deploy.asc # => Vaultwarden note "Deployed Secret Keys" ``` Verify the deploy keys from Vaultwarden with a scratch keyring, sign test with the memorable phrase, then distribute to the machines: ```bash # On each machine: gpgconf --kill gpg-agent gpg --import subkeys-deploy.asc ./scripts/bootstrap.sh # to configure public keys with ownertrust and a full verify gpg --list-secret-keys # Must show sec# and two ssb (external subkeys, if any, as ssb#) echo test | gpg --pinentry-mode loopback -u ! --sign -o /dev/null shred -u subkeys-deploy.asc ``` Finally, clean up the temporary files: ```bash shred -u /tmp/subkeys.asc /tmp/subkeys-deploy.asc ``` ## Key History All keys I have ever published live in the [`history`](history/) folder as armored exports in their final state. Each file is self-proving: ```bash gpg --show-keys history/*.asc # shows the revocations and expiries ``` | Key ID | Created | Terminated | Note | |--------------------|------------|--------------------|-------------------------------| | `6D11C1E91D3B0265` | 2026-02-16 | revoked 2026-07-08 | main key for 5 months in 2026 | | `344EA5AB10D868E0` | 2020-07-17 | revoked 2026-07-08 | main key from 2020 to 2026 | | `6F75AAA4E8C99523` | 2020-06-16 | revoked 2020-07-17 | discarded | | `9EE068C095A840A0` | 2018-10-16 | revoked same day | botched, **never expires** | | `77D28D388024AE85` | 2018-06-17 | revoked same day | botched, **never expires** | | `887603E7D66B8AD4` | 2018-06-17 | revoked same day | botched, **never expires** | | `CD9A863C023DB05B` | 2018-06-17 | revoked same day | **never expires** (see below) | | `12325FF0C2459FA0` | 2017-02-21 | expired 2018-01-02 | first key ever | - All private keys are destroyed; so, none of the keys listed here can ever sign or decrypt again - These exports are the union of the keyservers listed above plus local archives, making them more complete than any single public source - The expiry policy above is a lesson from `CD9A863C023DB05B`: A revocation must reach every copy whereas an expiry travels *inside* every copy