diff --git a/examples/tofu.rs b/examples/tofu.rs new file mode 100644 index 0000000..6228395 --- /dev/null +++ b/examples/tofu.rs @@ -0,0 +1,45 @@ +extern crate electrum_client; + +use electrum_client::{Client, Config, ElectrumApi, TofuStore}; +use std::collections::HashMap; +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>, Box> { + let certs = self.certs.lock().unwrap(); + Ok(certs.get(host).cloned()) + } + + fn set_certificate( + &self, + host: &str, + cert: Vec, + ) -> Result<(), Box> { + 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 7e4e626..df0e516 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; @@ -116,10 +118,14 @@ impl ClientType { config.validate_domain(), socks5, config.timeout(), + None, + )?, + None => RawClient::new_ssl( + url.as_str(), + config.validate_domain(), + config.timeout(), + None, )?, - None => { - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())? - } }; Ok(ClientType::SSL(client)) @@ -136,6 +142,39 @@ impl ClientType { }) } } + + /// Constructor that supports TOFU (Trust On First Use) certificate validation. + /// Only works with SSL connections. + pub fn from_config_with_tofu( + url: &str, + config: &Config, + tofu_store: Arc, + ) -> Result { + if !url.starts_with("ssl://") { + return Err(Error::Message( + "TOFU validation is available only for SSL connections".to_string(), + )); + } + + let url = url.replacen("ssl://", "", 1); + let client = match config.socks5() { + Some(socks5) => RawClient::new_proxy_ssl( + url.as_str(), + config.validate_domain(), + socks5, + config.timeout(), + Some(tofu_store), + )?, + None => RawClient::new_ssl( + url.as_str(), + config.validate_domain(), + config.timeout(), + Some(tofu_store), + )?, + }; + + Ok(ClientType::SSL(client)) + } } impl Client { @@ -163,6 +202,22 @@ impl Client { url: url.to_string(), }) } + + /// 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_with_tofu(url, &config, tofu_store)?); + + Ok(Client { + client_type, + config, + url: url.to_string(), + }) + } } impl ElectrumApi for Client { diff --git a/src/lib.rs b/src/lib.rs index 491665a..ebc37e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,3 +81,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 5d67467..e5e2c57 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -31,6 +31,7 @@ use openssl::ssl::{SslConnector, SslMethod, SslStream, SslVerifyMode}; not(feature = "use-openssl") ))] use rustls::{ + crypto::CryptoProvider, pki_types::ServerName, pki_types::{Der, TrustAnchor}, ClientConfig, ClientConnection, RootCertStore, StreamOwned, @@ -45,6 +46,13 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::types::*; +#[cfg(any( + feature = "use-openssl", + feature = "use-rustls", + feature = "use-rustls-ring" +))] +use crate::tofu::TofuStore; + macro_rules! impl_batch_call { ( $self:expr, $data:expr, $call:ident ) => {{ impl_batch_call!($self, $data, $call, ) @@ -233,33 +241,41 @@ fn connect_with_total_timeout( pub type ElectrumSslStream = SslStream; #[cfg(feature = "use-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())?, + }; + + match tofu_store { + Some(store) => Self::new_ssl_with_tofu_from_stream(socket_addrs, store, stream), + None => Self::new_ssl_from_stream(socket_addrs, validate_domain, stream), } } @@ -287,6 +303,57 @@ impl RawClient { Ok(stream.into()) } + + /// Creates a new SSL client with TOFU from an existing TcpStream + fn new_ssl_with_tofu_from_stream( + socket_addrs: A, + tofu_store: Arc, + stream: TcpStream, + ) -> Result { + let mut builder = + SslConnector::builder(SslMethod::tls()).map_err(Error::InvalidSslMethod)?; + + builder.set_verify(SslVerifyMode::NONE); + let connector = builder.build(); + + let domain = socket_addrs + .domain() + .ok_or(Error::MissingDomain)? + .to_string(); + + let stream = connector + .connect(&domain, stream) + .map_err(Error::SslHandshakeError)?; + + 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_store + .get_certificate(&domain) + .map_err(|e| Error::TofuPersistError(e.to_string()))? + { + Some(saved_der) => { + if saved_der != der { + return Err(Error::TlsCertificateChanged(domain)); + } + } + None => { + // first time: persist certificate + tofu_store + .set_certificate(&domain, der) + .map_err(|e| Error::TofuPersistError(e.to_string()))?; + } + } + } else { + return Err(Error::TofuPersistError( + "Peer Certificate not available".to_string(), + )); + } + + Ok(stream.into()) + } } #[cfg(all( @@ -299,6 +366,8 @@ impl RawClient { ))] 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}; @@ -347,6 +416,90 @@ 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: std::sync::Arc, + } + + impl TofuVerifier { + pub fn new( + provider: CryptoProvider, + host: String, + tofu_store: std::sync::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) + .map_err(|e| crate::Error::TofuPersistError(e.to_string()))? + { + 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()) + .map_err(|e| crate::Error::TofuPersistError(e.to_string()))?; + } + } + + 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( @@ -368,33 +521,47 @@ pub type ElectrumSslStream = StreamOwned; not(feature = "use-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) + stream } - None => { - let stream = TcpStream::connect(socket_addrs.clone())?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + None => TcpStream::connect(socket_addrs.clone())?, + }; + + match tofu_store { + Some(store) => { + let domain = socket_addrs + .domain() + .ok_or(Error::MissingDomain)? + .to_string(); + Self::new_ssl_with_tofu_from_stream(&domain, store, tcp_stream) } + None => Self::new_ssl_from_stream(socket_addrs, validate_domain, tcp_stream), } } @@ -468,6 +635,59 @@ impl RawClient { Ok(stream.into()) } + + /// Creates a new SSL client with TOFU from an existing TcpStream + fn new_ssl_with_tofu_from_stream( + domain: &str, + tofu_store: Arc, + tcp_stream: TcpStream, + ) -> Result { + if CryptoProvider::get_default().is_none() { + #[cfg(all(feature = "use-rustls", not(feature = "use-rustls-ring")))] + CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .map_err(|_| { + Error::CouldNotCreateConnection(rustls::Error::General( + "Failed to install CryptoProvider".to_string(), + )) + })?; + + #[cfg(feature = "use-rustls-ring")] + CryptoProvider::install_default(rustls::crypto::ring::default_provider()).map_err( + |_| { + Error::CouldNotCreateConnection(rustls::Error::General( + "Failed to install CryptoProvider".to_string(), + )) + }, + )?; + } + + let builder = rustls::ClientConfig::builder(); + + let verifier = danger::TofuVerifier::new( + #[cfg(all(feature = "use-rustls", not(feature = "use-rustls-ring")))] + rustls::crypto::aws_lc_rs::default_provider(), + #[cfg(feature = "use-rustls-ring")] + rustls::crypto::ring::default_provider(), + domain.to_string(), + tofu_store, + ); + + let config = builder + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(verifier)) + .with_no_client_auth(); + + let session = ClientConnection::new( + std::sync::Arc::new(config), + ServerName::try_from(domain.to_string()) + .map_err(|_| Error::InvalidDNSNameError(domain.to_string()))?, + ) + .map_err(Error::CouldNotCreateConnection)?; + + let stream = StreamOwned::new(session, tcp_stream); + + Ok(stream.into()) + } } #[cfg(any(feature = "default", feature = "proxy"))] @@ -512,6 +732,7 @@ impl RawClient { validate_domain: bool, proxy: &crate::Socks5Config, timeout: Option, + tofu_store: Option>, ) -> Result, Error> { let target = target_addr.to_target_addr()?; @@ -528,7 +749,15 @@ 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()) + let tcp_stream = stream.into_inner(); + + match tofu_store { + Some(store) => { + let domain = target.domain().ok_or(Error::MissingDomain)?; + RawClient::new_ssl_with_tofu_from_stream(domain, store, tcp_stream) + } + None => RawClient::new_ssl_from_stream(target, validate_domain, tcp_stream), + } } } diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs new file mode 100644 index 0000000..6f519c2 --- /dev/null +++ b/src/tofu/mod.rs @@ -0,0 +1,145 @@ +use std::error::Error; +use std::fmt::Debug; + +/// 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) -> Result>, Box>; + + /// 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, + ) -> Result<(), Box>; +} + +#[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, + ) -> Result>, Box> { + let store = self.store.lock().unwrap(); + Ok(store.get(host).cloned()) + } + + fn set_certificate( + &self, + host: &str, + cert: Vec, + ) -> Result<(), Box> { + 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 ce3ef9f..8472866 100644 --- a/src/types.rs +++ b/src/types.rs @@ -320,6 +320,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 @@ -376,6 +380,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), } } }