feat(contrib): add ping key revocation helper

- Add make_ping_key_revocation.py: onion revocations are signed by the
  identity key, DNS TXT revocations are self-signed by the revoked key
- Document the revocation format, endpoint, helper usage, lifetime and
  no-revocation-of-identity-key limits
- Update the security audit, references and env docs for the new flow and
  the v2/onion-only scope
This commit is contained in:
2026-11-09 00:45:43 -04:00
parent 78e4f72255
commit 707d5b1032
11 changed files with 358 additions and 30 deletions

View File

@@ -10,10 +10,10 @@ In v2 the WeTime service discovers the executor public key from:
- the Ed25519 identity key embedded in a v3 `.onion` address, or a dedicated
ping key delegated by that identity key.
This guide covers the DNS TXT record. Onion authentication needs no DNS record
and should use delegation so the onion identity key stays offline (see
[Onion URLs](#onion-urls)). For local development no record is needed either
(see [Local development](#local-development)).
Sections 1–10 below cover the DNS TXT record; onion delegation is documented in
[Onion URLs](#onion-urls). Onion authentication needs no DNS record and should
use delegation so the onion identity key stays offline. For local development
no record is needed either (see [Local development](#local-development)).
All values below use `we.example.com` as a placeholder for the hostname in
`BAL_SERVER_URL`. Replace it with your real executor hostname.
@@ -290,6 +290,7 @@ For onion hosts, also configure the delegation certificate (see
export SSL_KEY_PATH="/absolute/path/to/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/absolute/path/to/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion"
export WELIST_PING_SIG_VERSION=2
```
- `SSL_KEY_PATH` is the **dedicated ping key**, not the onion identity key.
@@ -304,6 +305,9 @@ export BAL_SERVER_URL="http://<address>.onion"
Restart the pusher after changing the key or environment.
If a ping key leaks, issue and submit a revocation before rotating; see
[Revocation and lifetime](#revocation-and-lifetime).
With v2, the pusher also fetches `info`, `base_fee`, and `version` from the
local `/{chain}/info` endpoint and signs those values. If the fetch fails, the
fields are signed as `null`; the ping is still valid. Metadata is also signed
@@ -379,6 +383,12 @@ For an onion pusher with delegation configured:
answer `"ko"` and log `PING REJECTED invalid onion delegation`. Do not run
the pusher with a tampered certificate: it disables delegation locally
instead of sending it.
4. Revocation check (destructive; use a test executor): issue a revocation with
`contrib/make_ping_key_revocation.py --certificate ...`, submit it with
`curl`, then send the delegated ping again. The service must log
`PING KEY REVOKED` on submission, `PING REJECTED revoked onion delegation`
on the ping, and answer `"ko"`. Generate a new ping key and certificate
afterwards: a revoked key stays revoked.
`bal-welist/scripts/test_v2_ping.py` is a local end-to-end reference: it
generates a throwaway onion identity key, issues a delegation certificate, and
@@ -415,13 +425,21 @@ keys for that host.
| `malformed record` | Wrong version, algorithm, or syntax | Use `v=we1; alg=ed25519; key=...` |
| `invalid key` | Not a base64 Ed25519 SPKI DER | Regenerate with the `openssl` command above |
| `v2 DNS lookup requires a hostname` | `BAL_SERVER_URL` uses an IP address | Use a DNS hostname |
| `invalid v2 signature` | Key mismatch or non-canonical URL | Ensure the TXT key matches `SSL_KEY_PATH` and the URL has no trailing slash |
| `invalid v2 signature` | Key mismatch or non-canonical URL | Ensure the TXT key matches `SSL_KEY_PATH` and the URL has no trailing slash; for onion delegation, ensure `SSL_KEY_PATH` is the delegated ping key |
| `invalid onion delegation` | Certificate signature does not verify against the onion identity key | Re-issue the certificate with the correct onion key; do not edit `key`/`valid_until` after signing |
| `delegation does not match the ping onion host` | `onion` field differs from `BAL_SERVER_URL` after normalization | Re-issue the certificate for the exact host |
| `delegation expired` | `valid_until` is at or before the current time | Re-issue the certificate and restart the pusher |
| `delegation is only supported for onion hosts` | `delegation` sent with a DNS URL | Unset `WELIST_PING_DELEGATION_FILE` for DNS hosts |
| `invalid revocation` | Signature does not verify, the host/key is invalid, or an onion identity key was targeted | Re-issue with the helper; onion revocations must be signed by the identity key and cannot target it |
| `revoked_at is in the future` | Issuer clock is more than 24 h ahead | Use the current time or fix the issuer clock |
| `revoked onion delegation` | The delegated key is on the revocation list | Generate a new ping key and certificate; a revoked key cannot be re-enabled |
| `legacy ping disabled` | v1 ping sent while the service requires v2 | Set `WELIST_PING_SIG_VERSION=2` (or set `WELIST_ALLOW_LEGACY_PING=true` if it was disabled) |
Pusher warning `disabling onion delegation` (not a service log): the
certificate is missing, expired, bound to another host, or does not match
`SSL_KEY_PATH`. In the recommended setup onion pings will then be rejected;
re-issue the certificate before restarting the pusher.
Useful diagnostics:
```bash
@@ -515,8 +533,8 @@ pip install cryptography pynacl
```
If the onion identity key is a PKCS#8 PEM file, point `--identity-key` at it and
`pynacl` is not needed. `--days` accepts 1–3650 (default 365); prefer short
lifetimes as explained in [Revocation and lifetime](#revocation-and-lifetime).
`pynacl` is not needed. `--days` accepts 1–3650 (default 365); prefer 30–90
days as explained in [Revocation and lifetime](#revocation-and-lifetime).
Run this step on a machine that holds the identity key (ideally offline), then
copy only `onion-delegation.json` and `ping_private_key.pem` to the executor.
@@ -579,28 +597,97 @@ above still apply; delegation itself needs no network access.
The onion address does not change, and the identity key stays offline. There is
no DNS cache to wait for; the service checks the certificate on every ping.
Rotation does **not** revoke the previous certificate; see
[Revocation and lifetime](#revocation-and-lifetime).
Rotation does **not** revoke the previous certificate; use the revocation helper
below before or after rotating.
### Revocation and lifetime
The service has no revocation list. It accepts any certificate that is bound to
the onion host and not expired, so rotating to a new ping key does not stop the
old key from working until the old certificate's `valid_until` passes.
The WeTime service keeps a permanent, key-scoped revocation list
(`POST /ping-keys/revoke`). A revoked key is rejected for both the onion
delegation path and the DNSSEC TXT path, even while a cached TXT record or a
still-valid certificate lists it.
Treat the certificate lifetime as the exposure window:
#### Onion delegation (identity-signed)
- Prefer short lifetimes (for example 30–90 days) and re-issue before expiry.
- Keep `--days` well below the helper maximum (3650).
- If a ping key leaks, the only immediate revocation is changing the onion
identity, which also changes the `.onion` address. Otherwise the leaked key
remains usable until the certificate expires.
The onion identity key signs the revocation, so only the onion owner can revoke
a delegated key. The identity key itself cannot be revoked; changing it changes
the `.onion` address.
```bash
contrib/make_ping_key_revocation.py \
--onion "$ONION" \
--identity-key "$ONION_KEY" \
--certificate /etc/bal/onion-delegation.json \
--service-url "https://<service>" \
--out /tmp/onion-revocation.json
curl -sS -X POST -H 'Content-Type: application/json' \
--data @/tmp/onion-revocation.json "https://<service>/ping-keys/revoke"
```
#### DNSSEC TXT key (self-signed)
For a TXT key the only available authority is the key itself, so the helper
signs the revocation with the key being revoked:
```bash
contrib/make_ping_key_revocation.py \
--host we.example.com \
--key /etc/bal/private_key.pem \
--service-url "https://<service>" \
--out /tmp/dns-revocation.json
curl -sS -X POST -H 'Content-Type: application/json' \
--data @/tmp/dns-revocation.json "https://<service>/ping-keys/revoke"
```
Also remove the key from `_welist-key.<host>`: revocation makes the stop
immediate, but the TXT record should stop advertising the key to new resolvers.
If the private key was destroyed and cannot sign the revocation, rotating the
TXT record and waiting for the DNS cache TTL is the only remaining path.
#### Certificate format
```json
{
"host": "<address>.onion or a DNS hostname",
"key": "<base64 SPKI DER of the revoked Ed25519 key>",
"revoked_at": 1800000000,
"signature": "<base64 signature>"
}
```
The signature covers:
```text
WELIST-PING-REVOCATION-V1\n{"host":"...","key":"...","revoked_at":1800000000}
```
Rules:
- `host` is normalized (lowercase, no trailing dot).
- Onion hosts require the onion identity signature; DNS hosts require a
self-signature from the revoked key.
- `revoked_at` may be at most 24 hours in the future.
- The key id is the hex of the raw public key, so re-encoded base64 cannot
bypass the list.
- Entries never expire and submissions are idempotent (`"ok"`); at most 64
distinct keys are kept per host.
- Revoking a key is permanent; a later certificate with the same key is also
rejected.
Prefer short certificate lifetimes (for example 30–90 days) anyway: revocation
requires the onion identity key (onion) or the revoked key (DNS), so a key that
is both leaked and lost without a remaining signer cannot be revoked.
### Security notes
- Delegation limits the blast radius: a compromised online ping key can publish
pings and metadata for that onion host until the certificate expires, but it
cannot take over the onion service identity or sign for another host.
pings and metadata for that onion host until it is revoked or the certificate
expires, but it cannot take over the onion service identity or sign for
another host.
- A leaked key should be revoked immediately; rotation alone is not a
revocation.
- The delegated key authorizes the full ping capability for that host, including
the `info`, `base_fee` and `version` metadata.
- The certificate is public data and can be copied freely; only the ping private
@@ -631,6 +718,8 @@ authenticated and stored.
- [ ] `dig +dnssec` / `delv` shows a validated answer.
- [ ] `BAL_SERVER_URL` is the public DNS hostname.
- [ ] `WELIST_PING_SIG_VERSION=2` is set.
- [ ] `contrib/make_ping_key_revocation.py --host ... --key ...` can produce a
self-signed revocation for the TXT key.
- [ ] Pusher restarted and reports `body="ok"`.
For onion hosts, replace the TXT/DNSSEC items with:
@@ -644,5 +733,7 @@ For onion hosts, replace the TXT/DNSSEC items with:
- [ ] `WELIST_PING_SIG_VERSION=2` is set.
- [ ] `valid_until` is strictly in the future, monitored, and rotation is
scheduled before it.
- [ ] The identity key is available offline so a leaked ping key can be revoked
with `contrib/make_ping_key_revocation.py`.
- [ ] The pusher starts without a `disabling onion delegation` warning.
- [ ] Pusher restarted and reports `body="ok"`.