From 15eda699e0d487234f7c70716c3a410fc3f075cf Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Thu, 16 Apr 2026 14:30:03 -0300 Subject: [PATCH] feat(tofu): add option for Trust On First Use validation for SSL connections Trust On First Use (TOFU) is a security model where a client trusts a certificate upon the first connection and subsequent connections are verified against that initially stored record. This commit introduces the following features: - `TofuStore` trait to manage certificates - Implement TOFU validations both for OpenSSL and Rustls configurations - Add custom certificate verifier for Rustls - Support TOFU validation in proxy SSL connections - Extend enum with error variants specifics to TOFU - New method to initialize a client with TOFU from config - Update existing constructors to support the revised SSL backend signatures - Add some unit tests related to TOFU. The tests cover the following cases: first-use storage and certificate matching/replacement - Add a TOFU certificates validation example, based on a in-memory store implementation for demonstration purposes NOTE: Unit tests, supporting mock implementation, and the custom verifier for Rustls were created with AI assistance. --- Cargo.toml | 5 + examples/tofu.rs | 36 +++++ src/client.rs | 100 +++++++++++++- src/lib.rs | 4 + src/raw_client.rs | 336 ++++++++++++++++++++++++++++++++++++++++++++++ src/tofu/mod.rs | 124 +++++++++++++++++ src/types.rs | 12 ++ 7 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 examples/tofu.rs create mode 100644 src/tofu/mod.rs diff --git a/Cargo.toml b/Cargo.toml index d13295e..cae3f88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ edition = "2021" name = "electrum_client" path = "src/lib.rs" +[[example]] +name = "tofu" +required-features = ["tofu"] + [dependencies] log = "^0.4" bitcoin = { version = "0.32", features = ["serde"] } @@ -43,6 +47,7 @@ proxy = ["byteorder", "winapi", "libc"] rustls = ["webpki-roots", "dep:rustls", "rustls/default"] rustls-ring = ["webpki-roots", "dep:rustls", "rustls/ring", "rustls/logging", "rustls/std", "rustls/tls12"] openssl = ["dep:openssl"] +tofu = [] # Old feature names use-rustls = ["rustls"] diff --git a/examples/tofu.rs b/examples/tofu.rs new file mode 100644 index 0000000..6b2484a --- /dev/null +++ b/examples/tofu.rs @@ -0,0 +1,36 @@ +// cargo run --example tofu --features tofu +extern crate electrum_client; + +use electrum_client::{Client, Config, ElectrumApi, TofuStore}; +use std::collections::HashMap; +use std::io; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Default)] +struct InMemoryTofuStore { + certs: Mutex>>, +} + +impl TofuStore for InMemoryTofuStore { + fn get_certificate(&self, host: &str) -> io::Result>> { + Ok(self.certs.lock().unwrap().get(host).cloned()) + } + + fn set_certificate(&self, host: &str, cert: Vec) -> io::Result<()> { + self.certs.lock().unwrap().insert(host.to_string(), cert); + Ok(()) + } +} + +fn main() { + let store = Arc::new(InMemoryTofuStore::default()); + + let client = Client::from_config_with_tofu( + "ssl://electrum.blockstream.info:50002", + Config::default(), + store, + ) + .unwrap(); + + println!("{:#?}", client.server_features()); +} diff --git a/src/client.rs b/src/client.rs index 4f9e457..cbc8b8d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,7 @@ //! Electrum Client +#[cfg(feature = "tofu")] +use std::sync::Arc; use std::{borrow::Borrow, sync::RwLock}; use log::{info, warn}; @@ -10,6 +12,8 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::config::Config; use crate::raw_client::*; +#[cfg(feature = "tofu")] +use crate::tofu::TofuStore; use crate::types::*; use std::convert::TryFrom; @@ -35,6 +39,8 @@ pub struct Client { client_type: RwLock, config: Config, url: String, + #[cfg(feature = "tofu")] + tofu_store: Option>, } macro_rules! impl_inner_call { @@ -74,7 +80,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 $self.client_type_adapter() { Ok(new_client) => { info!("Succesfully created new client"); *write_client = new_client; @@ -179,6 +185,61 @@ impl ClientType { Ok(client) } } + + /// Constructor that supports multiple backends and allows configuration through + /// the Config, enabling TOFU certificate checks for SSL connections + #[cfg(feature = "tofu")] + pub fn from_config_with_tofu( + url: &str, + config: &Config, + tofu_store: Arc, + ) -> Result { + let auth_provider = config.authorization_provider().cloned(); + + #[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))] + if url.starts_with("ssl://") { + let url = url.replacen("ssl://", "", 1); + #[cfg(feature = "proxy")] + let raw_client = match config.socks5() { + Some(socks5) => RawClient::new_proxy_ssl_with_tofu( + url.as_str(), + config.validate_domain(), + socks5, + config.timeout(), + tofu_store, + auth_provider, + )?, + None => RawClient::new_ssl_with_tofu( + url.as_str(), + config.validate_domain(), + config.timeout(), + tofu_store, + auth_provider, + )?, + }; + #[cfg(not(feature = "proxy"))] + let raw_client = RawClient::new_ssl_with_tofu( + url.as_str(), + config.validate_domain(), + config.timeout(), + tofu_store, + auth_provider, + )?; + + return Ok(ClientType::SSL(raw_client)); + } + + #[cfg(not(any(feature = "openssl", feature = "rustls", feature = "rustls-ring")))] + if url.starts_with("ssl://") { + return Err(Error::Message( + "SSL connections require one of the following features to be enabled: openssl, rustls, or rustls-ring".to_string() + )); + } + + Err(Error::Message( + "TOFU validation is available only for SSL connections".to_string(), + )) + } } impl Client { @@ -204,8 +265,45 @@ impl Client { client_type, config, url: url.to_string(), + #[cfg(feature = "tofu")] + tofu_store: None, + }) + } + + /// Creates a new client with TOFU (Trust On First Use) certificate validation. + /// This constructor requires an SSL URL and stores/verifies server + /// certificates using the provided store + #[cfg(feature = "tofu")] + 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.clone(), + )?); + + Ok(Client { + client_type, + config, + url: url.to_string(), + tofu_store: Some(tofu_store), }) } + + // Recreate the client using the same strategy as the original constructor + fn client_type_adapter(&self) -> Result { + #[cfg(feature = "tofu")] + { + if let Some(store) = self.tofu_store.as_ref() { + return ClientType::from_config_with_tofu(&self.url, &self.config, store.clone()); + } + } + + ClientType::from_config(&self.url, &self.config) + } } impl ElectrumApi for Client { diff --git a/src/lib.rs b/src/lib.rs index 94ff0aa..7201cce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,8 @@ mod config; pub mod raw_client; mod stream; +#[cfg(feature = "tofu")] +mod tofu; mod types; pub mod utils; @@ -59,4 +61,6 @@ pub use api::ElectrumApi; pub use batch::Batch; pub use client::*; pub use config::{AuthProvider, Config, ConfigBuilder, Socks5Config}; +#[cfg(feature = "tofu")] +pub use tofu::TofuStore; pub use types::*; diff --git a/src/raw_client.rs b/src/raw_client.rs index daa9fe6..48e4306 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -38,6 +38,8 @@ use crate::stream::ClonableStream; use crate::api::ElectrumApi; use crate::batch::Batch; use crate::config::AuthProvider; +#[cfg(feature = "tofu")] +use crate::tofu::TofuStore; use crate::types::*; /// Client name sent to the server during protocol version negotiation. @@ -345,6 +347,92 @@ impl RawClient { Ok(client) } + + /// Creates a new SSL client and tries to connect to `socket_addr` using TOFU + /// certificate validation. + #[cfg(feature = "tofu")] + pub fn new_ssl_with_tofu( + socket_addrs: A, + validate_domain: bool, + timeout: Option, + tofu_store: Arc, + auth_provider: Option, + ) -> Result { + socket_addrs.domain().ok_or(Error::MissingDomain)?; + + 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))?; + stream + } + None => TcpStream::connect(socket_addrs.clone())?, + }; + + Self::new_ssl_from_stream_with_tofu( + socket_addrs, + validate_domain, + stream, + tofu_store, + auth_provider, + ) + } + + /// Create a new SSL client using an existing TcpStream with TOFU validation. + #[cfg(feature = "tofu")] + pub fn new_ssl_from_stream_with_tofu( + socket_addrs: A, + validate_domain: bool, + stream: TcpStream, + tofu_store: Arc, + auth_provider: Option, + ) -> Result { + let mut builder = + SslConnector::builder(SslMethod::tls()).map_err(Error::InvalidSslMethod)?; + + if !validate_domain { + builder.set_verify(SslVerifyMode::NONE); + } + + let domain = socket_addrs + .domain() + .ok_or(Error::MissingDomain)? + .to_string(); + + let connector = builder.build(); + let stream = connector + .connect(&domain, stream) + .map_err(Error::SslHandshakeError)?; + + let peer_cert = stream.ssl().peer_certificate().ok_or_else(|| { + Error::Message("peer certificate not available during TLS handshake".to_string()) + })?; + let der = peer_cert + .to_der() + .map_err(|e| Error::Message(e.to_string()))?; + + match tofu_store + .get_certificate(&domain) + .map_err(Error::TofuPersistError)? + { + Some(saved_der) if saved_der != der => { + return Err(Error::TlsCertificateChanged(domain)); + } + Some(_) => {} + None => { + tofu_store + .set_certificate(&domain, der) + .map_err(Error::TofuPersistError)?; + } + } + + let client = Self::from(stream) + .with_auth(auth_provider) + .negotiate_protocol_version()?; + + Ok(client) + } } #[cfg(any(feature = "rustls", feature = "rustls-ring"))] @@ -355,6 +443,12 @@ mod danger { use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::DigitallySignedStruct; + #[cfg(feature = "tofu")] + use rustls::client::danger::ServerCertVerifier; + #[cfg(feature = "tofu")] + use std::sync::Arc; + #[cfg(feature = "tofu")] + use crate::tofu::TofuStore; #[derive(Debug)] pub struct NoCertificateVerification(CryptoProvider); @@ -399,6 +493,93 @@ mod danger { self.0.signature_verification_algorithms.supported_schemes() } } + + /// A certificate verifier that uses TOFU (Trust On First Use) validation. + #[cfg(feature = "tofu")] + #[derive(Debug)] + pub struct TofuVerifier { + inner: Arc, + host: String, + tofu_store: Arc, + } + + #[cfg(feature = "tofu")] + impl TofuVerifier { + pub fn new( + inner: Arc, + host: String, + tofu_store: Arc, + ) -> Self { + Self { + inner, + host, + tofu_store, + } + } + + fn verify_tofu(&self, cert_der: &[u8]) -> Result<(), crate::Error> { + match self + .tofu_store + .get_certificate(&self.host) + .map_err(crate::Error::TofuPersistError)? + { + Some(saved_der) => { + if saved_der != cert_der { + return Err(crate::Error::TlsCertificateChanged(self.host.clone())); + } + } + None => { + self.tofu_store + .set_certificate(&self.host, cert_der.to_vec()) + .map_err(crate::Error::TofuPersistError)?; + } + } + + Ok(()) + } + } + + #[cfg(feature = "tofu")] + impl ServerCertVerifier for TofuVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp: &[u8], + now: UnixTime, + ) -> Result { + self.inner + .verify_server_cert(end_entity, intermediates, server_name, ocsp, now)?; + + 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 { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } + } } #[cfg(all( @@ -520,6 +701,126 @@ impl RawClient { Ok(client) } + + /// Creates a new SSL client and tries to connect to `socket_addr` using TOFU + /// certificate validation. + #[cfg(feature = "tofu")] + pub fn new_ssl_with_tofu( + socket_addrs: A, + validate_domain: bool, + timeout: Option, + tofu_store: Arc, + auth_provider: Option, + ) -> Result { + socket_addrs.domain().ok_or(Error::MissingDomain)?; + + 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))?; + stream + } + None => TcpStream::connect(socket_addrs.clone())?, + }; + + Self::new_ssl_from_stream_with_tofu( + socket_addrs, + validate_domain, + stream, + tofu_store, + auth_provider, + ) + } + + /// Create a new SSL client using an existing TcpStream with TOFU validation. + #[cfg(feature = "tofu")] + pub fn new_ssl_from_stream_with_tofu( + socket_addr: A, + validate_domain: bool, + tcp_stream: TcpStream, + tofu_store: Arc, + auth_provider: Option, + ) -> Result { + use std::convert::TryFrom; + + if rustls::crypto::CryptoProvider::get_default().is_none() { + #[cfg(all(feature = "rustls", not(feature = "rustls-ring")))] + rustls::crypto::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 = "rustls-ring")] + rustls::crypto::CryptoProvider::install_default( + rustls::crypto::ring::default_provider(), + ) + .map_err(|_| { + Error::CouldNotCreateConnection(rustls::Error::General( + "Failed to install CryptoProvider".to_string(), + )) + })?; + } + + let builder = ClientConfig::builder(); + let domain = socket_addr + .domain() + .ok_or(Error::MissingDomain)? + .to_string(); + + let inner_verifier: Arc = + 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::(); + + rustls::client::WebPkiServerVerifier::builder(Arc::new(store)) + .build() + .map_err(|e| Error::Message(format!("Failed to build WebPKI verifier: {e}")))? + } else { + 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()), + ) + }; + + let verifier = danger::TofuVerifier::new( + inner_verifier, + domain.clone(), + tofu_store, + ); + + let config: ClientConfig = builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + let session = ClientConnection::new( + Arc::new(config), + ServerName::try_from(domain.clone()) + .map_err(|_| Error::InvalidDNSNameError(domain.clone()))?, + ) + .map_err(Error::CouldNotCreateConnection)?; + + let stream = StreamOwned::new(session, tcp_stream); + + let client = Self::from(stream) + .with_auth(auth_provider) + .negotiate_protocol_version()?; + + Ok(client) + } } #[cfg(feature = "proxy")] @@ -588,6 +889,41 @@ impl RawClient { RawClient::new_ssl_from_stream(target, validate_domain, stream.into_inner(), auth_provider) } + + #[cfg(feature = "tofu")] + /// Creates a new TLS client through socks5 using TOFU certificate validation. + pub fn new_proxy_ssl_with_tofu( + target_addr: T, + validate_domain: bool, + proxy: &crate::Socks5Config, + timeout: Option, + tofu_store: Arc, + auth_provider: Option, + ) -> Result, Error> { + let target = target_addr.to_target_addr()?; + + let mut stream = match proxy.credentials.as_ref() { + Some(cred) => Socks5Stream::connect_with_password( + &proxy.addr, + target_addr, + &cred.username, + &cred.password, + timeout, + )?, + None => Socks5Stream::connect(&proxy.addr, target.clone(), timeout)?, + }; + + stream.get_mut().set_read_timeout(timeout)?; + stream.get_mut().set_write_timeout(timeout)?; + + RawClient::new_ssl_from_stream_with_tofu( + target, + validate_domain, + stream.into_inner(), + tofu_store, + auth_provider, + ) + } } #[derive(Debug)] diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs new file mode 100644 index 0000000..a1d7941 --- /dev/null +++ b/src/tofu/mod.rs @@ -0,0 +1,124 @@ +use std::fmt::Debug; +use std::io; + +/// A store used by TOFU (Trust On First Use) certificate validation. +/// +/// Implementors are responsible for persisting certificates by host and +/// returning the previously-seen certificate bytes on future connections. +pub trait TofuStore: Send + Sync + Debug { + /// Returns the saved certificate bytes for `host`, if present. + fn get_certificate(&self, host: &str) -> io::Result>>; + + /// Saves or updates certificate bytes for `host`. + 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, Default)] + struct InMemoryTofuStore { + store: Mutex>>, + } + + 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::default(); + + 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::default(); + + 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::default(); + + 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::default(); + + 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 d8352be..5357d6a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -430,6 +430,12 @@ pub enum Error { AllAttemptsErrored(Vec), /// There was an io error reading the socket, to be shared between threads SharedIOError(Arc), + #[cfg(feature = "tofu")] + /// Certificate presented by server changed compared to saved TOFU value + TlsCertificateChanged(String), + #[cfg(feature = "tofu")] + /// Error while reading/writing TOFU certificate storage + TofuPersistError(std::io::Error), /// Couldn't take a lock on the reader mutex. This means that there's already another reader /// thread running @@ -456,6 +462,8 @@ impl Display for Error { Error::Hex(e) => Display::fmt(e, f), Error::Bitcoin(e) => Display::fmt(e, f), Error::SharedIOError(e) => Display::fmt(e, f), + #[cfg(feature = "tofu")] + Error::TofuPersistError(e) => Display::fmt(e, f), #[cfg(feature = "openssl")] Error::SslHandshakeError(e) => Display::fmt(e, f), #[cfg(feature = "openssl")] @@ -486,6 +494,10 @@ 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"), + #[cfg(feature = "tofu")] + Error::TlsCertificateChanged(domain) => { + write!(f, "TLS certificate changed for host: {}", domain) + } } } }