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:
@@ -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
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
214
contrib/make_onion_delegation.py
Executable file
214
contrib/make_onion_delegation.py
Executable 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)
|
||||
@@ -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.<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
|
||||
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.<host>` with value
|
||||
`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
|
||||
- Uses `set_rcvtimeo(5000)` (5-second timeout).
|
||||
|
||||
@@ -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>`)
|
||||
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.<host>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
560
docs/10_dnssec_key_publishing.md
Normal file
560
docs/10_dnssec_key_publishing.md
Normal 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"`.
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<OnionDelegation>,
|
||||
}
|
||||
|
||||
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<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.
|
||||
///
|
||||
/// 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<String> {
|
||||
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<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.
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user