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
This commit is contained in:
2026-11-08 23:07:08 -04:00
parent 0eff7a649a
commit 63f65f930a
10 changed files with 1106 additions and 25 deletions

View File

@@ -55,6 +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
# 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) # ZMQ endpoints per network (default: localhost only)
BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332 BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332

View File

@@ -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` | | `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 |
### Per-network configuration ### Per-network configuration

View File

@@ -27,9 +27,12 @@ export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
export BAL_PUSHER_SEND_STATS=true export BAL_PUSHER_SEND_STATS=true
export WELIST_SERVER_URL=http://localhost:8086 export WELIST_SERVER_URL=http://localhost:8086
export WELIST_SKIP_URL_VALIDATION=true export WELIST_SKIP_URL_VALIDATION=true
# Sign WeTime pings with the v2 canonical JSON scheme (DNSSEC/onion public key # Local development uses the legacy v1 signature, verified by the service
# discovery). Remove this line to fall back to the legacy v1 signature. # against GET /.pub_key.pem: a localhost URL cannot use DNSSEC key discovery.
export WELIST_PING_SIG_VERSION=2 # 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 BAL_SERVER_URL="http://127.0.0.1:9133"
export SSL_KEY_PATH="$WORKING_DIR/private_key.pem" export SSL_KEY_PATH="$WORKING_DIR/private_key.pem"

214
contrib/make_onion_delegation.py Executable file
View File

@@ -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":"<base64 SPKI DER>","onion":"<host>","valid_until":<unix>}
Usage:
contrib/make_onion_delegation.py \
--onion <address>.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)

View File

@@ -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`). - 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: - `WELIST_PING_SIG_VERSION` selects the signature scheme:
- `1` (default): legacy concatenation of URL, chain, height, median time and - `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 - `2`: canonical JSON signed with the `WELIST-PING-V2\n` prefix. The pusher
fetches `info`, `base_fee` and `version` from the local fetches `info`, `base_fee` and `version` from the local
`/{chain}/info` endpoint and signs them together with the block metadata. `/{chain}/info` endpoint and signs them together with the block metadata.
The WeTime service discovers the public key from a DNSSEC-validated The WeTime service discovers the public key from a DNSSEC-validated
`_welist-key.<host>` TXT record or from a v3 onion address, so no HTTP `_welist-key.<host>` TXT record or from a v3 onion address, so no HTTP
callback is needed. The `url` is trimmed of trailing slashes to match the callback is needed. The `url` is trimmed of trailing slashes to match the
service-side normalization; absent metadata is signed as `null`. service-side normalization; absent metadata is signed as `null`. When
- `bal-pusher.sh` sets `WELIST_PING_SIG_VERSION=2` for the development setup; onion delegation is configured, the pusher verifies at startup that the
remove that line to fall back to the legacy scheme. 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: - To publish the v2 public key, derive the base64 SubjectPublicKeyInfo DER:
`openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0` and `openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0` and
publish it in a DNSSEC-signed TXT record named `_welist-key.<host>` with value publish it in a DNSSEC-signed TXT record named `_welist-key.<host>` with value
`v=we1; alg=ed25519; key=<base64>`. A v3 onion URL needs no DNS record: its `v=we1; alg=ed25519; key=<base64>`. 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 ### ZMQ Timeout Handling
- Uses `set_rcvtimeo(5000)` (5-second timeout). - Uses `set_rcvtimeo(5000)` (5-second timeout).

View File

@@ -168,13 +168,32 @@ scheme is selected with `WELIST_PING_SIG_VERSION`:
`_welist-key.<host>` TXT record (`v=we1; alg=ed25519; key=<base64 SPKI DER>`) `_welist-key.<host>` TXT record (`v=we1; alg=ed25519; key=<base64 SPKI DER>`)
or from a v3 `.onion` address. No HTTP callback to `/.pub_key.pem` is made. 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 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
operator guide on publishing `_welist-key.<host>` and on onion delegation.
### Version 1 (legacy) ### Version 1 (legacy)
1. Concatenates `url + chain + height + median_time + last_block_hash`. 1. Concatenates `url + chain + height + median_time + last_block_hash`.
2. Signs that string with the Ed25519 private key. 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 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.

View File

@@ -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_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 |
#### Per-Network Settings #### Per-Network Settings

View File

