feat(pusher): add validated onion delegation for v2 pings

- Load WELIST_PING_DELEGATION_FILE into a typed OnionDelegation
- Send the certificate only with v2 pings for onion hosts
- Validate the onion host, expiry and delegated key against
  BAL_SERVER_URL and SSL_KEY_PATH at startup; disable delegation
  with a warning on any mismatch
- Stop sending unsigned info/base_fee/version with v1 pings so
  v1 remains liveness-only
- Document v1/v2 metadata handling and onion delegation implications
- Add contrib/make_onion_delegation.py to issue certificates offline,
  with Ed25519 key validation and clean CLI error handling
This commit is contained in:
2026-11-08 23:07:08 -04:00
parent 0eff7a649a
commit 63f65f930a
10 changed files with 1106 additions and 25 deletions

View File

@@ -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 {