From 63f65f930a3b482b8f8d61fe61da848f707fc1e5 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Sun, 8 Nov 2026 23:07:08 -0400 Subject: [PATCH] feat(pusher): add validated onion delegation for v2 pings - Load WELIST_PING_DELEGATION_FILE into a typed OnionDelegation - Send the certificate only with v2 pings for onion hosts - Validate the onion host, expiry and delegated key against BAL_SERVER_URL and SSL_KEY_PATH at startup; disable delegation with a warning on any mismatch - Stop sending unsigned info/base_fee/version with v1 pings so v1 remains liveness-only - Document v1/v2 metadata handling and onion delegation implications - Add contrib/make_onion_delegation.py to issue certificates offline, with Ed25519 key validation and clean CLI error handling --- .env.example | 3 + README.md | 1 + bal-pusher.sh | 9 +- contrib/make_onion_delegation.py | 214 ++++++++++++ docs/04_modules_detail.md | 23 +- docs/05_api_reference.md | 23 +- docs/07_deployment_and_ops.md | 1 + docs/10_dnssec_key_publishing.md | 560 +++++++++++++++++++++++++++++++ docs/INDEX.md | 2 + src/bin/bal-pusher.rs | 295 +++++++++++++++- 10 files changed, 1106 insertions(+), 25 deletions(-) create mode 100755 contrib/make_onion_delegation.py create mode 100644 docs/10_dnssec_key_publishing.md diff --git a/.env.example b/.env.example index 51b8c27..27f07bd 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,9 @@ WELIST_SERVER_URL=https://welist.bitcoin-after.life # validated `_welist-key.` 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. +# WELIST_PING_DELEGATION_FILE=/var/bal/onion-delegation.json # ZMQ endpoints per network (default: localhost only) BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332 diff --git a/README.md b/README.md index 85493e7..3e561a6 100644 --- a/README.md +++ b/README.md @@ -149,6 +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 | ### Per-network configuration diff --git a/bal-pusher.sh b/bal-pusher.sh index 85620b6..3503b3c 100755 --- a/bal-pusher.sh +++ b/bal-pusher.sh @@ -27,9 +27,12 @@ export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332 export BAL_PUSHER_SEND_STATS=true export WELIST_SERVER_URL=http://localhost:8086 export WELIST_SKIP_URL_VALIDATION=true -# Sign WeTime pings with the v2 canonical JSON scheme (DNSSEC/onion public key -# discovery). Remove this line to fall back to the legacy v1 signature. -export WELIST_PING_SIG_VERSION=2 +# Local development uses the legacy v1 signature, verified by the service +# against GET /.pub_key.pem: a localhost URL cannot use DNSSEC key discovery. +# v2 requires a public DNS hostname (or a v3 onion URL) with a published key. +# The WeTime service must run with WELIST_ALLOW_LEGACY_PING=true and, for +# loopback addresses, WELIST_ALLOW_LOCALHOST_PING=true. +export WELIST_PING_SIG_VERSION=1 export BAL_SERVER_URL="http://127.0.0.1:9133" export SSL_KEY_PATH="$WORKING_DIR/private_key.pem" diff --git a/contrib/make_onion_delegation.py b/contrib/make_onion_delegation.py new file mode 100755 index 0000000..cf05381 --- /dev/null +++ b/contrib/make_onion_delegation.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Issue an onion delegation certificate for v2 WeTime ping signing. + +The onion v3 identity key signs a certificate that binds a dedicated Ed25519 +ping key to the onion host. The online pusher then signs pings with the +dedicated key, so the onion identity key can stay offline. + +The certificate covers these exact bytes: + + WELIST-PING-DELEGATION-V1\n + {"key":"","onion":"","valid_until":} + +Usage: + contrib/make_onion_delegation.py \ + --onion
.onion \ + --identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \ + --ping-key /etc/bal/ping_private_key.pem \ + --days 365 \ + --out onion-delegation.json + +`--identity-key` accepts either: + * a Tor `hs_ed25519_secret_key` file (32-byte header + secret key), or + * a standard PKCS#8 PEM Ed25519 private key. + +Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys. +""" + +import argparse +import base64 +import hashlib +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 +except ImportError: # pragma: no cover - dependency hint + print("error: the 'cryptography' package is required", file=sys.stderr) + sys.exit(2) + +DELEGATION_PREFIX = b"WELIST-PING-DELEGATION-V1\n" +TOR_HEADER_LEN = 32 + + +def b32_decode(value: str) -> bytes: + """Decode lowercase base32 without padding.""" + padding = "=" * ((8 - len(value) % 8) % 8) + return base64.b32decode(value.upper() + padding) + + +def decode_onion(host: str) -> bytes: + """Return the 32-byte Ed25519 identity public key of a v3 onion host.""" + host = host.strip().lower().rstrip(".") + if not host.endswith(".onion"): + raise ValueError("not an onion address") + address = host[: -len(".onion")] + if len(address) != 56: + raise ValueError("unsupported onion address length") + if not all(c in "abcdefghijklmnopqrstuvwxyz234567" for c in address): + raise ValueError("invalid onion base32") + decoded = b32_decode(address) + if len(decoded) != 35: + raise ValueError("invalid onion payload length") + public_key, checksum, version = decoded[:32], decoded[32:34], decoded[34] + if version != 3: + raise ValueError("unsupported onion version") + expected = hashlib.sha3_256( + b".onion checksum" + public_key + bytes([version]) + ).digest()[:2] + if checksum != expected: + raise ValueError("onion checksum mismatch") + return public_key + + +def load_ping_key(ping_key_path: Path) -> bytes: + """Return the delegated ping public key as base64 SPKI DER.""" + key = serialization.load_pem_private_key(ping_key_path.read_bytes(), password=None) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("--ping-key must be an Ed25519 private key") + return base64.b64encode( + key.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ).decode("ascii") + + +def sign_standard(key: Ed25519PrivateKey, message: bytes) -> bytes: + """Sign with a standard Ed25519 private key.""" + return key.sign(message) + + +def sign_expanded(secret: bytes, public_key: bytes, message: bytes) -> bytes: + """Sign with a Tor expanded Ed25519 secret key (a || prefix).""" + try: + from nacl.bindings import ( + crypto_core_ed25519_scalar_add, + crypto_core_ed25519_scalar_mul, + crypto_core_ed25519_scalar_reduce, + crypto_scalarmult_ed25519_base_noclamp, + ) + except ImportError as exc: # pragma: no cover - dependency hint + raise ValueError( + "pynacl is required to sign with a Tor-format onion key" + ) from exc + + scalar, prefix = secret[:32], secret[32:64] + try: + if crypto_scalarmult_ed25519_base_noclamp(scalar) != public_key: + raise ValueError("onion secret key does not match the onion address") + + nonce = crypto_core_ed25519_scalar_reduce( + hashlib.sha512(prefix + message).digest() + ) + point = crypto_scalarmult_ed25519_base_noclamp(nonce) + challenge = crypto_core_ed25519_scalar_reduce( + hashlib.sha512(point + public_key + message).digest() + ) + response = crypto_core_ed25519_scalar_add( + nonce, crypto_core_ed25519_scalar_mul(challenge, scalar) + ) + return point + response + except ValueError: + raise + except Exception as exc: # PyNaCl raises RuntimeError for invalid scalars + raise ValueError(f"cannot sign with onion identity key: {exc}") from exc + + +def load_identity_signer(identity_key_path: Path, public_key: bytes): + """Return a callable that signs with the onion identity key.""" + raw = identity_key_path.read_bytes() + if raw.lstrip().startswith(b"-----BEGIN"): + key = serialization.load_pem_private_key(raw, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("identity PEM key is not Ed25519") + derived = key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + if derived != public_key: + raise ValueError("identity key does not match the onion address") + return lambda message: sign_standard(key, message) + + if len(raw) < TOR_HEADER_LEN + 64: + raise ValueError("Tor secret key file is too short") + secret = raw[TOR_HEADER_LEN : TOR_HEADER_LEN + 64] + + # Tor stores the expanded secret key (scalar || prefix). Some tools export + # a standard 32-byte seed instead, so accept that form too. + try: + seed_key = Ed25519PrivateKey.from_private_bytes(secret[:32]) + derived = seed_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + except Exception: + seed_key = None + derived = b"" + if seed_key is not None and derived == public_key: + return lambda message: sign_standard(seed_key, message) + + return lambda message: sign_expanded(secret, public_key, message) + + +def canonical_message(key_b64: str, onion: str, valid_until: int) -> bytes: + """Build the exact certificate bytes signed by the identity key.""" + body = {"key": key_b64, "onion": onion, "valid_until": valid_until} + encoded = json.dumps( + body, separators=(",", ":"), sort_keys=True, ensure_ascii=False + ).encode("utf-8") + return DELEGATION_PREFIX + encoded + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--onion", required=True, help="onion host (address.onion)") + parser.add_argument("--identity-key", required=True, + help="Tor hs_ed25519_secret_key or PKCS#8 PEM") + parser.add_argument("--ping-key", required=True, + help="PKCS#8 PEM private key of the delegated ping signer") + parser.add_argument("--days", type=int, default=365, + help="certificate lifetime in days (default: 365)") + parser.add_argument("--out", default="onion-delegation.json", + help="output JSON path (default: onion-delegation.json)") + args = parser.parse_args() + + if args.days <= 0 or args.days > 3650: + print("error: --days must be between 1 and 3650", file=sys.stderr) + return 2 + + onion = args.onion.strip().lower().rstrip(".") + public_key = decode_onion(onion) + key_b64 = load_ping_key(Path(args.ping_key)) + valid_until = int(time.time()) + args.days * 86_400 + message = canonical_message(key_b64, onion, valid_until) + signer = load_identity_signer(Path(args.identity_key), public_key) + + certificate = { + "onion": onion, + "key": key_b64, + "valid_until": valid_until, + "signature": base64.b64encode(signer(message)).decode("ascii"), + } + Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n") + print(f"wrote {args.out} (onion={onion}, valid_until={valid_until})") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, TypeError, ValueError) as err: + print(f"error: {err}", file=sys.stderr) + sys.exit(1) diff --git a/docs/04_modules_detail.md b/docs/04_modules_detail.md index 5623a50..8208b7a 100644 --- a/docs/04_modules_detail.md +++ b/docs/04_modules_detail.md @@ -195,21 +195,34 @@ This is the async daemon that monitors the blockchain and pushes pending transac - The `WELIST_SERVER_URL` is validated via `is_valid_welist_url()` before sending (can be bypassed with `WELIST_SKIP_URL_VALIDATION=true`). - `WELIST_PING_SIG_VERSION` selects the signature scheme: - `1` (default): legacy concatenation of URL, chain, height, median time and - block hash. The WeTime service verifies it against `/.pub_key.pem`. + block hash. The WeTime service verifies it against `/.pub_key.pem`. The + signature authenticates liveness only: the pusher does not send `info`, + `base_fee` or `version`, and the service refreshes `last_update` without + persisting executor metadata. Use v2 when metadata must be published and + authenticated. - `2`: canonical JSON signed with the `WELIST-PING-V2\n` prefix. The pusher fetches `info`, `base_fee` and `version` from the local `/{chain}/info` endpoint and signs them together with the block metadata. The WeTime service discovers the public key from a DNSSEC-validated `_welist-key.` TXT record or from a v3 onion address, so no HTTP callback is needed. The `url` is trimmed of trailing slashes to match the - service-side normalization; absent metadata is signed as `null`. -- `bal-pusher.sh` sets `WELIST_PING_SIG_VERSION=2` for the development setup; - remove that line to fall back to the legacy scheme. + service-side normalization; absent metadata is signed as `null`. When + onion delegation is configured, the pusher verifies at startup that the + certificate is bound to the configured onion host, has not expired, and + delegates to the key at `SSL_KEY_PATH`. +- `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 + or a v3 onion address. - To publish the v2 public key, derive the base64 SubjectPublicKeyInfo DER: `openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0` and publish it in a DNSSEC-signed TXT record named `_welist-key.` with value `v=we1; alg=ed25519; key=`. A v3 onion URL needs no DNS record: its - Ed25519 identity key is embedded in the address. + Ed25519 identity key is embedded in the address, and the recommended setup + keeps that key offline while it certifies a dedicated ping key + (`WELIST_PING_DELEGATION_FILE`). See + [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) for the full + operator guide. ### ZMQ Timeout Handling - Uses `set_rcvtimeo(5000)` (5-second timeout). diff --git a/docs/05_api_reference.md b/docs/05_api_reference.md index 1062dd1..d082d0d 100644 --- a/docs/05_api_reference.md +++ b/docs/05_api_reference.md @@ -168,13 +168,32 @@ scheme is selected with `WELIST_PING_SIG_VERSION`: `_welist-key.` TXT record (`v=we1; alg=ed25519; key=`) or from a v3 `.onion` address. No HTTP callback to `/.pub_key.pem` is made. +For `.onion` URLs the pusher includes a `delegation` object when +`WELIST_PING_DELEGATION_FILE` is set. The onion identity key signs that +certificate offline; `SSL_KEY_PATH` is then the dedicated ping key, and the +service verifies the certificate before checking the ping signature. The +pusher validates the certificate at startup: `onion` must match the configured +host, `valid_until` must be in the future, and `key` must match `SSL_KEY_PATH`. +If the file is missing or malformed, or any check fails, the pusher logs a +warning and sends no `delegation` object. It then signs with `SSL_KEY_PATH` +directly, which works only when that key is the onion identity key. + 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.` and on onion delegation. + ### Version 1 (legacy) 1. Concatenates `url + chain + height + median_time + last_block_hash`. 2. Signs that string with the Ed25519 private key. -3. POSTs the fields plus the base64 signature to `{WELIST_SERVER_URL}/ping`. +3. POSTs those five fields plus the base64 signature to + `{WELIST_SERVER_URL}/ping`. No `info`, `base_fee` or `version` is sent. 4. The `welist` server verifies the signature with the public key served at - `GET /.pub_key.pem`; this scheme only refreshes `last_update`. + `GET /.pub_key.pem`. +5. The v1 signature authenticates only liveness. The service refreshes + `last_update` for an accepted ping but does not persist executor metadata. + Because any metadata added to a v1 ping would be outside the signature, use + v2 when `info`, `base_fee` and `version` must be published and + authenticated. diff --git a/docs/07_deployment_and_ops.md b/docs/07_deployment_and_ops.md index dfeb6f0..c6062c6 100644 --- a/docs/07_deployment_and_ops.md +++ b/docs/07_deployment_and_ops.md @@ -70,6 +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 | #### Per-Network Settings diff --git a/docs/10_dnssec_key_publishing.md b/docs/10_dnssec_key_publishing.md new file mode 100644 index 0000000..c02acea --- /dev/null +++ b/docs/10_dnssec_key_publishing.md @@ -0,0 +1,560 @@ +# DNSSEC-authenticated WeTime pings (v2) + +This document explains how to publish the Ed25519 public key used by the +`bal-pusher` v2 WeTime ping and how to verify that the record is usable. + +In v2 the WeTime service discovers the executor public key from: + +- a DNSSEC-validated TXT record at `_welist-key.`, or +- 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)). + +All values below use `we.example.com` as a placeholder for the hostname in +`BAL_SERVER_URL`. Replace it with your real executor hostname. + +--- + +## 1. Choose the hostname + +Use the public hostname that clients use to reach your executor: + +```text +BAL_SERVER_URL=https://we.example.com +``` + +The DNS key is published at: + +```text +_welist-key.we.example.com +``` + +Rules: + +- Use a DNS hostname, not an IP address. The service rejects v2 pings whose URL + host is an IP address with `v2 DNS lookup requires a hostname`. +- Use the host only: no scheme, port, path, or trailing slash. +- The record must exist at exactly `_welist-key.`. CNAME answers are + rejected by the service. + +--- + +## 2. Generate or confirm the Ed25519 key pair + +The pusher signs with the private key at `SSL_KEY_PATH`. The TXT record must +contain the matching public key. + +> **The pusher and the bal-server use two separate settings.** `SSL_KEY_PATH` +> is the private key used for signing; `BAL_SERVER_PUB_KEY_PATH` is the public +> key served at `/.pub_key.pem` and used by the legacy v1 scheme (and by the +> check in section 7). Configure both with the same key pair. + +If you already have a key pair for the executor, reuse it. Do not generate a +new pair for `SSL_KEY_PATH` alone: the legacy v1 signature is verified against +`/.pub_key.pem`, so a split pair breaks v1 authentication and makes the +section 7 check fail. + +> **Onion hosts:** do not point `SSL_KEY_PATH` at the onion identity key. Use a +> separate ping key and issue a delegation certificate (see +> [Onion URLs](#onion-urls)). The identity key is only needed offline, once, to +> sign that certificate. + +If you do not have a key pair yet: + +```bash +openssl genpkey -algorithm ED25519 -out private_key.pem +openssl pkey -in private_key.pem -pubout -out public_key.pem +chmod 600 private_key.pem +``` + +Then point both settings at the new pair and restart `bal-server` and +`bal-pusher`: + +```bash +export SSL_KEY_PATH="/absolute/path/to/private_key.pem" +export BAL_SERVER_PUB_KEY_PATH="/absolute/path/to/public_key.pem" +``` + +Keep `private_key.pem` private and outside version control. The TXT record only +contains the public key. + +--- + +## 3. Build the TXT value + +Derive the base64 SubjectPublicKeyInfo (SPKI) DER from the private key: + +```bash +KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0) +echo "v=we1; alg=ed25519; key=$KEY" +``` + +On platforms where `base64 -w0` is unavailable (for example macOS): + +```bash +KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 | tr -d '\n') +echo "v=we1; alg=ed25519; key=$KEY" +``` + +The value has this form: + +```text +v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA... +``` + +Notes: + +- `key` is the base64 of the **full SPKI DER**, not a hash and not the PEM text. + It is the same base64 body that appears in `public_key.pem`. +- Use standard base64 (`+`, `/`, `=` padding), not base64url. +- Ed25519 SPKI DER is 44 bytes, so the base64 value is about 60 characters and + the whole TXT string is about 90 characters. It fits in a single TXT string. +- If a DNS provider splits the value into multiple quoted strings, that is + acceptable: the resolver concatenates TXT chunks before parsing. + +--- + +## 4. Enable DNSSEC + +The service validates DNSSEC and **fails closed**. An unsigned or bogus answer +is rejected; there is no unauthenticated fallback. + +Enabling DNSSEC normally requires two steps: + +1. Enable DNSSEC signing for the zone at the DNS provider. +2. Publish the provider's **DS record** at the registrar or parent zone. + +The whole chain from the root to the zone that contains +`_welist-key.we.example.com` must be signed. + +### Verify DNSSEC + +```bash +dig +short DS example.com +dig +dnssec DNSKEY example.com +dig +dnssec TXT _welist-key.we.example.com +``` + +Look for `RRSIG` records in the answers. A validating resolver also sets the +`ad` flag: + +```text +;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, ... +``` + +If `dig` does not show `ad`, your local resolver may not validate. Use a +validating tool instead: + +```bash +delv TXT _welist-key.we.example.com +``` + +or: + +```bash +kdig +dnssec TXT _welist-key.we.example.com +``` + +### Resolver used by the WeTime service + +The service performs its own validation with a built-in root trust anchor. The +upstream resolver only needs to return DNSSEC records. + +If the service host uses a stub resolver that strips DNSSEC data, point it at a +DNSSEC-capable resolver: + +```bash +export WELIST_DNS_NAMESERVERS=1.1.1.1,9.9.9.9 +``` + +`WELIST_DNS_NAMESERVERS` replaces the system resolver configuration; each entry +is an `IP` or `IP:PORT` (default port 53). + +You can also point at a custom trust anchor file: + +```bash +export WELIST_DNSSEC_TRUST_ANCHOR=/path/to/root.key +``` + +### Local development + +DNSSEC cannot authenticate `localhost` or an IP address. For local development +use the legacy scheme and let the service verify the published public key: + +- Keep `WELIST_PING_SIG_VERSION` unset (or `1`), so the pusher signs with + `SSL_KEY_PATH` and the service verifies against `GET /.pub_key.pem`. +- Start the service with `WELIST_ALLOW_LEGACY_PING=true` and, for loopback or + private addresses, `WELIST_ALLOW_LOCALHOST_PING=true`. + +No TXT record and no DNSSEC setup are required in this case. + +--- + +## 5. Publish the TXT record + +| Field | Value | +|-------|-------| +| Name | `_welist-key.we.example.com` | +| Type | `TXT` | +| Value | `v=we1; alg=ed25519; key=` | +| TTL | `300` to `3600` seconds | + +The WeTime service caches DNS keys for the TXT TTL, clamped to 60–86400 +seconds. A TTL of `300`–`3600` keeps rotation reasonably fast. + +### BIND / generic zone file + +```zone +_welist-key.we.example.com. 3600 IN TXT "v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..." +``` + +Reload or re-sign the zone after adding the record. If you use inline signing, +the record is signed automatically. If you sign offline, re-run your signing +step and publish the new signed zone. + +### Cloudflare + +1. Enable DNSSEC in the Cloudflare dashboard. +2. Add the DS record shown by Cloudflare at your registrar. +3. Create the TXT record: + +- Type: `TXT` +- Name: `_welist-key.we` (Cloudflare appends the zone name) +- Content: `v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA...` +- TTL: `Auto` or `1h` + +Cloudflare proxying applies to HTTP records, not TXT. The TXT record is served +directly, so DNSSEC validation works normally. + +### Route 53 + +1. Enable DNSSEC signing for the hosted zone. +2. Add the resulting DS record at the registrar or parent zone. +3. Create the record: + +- Record name: `_welist-key.we.example.com` +- Record type: `TXT` +- Value: `"v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..."` +- TTL: `3600` + +Route 53 may split long TXT values into quoted chunks. That is supported. + +### Important + +- Do not create a CNAME for `_welist-key`. The service rejects CNAME answers. +- Do not put the private key, a hash, or PEM headers in the TXT value. +- If you need to host the record in another zone, use a proper DNSSEC-signed + delegation (NS + DS), not a CNAME. + +--- + +## 6. Configure the pusher + +Set these variables in the pusher environment (for local development, +`bal-pusher.sh`): + +```bash +export SSL_KEY_PATH="/absolute/path/to/private_key.pem" +export BAL_SERVER_URL="https://we.example.com" +export WELIST_PING_SIG_VERSION=2 +``` + +- `SSL_KEY_PATH` must point to the private key matching the published TXT key. +- `BAL_SERVER_URL` must be the public DNS hostname. The pusher normalizes it by + trimming whitespace and trailing slashes before signing. +- `WELIST_PING_SIG_VERSION=2` activates the canonical JSON signature. Leaving it + unset (or `1`) keeps the legacy v1 scheme, which the service rejects unless + `WELIST_ALLOW_LEGACY_PING=true`. + +For onion hosts, also configure the delegation certificate (see +[Onion URLs](#onion-urls)): + +```bash +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://
.onion" +``` + +- `SSL_KEY_PATH` is the **dedicated ping key**, not the onion identity key. +- `WELIST_PING_DELEGATION_FILE` is optional. When set, its JSON certificate is + sent only with onion pings. The pusher validates it at startup: `onion` must + match `BAL_SERVER_URL`, `valid_until` must be in the future, and `key` must + match `SSL_KEY_PATH`. If the file is missing or malformed, or any check + fails, it logs a warning and disables delegation, then signs with + `SSL_KEY_PATH` directly. That fallback works only when `SSL_KEY_PATH` is the + onion identity key; otherwise the service rejects the pings until the + certificate or key is corrected. + +Restart the pusher after changing the key or environment. + +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 +as `null` when it fails validation: + +- `info` longer than 4096 bytes, or empty; +- `version` longer than 64 bytes, or empty; +- `base_fee` negative. + +For `.onion` URLs the pusher skips the fetch entirely (it cannot reach its own +onion address without a Tor proxy), so those fields are always `null`. + +--- + +## 7. Verify the published key + +Query the record and extract the `key=` field (this also joins TXT chunks and +drops the quotes the resolver adds): + +```bash +dig +short TXT _welist-key.we.example.com | tr -d '" ' | sed 's/^.*key=//' +``` + +Compare it with the local public key: + +```bash +openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0 +``` + +The two commands must print the same base64 string. + +Optionally check that the legacy `/.pub_key.pem` endpoint serves the same key: + +```bash +curl -s https://we.example.com/.pub_key.pem \ + | openssl pkey -pubin -outform DER \ + | base64 -w0 +``` + +This third check is only meaningful when `BAL_SERVER_PUB_KEY_PATH` and +`SSL_KEY_PATH` reference the same key pair, which is the recommended setup. The +endpoint serves whatever `BAL_SERVER_PUB_KEY_PATH` points at; if the two +settings use different pairs, compare the TXT record with `SSL_KEY_PATH` only. + +--- + +## 8. Test a v2 ping + +Wait for the next block, or restart the pusher. With trace logging enabled you +should see: + +```text +canonical message to be sent: WELIST-PING-V2\n{"base_fee":... +Report to welist(...) status=200 body="ok" +``` + +The WeTime service should log `PING ADDED` and refresh the executor's +`last_update`. After a valid v2 ping, signed `info`, `base_fee`, and `version` +values are persisted. + +A response of `"ko"` means the signature was rejected. Check the service logs +for the specific `PING REJECTED` reason. + +--- + +## 9. Key rotation + +Multiple TXT records are supported and any one of them can verify a signature. +To rotate without downtime: + +1. Publish the new public key as an additional TXT record. +2. Switch `SSL_KEY_PATH` to the new private key and restart the pusher. +3. Wait at least the old TXT TTL, and no more than the 86400-second cache cap. +4. Remove the old TXT record. + +The service stores a key only after it verifies a signature, so an invalid DNS +answer cannot poison the cache. A refreshed TXT answer replaces the cached v2 +keys for that host. + +--- + +## 10. Troubleshooting + +| Service log | Cause | Fix | +|-------------|-------|-----| +| `no TXT records` | Wrong record name or not propagated | Query `_welist-key.` exactly | +| `zone is not DNSSEC-signed` | DNSSEC not enabled or DS missing | Enable DNSSEC and publish the DS record | +| `DNSSEC validation failed` | Expired/bogus signatures, clock skew, broken chain | Check with `delv` or a DNSSEC debugger | +| `DNSSEC status is indeterminate` | Upstream resolver strips DNSSEC data | Set `WELIST_DNS_NAMESERVERS` to a capable resolver | +| `dns unavailable` | Resolver unreachable or invalid configuration | Check `WELIST_DNS_NAMESERVERS` and `WELIST_DNSSEC_TRUST_ANCHOR` | +| `CNAME answers are not accepted` | `_welist-key` is an alias | Publish a real TXT record | +| `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 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` | Re-issue the certificate for the exact host | +| `delegation expired` | `valid_until` is in the past | 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 | +| `legacy ping disabled` | v1 ping sent while the service requires v2 | Set `WELIST_PING_SIG_VERSION=2` (or enable legacy on the service) | + +Useful diagnostics: + +```bash +dig +dnssec TXT _welist-key.we.example.com +delv TXT _welist-key.we.example.com +dig +trace +dnssec TXT _welist-key.we.example.com +``` + +--- + +## Onion URLs + +A v3 `.onion` address embeds the service's Ed25519 identity key, so no TXT +record is needed. The recommended design keeps that identity key **offline** and +delegates ping signing to a dedicated, rotatable key: + +- `ping_private_key.pem` (`SSL_KEY_PATH`) signs every v2 ping. +- The onion identity key signs a delegation certificate once. +- Each onion ping carries the certificate in a `delegation` object. +- The WeTime service verifies the certificate against the onion address, then + verifies the ping signature with the delegated key. + +Without a `delegation` object the service still accepts pings signed directly by +the onion identity key. That fallback exists for compatibility; it requires the +identity key to be online and should not be used for new deployments. + +The ping `url` must itself use the `.onion` host. The `onion_url` database field +is display-only and does not affect authentication. + +### Certificate format + +The request carries: + +```json +{ + "onion": "
.onion", + "key": "", + "valid_until": 1767225600, + "signature": "" +} +``` + +The identity signature covers exactly: + +```text +WELIST-PING-DELEGATION-V1\n{"key":"...","onion":"...","valid_until":1767225600} +``` + +Rules enforced by the service: + +- `onion` must exactly equal the ping URL host (lowercase, no trailing dot). +- `valid_until` is a Unix timestamp in seconds and must be in the future. +- `key` must be a valid Ed25519 SubjectPublicKeyInfo DER in standard base64. +- The identity signature must verify against the key embedded in the onion + address. +- A present but invalid, expired, or mismatched certificate is rejected; the + service does not fall back to the identity key. +- `delegation` is rejected on non-onion hosts. + +### Issue the certificate + +Create a dedicated ping key pair: + +```bash +openssl genpkey -algorithm ED25519 -out ping_private_key.pem +chmod 600 ping_private_key.pem +``` + +Use the bundled helper with your onion key. It accepts either a Tor +`hs_ed25519_secret_key` file or a standard PKCS#8 PEM Ed25519 key: + +```bash +export ONION="
.onion" +export ONION_KEY="/var/lib/tor/hidden_service/hs_ed25519_secret_key" + +contrib/make_onion_delegation.py \ + --onion "$ONION" \ + --identity-key "$ONION_KEY" \ + --ping-key /etc/bal/ping_private_key.pem \ + --days 365 \ + --out /etc/bal/onion-delegation.json +``` + +For Tor-format keys the helper needs Python `cryptography` and `pynacl`: + +```bash +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. 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. The certificate contains public data; +the ping private key must remain secret. + +### Configure the pusher + +```bash +export SSL_KEY_PATH="/etc/bal/ping_private_key.pem" +export WELIST_PING_DELEGATION_FILE="/etc/bal/onion-delegation.json" +export BAL_SERVER_URL="http://
.onion" +export WELIST_PING_SIG_VERSION=2 +``` + +The pusher sends `delegation` only when `BAL_SERVER_URL` is an `.onion` host. It +validates the certificate at startup against the onion host, its expiry, and +`SSL_KEY_PATH`. If the file is missing or malformed, or validation fails, +delegation is disabled and the pusher signs with `SSL_KEY_PATH` directly; that +works only when `SSL_KEY_PATH` is the onion identity key. + +For local development, the `WELIST_ALLOW_LOCALHOST_PING` and legacy v1 notes +above still apply; delegation itself needs no network access. + +### Rotation + +1. Generate a new `ping_private_key.pem`. +2. Re-run `contrib/make_onion_delegation.py` with the new key and a new + `valid_until`. +3. Replace `onion-delegation.json` and `SSL_KEY_PATH` on the executor. +4. Restart the pusher. + +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. + +### Security notes + +- Delegation limits the blast radius: a compromised online ping key forges + pings only until rotation, not the onion service identity. +- A long `valid_until` increases the impact of a leaked delegated key. Prefer + one year or less and rotate before expiry. +- The onion identity key should never be copied to the executor. + +### Metadata for onion pings + +The pusher cannot fetch its own `.onion` endpoint without a Tor proxy, so +`info`, `base_fee`, and `version` are signed as `null` for onion pings. + +With `WELIST_PING_SIG_VERSION=1` the pusher sends no metadata at all. The v1 +signature covers only the legacy liveness concatenation (`url`, `chain`, +`height`, `median_time`, `last_block_hash`), and the WeTime service refreshes +`last_update` without persisting `info`, `base_fee`, or `version`. Unsigned +metadata is therefore not published. Use v2 when executor metadata must be +authenticated and stored. + +--- + +## Minimal checklist + +- [ ] Ed25519 key pair generated; `private_key.pem` is `chmod 600`. +- [ ] `SSL_KEY_PATH` and `BAL_SERVER_PUB_KEY_PATH` use the same key pair. +- [ ] TXT value built from `openssl pkey ... -outform DER | base64 -w0`. +- [ ] TXT record `_welist-key.` published, not a CNAME. +- [ ] Zone DNSSEC-signed and DS published at the registrar. +- [ ] `dig +dnssec` / `delv` shows a validated answer. +- [ ] `BAL_SERVER_URL` is the public DNS hostname. +- [ ] `WELIST_PING_SIG_VERSION=2` is set. +- [ ] Pusher restarted and reports `body="ok"`. + +For onion hosts, replace the TXT/DNSSEC items with: + +- [ ] A dedicated `ping_private_key.pem` exists and is `chmod 600`. +- [ ] `onion-delegation.json` was issued offline by the onion identity key. +- [ ] `SSL_KEY_PATH` points at the delegated ping key, not the identity key. +- [ ] `WELIST_PING_DELEGATION_FILE` points at the certificate. +- [ ] `valid_until` is in the future and rotation is scheduled. +- [ ] Pusher restarted and reports `body="ok"`. diff --git a/docs/INDEX.md b/docs/INDEX.md index 27e0dd8..6bdb41f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -8,6 +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 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) @@ -25,6 +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 | --- diff --git a/src/bin/bal-pusher.rs b/src/bin/bal-pusher.rs index fba719d..685ecb9 100644 --- a/src/bin/bal-pusher.rs +++ b/src/bin/bal-pusher.rs @@ -5,7 +5,10 @@ use bitcoin::Network; use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin}; use bitcoincore_rpc_json::GetBlockchainInfoResult; -use ed25519_dalek::{Signer as _, SigningKey, pkcs8::DecodePrivateKey}; +use ed25519_dalek::{ + Signer as _, SigningKey, + pkcs8::{DecodePrivateKey, EncodePublicKey}, +}; use log::{debug, error, info, trace, warn}; use serde::Deserialize; use serde::Serialize; @@ -14,7 +17,8 @@ use std::collections::{BTreeMap, HashMap}; use std::env; use std::error::Error as StdError; use std::str; -use std::{thread, time::Duration}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use zmq::{Context, Socket}; use bal_server::db::{calculate_and_upsert_stats, get_pending_txs, open_database}; @@ -40,6 +44,23 @@ const MAX_INFO_BYTES: usize = 4096; /// Maximum accepted `version` length, matching the WeTime `/ping` validation. const MAX_VERSION_BYTES: usize = 64; +/// Onion delegation certificate issued offline by the onion identity key. +/// +/// The field names and types match the WeTime service's `OnionDelegation` +/// wire format. The certificate is public data; only the onion identity key +/// and the delegated ping key are secret. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OnionDelegation { + /// Onion host the certificate is bound to. + onion: String, + /// Base64 SubjectPublicKeyInfo DER of the delegated Ed25519 key. + key: String, + /// Unix timestamp in seconds after which the certificate is invalid. + valid_until: u64, + /// Base64 Ed25519 signature by the onion identity key. + signature: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct MyConfig { db_backend: String, @@ -57,6 +78,12 @@ struct MyConfig { /// WeTime ping signature version. Defaults to the legacy scheme so an /// unmodified deployment keeps working. ping_sig_version: u8, + /// Optional onion delegation certificate loaded from + /// `WELIST_PING_DELEGATION_FILE`. When present and valid, onion pings are + /// signed with `SSL_KEY_PATH` as the delegated key instead of the onion + /// identity key. The certificate is checked at startup against the + /// configured onion host, its expiry, and the signing key. + onion_delegation: Option, } impl Default for MyConfig { @@ -78,6 +105,41 @@ impl Default for MyConfig { url: env::var("BAL_SERVER_URL").unwrap_or("http://127.0.0.1:9133".to_string()), ssl_key_path: env::var("SSL_KEY_PATH").unwrap_or("privkey.pem".to_string()), ping_sig_version: parse_ping_sig_version(), + onion_delegation: None, + } + } +} + +/// Load the onion delegation certificate from `WELIST_PING_DELEGATION_FILE`. +/// +/// The file is a JSON object with `onion`, `key`, `valid_until` and +/// `signature`, produced offline with the onion identity key. A missing or +/// malformed file disables delegation, so the pusher falls back to signing with +/// `SSL_KEY_PATH` directly. +fn load_onion_delegation() -> Option { + let path = env::var("WELIST_PING_DELEGATION_FILE").ok()?; + let path = path.trim(); + if path.is_empty() { + return None; + } + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) => { + warn!( + "Cannot read WELIST_PING_DELEGATION_FILE={} ({}), onion delegation disabled", + path, err + ); + return None; + } + }; + match serde_json::from_str::(&text) { + Ok(delegation) => Some(delegation), + Err(err) => { + warn!( + "Cannot parse WELIST_PING_DELEGATION_FILE={} ({}), onion delegation disabled", + path, err + ); + None } } } @@ -440,19 +502,138 @@ fn sanitize_metadata(metadata: ExecutorMetadata) -> ExecutorMetadata { } } -/// Return `true` when the URL host is a v3 `.onion` address. -/// -/// The pusher cannot reach its own onion address without a Tor proxy, so v2 -/// pings for onion URLs are signed with `null` metadata instead. -fn is_onion_url(url: &str) -> bool { +/// Return the URL host exactly as the WeTime service extracts it from the +/// normalized executor URL. +fn executor_host(url: &str) -> Option { Url::parse(url) .ok() - .and_then(|parsed| { - parsed - .host_str() - .map(|host| host.trim_end_matches('.').to_ascii_lowercase()) - }) - .is_some_and(|host| host.ends_with(".onion")) + .and_then(|parsed| parsed.host_str().map(str::to_string)) +} + +/// Return `true` when the URL host is a v3 `.onion` address. +/// +/// This mirrors the service-side check (`host.ends_with(".onion")`) so both +/// sides agree on whether delegation may be sent. The pusher cannot reach its +/// own onion address without a Tor proxy, so v2 pings for onion URLs are +/// signed with `null` metadata instead. +fn is_onion_url(url: &str) -> bool { + executor_host(url).is_some_and(|host| host.ends_with(".onion")) +} + +/// Return the current Unix time in seconds, or `0` if the clock is before the +/// epoch. +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +/// Return whether `delegation.key` matches the public key derived from +/// `signing_key`. +/// +/// `None` means the public key could not be encoded as SPKI DER. Only the +/// public half is compared; the private key is never logged or transmitted. +fn delegation_key_matches(signing_key: &SigningKey, delegation: &OnionDelegation) -> Option { + let public_der = signing_key.verifying_key().to_public_key_der().ok()?; + let expected = general_purpose::STANDARD.encode(public_der.as_bytes()); + Some(delegation.key.trim() == expected) +} + +/// Validate the loaded onion delegation certificate against the configured +/// executor URL and signing key. +/// +/// The WeTime service rejects a present-but-invalid certificate instead of +/// falling back to the onion identity key, so a stale certificate would make +/// every onion ping fail. Check the parts the pusher can verify locally at +/// startup: the certificate is bound to the configured onion host, it has not +/// expired, and its delegated key matches `SSL_KEY_PATH`. On any mismatch, +/// delegation is disabled with a warning and the pusher signs with +/// `SSL_KEY_PATH` directly. +fn validate_onion_delegation(cfg: &mut MyConfig) { + let Some(delegation) = cfg.onion_delegation.as_ref() else { + return; + }; + + let normalized_url = normalize_executor_url(&cfg.url); + let Some(host) = executor_host(&normalized_url) else { + warn!( + "WELIST_PING_DELEGATION_FILE is set but BAL_SERVER_URL={} is not a valid URL; disabling onion delegation", + cfg.url + ); + cfg.onion_delegation = None; + return; + }; + + if !host.ends_with(".onion") { + warn!( + "WELIST_PING_DELEGATION_FILE is set but BAL_SERVER_URL host {} is not an onion address; disabling onion delegation", + host + ); + cfg.onion_delegation = None; + return; + } + + if delegation.onion != host { + warn!( + "Onion delegation is bound to {} but the executor URL host is {}; disabling onion delegation. Re-issue the certificate for the exact host.", + delegation.onion, host + ); + cfg.onion_delegation = None; + return; + } + + let now = unix_now(); + if delegation.valid_until < now { + warn!( + "Onion delegation expired at {} (now {}); disabling onion delegation. Re-issue the certificate.", + delegation.valid_until, now + ); + cfg.onion_delegation = None; + return; + } + + let signing_key = match SigningKey::read_pkcs8_pem_file(&cfg.ssl_key_path) { + Ok(key) => key, + Err(err) => { + warn!( + "Cannot read SSL_KEY_PATH={} to validate onion delegation ({}); disabling onion delegation", + cfg.ssl_key_path, err + ); + cfg.onion_delegation = None; + return; + } + }; + + match delegation_key_matches(&signing_key, delegation) { + Some(true) => {} + Some(false) => { + warn!( + "Onion delegation delegates to a different key than SSL_KEY_PATH={}; disabling onion delegation. Re-issue the certificate for the current ping key.", + cfg.ssl_key_path + ); + cfg.onion_delegation = None; + } + None => { + warn!( + "Cannot encode the public key from SSL_KEY_PATH={}; disabling onion delegation", + cfg.ssl_key_path + ); + cfg.onion_delegation = None; + } + } +} + +/// Return the delegation certificate to send for `url`, if any. +/// +/// Delegation is onion-only: sending it for a DNS host would be rejected by +/// the WeTime service. +fn delegation_for_url<'a>(cfg: &'a MyConfig, url: &str) -> Option<&'a OnionDelegation> { + if is_onion_url(url) { + cfg.onion_delegation.as_ref() + } else { + None + } } /// Fetch the signed metadata published by the local bal-server. @@ -603,7 +784,7 @@ async fn send_stats_report( return Ok(()); } }; - request.json(&json!({ + let mut body = json!({ "url": executor_url, "chain": chain, "height": bcinfo.blocks, @@ -614,8 +795,17 @@ async fn send_stats_report( "info": metadata.info, "base_fee": metadata.base_fee, "version": metadata.version, - })) + }); + if let Some(delegation) = delegation_for_url(cfg, &executor_url) { + body["delegation"] = json!(delegation); + } + request.json(&body) } else { + // The v1 signature authenticates only the legacy liveness + // concatenation. `info`, `base_fee` and `version` are intentionally + // not sent: they are outside the signature, and the WeTime service + // ignores them for v1 pings. Use v2 when metadata must be published + // and authenticated. let message = format!( "{0}{1}{2}{3}{4}", cfg.url, chain, bcinfo.blocks, bcinfo.median_time, last_block_hash @@ -730,6 +920,8 @@ async fn main() -> std::io::Result<()> { let mut cfg = MyConfig::default(); parse_env(&mut cfg); + cfg.onion_delegation = load_onion_delegation(); + validate_onion_delegation(&mut cfg); let mut args = std::env::args(); let _exe_name = args.next().unwrap(); let arg_network = match args.next() { @@ -882,6 +1074,79 @@ mod tests { assert!(!is_onion_url("not a url")); } + fn test_delegation(onion: &str) -> OnionDelegation { + OnionDelegation { + onion: onion.to_string(), + key: "KEY".to_string(), + valid_until: unix_now() + 3600, + signature: "SIG".to_string(), + } + } + + #[test] + fn delegation_is_only_returned_for_onion_urls() { + let cfg = MyConfig { + onion_delegation: Some(test_delegation("example.onion")), + ..MyConfig::default() + }; + + assert!(delegation_for_url(&cfg, "http://example.onion").is_some()); + assert!(delegation_for_url(&cfg, "https://we.example.com").is_none()); + + let cfg = MyConfig::default(); + assert!(delegation_for_url(&cfg, "http://example.onion").is_none()); + } + + #[test] + fn delegation_bound_to_another_onion_is_disabled() { + let mut cfg = MyConfig { + url: "http://expected.onion".to_string(), + onion_delegation: Some(test_delegation("other.onion")), + ..MyConfig::default() + }; + + validate_onion_delegation(&mut cfg); + + assert!(cfg.onion_delegation.is_none()); + } + + #[test] + fn delegation_on_non_onion_url_is_disabled() { + let mut cfg = MyConfig { + url: "https://we.example.com".to_string(), + onion_delegation: Some(test_delegation("example.onion")), + ..MyConfig::default() + }; + + validate_onion_delegation(&mut cfg); + + assert!(cfg.onion_delegation.is_none()); + } + + #[test] + fn delegation_key_must_match_the_signing_key() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let public_der = signing_key + .verifying_key() + .to_public_key_der() + .expect("public key encodes as SPKI DER"); + let matching = OnionDelegation { + key: general_purpose::STANDARD.encode(public_der.as_bytes()), + ..test_delegation("example.onion") + }; + + assert_eq!(delegation_key_matches(&signing_key, &matching), Some(true)); + + let mismatching = OnionDelegation { + key: "different".to_string(), + ..matching + }; + assert_eq!( + delegation_key_matches(&signing_key, &mismatching), + Some(false) + ); + } + #[test] fn canonical_ping_payload_is_sorted_compact_and_complete() { let payload = PingPayload {