The subkey 16D5D4DFC0C36022 lives only on the project's machines, with its own passphrase, and expires together with the primary key on 2027-01-31 - Add the subkey to the table in OPENPGP.md - Replace the 6-month expiry policy for external subkeys: they now follow the yearly update and are revoked as "retired" at project end - Export the updated public key and a dated snapshot - Re-sign OPENPGP.md
11 KiB
OpenPGP Key Documentation & Ceremonies
This document specifies how I maintain my PGP keys. It is re-signed at every ceremony, like so:
./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 2680on OpenPGP
| 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 <ah@alexander-hess.com> |
| Other UID 1 | Alexander Hess <alexander@webartifex.biz> |
| Other UID 2 | Alexander Hess <alexander@webartifex.de> |
Subkeys
| Key ID | Type | Purpose | Expires |
|---|---|---|---|
253A7FFDDF34BE5E |
E | encryption | 2027-01-31 |
B46EDB4DFC26805D |
S | signing on my own machines | 2027-01-31 |
16D5D4DFC0C36022 |
S | signing on client machines (leonardo) |
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~/.sshbecause I prefer defaults over cleverness - Internal
[S]subkey pinned everywhere with bang syntax (<S_SUBKEY_FPR>!) because I never want to rely on GPG's newest-subkey auto-selection - External
[S]subkeys per client project, living only on that project's machines with their own passphrase:- Expire with the primary key at the end of January
- Extended at the yearly update only while the project continues
- Revoked as "retired" when the project ends, so that their past signatures stay valid
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
- "Client Secret Subkey (<project_name>)": Armored export of the client subkey(s) 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, andAGE.md, each with a detached.ascsignature, refreshed byscripts/sign.shpublic-key.asc, the armored export of the current PGP key, for convenient usage, refreshed byscripts/snapshot.shsecurity.txt, clearsigned, expiry matches the key, refreshed byscripts/security-txt.shceremonies/, signed records of exceptional events, such as the 2026 supersession and theagekey introductionhistory/, armored exports of all retired keys, see Key History belowsnapshots/<KEYID>-public_YYYY-MM-DD.asc, one file per ceremony, written byscripts/snapshot.sh- One commit per ceremony to keep
snapshot, updated tables,
and re-signed
KEYS.md,OPENPGP.md,AGE.md, andsecurity.txttogether
- 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:
- Machines: subkeys only
via
gpg --export-secret-subkeys <FPR>=> Keyring showssec# - Rationale: secrets and public record in independent systems; so, either one alone suffices to recover or verify the other
Publication to Keyservers
./scripts/publish.sh
The script pushes to these five servers:
hkps://keys.openpgp.orghkps://keyserver.ubuntu.comhkps://pgpkeys.euhkps://pgp.mit.eduhkps://keyserver.escomposlinux.org
Note:
keys.openpgp.orgstrips 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-agentcaches passphrases => Rungpg-connect-agent reloadagent /byebefore 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-agentandgpg ... --pinentry-mode loopback(Note:loopbackasks for a new passphrase only ONCE without confirmation) -
Scratch keyrings isolate any step from the main keyring:
# 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:
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 <FPR>
expire # Primary => At the end of NEXT January
key 1
key 2
key ... # Any further subkeys, including external ones of ongoing projects
expire # Selected subkeys => Same date
save
gpg --homedir "$T" --armor --export-secret-keys <FPR>
# => Vaultwarden note "Full Secret Key"
gpg --homedir "$T" --armor --export <FPR> | gpg --import
# => To make the main keyring carry the new expiry
gpgconf --homedir "$T" --kill gpg-agent
rm -rf "$T"
gpg --list-secret-keys <FPR>
# => Must still show sec# and two ssb (external subkeys, if any, as ssb#)
Then, in this repo:
./scripts/snapshot.sh./scripts/security-txt.sh- Update the dates in the two tables above
./scripts/sign.sh./scripts/verify.sh./scripts/publish.sh- One commit
Then, on every other machine:
./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.
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 <ah@alexander-hess.com>
# passphrase: 32 random chars from Vaultwarden
gpg --edit-key <FPR>
adduid # Alexander Hess <alexander@webartifex.biz>
adduid # Alexander Hess <alexander@webartifex.de>
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 <FPR>
# => Vaultwarden login "Key Info" with all key IDs
gpg --gen-revoke <FPR>
# reason 1 = compromised
# => Vaultwarden note "Revocation Certificate"
gpg --armor --export-secret-keys <FPR>
# => 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:
gpg --armor --export-secret-subkeys <FPR> > /tmp/subkeys.asc
gpg --delete-secret-keys <FPR>
gpg --import /tmp/subkeys.asc
gpg --list-secret-keys <FPR> # 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:
gpg --edit-key <FPR>
passwd
quit
gpg --armor --export-secret-subkeys <FPR> > /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:
# 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 <FPR> # Must show sec# and two ssb (external subkeys, if any, as ssb#)
echo test | gpg --pinentry-mode loopback -u <S_SUBKEY_FPR>! --sign -o /dev/null
shred -u subkeys-deploy.asc
Finally, clean up the temporary files:
shred -u /tmp/subkeys.asc /tmp/subkeys-deploy.asc
Key History
All keys I have ever published live in the history folder
as armored exports in their final state.
Each file is self-proving:
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