Watch
1
0
Fork
You've already forked keys
0
keys/OPENPGP.md
Alexander Hess 8ece401276
Restructure the repository
- Make OpenPGP only one of many protocols
  + KEYS.md only holds the current keys
  + OPENPGP.md describes all OpenPGP details
- Make the current public key available at the root
- Add scripts/ to automate the various steps:
  + bootstrap.sh
  + publish.sh
  + sign.sh
  + snapshot.sh
  + verify.sh
2026-09-21 21:17:49 +02:00

10 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 2680 on 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

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 (<S_SUBKEY_FPR>!) 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
    • history/, armored exports of all retired keys, see Key History below
    • snapshots/<KEYID>-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:
  • Machines: subkeys only via gpg --export-secret-subkeys <FPR> => 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

./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:

    # 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 internal subkeys; external ones keep their own 6-month expiry
  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:

  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:

./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