- 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
308 lines
10 KiB
Markdown
308 lines
10 KiB
Markdown
# 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 <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](#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:
|
|
- 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 <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
|
|
|
|
```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 <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:
|
|
|
|
```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 <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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```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 <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:
|
|
|
|
```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
|