Watch
1
0
Fork
You've already forked keys
0

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
This commit is contained in:
Alexander Hess 2026-07-08 19:10:11 +02:00 • committed by Alexander Hess
commit 8ece401276
Signed by: alexander
GPG key ID: B46EDB4DFC26805D
11 changed files with 861 additions and 239 deletions

240
KEYS.md
View file

@ -1,239 +1,25 @@
# OpenPGP Key Documentation & Ceremonies # Keys
This document specifies how the PGP keys are maintained The current public keys of Alexander Hess
for Alexander Hess (i.e., "me" in the following). (i.e., "me" in this repository).
It is re-signed at every ceremony, like so:
This file is re-signed at every ceremony,
which can be verified:
```bash ```bash
gpg --detach-sign --armor KEYS.md # to produce KEYS.md.asc ./scripts/sign.sh KEYS.md # to produce KEYS.md.asc
gpg --verify KEYS.md.asc KEYS.md gpg --verify KEYS.md.asc KEYS.md
``` ```
## Current Key ## OpenPGP
Fingerprint:
> [`8B11 40AD B518 5334 E9FA 513B 7568 1C81 6731 2680`](https://keys.openpgp.org/vks/v1/by-fingerprint/8B1140ADB5185334E9FA513B75681C8167312680) > [`8B11 40AD B518 5334 E9FA 513B 7568 1C81 6731 2680`](https://keys.openpgp.org/vks/v1/by-fingerprint/8B1140ADB5185334E9FA513B75681C8167312680)
on OpenPGP on [OpenPGP](https://keys.openpgp.org)
Armored export: [`public-key.asc`](public-key.asc)
| Field | Value | For policies, ceremonies, and history, see: [`OPENPGP.md`](OPENPGP.md)
|-------------|-------------------------------------------------------|
| 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
- All parts (primary and every subkey) 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 (`<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: **Vaultwarden** as secure notes
(because they are offline-synced text, unlike attachments):
- Armored export of the primary secret key
- Revocation certificate, generated at key creation
- Public record: **this repo**:
- `KEYS.md` and detached signature `KEYS.md.asc`
- `snapshots/<KEYID>-public-YYYY.asc`, committed at every ceremony
- One commit per ceremony to keep
snapshot, changelog, and re-signed `KEYS.md` together
- `ceremonies/<date>_<event>.md` with own signatures
for exceptional events (e.g., key supersessions);
routine ceremonies go into the changelog below instead
- Distribution:
- WKD on all three UID domains
- public keyservers (see section below)
- public websites
because this repo is the archive and not the channel:
- [alexander-hess.com](https://www.alexander-hess.com/KEYS.md)
([Signature](https://www.alexander-hess.com/KEYS.md.asc))
- [webartifex.biz](https://www.webartifex.biz/KEYS.md)
([Signature](https://www.webartifex.biz/KEYS.md.asc))
- [webartifex.de](https://www.webartifex.de/KEYS.md)
([Signature](https://www.webartifex.de/KEYS.md.asc))
- 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
FPR=8B1140ADB5185334E9FA513B75681C8167312680
for KS in \
hkps://keys.openpgp.org \
hkps://keyserver.ubuntu.com \
hkps://pgpkeys.eu \
hkps://pgp.mit.edu \
hkps://keyserver.escomposlinux.org; do
echo "=== $KS ==="
gpg --keyserver "$KS" --send-keys "$FPR"
done
````
- `keys.openpgp.org` strips UIDs until each address is verified
=> Click the links mailed to all addresses
- *Hockeypuck* servers (the other four) 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"
```
### 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!
````
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
echo "<FPR>:6:" | gpg --import-ownertrust
gpg --list-secret-keys <FPR> # Must show sec# and two 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 |
| `77D28D388024AE85` | 2018-06-17 | revoked same day | botched |
| `887603E7D66B8AD4` | 2018-06-17 | revoked same day | botched |
| `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 four public keyservers 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

View file

@ -1,7 +1,7 @@
-----BEGIN PGP SIGNATURE----- -----BEGIN PGP SIGNATURE-----
iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCak5t4QAKCRC0bttN/CaA iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCaqcTMgAKCRC0bttN/CaA
XUJ6AQC5D4Mq9Zg9LBxEsXBf4vXckBdHS0gNoDWElPngLHq73wEApM6GZC3RpWHm XQsIAQCbVwRUzKeoM3OMJlSbGq4bpuSDmWXGPo/s5KbJlMEkNAEA1gVHfRpomGwL
6d/V4l1k5akWovyhJaH5AzQHMi/nGwM= coNc4VC9qDwk5wdpis+NDRNtpIOIqgs=
=et51 =quB1
-----END PGP SIGNATURE----- -----END PGP SIGNATURE-----

308
OPENPGP.md Normal file
View file

@ -0,0 +1,308 @@
# 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

7
OPENPGP.md.asc Normal file
View file

@ -0,0 +1,7 @@
-----BEGIN PGP SIGNATURE-----
iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCarGC1AAKCRC0bttN/CaA
XS5IAQDcDzL+O2P0LvrrDvTiLPme2mcyAifUCbCCNSnHXjzerQEA5TAwKK0ktW29
7yDzIi4T+uQ0kAttq91U6oIT8Wzb1QM=
=VAgU
-----END PGP SIGNATURE-----

111
README.md
View file

@ -1,19 +1,114 @@
# OpenPGP Keys # Keys
This repository documents my OpenPGP keys: Policies, ceremonies, and history. Public record of the cryptographic keys of Alexander Hess
(i.e., "me" in this repository):
What they are, how they are handled, and how to verify them.
There are two audiences for this repository:
## Contents - **I** want a how-to:
[`OPENPGP.md`](OPENPGP.md) and [`AGE.md`](AGE.md) hold the policies and ceremonies,
and the [`scripts/`](scripts/) run them.
- **Readers** want to confirm my keys and my identity:
Everything needed for that is in the [Verify](#verify) section below.
- [`KEYS.md`](KEYS.md): current key, policies, and all ceremonies
- [`KEYS.md.asc`](KEYS.md.asc): detached signature, renewed at every ceremony
- [`snapshots/`](snapshots/): yearly exports of the current key
- [`ceremonies/`](ceremonies/): signed records of exceptional events (e.g., key supersessions)
- [`history/`](history/): armored public keys of all keys ever published
## Verify ## Verify
The fingerprint of my [current PGP key](public-key.asc) is in [`KEYS.md`](KEYS.md),
which is signed.
Compare the fingerprint with at least one source outside this repository
to confirm my identity:
- [WKD](https://wiki.gnupg.org/WKD),
for example, via `gpg --locate-keys ah@alexander-hess.com`
- The keyservers listed in [`OPENPGP.md`](OPENPGP.md),
for example, `gpg --keyserver hkps://keys.openpgp.org --search-keys alexander@webartifex.de`
- [`KEYS.md`](KEYS.md) obtained from my websites:
- [alexander-hess.com](https://www.alexander-hess.com/KEYS.md)
- [webartifex.de](https://www.webartifex.de/KEYS.md)
Then:
```bash ```bash
gpg --import public-key.asc
gpg --verify KEYS.md.asc KEYS.md gpg --verify KEYS.md.asc KEYS.md
gpg --verify OPENPGP.md.asc OPENPGP.md
``` ```
Each `gpg --verify` will print two things:
1. `Good signature from "Alexander Hess <...>"`:
The file is intact and was signed by this key.
2. `WARNING: This key is not certified with a trusted signature!`:
Because `gpg` likely has no record that this key is mine.
So, a good signature plus a matching `Primary key fingerprint` line is the full result.
To silence the warning on your machine,
tell `gpg` about your check:
```bash
gpg --lsign-key <FPR> # after comparing the fingerprint yourself
```
If you trusted one of the two keys I retired in 2026,
they certified the current one,
and the supersession is recorded in [`ceremonies/`](ceremonies/):
```bash
gpg --import history/344EA5AB10D868E0-public.asc history/6D11C1E91D3B0265-public.asc
gpg --check-signatures 8B1140ADB5185334E9FA513B75681C8167312680
gpg --verify ceremonies/2026-07-08_supersession.md.asc ceremonies/2026-07-08_supersession.md
```
## Contents
The files in this repository are described below.
### OpenPGP
How I implement [OpenPGP](https://www.openpgp.org/):
- [`KEYS.md`](KEYS.md):
Current keys, signed
- [`KEYS.md.asc`](KEYS.md.asc):
Detached signature, renewed at every ceremony
- [`OPENPGP.md`](OPENPGP.md):
OpenPGP policies, ceremonies, and history
- [`OPENPGP.md.asc`](OPENPGP.md.asc):
Detached signature, renewed at every ceremony
- [`public-key.asc`](public-key.asc):
Armored export of the current OpenPGP key
- [`snapshots/`](snapshots/):
Yearly exports of the current OpenPGP key
- [`ceremonies/`](ceremonies/):
Signed records of exceptional events
- [`history/`](history/):
Armored public keys of all previous OpenPGP keys
### Scripts
`sh` scripts for my own use:
- [`bootstrap.sh`](scripts/bootstrap.sh):
Imports and trusts all keys on a new machine,
then verifies all files
- [`sign.sh`](scripts/sign.sh):
Renews the detached signatures
- [`verify.sh`](scripts/verify.sh):
Verifies all signed files against the pinned signing subkey,
and warns when the key is about to expire
- [`snapshot.sh`](scripts/snapshot.sh):
Exports the current key to `snapshots/` and `public-key.asc`
- [`publish.sh`](scripts/publish.sh):
Pushes the current key to the keyservers

71
public-key.asc Normal file
View file

@ -0,0 +1,71 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEak4yahYJKwYBBAHaRw8BAQdAs7q0+BT6idVC9u40uPHMtMJ6pUWGDimiR5z9
2zvwmQW0JkFsZXhhbmRlciBIZXNzIDxhaEBhbGV4YW5kZXItaGVzcy5jb20+iJkE
ExYKAEECGwEFCQEQ4eMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSLEUCttRhT
NOn6UTt1aByBZzEmgAUCak40jwIZAQAKCRB1aByBZzEmgD30AP9Vf7NrDSh3cpK2
abbeYXvWbwQMMw60179e1k1oNfLI/QD+MzdeJGKNM6ax9ctoxX7JgHCND7u7oXXs
RXzmfBYr1gaIdQQTFgoAHRYhBOiC7hUeuQcDEz3+n20RwekdOwJlBQJqTl43AAoJ
EG0RwekdOwJlSF4BAI0zfXyQpLZM57ZjpOMpyBtuSC8JQ16rFA3HkQntk6G8AQD0
xYrxnK4I2recHmbu/mfDUKLxcPIokf0ikSGCxSzuAokCMwQTAQoAHRYhBKtcDjGd
dzUPumzxQzROpasQ2GjgBQJqTl6MAAoJEDROpasQ2Gjg0usP/iDeidFqr7d+G64f
DTYYbqFyo/CEF0a7SImTgvpbnlGUdeSK5SHGjUb9dqNmr4p82Camw7ZIvgJumTBq
g9VL4y6MiR/0CNMnKMu5lM/Vfi88N+WiXuaSGj9swCG+al5P/K4vUr8m6Zh7q1L9
vFLsExznJJsYgDqslZoCbI1UfK1VgS5DLp7fimKXxXrqQaMKOP7+9QKIyD65b6Xe
X5roOAy6KnzLD0ZWvhIbzI4fMrGOCiuoFdDT2DJLunBfsbI+bwP33uDTTSjkPbvw
oRW9XOFH7x1raWZ8WmJHPc2nq+UA/zz9Z0TLgvw41NQ4PvbkhdZrInI1nH4MdiyI
FgFDwptaKx1W6VlnIBre93hm518CZjoK/eWnizqRcgzaE4Jg3qyeP4LZJ6EURSHz
LhpZ9dL6Y7ZxPDhEpPqsvnHMWX9vfUebSjxwu9zslvJw/s4qtscTaQ3MnoDOneQs
6fgBo4Lmi5CVwklZNe6TYwastgdGcjAAXLFY2a8FEpKrBXtU6whC0j6zzcQPj4jx
cRt9u4glpx/d0K6PhAPwI2pQegp1EKYs80AlYy+qXFdgVKtfhX/+7lBmYxktUEo/
M0p4eD2tz8eaowRWpb74xTLuDvgBVb8OW1nMQm5Ri8ndk3j3W5tDqxVkf0UoO6vw
fFfwzy6E4Wt+RplnztuLgmwAL1rPtClBbGV4YW5kZXIgSGVzcyA8YWxleGFuZGVy
QHdlYmFydGlmZXguYml6PoiWBBMWCgA+FiEEixFArbUYUzTp+lE7dWgcgWcxJoAF
AmpOM9MCGwEFCQEQ4eMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQdWgcgWcx
JoDzgQEAlUbQuIeVH4mKeEVHcR+S5goKXwBb+XsmvLHp4blZ4qEBAJkXYkRXYcA7
QcnqoHx2+2CHfDuUsAzh+ajQFoTY60ACiHUEExYKAB0WIQTogu4VHrkHAxM9/p9t
EcHpHTsCZQUCak5ePwAKCRBtEcHpHTsCZWZHAQDjFg3r7Glga6gA5OvVPYOCbxdt
8pz/rKsZOPhF0E9MaQD+J/mhkjgIZQfExy0UnjNyb3HbjGdg8j60gKuichKeewOJ
AjMEEwEKAB0WIQSrXA4xnXc1D7ps8UM0TqWrENho4AUCak5ejQAKCRA0TqWrENho
4A0LD/9kpsfNvsLmgqHY6/zuLWwGNMCXIymMnUxKu2naQxdOHP/JggHaepraby5K
juvfRuyePZq0+9/3BVQMrbY+F2/WX0LQqxUbMvFtFEAwWgphxppvJClcOE1HRItb
sxLgR8xRp19qLif9Qpr18b59TRNXfZhJGDqb6Ro0Cf7kLIkLXRRMC6/bIsKzBuB7
gB+msxy+PkdJb8WkYmSlFJpcm7O2cV09qwH87bCtEGdd7n/wK28E5YymjlJlnXxd
Sxtr6koy/jurX7hyEjquZ4h/+C7IfR2rgJZFvV/oma0grWkenf3cINigjs5OSVwW
4dB9lKmlOP9gho+56679UWP0HVOS3kUCEY/xO2jOh9uYXT9shYlGwEH7KOlHSsfB
ZyWcoKKW5eDdJrzJY2MUjTfcYhvls2v38wVig/pne5WxN08x1fbpLoBKv0hu5dEH
RLA/iaeBzTAt2AQRQQBADBWzZk0K3qM9anf0qdZmfG+ipLbbLjlOW3KdGRTdSRiU
ueByB7gyYKXzIHOmXqT//H0+itgBwcCN8MUc62MRL+YJ1kPoCGNV+ugFs5RboxR0
x+bk3u1fE6XFhAyDRcR91L6ugNEn4SFQcGHPUrDB55nyOO7Tocj4W2UM1QS2WDNy
/AbYRfL4zAEY5YmC5BhVnyjb3/kUkOGzT1JyyeZBG/o+xaQNRrQoQWxleGFuZGVy
IEhlc3MgPGFsZXhhbmRlckB3ZWJhcnRpZmV4LmRlPoiWBBMWCgA+AhsBBQkBEOHj
BQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEEixFArbUYUzTp+lE7dWgcgWcxJoAF
AmpONI8ACgkQdWgcgWcxJoCtdAEAwAaSk3vkAJN/nGaNB+BKO9czcjWSSN4g9JYz
peVojU4A/1ndGCaEalLnCcnJmHNYFhM82bsfMFGrckKgod3lu9wFiHUEExYKAB0W
IQTogu4VHrkHAxM9/p9tEcHpHTsCZQUCak5ePwAKCRBtEcHpHTsCZQbwAQCk5nse
Cp3k+lFyy8gvbvcD9XI/oD+FnuF/CScQm0p9vAEA0K1xN6SWI2OVsNTh/nezfAr2
vETMUFBhtfMY7Im1AAaJAjMEEwEKAB0WIQSrXA4xnXc1D7ps8UM0TqWrENho4AUC
ak5ejQAKCRA0TqWrENho4LrSD/9gKFDaaeF5yiTPiiJFrlaQ1chYrHy7nuLcHpgt
uMC2HGlExVyFGxooiGCgijyxgXXxgMD7X3LBYc89qL0j5YjndSfltmyQxsvGVZvo
vXn/grMS1l8Gnz0yLNSmBfHrPHlpAaXIZU4jv/zum+xebWStDWvwuDloEQ/G8NEe
IukHFKT646PrwTSPFU1nObmsrUcRNZf/Luw1XGOSV1nyINrYcTGrdDMYgu4IqHF9
nNovHsOKr30P7RbglLzxcRiRVjeOK40YUYhdfzgXacNm0kdPIVc34n/RZWAxGaRG
7F4TBZqZqFwDqKhwdGY3YNCr11+ydqXMsOPcAEIlX8yyQw7hv3hIapmmkkK0wx7H
lBiDhNWWYxFgzh4imZg3xzOhkBltzCsJvfxe1Av04hMEnGL7bnFAVybqKB8XbtL1
L0lds3gWZG4XZw7+y45GCiVHaeWTqXJpnozESOcX0AeN+azfxbkBMRF59I7HAs4d
bLxA9rnnnUYXpXt5x1UXs6ZNPGiVYWn4CXqwvab/xk+M8JnCqqxaydZxi92oqkRN
odiVI9lu2t3b8wtkpEVzygMGJP48N949VwqMnAECFATOeT9nqDubIoa3NF/ph5Cw
I3SGHs2gDLWZXv+Bdlsi9xbi9K/Ima21LItFLDDU2Q37/lS28GlfaJACK9TmK6BN
DDHdfbg4BGpONP0SCisGAQQBl1UBBQEBB0Auh/Z95jnIeEjfulzBYSmsEs83pbpO
SgbAdlDqId1BaAMBCAeIfgQYFgoAJhYhBIsRQK21GFM06fpRO3VoHIFnMSaABQJq
TjT9AhsMBQkBEN8fAAoJEHVoHIFnMSaAIMcBANtQlsya/TfaSRTazAtBe+n7PdqX
GbCuEllKFerSAXUAAQCz8txVvInbh6fuXyIkEW7japZML3jbVwkp007tRq5KCrgz
BGpONS4WCSsGAQQB2kcPAQEHQMBrbhqUWKSBFiZ/kUTFBHt5icyI9ba7acWR70S5
XB7OiPUEGBYKACYWIQSLEUCttRhTNOn6UTt1aByBZzEmgAUCak41LgIbAgUJARDe
8ACBCRB1aByBZzEmgHYgBBkWCgAdFiEE1tGYnfH2sj/Xdik9tG7bTfwmgF0FAmpO
NS4ACgkQtG7bTfwmgF2ogwEAtSjb9C8zSAsh206Xkil36P/FOrR2C7H2r7D5tv1b
xKABAP05CbMhCLkmwq9kCluFaewsuA5VbsRlJ3d8c4hMXvEGbGYBAISOkpRSFDbo
Bz44V34y+RXTClxl1sWhBcF9UCLgDtHXAP0Q+2JN1c+jWYidnStsq/eL+JV7qB4o
ktfVqN0JwAtZDQ==
=6Fh2
-----END PGP PUBLIC KEY BLOCK-----

72
scripts/bootstrap.sh Executable file
View file

@ -0,0 +1,72 @@
#!/bin/sh
# Set up a machine's keyring to verify everything in this repo
#
# Usage: ./scripts/bootstrap.sh
#
# Imports the current public key and all historical keys,
# trusts them ultimately, and runs ./scripts/verify.sh
#
# Notes:
# - Idempotent => Safe to re-run at any time
# - Touches public material only; the deployed secret subkeys
# come from Vaultwarden, see OPENPGP.md
set -eu
# Internal [S] subkey; only used to look up the key
SIGNING_KEY='D6D1989DF1F6B23FD776293DB46EDB4DFC26805D'
REPO=$(cd "$(dirname "$0")/.." && pwd)
gpg --quiet \
--import "$REPO/public-key.asc"
echo "Imported public-key.asc"
gpg --quiet \
--import "$REPO"/history/*.asc
echo "Imported history/*.asc"
# Notes:
# - The first "fpr" line is the primary key's fingerprint
# - A subkey resolves to it
FPR=$(gpg --list-keys --with-colons "$SIGNING_KEY" \
| awk -F: '$1 == "fpr" { print $10; exit }')
if [ -z "$FPR" ]; then
echo "Error: Key $SIGNING_KEY not found" >&2
exit 1
fi
# Notes:
# - One "--import-ownertrust" call
# => `gpg` checks the trustdb only once
# - "--show-keys" reads a file without importing
# => Its warnings about revoked keys are expected and silenced
TRUST=$(mktemp)
trap 'rm -f "$TRUST"' EXIT
echo "$FPR:6:" > "$TRUST"
for FILE in "$REPO"/history/*.asc; do
gpg --show-keys --with-colons "$FILE" 2>/dev/null \
| awk -F: '$1 == "fpr" { print $10 ":6:"; exit }' >> "$TRUST"
done
gpg --quiet --import-ownertrust "$TRUST"
sed 's/^/Trusted /; s/:6:$/ ultimately/' "$TRUST"
gpg --check-signatures "$FPR" >&2
echo ""
"$REPO/scripts/verify.sh"

59
scripts/publish.sh Executable file
View file

@ -0,0 +1,59 @@
#!/bin/sh
# Publish the current OpenPGP public key to the keyservers
#
# Usage: ./scripts/publish.sh
#
# Run after every change to the key
#
# Notes:
# - The SKS-style servers merge and never delete
# => Every change must be pushed
# - keys.openpgp.org strips UIDs until each address is verified
# => Click the links mailed to all addresses after new or changed UIDs
#
# Exits non-zero if any keyserver rejected the upload
set -eu
# Internal [S] subkey; only used to look up the key
SIGNING_KEY='D6D1989DF1F6B23FD776293DB46EDB4DFC26805D'
KEYSERVERS='
hkps://keys.openpgp.org
hkps://keyserver.ubuntu.com
hkps://pgpkeys.eu
hkps://pgp.mit.edu
hkps://keyserver.escomposlinux.org
'
# Notes:
# - the first fingerprint line is the primary key's fingerprint
# - a subkey resolves to it
FPR=$(gpg --list-keys --with-colons "$SIGNING_KEY" \
| awk -F: '$1 == "fpr" { print $10; exit }')
if [ -z "$FPR" ]; then
echo "Error: Key $SIGNING_KEY not found" >&2
exit 1
fi
FAILED=0
for server in $KEYSERVERS; do
if gpg --keyserver "$server" --send-keys "$FPR"; then
echo "OK $server"
else
echo "FAIL $server" >&2
FAILED=1
fi
done
exit "$FAILED"

49
scripts/sign.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/sh
# Create detached signatures for the signed documents in this repo
#
# Usage: ./scripts/sign.sh [FILE...]
#
# FILE defaults to KEYS.md OPENPGP.md
#
# Writes FILE.asc next to each FILE and verifies it
set -eu
# Internal [S] subkey; pinned with "!" below so `gpg` never picks another subkey
SIGNING_KEY='D6D1989DF1F6B23FD776293DB46EDB4DFC26805D'
REPO=$(cd "$(dirname "$0")/.." && pwd)
if [ $# -eq 0 ]; then
set -- \
"$REPO/KEYS.md" \
"$REPO/OPENPGP.md" \
# Keep this list aligned with the documentation at the top
fi
for FILE; do
if [ ! -f "$FILE" ]; then
echo "Error: $FILE not found" >&2
exit 1
fi
gpg --armor \
--detach-sign \
--digest-algo SHA512 \
--local-user "${SIGNING_KEY}!" \
--yes \
--output "$FILE.asc" \
"$FILE"
gpg --verify "$FILE.asc" "$FILE"
echo "Signed $FILE"
echo ""
done

72
scripts/snapshot.sh Executable file
View file

@ -0,0 +1,72 @@
#!/bin/sh
# Export the current OpenPGP public key as a yearly snapshot
#
# Usage: ./scripts/snapshot.sh [YEAR]
#
# YEAR defaults to the current UTC year
#
# Writes snapshots/<KEYID>-public-<YEAR>.asc and refreshes public-key.asc
set -eu
# Internal [S] subkey; only used to look up the key
SIGNING_KEY='D6D1989DF1F6B23FD776293DB46EDB4DFC26805D'
REPO=$(cd "$(dirname "$0")/.." && pwd)
YEAR=${1:-$(date -u +%Y)}
# Notes:
# - the first "fpr" line is the primary key's fingerprint
# - a subkey resolves to it
FPR=$(gpg --list-keys --with-colons "$SIGNING_KEY" \
| awk -F: '$1 == "fpr" { print $10; exit }')
if [ -z "$FPR" ]; then
echo "Error: Key $SIGNING_KEY not found" >&2
exit 1
fi
# The last 16 hex chars, as used in OPENPGP.md
KEYID=${FPR#"${FPR%????????????????}"}
OUT="$REPO/snapshots/$KEYID-public-$YEAR.asc"
mkdir -p "$REPO/snapshots"
# Merge the repo copy in first, so a lagging keyring cannot shrink the export
gpg --quiet \
--import "$REPO/public-key.asc"
# The retired keys must be present,
# or "export-clean" below drops their certifications as unverifiable
gpg --quiet \
--import "$REPO"/history/*.asc
gpg --armor \
--export-options export-clean \
--export "$FPR" \
> "$OUT" \
|| {
rm -f "$OUT"
exit 1
}
if [ ! -s "$OUT" ]; then
rm -f "$OUT"
echo "Error: Nothing exported for $FPR" >&2
exit 1
fi
cp "$OUT" "$REPO/public-key.asc"
gpg --show-keys "$OUT" >&2
echo "Wrote snapshots/$KEYID-public-$YEAR.asc and public-key.asc"

103
scripts/verify.sh Executable file
View file

@ -0,0 +1,103 @@
#!/bin/sh
# Verify the signed files in this repo against the pinned [S] subkey
#
# Usage: ./scripts/verify.sh [FILE...]
#
# FILE defaults to KEYS.md OPENPGP.md ceremonies/*.md
#
# A FILE with a FILE.asc next to it is checked as a detached signature,
# any other FILE as a clearsigned document
#
# Also checks the key's expiry
#
# Exits non-zero if any file lacks a valid signature from the pinned key,
# or if the key has expired
set -eu
# Internal [S] subkey; every file must carry a valid signature from this key
SIGNING_KEY='D6D1989DF1F6B23FD776293DB46EDB4DFC26805D'
# Warn when the key expires within this many days
WARN_DAYS=60
REPO=$(cd "$(dirname "$0")/.." && pwd)
if [ $# -eq 0 ]; then
set -- \
"$REPO/KEYS.md" \
"$REPO/OPENPGP.md" \
"$REPO"/ceremonies/*.md \
# Keep this list aligned with the documentation at the top
fi
# Print the fingerprints of all keys that made a valid signature, one per line
signers() {
gpg \
--status-fd 1 \
--verify "$@" \
2>/dev/null \
| awk '$2 == "VALIDSIG" { print $3 }'
}
FAILED=0
for FILE; do
if [ ! -f "$FILE" ]; then
echo "Error: $FILE not found" >&2
exit 1
fi
if [ -f "$FILE.asc" ]; then
SIGNERS=$(signers "$FILE.asc" "$FILE")
else
SIGNERS=$(signers "$FILE")
fi
if printf '%s\n' "$SIGNERS" | grep -qx "$SIGNING_KEY"; then
echo "OK $FILE"
else
echo "FAIL $FILE (signed by: $(printf '%s' "${SIGNERS:-nobody}" | tr '\n' ' '))" >&2
FAILED=1
fi
done
# Notes:
# - The 7th field of the pub line is the primary key's expiry in epoch seconds
# - A subkey resolves to the primary key
EXPIRY=$(gpg --list-keys --with-colons "$SIGNING_KEY" \
| awk -F: '$1 == "pub" { print $7; exit }')
if [ -z "$EXPIRY" ]; then
echo "Error: No expiry found for key $SIGNING_KEY" >&2
exit 1
fi
NOW=$(date -u +%s)
DAYS_LEFT=$(( (EXPIRY - NOW) / 86400 ))
if [ "$EXPIRY" -le "$NOW" ]; then
echo "FAIL Key expired $(( (NOW - EXPIRY) / 86400 )) days ago" >&2
FAILED=1
elif [ "$DAYS_LEFT" -lt "$WARN_DAYS" ]; then
echo "WARN Key expires in $DAYS_LEFT days" >&2
else
echo "OK Key expires in $DAYS_LEFT days"
fi
exit "$FAILED"