diff --git a/KEYS.md b/KEYS.md index 8a7fc48..8bca793 100644 --- a/KEYS.md +++ b/KEYS.md @@ -1,239 +1,25 @@ -# OpenPGP Key Documentation & Ceremonies +# Keys -This document specifies how the PGP keys are maintained - for Alexander Hess (i.e., "me" in the following). -It is re-signed at every ceremony, like so: +The current public keys of Alexander Hess + (i.e., "me" in this repository). + +This file is re-signed at every ceremony, + which can be verified: ```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 ``` -## Current Key +## OpenPGP + +Fingerprint: > [`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 | -|-------------|-------------------------------------------------------| -| 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 - -- 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 (`!`) - 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/-public-YYYY.asc`, committed at every ceremony - - One commit per ceremony to keep - snapshot, changelog, and re-signed `KEYS.md` together - - `ceremonies/_.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 !` => 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 -# 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! -```` - -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 -echo ":6:" | gpg --import-ownertrust - -gpg --list-secret-keys # Must show sec# and two 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 | -| `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 +For policies, ceremonies, and history, see: [`OPENPGP.md`](OPENPGP.md) diff --git a/KEYS.md.asc b/KEYS.md.asc index 8eb7115..3592cb8 100644 --- a/KEYS.md.asc +++ b/KEYS.md.asc @@ -1,7 +1,7 @@ -----BEGIN PGP SIGNATURE----- -iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCak5t4QAKCRC0bttN/CaA -XUJ6AQC5D4Mq9Zg9LBxEsXBf4vXckBdHS0gNoDWElPngLHq73wEApM6GZC3RpWHm -6d/V4l1k5akWovyhJaH5AzQHMi/nGwM= -=et51 +iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCaqcTMgAKCRC0bttN/CaA +XQsIAQCbVwRUzKeoM3OMJlSbGq4bpuSDmWXGPo/s5KbJlMEkNAEA1gVHfRpomGwL +coNc4VC9qDwk5wdpis+NDRNtpIOIqgs= +=quB1 -----END PGP SIGNATURE----- diff --git a/OPENPGP.md b/OPENPGP.md new file mode 100644 index 0000000..ba90f9f --- /dev/null +++ b/OPENPGP.md @@ -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 ` | +| 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 + - `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 diff --git a/OPENPGP.md.asc b/OPENPGP.md.asc new file mode 100644 index 0000000..f51ef8c --- /dev/null +++ b/OPENPGP.md.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTW0Zid8fayP9d2KT20bttN/CaAXQUCarGC1AAKCRC0bttN/CaA +XS5IAQDcDzL+O2P0LvrrDvTiLPme2mcyAifUCbCCNSnHXjzerQEA5TAwKK0ktW29 +7yDzIi4T+uQ0kAttq91U6oIT8Wzb1QM= +=VAgU +-----END PGP SIGNATURE----- diff --git a/README.md b/README.md index 1b8bd47..01f6f3c 100644 --- a/README.md +++ b/README.md @@ -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 +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 +gpg --import public-key.asc + 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 # 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 diff --git a/public-key.asc b/public-key.asc new file mode 100644 index 0000000..1fcd41b --- /dev/null +++ b/public-key.asc @@ -0,0 +1,71 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +ktfVqN0JwAtZDQ== +=6Fh2 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..3666baa --- /dev/null +++ b/scripts/bootstrap.sh @@ -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" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..ed3a56d --- /dev/null +++ b/scripts/publish.sh @@ -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" diff --git a/scripts/sign.sh b/scripts/sign.sh new file mode 100755 index 0000000..ad3fae8 --- /dev/null +++ b/scripts/sign.sh @@ -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 diff --git a/scripts/snapshot.sh b/scripts/snapshot.sh new file mode 100755 index 0000000..793ccad --- /dev/null +++ b/scripts/snapshot.sh @@ -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/-public-.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" diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..8381517 --- /dev/null +++ b/scripts/verify.sh @@ -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"