@@ -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.<host>`, 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.<host>`. 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=<base64 SPKI DER>` |
| 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://<address>.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.<host>` 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": "<address>.onion",
"key": "<base64 SPKI DER of the delegated Ed25519 key>",
"valid_until": 1767225600,
"signature": "<base64 Ed25519 signature by the onion identity key>"
}
```
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="<address>.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://<address>.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.<host>` 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"`.

View File

@@ -8,6 +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 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)
@@ -25,6 +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 |
--- ---

View File

@@ -5,7 +5,10 @@ use bitcoin::Network;
use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin}; use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin};
use bitcoincore_rpc_json::GetBlockchainInfoResult; 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 log::{debug, error, info, trace, warn};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -14,7 +17,8 @@ use std::collections::{BTreeMap, HashMap};
use std::env; use std::env;
use std::error::Error as StdError; use std::error::Error as StdError;
use std::str; use std::str;
use std::{thread, time::Duration}; use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use zmq::{Context, Socket}; use zmq::{Context, Socket};
use bal_server::db::{calculate_and_upsert_stats, get_pending_txs, open_database}; 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. /// Maximum accepted `version` length, matching the WeTime `/ping` validation.
const MAX_VERSION_BYTES: usize = 64; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct MyConfig { struct MyConfig {
db_backend: String, db_backend: String,
@@ -57,6 +78,12 @@ struct MyConfig {
/// WeTime ping signature version. Defaults to the legacy scheme so an /// WeTime ping signature version. Defaults to the legacy scheme so an
/// unmodified deployment keeps working. /// unmodified deployment keeps working.
ping_sig_version: u8, 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<OnionDelegation>,
} }
impl Default for MyConfig { 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()), 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()), ssl_key_path: env::var("SSL_KEY_PATH").unwrap_or("privkey.pem".to_string()),
ping_sig_version: parse_ping_sig_version(), 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<OnionDelegation> {
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::<OnionDelegation>(&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. /// Return the URL host exactly as the WeTime service extracts it from the
/// /// normalized executor URL.
/// The pusher cannot reach its own onion address without a Tor proxy, so v2 fn executor_host(url: &str) -> Option<String> {
/// pings for onion URLs are signed with `null` metadata instead.
fn is_onion_url(url: &str) -> bool {
Url::parse(url) Url::parse(url)
.ok() .ok()
.and_then(|parsed| { .and_then(|parsed| parsed.host_str().map(str::to_string))
parsed }
.host_str()
.map(|host| host.trim_end_matches('.').to_ascii_lowercase()) /// Return `true` when the URL host is a v3 `.onion` address.
}) ///
.is_some_and(|host| host.ends_with(".onion")) /// 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<bool> {
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. /// Fetch the signed metadata published by the local bal-server.
@@ -603,7 +784,7 @@ async fn send_stats_report(
return Ok(()); return Ok(());
} }
}; };
request.json(&json!({ let mut body = json!({
"url": executor_url, "url": executor_url,
"chain": chain, "chain": chain,
"height": bcinfo.blocks, "height": bcinfo.blocks,
@@ -614,8 +795,17 @@ async fn send_stats_report(
"info": metadata.info, "info": metadata.info,
"base_fee": metadata.base_fee, "base_fee": metadata.base_fee,
"version": metadata.version, "version": metadata.version,
})) });
if let Some(delegation) = delegation_for_url(cfg, &executor_url) {
body["delegation"] = json!(delegation);
}
request.json(&body)
} else { } 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!( let message = format!(
"{0}{1}{2}{3}{4}", "{0}{1}{2}{3}{4}",
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, last_block_hash 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(); let mut cfg = MyConfig::default();
parse_env(&mut cfg); parse_env(&mut cfg);
cfg.onion_delegation = load_onion_delegation();
validate_onion_delegation(&mut cfg);
let mut args = std::env::args(); let mut args = std::env::args();
let _exe_name = args.next().unwrap(); let _exe_name = args.next().unwrap();
let arg_network = match args.next() { let arg_network = match args.next() {
@@ -882,6 +1074,79 @@ mod tests {
assert!(!is_onion_url("not a url")); 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] #[test]
fn canonical_ping_payload_is_sorted_compact_and_complete() { fn canonical_ping_payload_is_sorted_compact_and_complete() {
let payload = PingPayload { let payload = PingPayload {