mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -257,6 +257,12 @@ pub struct UtxoConfig {
|
|||||||
pub network: String,
|
pub network: String,
|
||||||
pub electrum_primary: Option<String>,
|
pub electrum_primary: Option<String>,
|
||||||
pub electrum_fallbacks: Vec<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.
|
/// Monero/Wownero daemon + light-wallet-server configuration.
|
||||||
@@ -740,11 +746,13 @@ impl Config {
|
|||||||
network: env_or("BTC_NETWORK", "mainnet"),
|
network: env_or("BTC_NETWORK", "mainnet"),
|
||||||
electrum_primary: env_opt("BTC_ELECTRUM_URL"),
|
electrum_primary: env_opt("BTC_ELECTRUM_URL"),
|
||||||
electrum_fallbacks: env_list("BTC_ELECTRUM_FALLBACKS"),
|
electrum_fallbacks: env_list("BTC_ELECTRUM_FALLBACKS"),
|
||||||
|
electrum_strict_tls: env_bool("ELECTRUM_STRICT_TLS", false),
|
||||||
},
|
},
|
||||||
ltc: UtxoConfig {
|
ltc: UtxoConfig {
|
||||||
network: env_or("LTC_NETWORK", "mainnet"),
|
network: env_or("LTC_NETWORK", "mainnet"),
|
||||||
electrum_primary: env_opt("LTC_ELECTRUM_URL"),
|
electrum_primary: env_opt("LTC_ELECTRUM_URL"),
|
||||||
electrum_fallbacks: env_list("LTC_ELECTRUM_FALLBACKS"),
|
electrum_fallbacks: env_list("LTC_ELECTRUM_FALLBACKS"),
|
||||||
|
electrum_strict_tls: env_bool("ELECTRUM_STRICT_TLS", false),
|
||||||
},
|
},
|
||||||
xmr: LwsConfig {
|
xmr: LwsConfig {
|
||||||
lws_url: env_or("XMR_LWS_URL", "http://127.0.0.1:8443"),
|
lws_url: env_or("XMR_LWS_URL", "http://127.0.0.1:8443"),
|
||||||
@@ -1295,6 +1303,7 @@ mod tests {
|
|||||||
network: "mainnet".into(),
|
network: "mainnet".into(),
|
||||||
electrum_primary: None,
|
electrum_primary: None,
|
||||||
electrum_fallbacks: vec![],
|
electrum_fallbacks: vec![],
|
||||||
|
electrum_strict_tls: false,
|
||||||
};
|
};
|
||||||
let lws = || LwsConfig {
|
let lws = || LwsConfig {
|
||||||
lws_url: String::new(),
|
lws_url: String::new(),
|
||||||
|
|||||||
+111
-9
@@ -7,8 +7,14 @@
|
|||||||
//! Every byte these servers return is treated as hostile — a server may be
|
//! Every byte these servers return is treated as hostile — a server may be
|
||||||
//! malicious, compromised, or MITM'd. The client is hardened accordingly:
|
//! malicious, compromised, or MITM'd. The client is hardened accordingly:
|
||||||
//!
|
//!
|
||||||
//! * **TLS is verified** against the webpki root store *with hostname
|
//! * **TLS is transport encryption, not the trust boundary.** Electrum servers
|
||||||
//! checking*. There is no accept-any-certificate path.
|
//! 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
|
//! * Each response read is **size-bounded** and each exchange is wrapped in a
|
||||||
//! **timeout** (no OOM, no slowloris).
|
//! **timeout** (no OOM, no slowloris).
|
||||||
//! * Addresses are decoded with the audited [`bech32`] crate (full checksum +
|
//! * Addresses are decoded with the audited [`bech32`] crate (full checksum +
|
||||||
@@ -353,7 +359,7 @@ impl ElectrumClient {
|
|||||||
network,
|
network,
|
||||||
primary,
|
primary,
|
||||||
fallbacks,
|
fallbacks,
|
||||||
tls: build_tls_connector(),
|
tls: build_tls_connector(cfg.electrum_strict_tls),
|
||||||
next_id: Arc::new(AtomicU64::new(1)),
|
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
|
/// Build the shared TLS connector seeded with the webpki root store. Hostname
|
||||||
/// verification is performed by rustls at connect time against the `ServerName`
|
/// verification is performed by rustls at connect time against the `ServerName`
|
||||||
/// passed in `exchange_ssl`.
|
/// passed in `exchange_ssl`.
|
||||||
fn build_tls_connector() -> TlsConnector {
|
/// Build the TLS connector for ssl:// electrum servers. `strict` selects CA-verified
|
||||||
let mut roots = rustls::RootCertStore::empty();
|
/// webpki (+ hostname) — for operators who run a CA-cert'd Fulcrum; the default
|
||||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
/// (false) accepts any server cert. Electrum servers are self-signed by convention
|
||||||
let config = rustls::ClientConfig::builder()
|
/// and their data is treated as hostile regardless, so CA-authenticating an
|
||||||
.with_root_certificates(roots)
|
/// untrusted PUBLIC fallback buys nothing while breaking it (webpki rejects the
|
||||||
.with_no_client_auth();
|
/// 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))
|
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`.
|
/// Parse `ssl://host:port`, `tls://host:port`, or `tcp://host:port`.
|
||||||
fn parse_server_url(url: &str) -> Result<ServerUrl, AppError> {
|
fn parse_server_url(url: &str) -> Result<ServerUrl, AppError> {
|
||||||
let (scheme, rest) = url
|
let (scheme, rest) = url
|
||||||
@@ -775,6 +851,7 @@ mod tests {
|
|||||||
network: "mainnet".into(),
|
network: "mainnet".into(),
|
||||||
electrum_primary: None,
|
electrum_primary: None,
|
||||||
electrum_fallbacks: vec![],
|
electrum_fallbacks: vec![],
|
||||||
|
electrum_strict_tls: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +863,31 @@ mod tests {
|
|||||||
ElectrumClient::new(UtxoNetwork::LitecoinMainnet, &cfg()).unwrap()
|
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 {
|
fn spk(addr: &str) -> ScriptPubKeyInfo {
|
||||||
ScriptPubKeyInfo {
|
ScriptPubKeyInfo {
|
||||||
address: Some(addr.to_string()),
|
address: Some(addr.to_string()),
|
||||||
|
|||||||
Reference in New Issue
Block a user