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:
@@ -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
|
||||
# set to 1 for the legacy scheme.
|
||||
WELIST_PING_SIG_VERSION=2
|
||||
# Onion hosts only: delegation certificate issued offline by the onion identity
|
||||
# key. SSL_KEY_PATH is then the dedicated ping key, not the identity key.
|
||||
# Onion hosts only (v2 pings): delegation certificate issued offline by the
|
||||
# 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
|
||||
|
||||
# ZMQ endpoints per network (default: localhost only)
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,6 +54,8 @@ download_bal_db.sh
|
||||
# Misc
|
||||
*.swp
|
||||
*.swo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
src/xpub.rs2
|
||||
public_key.pem
|
||||
.gitsecret/
|
||||
|
||||
@@ -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` |
|
||||
| `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_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
|
||||
|
||||
|
||||
209
contrib/make_ping_key_revocation.py
Executable file
209
contrib/make_ping_key_revocation.py
Executable 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)
|
||||
@@ -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
|
||||
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
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`chain` is lowercase, matching the service-side canonicalization.
|
||||
|
||||
See [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) for the
|
||||
operator guide on publishing `_welist-key.<host>` and on onion delegation.
|
||||
To stop accepting a ping key, run `contrib/make_ping_key_revocation.py` and
|
||||
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)
|
||||
|
||||
|
||||
@@ -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_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_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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
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.
|
||||
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
|
||||
- **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.
|
||||
**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
|
||||
**Description:** The broken HTML report generator file no longer exists in the source tree.
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
| 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 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 |
|
||||
| Dockerfile | `Dockerfile` | Multi-stage build from source, non-root user, tini, healthcheck |
|
||||
| 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_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/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) |
|
||||
| `Dockerfile` | Multi-stage Docker build | `07_deployment_and_ops.md` (Docker) |
|
||||
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- **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 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 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)
|
||||
@@ -26,7 +26,7 @@
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user