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
|
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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`).
|
- 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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user