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