diff --git a/examples/tofu.rs b/examples/tofu.rs new file mode 100644 index 0000000..0a3f9d4 --- /dev/null +++ b/examples/tofu.rs @@ -0,0 +1,39 @@ +extern crate electrum_client; + +use electrum_client::{Client, Config, ElectrumApi, TofuStore}; +use std::collections::HashMap; +use std::io::Result; +use std::sync::{Arc, Mutex}; + +/// A simple in-memory implementation of TofuStore for demonstration purposes. +#[derive(Debug, Default)] +struct MyTofuStore { + certs: Mutex>>, +} + +impl TofuStore for MyTofuStore { + fn get_certificate(&self, host: &str) -> Result>> { + let certs = self.certs.lock().unwrap(); + Ok(certs.get(host).cloned()) + } + + fn set_certificate(&self, host: &str, cert: Vec) -> Result<()> { + let mut certs = self.certs.lock().unwrap(); + certs.insert(host.to_string(), cert); + Ok(()) + } +} + +fn main() { + let store = Arc::new(MyTofuStore::default()); + + let client = Client::from_config_with_tofu( + "ssl://electrum.blockstream.info:50002", + Config::default(), + store, + ) + .unwrap(); + + let res = client.server_features(); + println!("{:#?}", res); +} diff --git a/src/client.rs b/src/client.rs index cb59a4d..a56ae73 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ //! Electrum Client +use std::sync::Arc; use std::{borrow::Borrow, sync::RwLock}; use log::{info, warn}; @@ -10,6 +11,7 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::config::Config; use crate::raw_client::*; +use crate::tofu::TofuStore; use crate::types::*; use std::convert::TryFrom; @@ -35,6 +37,7 @@ pub struct Client { client_type: RwLock, config: Config, url: String, + tofu_store: Option>, } macro_rules! impl_inner_call { @@ -74,7 +77,7 @@ macro_rules! impl_inner_call { if let Ok(mut write_client) = $self.client_type.try_write() { loop { std::thread::sleep(std::time::Duration::from_secs((1 << errors.len()).min(30) as u64)); - match ClientType::from_config(&$self.url, &$self.config) { + match ClientType::from_config(&$self.url, &$self.config, $self.tofu_store.clone()) { Ok(new_client) => { info!("Succesfully created new client"); *write_client = new_client; @@ -111,7 +114,11 @@ fn retries_exhausted(failed_attempts: usize, configured_retries: u8) -> bool { impl ClientType { /// Constructor that supports multiple backends and allows configuration through /// the [Config] - pub fn from_config(url: &str, config: &Config) -> Result { + pub fn from_config( + url: &str, + config: &Config, + tofu_store: Option>, + ) -> Result { #[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))] if url.starts_with("ssl://") { let url = url.replacen("ssl://", "", 1); @@ -122,14 +129,22 @@ impl ClientType { config.validate_domain(), socks5, config.timeout(), + tofu_store, + )?, + None => RawClient::new_ssl( + url.as_str(), + config.validate_domain(), + config.timeout(), + tofu_store, )?, - None => { - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())? - } }; #[cfg(not(feature = "proxy"))] - let client = - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())?; + let client = RawClient::new_ssl( + url.as_str(), + config.validate_domain(), + config.timeout(), + tofu_store, + )?; return Ok(ClientType::SSL(client)); } @@ -142,6 +157,12 @@ impl ClientType { } { + if tofu_store.is_some() { + return Err(Error::Message( + "TOFU validation is available only for SSL connections".to_string(), + )); + } + let url = url.replacen("tcp://", "", 1); #[cfg(feature = "proxy")] let client = match config.socks5() { @@ -178,12 +199,34 @@ impl Client { /// Generic constructor that supports multiple backends and allows configuration through /// the [Config] pub fn from_config(url: &str, config: Config) -> Result { - let client_type = RwLock::new(ClientType::from_config(url, &config)?); + let client_type = RwLock::new(ClientType::from_config(url, &config, None)?); + + Ok(Client { + client_type, + config, + url: url.to_string(), + tofu_store: None, + }) + } + + /// Creates a new client with TOFU (Trust On First Use) certificate validation. + /// This constructor creates a SSL client that uses TOFU for certificate validation. + pub fn from_config_with_tofu( + url: &str, + config: Config, + tofu_store: Arc, + ) -> Result { + let client_type = RwLock::new(ClientType::from_config( + url, + &config, + Some(tofu_store.clone()), + )?); Ok(Client { client_type, config, url: url.to_string(), + tofu_store: Some(tofu_store), }) } } diff --git a/src/lib.rs b/src/lib.rs index d916b12..70c99cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,3 +60,6 @@ pub use batch::Batch; pub use client::*; pub use config::{Config, ConfigBuilder, Socks5Config}; pub use types::*; + +mod tofu; +pub use tofu::TofuStore; diff --git a/src/raw_client.rs b/src/raw_client.rs index 68f5a63..412f58b 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -39,6 +39,9 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::types::*; +#[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))] +use crate::tofu::TofuStore; + /// Client name sent to the server during protocol version negotiation. pub const CLIENT_NAME: &str = ""; @@ -255,58 +258,105 @@ fn connect_with_total_timeout( pub type ElectrumSslStream = SslStream; #[cfg(feature = "openssl")] impl RawClient { - /// Creates a new SSL client and tries to connect to `socket_addr`. Optionally, if - /// `validate_domain` is `true`, validate the server's certificate. + /// Creates a new SSL client and tries to connect to `socket_addr`. + /// If `validate_domain` is `true`, validates the server's certificate. + /// If `tofu_store` is provided, uses TOFU (Trust On First Use) certificate validation, storing + /// the certificate on first connection and validating it matches on subsequent connections. pub fn new_ssl( socket_addrs: A, validate_domain: bool, timeout: Option, + tofu_store: Option>, ) -> Result { debug!( - "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?}", + "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?} tofu:{:?}", socket_addrs.domain(), validate_domain, - timeout + timeout, + tofu_store.is_some() ); - if validate_domain { + + if validate_domain || tofu_store.is_some() { socket_addrs.domain().ok_or(Error::MissingDomain)?; } - match timeout { + + let stream = match timeout { Some(timeout) => { let stream = connect_with_total_timeout(socket_addrs.clone(), timeout)?; stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) - } - None => { - let stream = TcpStream::connect(socket_addrs.clone())?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + stream } - } + None => TcpStream::connect(socket_addrs.clone())?, + }; + + Self::new_ssl_from_stream(socket_addrs, validate_domain, tofu_store, stream) } /// Create a new SSL client using an existing TcpStream pub fn new_ssl_from_stream( socket_addrs: A, validate_domain: bool, + tofu_store: Option>, stream: TcpStream, ) -> Result { let mut builder = SslConnector::builder(SslMethod::tls()).map_err(Error::InvalidSslMethod)?; - // TODO: support for certificate pinning - if validate_domain { - socket_addrs.domain().ok_or(Error::MissingDomain)?; - } else { - builder.set_verify(SslVerifyMode::NONE); - } - let connector = builder.build(); - let domain = socket_addrs.domain().unwrap_or("NONE").to_string(); + let domain = match tofu_store { + None => { + // TODO: support for certificate pinning + if validate_domain { + socket_addrs.domain().ok_or(Error::MissingDomain)?; + } else { + builder.set_verify(SslVerifyMode::NONE); + } + + socket_addrs.domain().unwrap_or("NONE").to_string() + } + Some(_) => { + builder.set_verify(SslVerifyMode::NONE); + + socket_addrs + .domain() + .ok_or(Error::MissingDomain)? + .to_string() + } + }; + + let connector = builder.build(); let stream = connector .connect(&domain, stream) .map_err(Error::SslHandshakeError)?; + match tofu_store { + None => (), + Some(tofu) => { + if let Some(peer_cert) = stream.ssl().peer_certificate() { + let der = peer_cert + .to_der() + .map_err(|e| Error::TofuPersistError(e.to_string()))?; + + match tofu.get_certificate(&domain)? { + Some(saved_der) => { + if saved_der != der { + return Err(Error::TlsCertificateChanged(domain)); + } + } + None => { + // first time: persist certificate + tofu.set_certificate(&domain, der)?; + } + } + } else { + return Err(Error::TofuPersistError( + "Peer Certificate not available".to_string(), + )); + } + } + } + let client: Self = stream.into(); client.negotiate_protocol_version()?; Ok(client) @@ -317,10 +367,13 @@ impl RawClient { #[allow(unused)] mod danger { use crate::raw_client::ServerName; + use crate::tofu::TofuStore; + use rustls::client::danger::ServerCertVerifier; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified}; use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::DigitallySignedStruct; + use std::sync::Arc; #[derive(Debug)] pub struct NoCertificateVerification(CryptoProvider); @@ -365,6 +418,81 @@ mod danger { self.0.signature_verification_algorithms.supported_schemes() } } + + /// A certificate verifier that uses TOFU (Trust On First Use) validation. + #[derive(Debug)] + pub struct TofuVerifier { + provider: CryptoProvider, + host: String, + tofu_store: Arc, + } + + impl TofuVerifier { + pub fn new(provider: CryptoProvider, host: String, tofu_store: Arc) -> Self { + Self { + provider, + host, + tofu_store, + } + } + + fn verify_tofu(&self, cert_der: &[u8]) -> Result<(), crate::Error> { + match self.tofu_store.get_certificate(&self.host)? { + Some(saved_der) => { + if saved_der != cert_der { + return Err(crate::Error::TlsCertificateChanged(self.host.clone())); + } + } + None => { + // First time: persist certificate. + self.tofu_store + .set_certificate(&self.host, cert_der.to_vec())?; + } + } + + Ok(()) + } + } + + impl ServerCertVerifier for TofuVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + // Verify using TOFU + self.verify_tofu(end_entity.as_ref()) + .map_err(|e| rustls::Error::General(format!("{:?}", e)))?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } + } } #[cfg(all( @@ -378,40 +506,46 @@ pub type ElectrumSslStream = StreamOwned; not(feature = "openssl") ))] impl RawClient { - /// Creates a new SSL client and tries to connect to `socket_addr`. Optionally, if - /// `validate_domain` is `true`, validate the server's certificate. + /// Creates a new SSL client and tries to connect to `socket_addr`. + /// If `validate_domain` is `true`, validates the server's certificate. + /// If `tofu_store` is provided, uses TOFU (Trust On First Use) certificate validation, storing + /// the certificate on first connection and validating it matches on subsequent connections. pub fn new_ssl( socket_addrs: A, validate_domain: bool, timeout: Option, + tofu_store: Option>, ) -> Result { debug!( - "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?}", + "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?} tofu:{:?}", socket_addrs.domain(), validate_domain, - timeout + timeout, + tofu_store.is_some() ); - if validate_domain { + + if validate_domain || tofu_store.is_some() { socket_addrs.domain().ok_or(Error::MissingDomain)?; } - match timeout { + + let tcp_stream = match timeout { Some(timeout) => { let stream = connect_with_total_timeout(socket_addrs.clone(), timeout)?; stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) - } - None => { - let stream = TcpStream::connect(socket_addrs.clone())?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + stream } - } + None => TcpStream::connect(socket_addrs.clone())?, + }; + + Self::new_ssl_from_stream(socket_addrs, validate_domain, tofu_store, tcp_stream) } /// Create a new SSL client using an existing TcpStream pub fn new_ssl_from_stream( socket_addr: A, validate_domain: bool, + tofu_store: Option>, tcp_stream: TcpStream, ) -> Result { use std::convert::TryFrom; @@ -441,35 +575,67 @@ impl RawClient { let builder = ClientConfig::builder(); - let config = if validate_domain { - socket_addr.domain().ok_or(Error::MissingDomain)?; - - let store = webpki_roots::TLS_SERVER_ROOTS - .iter() - .map(|t| TrustAnchor { - subject: Der::from_slice(t.subject), - subject_public_key_info: Der::from_slice(t.spki), - name_constraints: t.name_constraints.map(Der::from_slice), - }) - .collect::(); + let domain = match tofu_store { + None => socket_addr.domain().unwrap_or("NONE").to_string(), + Some(_) => socket_addr + .domain() + .ok_or(Error::MissingDomain)? + .to_string(), + }; - // TODO: cert pinning - builder.with_root_certificates(store).with_no_client_auth() - } else { - builder - .dangerous() - .with_custom_certificate_verifier(std::sync::Arc::new( + let config = match tofu_store { + Some(tofu) => { + let verifier = danger::TofuVerifier::new( #[cfg(all(feature = "rustls", not(feature = "rustls-ring")))] - danger::NoCertificateVerification::new(rustls::crypto::aws_lc_rs::default_provider()), + rustls::crypto::aws_lc_rs::default_provider(), #[cfg(feature = "rustls-ring")] - danger::NoCertificateVerification::new(rustls::crypto::ring::default_provider()), - )) - .with_no_client_auth() + rustls::crypto::ring::default_provider(), + domain.clone(), + tofu, + ); + + let config: ClientConfig = builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + config + } + None => { + let config = if validate_domain { + let store = webpki_roots::TLS_SERVER_ROOTS + .iter() + .map(|t| TrustAnchor { + subject: Der::from_slice(t.subject), + subject_public_key_info: Der::from_slice(t.spki), + name_constraints: t.name_constraints.map(Der::from_slice), + }) + .collect::(); + + // TODO: cert pinning + builder.with_root_certificates(store).with_no_client_auth() + } else { + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new( + #[cfg(all(feature = "rustls", not(feature = "rustls-ring")))] + danger::NoCertificateVerification::new( + rustls::crypto::aws_lc_rs::default_provider(), + ), + #[cfg(feature = "rustls-ring")] + danger::NoCertificateVerification::new( + rustls::crypto::ring::default_provider(), + ), + )) + .with_no_client_auth() + }; + + config + } }; - let domain = socket_addr.domain().unwrap_or("NONE").to_string(); let session = ClientConnection::new( - std::sync::Arc::new(config), + Arc::new(config), ServerName::try_from(domain.clone()) .map_err(|_| Error::InvalidDNSNameError(domain.clone()))?, ) @@ -525,6 +691,7 @@ impl RawClient { validate_domain: bool, proxy: &crate::Socks5Config, timeout: Option, + tofu_store: Option>, ) -> Result, Error> { let target = target_addr.to_target_addr()?; @@ -541,7 +708,7 @@ impl RawClient { stream.get_mut().set_read_timeout(timeout)?; stream.get_mut().set_write_timeout(timeout)?; - RawClient::new_ssl_from_stream(target, validate_domain, stream.into_inner()) + RawClient::new_ssl_from_stream(target, validate_domain, tofu_store, stream.into_inner()) } } @@ -1319,7 +1486,7 @@ mod test { fn get_test_client() -> RawClient { let server = std::env::var("TEST_ELECTRUM_SERVER").unwrap_or("fortress.qtornado.com:443".into()); - RawClient::new_ssl(&*server, false, None).unwrap() + RawClient::new_ssl(&*server, false, None, None).unwrap() } #[test] diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs new file mode 100644 index 0000000..e0c5973 --- /dev/null +++ b/src/tofu/mod.rs @@ -0,0 +1,134 @@ +use std::fmt::Debug; +use std::io; + +/// A trait for storing and retrieving TOFU (Trust On First Use) certificate data. +/// Implementors of this trait are responsible for persisting certificate data and retrieving it based on the host. +pub trait TofuStore: Send + Sync + Debug { + /// Retrieves the certificate for the given host. + /// Returns `Ok(Some(cert))` if a certificate is found, `Ok(None)` if no certificate + /// is stored for this host, or an error if the operation fails. + fn get_certificate(&self, host: &str) -> io::Result>>; + + /// Stores or updates the certificate for the given host. + /// If a certificate already exists for this host, it should be replaced. + /// Returns an error if the operation fails. + fn set_certificate(&self, host: &str, cert: Vec) -> io::Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + #[derive(Debug)] + struct InMemoryTofuStore { + store: Mutex>>, + } + + impl InMemoryTofuStore { + fn new() -> Self { + Self { + store: Mutex::new(HashMap::new()), + } + } + } + + impl TofuStore for InMemoryTofuStore { + fn get_certificate(&self, host: &str) -> io::Result>> { + let store = self.store.lock().unwrap(); + Ok(store.get(host).cloned()) + } + + fn set_certificate(&self, host: &str, cert: Vec) -> io::Result<()> { + let mut store = self.store.lock().unwrap(); + store.insert(host.to_string(), cert); + Ok(()) + } + } + + #[test] + fn test_tofu_first_use() { + let store = InMemoryTofuStore::new(); + + let host = "example.com"; + let cert = b"test certificate data".to_vec(); + + // First use: certificate should not exist + let result = store.get_certificate(host).unwrap(); + assert!( + result.is_none(), + "Certificate should not exist on first use" + ); + + store.set_certificate(host, cert.clone()).unwrap(); + + let stored = store.get_certificate(host).unwrap(); + assert_eq!(stored, Some(cert), "Certificate should be stored"); + } + + #[test] + fn test_tofu_certificate_match() { + let store = InMemoryTofuStore::new(); + + let host = "example.com"; + let cert = b"test certificate data".to_vec(); + + // Store certificate + store.set_certificate(host, cert.clone()).unwrap(); + + // Retrieve and verify it matches + let stored = store.get_certificate(host).unwrap(); + assert_eq!(stored, Some(cert), "Stored certificate should match"); + } + + #[test] + fn test_tofu_certificate_change() { + let store = InMemoryTofuStore::new(); + + let host = "example.com"; + let cert1 = b"first certificate".to_vec(); + let cert2 = b"second certificate".to_vec(); + + // Store first certificate + store.set_certificate(host, cert1.clone()).unwrap(); + let stored1 = store.get_certificate(host).unwrap(); + assert_eq!( + stored1, + Some(cert1.clone()), + "First certificate should be stored" + ); + + // Update with different certificate + store.set_certificate(host, cert2.clone()).unwrap(); + let stored2 = store.get_certificate(host).unwrap(); + assert_eq!( + stored2, + Some(cert2.clone()), + "Second certificate should replace first" + ); + assert_ne!( + stored2, + Some(cert1), + "Stored certificate should not match first" + ); + } + + #[test] + fn test_tofu_large_certificate() { + let store = InMemoryTofuStore::new(); + + let host = "example.com"; + // Create a large certificate (10KB) + let cert = vec![0x42; 10 * 1024]; + + // Store large certificate + store.set_certificate(host, cert.clone()).unwrap(); + let stored = store.get_certificate(host).unwrap(); + assert_eq!( + stored, + Some(cert), + "Large certificate should be stored correctly" + ); + } +} diff --git a/src/types.rs b/src/types.rs index 0714314..6168d1b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -419,6 +419,10 @@ pub enum Error { AllAttemptsErrored(Vec), /// There was an io error reading the socket, to be shared between threads SharedIOError(Arc), + /// Certificate presented by server changed vs saved TOFU value + TlsCertificateChanged(String), + /// Could not persist TOFU store + TofuPersistError(String), /// Couldn't take a lock on the reader mutex. This means that there's already another reader /// thread running @@ -475,6 +479,8 @@ impl Display for Error { Error::MissingDomain => f.write_str("Missing domain while it was explicitly asked to validate it"), Error::CouldntLockReader => f.write_str("Couldn't take a lock on the reader mutex. This means that there's already another reader thread is running"), Error::Mpsc => f.write_str("Broken IPC communication channel: the other thread probably has exited"), + Error::TlsCertificateChanged(domain) => write!(f, "TLS certificate changed for host: {}", domain), + Error::TofuPersistError(msg) => write!(f, "TOFU persistence error: {}", msg), } } }