diff --git a/examples/tofu.rs b/examples/tofu.rs new file mode 100644 index 0000000..603e4e9 --- /dev/null +++ b/examples/tofu.rs @@ -0,0 +1,43 @@ +extern crate electrum_client; + +use electrum_client::{Client, ConfigBuilder, 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 config = ConfigBuilder::new().tofu_store(store).build(); + + let client = + Client::from_config("ssl://electrum.blockstream.info:50002", config).unwrap(); + let res = client.server_features(); + println!("{:#?}", res); +} + + diff --git a/src/client.rs b/src/client.rs index 7e4e626..33e8f42 100644 --- a/src/client.rs +++ b/src/client.rs @@ -110,16 +110,25 @@ impl ClientType { pub fn from_config(url: &str, config: &Config) -> Result { if url.starts_with("ssl://") { let url = url.replacen("ssl://", "", 1); - let client = match config.socks5() { - Some(socks5) => RawClient::new_proxy_ssl( + let client = match (config.socks5(), config.tofu_store()) { + (Some(socks5), _) => RawClient::new_proxy_ssl( url.as_str(), config.validate_domain(), socks5, config.timeout(), )?, - None => { - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())? - } + + (None, Some(tofu_store)) => RawClient::new_ssl_with_tofu( + url.as_str(), + tofu_store.clone(), + config.timeout(), + )?, + + (None, None) => RawClient::new_ssl( + url.as_str(), + config.validate_domain(), + config.timeout(), + )?, }; Ok(ClientType::SSL(client)) diff --git a/src/config.rs b/src/config.rs index e4c5770..c7c2f61 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,6 @@ use std::time::Duration; +use std::sync::Arc; +use crate::tofu::TofuStore; /// Configuration for an electrum client /// @@ -16,6 +18,8 @@ pub struct Config { retry: u8, /// when ssl, validate the domain, default true validate_domain: bool, + /// TOFU store for certificate validation + tofu_store: Option>, } /// Configuration for Socks5 @@ -72,6 +76,12 @@ impl ConfigBuilder { self } + /// Sets the TOFU store + pub fn tofu_store(mut self, store: Arc) -> Self { + self.config.tofu_store = Some(store); + self + } + /// Return the config and consume the builder pub fn build(self) -> Config { self.config @@ -131,6 +141,13 @@ impl Config { self.validate_domain } + /// Get the TOFU store + /// + /// Set this with [`ConfigBuilder::tofu_store`] + pub fn tofu_store(&self) -> &Option> { + &self.tofu_store + } + /// Convenience method for calling [`ConfigBuilder::new`] pub fn builder() -> ConfigBuilder { ConfigBuilder::new() @@ -144,6 +161,7 @@ impl Default for Config { timeout: None, retry: 1, validate_domain: true, + tofu_store: None, } } } diff --git a/src/lib.rs b/src/lib.rs index 491665a..ee08b74 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; \ No newline at end of file diff --git a/src/raw_client.rs b/src/raw_client.rs index 5d67467..e092d90 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -11,6 +11,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex, TryLockError}; use std::time::Duration; +use std::convert::TryFrom; + #[allow(unused_imports)] use log::{debug, error, info, trace, warn}; @@ -34,6 +36,7 @@ use rustls::{ pki_types::ServerName, pki_types::{Der, TrustAnchor}, ClientConfig, ClientConnection, RootCertStore, StreamOwned, + crypto::CryptoProvider }; #[cfg(any(feature = "default", feature = "proxy"))] @@ -45,6 +48,8 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::types::*; +use crate::TofuStore; + macro_rules! impl_batch_call { ( $self:expr, $data:expr, $call:ident ) => {{ impl_batch_call!($self, $data, $call, ) @@ -285,10 +290,67 @@ impl RawClient { .connect(&domain, stream) .map_err(Error::SslHandshakeError)?; + if !validate_domain { + return Err(Error::Message( + "TOFU certificate validation requires a TofuStore implementation. \ + Please use a constructor that accepts a TofuStore, or enable domain validation." + .to_string(), + )); + } + + Ok(stream.into()) + } + + /// Creates a new SSL client with TOFU (Trust On First Use) certificate validation. + /// This method establishes an SSL connection and verify certificates. On first connection, + /// the certificate is stored. On subsequent onnections, the certificate must match the stored one. + pub fn new_ssl_with_tofu( + socket_addrs: &dyn ToSocketAddrsDomain, + tofu_store: std::sync::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().unwrap_or("NONE").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( any( feature = "default", @@ -299,10 +361,13 @@ impl RawClient { ))] mod danger { use crate::raw_client::ServerName; + use crate::TofuStore; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified}; use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::DigitallySignedStruct; + use rustls::client::danger::ServerCertVerifier; + use std::sync::Arc; #[derive(Debug)] pub struct NoCertificateVerification(CryptoProvider); @@ -347,6 +412,87 @@ 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) + .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( @@ -381,6 +527,7 @@ impl RawClient { validate_domain, timeout ); + if validate_domain { socket_addrs.domain().ok_or(Error::MissingDomain)?; } @@ -404,8 +551,6 @@ impl RawClient { validate_domain: bool, tcp_stream: TcpStream, ) -> Result { - use std::convert::TryFrom; - if rustls::crypto::CryptoProvider::get_default().is_none() { // We install a crypto provider depending on the set feature. #[cfg(all(feature = "use-rustls", not(feature = "use-rustls-ring")))] @@ -431,6 +576,7 @@ impl RawClient { let builder = ClientConfig::builder(); + let domain = socket_addr.domain().unwrap_or("NONE").to_string(); let config = if validate_domain { socket_addr.domain().ok_or(Error::MissingDomain)?; @@ -446,24 +592,115 @@ impl RawClient { // TODO: cert pinning builder.with_root_certificates(store).with_no_client_auth() } else { + // Without domain validation, we skip certificate validation entirely + // For TOFU support, use new_ssl_with_tofu instead builder .dangerous() .with_custom_certificate_verifier(std::sync::Arc::new( - #[cfg(all(feature = "use-rustls", not(feature = "use-rustls-ring")))] - danger::NoCertificateVerification::new(rustls::crypto::aws_lc_rs::default_provider()), - #[cfg(feature = "use-rustls-ring")] - danger::NoCertificateVerification::new(rustls::crypto::ring::default_provider()), + danger::NoCertificateVerification::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(), + ) )) .with_no_client_auth() }; - let domain = socket_addr.domain().unwrap_or("NONE").to_string(); let session = ClientConnection::new( std::sync::Arc::new(config), ServerName::try_from(domain.clone()) .map_err(|_| Error::InvalidDNSNameError(domain.clone()))?, ) - .map_err(Error::CouldNotCreateConnection)?; + .map_err(|e| { + let error_msg = format!("{}", e); + if error_msg.contains("TLS certificate changed") { + Error::TlsCertificateChanged(domain.clone()) + } else if error_msg.contains("TOFU") { + Error::TofuPersistError(error_msg) + } else { + Error::CouldNotCreateConnection(e) + } + })?; + let stream = StreamOwned::new(session, tcp_stream); + + Ok(stream.into()) + } + + /// Create a new SSL client with TOFU (Trust On First Use) certificate validation. + /// This method establishes an SSL connection and verify certificates. On first connection, + /// the certificate is stored. On subsequent connections, the certificate must match the stored one. + pub fn new_ssl_with_tofu( + socket_addrs: A, + tofu_store: std::sync::Arc, + timeout: Option, + ) -> 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 domain = socket_addrs.domain().ok_or(Error::MissingDomain)?.to_string(); + + 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))?; + stream + } + None => TcpStream::connect(socket_addrs)?, + }; + + 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.clone(), + 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.clone()) + .map_err(|_| Error::InvalidDNSNameError(domain.clone()))?, + ) + .map_err(|e| { + let error_msg = format!("{}", e); + if error_msg.contains("TLS certificate changed") { + Error::TlsCertificateChanged(domain.clone()) + } else if error_msg.contains("TOFU") { + Error::TofuPersistError(error_msg) + } else { + Error::CouldNotCreateConnection(e) + } + })?; let stream = StreamOwned::new(session, tcp_stream); Ok(stream.into()) diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs new file mode 100644 index 0000000..6d8374e --- /dev/null +++ b/src/tofu/mod.rs @@ -0,0 +1,114 @@ +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..58fd7e3 100644 --- a/src/types.rs +++ b/src/types.rs @@ -320,7 +320,11 @@ 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 CouldntLockReader, @@ -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), } } }