fix(electrum): accept self-signed certs by default so ssl:// fallbacks work

The electrum client did strict webpki TLS verification (CA chain + hostname). But
public electrum servers are self-signed by ecosystem convention — e.g. emzy.de
presents a cert webpki rejects with `UnsupportedCertVersion`. So the ssl:// fallback
layer was non-functional: whenever the trusted primary Fulcrum hiccuped (the
recurring nebula-firewall issue), every fallback failed the TLS handshake and
BTC/LTC returned 503 NODE_UNAVAILABLE (observed live on api.smirk.cash).

TLS here is transport encryption, not the trust boundary — the client already
treats every server byte as hostile (size bounds, id correlation, checked amount
conversion, address validation), so CA-authenticating an untrusted PUBLIC fallback
buys nothing while breaking it. Default to accepting any server cert; gate strict
webpki behind ELECTRUM_STRICT_TLS=1 for operators who run a CA-cert'd Fulcrum.

Verified (network-gated test): the lenient connector completes get_tip_height
against blockstream (CA-signed) AND emzy.de (self-signed) where strict fails.
cargo check + electrum/config unit tests green.
This commit is contained in:
jwinterm
2026-07-08 13:59:49 -04:00
parent 2f610aecb2
commit 57ebb2a5aa
2 changed files with 120 additions and 9 deletions
+9
View File
@@ -257,6 +257,12 @@ pub struct UtxoConfig {
pub network: String,
pub electrum_primary: Option<String>,
pub electrum_fallbacks: Vec<String>,
/// Require CA-verified TLS (webpki + hostname) for ssl:// electrum servers.
/// Default false: electrum servers are self-signed by convention and their
/// data is treated as hostile regardless (see electrum.rs), so accepting any
/// cert keeps the public fallbacks usable. Operators who run their own
/// CA-cert'd Fulcrum can set `ELECTRUM_STRICT_TLS=1` to harden.
pub electrum_strict_tls: bool,
}
/// Monero/Wownero daemon + light-wallet-server configuration.
@@ -740,11 +746,13 @@ impl Config {
network: env_or("BTC_NETWORK", "mainnet"),
electrum_primary: env_opt("BTC_ELECTRUM_URL"),
electrum_fallbacks: env_list("BTC_ELECTRUM_FALLBACKS"),
electrum_strict_tls: env_bool("ELECTRUM_STRICT_TLS", false),
},
ltc: UtxoConfig {
network: env_or("LTC_NETWORK", "mainnet"),
electrum_primary: env_opt("LTC_ELECTRUM_URL"),
electrum_fallbacks: env_list("LTC_ELECTRUM_FALLBACKS"),
electrum_strict_tls: env_bool("ELECTRUM_STRICT_TLS", false),
},
xmr: LwsConfig {
lws_url: env_or("XMR_LWS_URL", "http://127.0.0.1:8443"),
@@ -1295,6 +1303,7 @@ mod tests {
network: "mainnet".into(),
electrum_primary: None,
electrum_fallbacks: vec![],
electrum_strict_tls: false,
};
let lws = || LwsConfig {
lws_url: String::new(),
+111 -9
View File
@@ -7,8 +7,14 @@
//! Every byte these servers return is treated as hostile — a server may be
//! malicious, compromised, or MITM'd. The client is hardened accordingly:
//!
//! * **TLS is verified** against the webpki root store *with hostname
//! checking*. There is no accept-any-certificate path.
//! * **TLS is transport encryption, not the trust boundary.** Electrum servers
//! present self-signed certs by ecosystem convention (public servers use
//! X.509v1/self-signed certs a webpki root check rejects with
//! `UnsupportedCertVersion`), so we accept any server cert — authenticating
//! an *untrusted public* fallback against a CA adds nothing when its data is
//! already treated as hostile. Trust comes entirely from the hardening below
//! (bounds, id correlation, checked amount conversion, address validation),
//! not from PKI. The primary is a trusted node on the private mesh.
//! * Each response read is **size-bounded** and each exchange is wrapped in a
//! **timeout** (no OOM, no slowloris).
//! * Addresses are decoded with the audited [`bech32`] crate (full checksum +
@@ -353,7 +359,7 @@ impl ElectrumClient {
network,
primary,
fallbacks,
tls: build_tls_connector(),
tls: build_tls_connector(cfg.electrum_strict_tls),
next_id: Arc::new(AtomicU64::new(1)),
})
}
@@ -639,15 +645,85 @@ impl ElectrumClient {
/// Build the shared TLS connector seeded with the webpki root store. Hostname
/// verification is performed by rustls at connect time against the `ServerName`
/// passed in `exchange_ssl`.
fn build_tls_connector() -> TlsConnector {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
/// Build the TLS connector for ssl:// electrum servers. `strict` selects CA-verified
/// webpki (+ hostname) — for operators who run a CA-cert'd Fulcrum; the default
/// (false) accepts any server cert. Electrum servers are self-signed by convention
/// and their data is treated as hostile regardless, so CA-authenticating an
/// untrusted PUBLIC fallback buys nothing while breaking it (webpki rejects the
/// self-signed / X.509v1 cert with UnsupportedCertVersion). TLS remains as transport
/// encryption; trust lives in the response hardening, not PKI.
fn build_tls_connector(strict: bool) -> TlsConnector {
let config = if strict {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth()
} else {
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth()
};
TlsConnector::from(Arc::new(config))
}
/// Accepts ANY server certificate (self-signed included). Safe for electrum because
/// the transport carries only read-only public chain data that is independently
/// validated as hostile (bounds, id correlation, checked amount conversion, address
/// validation) — see the module header. A MITM gains nothing a malicious-but-
/// CA-valid server couldn't already do. Gate strict verification behind
/// `ELECTRUM_STRICT_TLS=1` if you run a trusted CA-cert'd Fulcrum.
#[derive(Debug)]
struct AcceptAnyServerCert;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
use rustls::SignatureScheme::*;
vec![
RSA_PKCS1_SHA256,
RSA_PKCS1_SHA384,
RSA_PKCS1_SHA512,
ECDSA_NISTP256_SHA256,
ECDSA_NISTP384_SHA384,
ECDSA_NISTP521_SHA512,
RSA_PSS_SHA256,
RSA_PSS_SHA384,
RSA_PSS_SHA512,
ED25519,
]
}
}
/// Parse `ssl://host:port`, `tls://host:port`, or `tcp://host:port`.
fn parse_server_url(url: &str) -> Result<ServerUrl, AppError> {
let (scheme, rest) = url
@@ -775,6 +851,7 @@ mod tests {
network: "mainnet".into(),
electrum_primary: None,
electrum_fallbacks: vec![],
electrum_strict_tls: false,
}
}
@@ -786,6 +863,31 @@ mod tests {
ElectrumClient::new(UtxoNetwork::LitecoinMainnet, &cfg()).unwrap()
}
/// The lenient (default) TLS connector must complete the handshake + an
/// electrum request against PUBLIC ssl:// servers regardless of their cert
/// (CA-signed OR self-signed) — which is the whole point of the default: the
/// strict path rejects the self-signed ones with UnsupportedCertVersion and
/// leaves the fallback layer non-functional. Network-gated; run `--ignored`.
#[tokio::test]
#[ignore = "network: dials public electrum servers over TLS"]
async fn lenient_tls_connects_to_public_electrum() {
for server in [
"ssl://electrum.blockstream.info:50002", // CA-signed
"ssl://electrum.emzy.de:50002", // self-signed (webpki would reject)
] {
let cfg = UtxoConfig {
network: "mainnet".into(),
electrum_primary: Some(server.into()),
electrum_fallbacks: vec![],
electrum_strict_tls: false,
};
let client = ElectrumClient::new(UtxoNetwork::BitcoinMainnet, &cfg).unwrap();
let tip = client.get_tip_height().await;
assert!(tip.is_ok(), "lenient TLS must connect to {server}: {tip:?}");
assert!(tip.unwrap() > 800_000, "sanity: BTC tip height from {server}");
}
}
fn spk(addr: &str) -> ScriptPubKeyInfo {
ScriptPubKeyInfo {
address: Some(addr.to_string()),