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

@@ -55,8 +55,9 @@ WELIST_SERVER_URL=https://welist.bitcoin-after.life
# validated `_welist-key.<host>` TXT record (or a v3 onion address). Omit or # validated `_welist-key.<host>` TXT record (or a v3 onion address). Omit or
# set to 1 for the legacy scheme. # set to 1 for the legacy scheme.
WELIST_PING_SIG_VERSION=2 WELIST_PING_SIG_VERSION=2
# Onion hosts only: delegation certificate issued offline by the onion identity # Onion hosts only (v2 pings): delegation certificate issued offline by the
# key. SSL_KEY_PATH is then the dedicated ping key, not the identity key. # onion identity key. SSL_KEY_PATH is then the dedicated ping key, not the
# identity key.
# WELIST_PING_DELEGATION_FILE=/var/bal/onion-delegation.json # WELIST_PING_DELEGATION_FILE=/var/bal/onion-delegation.json
# ZMQ endpoints per network (default: localhost only) # ZMQ endpoints per network (default: localhost only)

2
.gitignore vendored
View File

@@ -54,6 +54,8 @@ download_bal_db.sh
# Misc # Misc
*.swp *.swp
*.swo *.swo
__pycache__/
*.pyc
src/xpub.rs2 src/xpub.rs2
public_key.pem public_key.pem
.gitsecret/ .gitsecret/

View File

@@ -149,7 +149,7 @@ If no network is specified, defaults to `bitcoin`.
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` | | `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
| `WELIST_SERVER_URL` | Welist server URL. | `https://welist.bitcoin-after.life` | | `WELIST_SERVER_URL` | Welist server URL. | `https://welist.bitcoin-after.life` |
| `WELIST_PING_SIG_VERSION` | WeTime ping signature scheme (`1` legacy, `2` canonical JSON). | `1` | | `WELIST_PING_SIG_VERSION` | WeTime ping signature scheme (`1` legacy, `2` canonical JSON). | `1` |
| `WELIST_PING_DELEGATION_FILE` | Onion only: delegation certificate issued by the onion identity key. | unset | | `WELIST_PING_DELEGATION_FILE` | Onion only, v2 only: delegation certificate issued by the onion identity key. | unset |
### Per-network configuration ### Per-network configuration

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Issue a WeTime ping key revocation certificate.
A revocation is permanent and key-scoped: every ping signed with the revoked
key is rejected for that host until the key is replaced. Two modes exist:
* Onion delegation (signed by the onion identity key):
contrib/make_ping_key_revocation.py \\
--onion <address>.onion \\
--identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \\
--certificate onion-delegation.json \\
--out onion-revocation.json
* DNSSEC TXT key (self-signed by the key being revoked):
contrib/make_ping_key_revocation.py \\
--host we.example.com \\
--key private_key.pem \\
--out dns-revocation.json
Submit the result to the WeTime service (idempotent, safe to retry):
curl -sS -X POST -H 'Content-Type: application/json' \\
--data @onion-revocation.json https://<service>/ping-keys/revoke
The signed bytes are:
WELIST-PING-REVOCATION-V1\\n
{"host":"<host>","key":"<base64 SPKI DER>","revoked_at":<unix>}
Key-loading and signing helpers are shared with `make_onion_delegation.py`.
Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys.
"""
import argparse
import base64
import json
import sys
import time
from pathlib import Path
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError: # pragma: no cover - dependency hint
print("error: the 'cryptography' package is required", file=sys.stderr)
sys.exit(2)
from make_onion_delegation import decode_onion, load_identity_signer, load_ping_key
REVOCATION_PREFIX = b"WELIST-PING-REVOCATION-V1\n"
def validate_key_b64(key_b64: str) -> str:
"""Return `key_b64` after checking it is a base64 Ed25519 SPKI key."""
try:
der = base64.b64decode(key_b64, validate=True)
public_key = serialization.load_der_public_key(der)
except Exception as exc:
raise ValueError(f"invalid delegated key: {exc}") from exc
if not isinstance(public_key, Ed25519PublicKey):
raise ValueError("delegated key is not Ed25519")
return key_b64
def load_certificate(path: Path) -> tuple[str, str]:
"""Return `(onion, key_b64)` from a delegation certificate."""
try:
certificate = json.loads(path.read_text())
onion = str(certificate["onion"]).strip().lower().rstrip(".")
key = str(certificate["key"]).strip()
except (OSError, ValueError, KeyError) as exc:
raise ValueError(f"cannot read delegation certificate {path}: {exc}") from exc
return onion, validate_key_b64(key)
def load_ping_private_key(path: Path) -> Ed25519PrivateKey:
"""Load a PKCS#8 Ed25519 private key."""
try:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
except Exception as exc:
raise ValueError(f"cannot read private key {path}: {exc}") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"{path} is not an Ed25519 private key")
return key
def canonical_message(host: str, key_b64: str, revoked_at: int) -> bytes:
"""Build the exact bytes covered by the revocation signature."""
body = {"host": host, "key": key_b64, "revoked_at": revoked_at}
encoded = json.dumps(
body, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8")
return REVOCATION_PREFIX + encoded
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
target = parser.add_mutually_exclusive_group()
target.add_argument("--onion", help="onion host (address.onion)")
target.add_argument("--host", help="DNS hostname of the executor")
parser.add_argument("--identity-key",
help="Tor hs_ed25519_secret_key or PKCS#8 PEM identity key (onion mode)")
parser.add_argument("--certificate",
help="existing onion delegation certificate (reads host and key)")
parser.add_argument("--key", "--ping-key", dest="key",
help="PKCS#8 PEM of the key to revoke; also the signer for DNS mode")
parser.add_argument("--revoked-at", type=int,
help="revocation Unix timestamp (default: now)")
parser.add_argument("--out", default="ping-key-revocation.json",
help="output JSON path (default: ping-key-revocation.json)")
parser.add_argument("--service-url",
help="WeTime service base URL, used to print the curl command")
args = parser.parse_args()
if args.revoked_at is not None and args.revoked_at <= 0:
print("error: --revoked-at must be positive", file=sys.stderr)
return 2
revoked_at = args.revoked_at if args.revoked_at is not None else int(time.time())
mode: str
host: str
key_b64: str
if args.certificate:
mode = "onion"
certificate_onion, key_b64 = load_certificate(Path(args.certificate))
host = args.onion.strip().lower().rstrip(".") if args.onion else certificate_onion
if host != certificate_onion:
print(
f"error: certificate is bound to {certificate_onion}, not {host}",
file=sys.stderr,
)
return 2
if args.key:
certificate_key = load_ping_key(Path(args.key))
if certificate_key != key_b64:
print("error: --key does not match the certificate key", file=sys.stderr)
return 2
elif args.onion:
mode = "onion"
host = args.onion.strip().lower().rstrip(".")
if not args.key:
print("error: onion revocations require --key or --certificate", file=sys.stderr)
return 2
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
elif args.host:
mode = "dns"
host = args.host.strip().lower().rstrip(".")
if not args.key:
print("error: DNS revocations require --key", file=sys.stderr)
return 2
if args.identity_key:
print("error: --identity-key is only valid for onion hosts", file=sys.stderr)
return 2
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
else:
print("error: provide --onion or --host", file=sys.stderr)
return 2
if not host or host.endswith("."):
print("error: invalid host", file=sys.stderr)
return 2
message = canonical_message(host, key_b64, revoked_at)
if mode == "onion":
if not args.identity_key:
print("error: onion revocations require --identity-key", file=sys.stderr)
return 2
public_key = decode_onion(host)
signer = load_identity_signer(Path(args.identity_key), public_key)
signature = signer(message)
else:
signing_key = load_ping_private_key(Path(args.key))
signature = signing_key.sign(message)
certificate = {
"host": host,
"key": key_b64,
"revoked_at": revoked_at,
"signature": base64.b64encode(signature).decode("ascii"),
}
Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n")
service = (args.service_url or "https://<service>").rstrip("/")
print(f"wrote {args.out} (host={host}, revoked_at={revoked_at})")
print("submit with:")
print(
" curl -sS -X POST -H 'Content-Type: application/json' "
f"--data @{args.out} {service}/ping-keys/revoke"
)
if mode == "dns":
print("also remove the revoked key from the _welist-key TXT record.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except ValueError as err:
print(f"error: {err}", file=sys.stderr)
sys.exit(1)

View File

@@ -212,7 +212,9 @@ This is the async daemon that monitors the blockchain and pushes pending transac
verifies at startup that it is bound to the configured onion host, has not verifies at startup that it is bound to the configured onion host, has not
expired, and delegates to the key at `SSL_KEY_PATH`. If that check fails the expired, and delegates to the key at `SSL_KEY_PATH`. If that check fails the
pusher disables delegation and signs directly, which the service rejects pusher disables delegation and signs directly, which the service rejects
unless `SSL_KEY_PATH` is the onion identity key. unless `SSL_KEY_PATH` is the onion identity key. A leaked key is stopped
with `contrib/make_ping_key_revocation.py` (onion: identity-signed; DNS:
self-signed), submitted to the WeTime `/ping-keys/revoke` endpoint.
- `bal-pusher.sh` pins `WELIST_PING_SIG_VERSION=1` for the development setup: - `bal-pusher.sh` pins `WELIST_PING_SIG_VERSION=1` for the development setup:
a `localhost` URL cannot use DNSSEC, so the dev pusher uses the legacy a `localhost` URL cannot use DNSSEC, so the dev pusher uses the legacy
scheme verified against `/.pub_key.pem`. v2 requires a public DNS hostname scheme verified against `/.pub_key.pem`. v2 requires a public DNS hostname

View File

@@ -186,8 +186,12 @@ ping key, so the service rejects the pings until delegation is restored.
The `url` is trimmed of surrounding whitespace and trailing slashes, and the The `url` is trimmed of surrounding whitespace and trailing slashes, and the
`chain` is lowercase, matching the service-side canonicalization. `chain` is lowercase, matching the service-side canonicalization.
See [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) for the To stop accepting a ping key, run `contrib/make_ping_key_revocation.py` and
operator guide on publishing `_welist-key.<host>` and on onion delegation. submit the result to `{WELIST_SERVER_URL}/ping-keys/revoke`. Onion revocations
are signed by the onion identity key; DNS TXT revocations are self-signed by the
key being revoked. See [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md)
for the operator guide on publishing `_welist-key.<host>`, onion delegation and
revocation.
### Version 1 (legacy) ### Version 1 (legacy)

View File

@@ -70,7 +70,7 @@ the handler (including request body reading) exceeds the limit.
| `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) | | `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) |
| `WELIST_SKIP_URL_VALIDATION` | `false` | Bypass SSRF URL validation (for testing only) | | `WELIST_SKIP_URL_VALIDATION` | `false` | Bypass SSRF URL validation (for testing only) |
| `WELIST_PING_SIG_VERSION` | `1` | WeTime ping signature scheme: `1` (legacy) or `2` (canonical JSON) | | `WELIST_PING_SIG_VERSION` | `1` | WeTime ping signature scheme: `1` (legacy) or `2` (canonical JSON) |
| `WELIST_PING_DELEGATION_FILE` | unset | Onion only: JSON delegation certificate issued by the onion identity key | | `WELIST_PING_DELEGATION_FILE` | unset | Onion only, v2 only: JSON delegation certificate issued by the onion identity key |
#### Per-Network Settings #### Per-Network Settings

View File

@@ -13,6 +13,7 @@
2. **Private Keys (`privkey.pem`):** Used to sign statistics payloads for the `welist` server. Located in `.gitignore`. 2. **Private Keys (`privkey.pem`):** Used to sign statistics payloads for the `welist` server. Located in `.gitignore`.
3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access. Compromise allows arbitrary transaction broadcasting. 3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access. Compromise allows arbitrary transaction broadcasting.
4. **Server Availability (`bal-server`):** Public-facing HTTP endpoint. DoS attacks threaten service availability. 4. **Server Availability (`bal-server`):** Public-facing HTTP endpoint. DoS attacks threaten service availability.
5. **WeTime ping keys:** `SSL_KEY_PATH` signs stats/pings. Onion delegation adds a dedicated, rotatable online key; the onion identity key stays offline and is used only to issue delegation certificates.
### Attackers ### Attackers
- **Remote Anonymous Users:** Can interact with the API via public HTTP. No credentials required. - **Remote Anonymous Users:** Can interact with the API via public HTTP. No credentials required.
@@ -115,7 +116,19 @@
**Description:** Raw file logging (`valid_txs`/`invalid_txs`) has been removed. `info!`/`warn!` macros may still log txid and details in application logs. **Description:** Raw file logging (`valid_txs`/`invalid_txs`) has been removed. `info!`/`warn!` macros may still log txid and details in application logs.
**Mitigation (Operational):** Set production `RUST_LOG` to `warn` or higher. Log files restricted with `chmod 600`. **Mitigation (Operational):** Set production `RUST_LOG` to `warn` or higher. Log files restricted with `chmod 600`.
### 11. `bal-stats.rs.dontcompile` (REMOVED) ### 11. Onion Ping Delegation (DESIGN / ACCEPTED RISK)
**Severity:** LOW | **Status:** Accepted
**Location:** `src/bin/bal-pusher.rs` (`validate_onion_delegation`, `delegation_for_url`); certificate verification on the WeTime `/ping` endpoint
**Description:** Onion pings may carry a `delegation` certificate that binds a dedicated Ed25519 ping key to the onion host until `valid_until`. The certificate is signed by the onion identity key embedded in the `.onion` address, so the identity key can stay offline.
**Compromise impact:** An attacker who obtains the online ping key and its certificate can sign pings and metadata for that onion host until the key is revoked or the certificate expires. It cannot take over the onion service identity, sign for another host, or forge a certificate without the identity key.
**Revocation:** `POST /ping-keys/revoke` accepts a signed `WELIST-PING-REVOCATION-V1` statement. Onion revocations are signed by the identity key (which cannot itself be revoked); DNS TXT revocations are self-signed by the key being revoked, the only authority available for a TXT record. Entries are permanent and key-scoped, checked before the ping signature on both paths, and fail closed on database errors.
**Accepted risks:**
- **Rotation alone does not revoke:** a leaked key must be explicitly revoked. If the signing authority is lost (onion identity key, or the DNS private key), the key remains usable until `valid_until` or the DNS cache TTL passes. Use short lifetimes (30–90 days).
- **Self-signed DNS revocation:** anyone holding the DNS key, including an attacker who stole it, can submit a revocation. This only stops that key, which the attacker could already impersonate; it cannot revoke other keys.
- **Replay:** the signed payload has no nonce. The WeTime service discards duplicate `(executor, chain, height, blockhash)` pings without side effects, so a recorded ping cannot refresh liveness or roll back metadata.
**Mitigation Applied:** The offline identity key issues certificates; `SSL_KEY_PATH` holds only the delegated key. A present but invalid, expired or mismatched certificate is rejected without downgrade, `delegation` is rejected on non-onion hosts, and revoked keys are rejected for both authentication paths.
### 12. `bal-stats.rs.dontcompile` (REMOVED)
**Severity:** LOW | **Status:** Removed **Severity:** LOW | **Status:** Removed
**Description:** The broken HTML report generator file no longer exists in the source tree. **Description:** The broken HTML report generator file no longer exists in the source tree.

View File

@@ -28,6 +28,9 @@
| Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup | | Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup |
| Contrib (install bitcoind) | `contrib/download_and_install_bitcoincore.sh` | Bitcoind download, GPG verify, systemd, config | | Contrib (install bitcoind) | `contrib/download_and_install_bitcoincore.sh` | Bitcoind download, GPG verify, systemd, config |
| Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` | | Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` |
| Contrib (onion delegation) | `contrib/make_onion_delegation.py` | Issues the v3 onion ping delegation certificate from a Tor or PKCS#8 identity key |
| Contrib (key revocation) | `contrib/make_ping_key_revocation.py` | Issues an onion (identity-signed) or DNS (self-signed) ping key revocation |
| Contrib (TXT helper) | `contrib/make_welist_txt.sh` | Prints the `_welist-key` TXT record; generates an Ed25519 key pair when omitted |
| Nginx template | `contrib/nginx/bal-server.conf` | TLS termination, security headers, rate limiting | | Nginx template | `contrib/nginx/bal-server.conf` | TLS termination, security headers, rate limiting |
| Dockerfile | `Dockerfile` | Multi-stage build from source, non-root user, tini, healthcheck | | Dockerfile | `Dockerfile` | Multi-stage build from source, non-root user, tini, healthcheck |
| Dockerfile.release | `Dockerfile.release` | Download latest release from Gitea, SHA-256 verification | | Dockerfile.release | `Dockerfile.release` | Download latest release from Gitea, SHA-256 verification |
@@ -122,6 +125,9 @@
| `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) | | `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) |
| `contrib/download_and_install_bitcoincore.sh` | Bitcoin Core install/verify | `07_deployment_and_ops.md` (Systemd) | | `contrib/download_and_install_bitcoincore.sh` | Bitcoin Core install/verify | `07_deployment_and_ops.md` (Systemd) |
| `contrib/install_tor.sh` | Tor installation script | `07_deployment_and_ops.md` (Tor) | | `contrib/install_tor.sh` | Tor installation script | `07_deployment_and_ops.md` (Tor) |
| `contrib/make_onion_delegation.py` | Onion delegation certificate issuer | `10_dnssec_key_publishing.md` (Onion URLs) |
| `contrib/make_ping_key_revocation.py` | Ping key revocation issuer (onion and DNS) | `10_dnssec_key_publishing.md` (Revocation and lifetime) |
| `contrib/make_welist_txt.sh` | `_welist-key` TXT record helper | `10_dnssec_key_publishing.md` (sections 2–3) |
| `contrib/nginx/bal-server.conf` | Nginx TLS config template | `07_deployment_and_ops.md` (Nginx) | | `contrib/nginx/bal-server.conf` | Nginx TLS config template | `07_deployment_and_ops.md` (Nginx) |
| `Dockerfile` | Multi-stage Docker build | `07_deployment_and_ops.md` (Docker) | | `Dockerfile` | Multi-stage Docker build | `07_deployment_and_ops.md` (Docker) |

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 - the Ed25519 identity key embedded in a v3 `.onion` address, or a dedicated
ping key delegated by that identity key. ping key delegated by that identity key.
This guide covers the DNS TXT record. Onion authentication needs no DNS record Sections 1–10 below cover the DNS TXT record; onion delegation is documented in
and should use delegation so the onion identity key stays offline (see [Onion URLs](#onion-urls). Onion authentication needs no DNS record and should
[Onion URLs](#onion-urls)). For local development no record is needed either use delegation so the onion identity key stays offline. For local development
(see [Local development](#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 All values below use `we.example.com` as a placeholder for the hostname in
`BAL_SERVER_URL`. Replace it with your real executor hostname. `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 SSL_KEY_PATH="/absolute/path/to/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/absolute/path/to/onion-delegation.json" export WELIST_PING_DELEGATION_FILE="/absolute/path/to/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion" 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. - `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. 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 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 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 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 answer `"ko"` and log `PING REJECTED invalid onion delegation`. Do not run
the pusher with a tampered certificate: it disables delegation locally the pusher with a tampered certificate: it disables delegation locally
instead of sending it. 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 `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 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=...` | | `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 | | `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 | | `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 | | `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 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 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 | | `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) | | `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: Useful diagnostics:
```bash ```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 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 `pynacl` is not needed. `--days` accepts 1–3650 (default 365); prefer 30–90
lifetimes as explained in [Revocation and lifetime](#revocation-and-lifetime). days as explained in [Revocation and lifetime](#revocation-and-lifetime).
Run this step on a machine that holds the identity key (ideally offline), then 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. 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 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. no DNS cache to wait for; the service checks the certificate on every ping.
Rotation does **not** revoke the previous certificate; see Rotation does **not** revoke the previous certificate; use the revocation helper
[Revocation and lifetime](#revocation-and-lifetime). below before or after rotating.
### Revocation and lifetime ### Revocation and lifetime
The service has no revocation list. It accepts any certificate that is bound to The WeTime service keeps a permanent, key-scoped revocation list
the onion host and not expired, so rotating to a new ping key does not stop the (`POST /ping-keys/revoke`). A revoked key is rejected for both the onion
old key from working until the old certificate's `valid_until` passes. 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. The onion identity key signs the revocation, so only the onion owner can revoke
- Keep `--days` well below the helper maximum (3650). a delegated key. The identity key itself cannot be revoked; changing it changes
- If a ping key leaks, the only immediate revocation is changing the onion the `.onion` address.
identity, which also changes the `.onion` address. Otherwise the leaked key
remains usable until the certificate expires. ```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 ### Security notes
- Delegation limits the blast radius: a compromised online ping key can publish - 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 pings and metadata for that onion host until it is revoked or the certificate
cannot take over the onion service identity or sign for another host. 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 delegated key authorizes the full ping capability for that host, including
the `info`, `base_fee` and `version` metadata. the `info`, `base_fee` and `version` metadata.
- The certificate is public data and can be copied freely; only the ping private - 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. - [ ] `dig +dnssec` / `delv` shows a validated answer.
- [ ] `BAL_SERVER_URL` is the public DNS hostname. - [ ] `BAL_SERVER_URL` is the public DNS hostname.
- [ ] `WELIST_PING_SIG_VERSION=2` is set. - [ ] `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"`. - [ ] Pusher restarted and reports `body="ok"`.
For onion hosts, replace the TXT/DNSSEC items with: 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. - [ ] `WELIST_PING_SIG_VERSION=2` is set.
- [ ] `valid_until` is strictly in the future, monitored, and rotation is - [ ] `valid_until` is strictly in the future, monitored, and rotation is
scheduled before it. 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. - [ ] The pusher starts without a `disabling onion delegation` warning.
- [ ] Pusher restarted and reports `body="ok"`. - [ ] Pusher restarted and reports `body="ok"`.

View File

@@ -8,7 +8,7 @@
- **I am a security auditor** → Go straight to [`08_security_audit.md`](08_security_audit.md) - **I am a security auditor** → Go straight to [`08_security_audit.md`](08_security_audit.md)
- **I am deploying or operating this software** → Check [`07_deployment_and_ops.md`](07_deployment_and_ops.md) - **I am deploying or operating this software** → Check [`07_deployment_and_ops.md`](07_deployment_and_ops.md)
- **I need to integrate with the API** → Reference [`05_api_reference.md`](05_api_reference.md) - **I need to integrate with the API** → Reference [`05_api_reference.md`](05_api_reference.md)
- **I need to publish the v2 WeTime public key or delegate onion ping signing** → Follow [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) - **I need to publish the v2 WeTime public key, delegate onion ping signing or revoke a key** → Follow [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md)
- **I need to understand the database** → Use [`06_database_schema.md`](06_database_schema.md) - **I need to understand the database** → Use [`06_database_schema.md`](06_database_schema.md)
- **I need to understand Bitcoin concepts** → See [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md) - **I need to understand Bitcoin concepts** → See [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md)
- **I need source code references or external links** → Check [`09_references_and_links.md`](09_references_and_links.md) - **I need source code references or external links** → Check [`09_references_and_links.md`](09_references_and_links.md)
@@ -26,7 +26,7 @@
| 7 | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) | Environment variables, systemd, nginx, scripts, Tor, installation | | 7 | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) | Environment variables, systemd, nginx, scripts, Tor, installation |
| 8 | [`08_security_audit.md`](08_security_audit.md) | Threat model, vulnerability assessment, hardening recommendations | | 8 | [`08_security_audit.md`](08_security_audit.md) | Threat model, vulnerability assessment, hardening recommendations |
| 9 | [`09_references_and_links.md`](09_references_and_links.md) | Source code links, Cargo dependencies, existing file mapping | | 9 | [`09_references_and_links.md`](09_references_and_links.md) | Source code links, Cargo dependencies, existing file mapping |
| 10 | [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) | Publishing the v2 WeTime key via DNSSEC TXT, v3 onion identity, or delegated onion ping key | | 10 | [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) | Publishing the v2 WeTime key via DNSSEC TXT, v3 onion identity, delegated onion ping key, and key revocation |
--- ---