From 219615c91a6ac4bad3d8ffcea447cef3ddaa8b5a Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Mon, 5 Jan 2026 13:37:25 -0300 Subject: [PATCH 1/5] feat: implement Trust On First Use (TOFU) for TLS connections - Added using to persist server certificates - Integrated TOFU verification in for both OpenSSL and Rustls backends when domain validation is disabled - Implemented for Rustls to handle custom certificate validation logic - Added and variants to the enum. - Added dependency in --- Cargo.toml | 2 + src/lib.rs | 2 + src/raw_client.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++-- src/tofu/mod.rs | 52 +++++++++++++++++ src/types.rs | 8 ++- 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 src/tofu/mod.rs diff --git a/Cargo.toml b/Cargo.toml index d3fe8a3..9d80897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ rustls = { version = "0.23.21", optional = true, default-features = false } webpki-roots = { version = "0.25", optional = true } byteorder = { version = "1.0", optional = true } +dirs = "6.0.0" +rusqlite = "0.38.0" [target.'cfg(unix)'.dependencies] libc = { version = "0.2", optional = true } diff --git a/src/lib.rs b/src/lib.rs index 491665a..070c59b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,3 +81,5 @@ pub use batch::Batch; pub use client::*; pub use config::{Config, ConfigBuilder, Socks5Config}; pub use types::*; + +mod tofu; \ No newline at end of file diff --git a/src/raw_client.rs b/src/raw_client.rs index 5d67467..3d9251c 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -285,10 +285,40 @@ impl RawClient { .connect(&domain, stream) .map_err(Error::SslHandshakeError)?; + if !validate_domain { + let store = TofuData::setup() + .map_err(|e| Error::TofuPersistError(e.to_string()))?; + + if let Some(peer_cert) = stream.ssl().peer_certificate() { + let der = peer_cert.to_der() + .map_err(|e| Error::TofuPersistError(e.to_string()))?; + + match store.getData(&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 + store + .setData(&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 +329,12 @@ impl RawClient { ))] mod danger { use crate::raw_client::ServerName; + use crate::tofu::TofuData; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified}; use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::DigitallySignedStruct; + use std::sync::{Arc, Mutex}; #[derive(Debug)] pub struct NoCertificateVerification(CryptoProvider); @@ -347,6 +379,94 @@ mod danger { self.0.signature_verification_algorithms.supported_schemes() } } + + #[derive(Debug)] + pub struct TofuVerifier { + provider: CryptoProvider, + host: String, + tofu_store: Arc>>>, + } + + impl TofuVerifier { + pub fn new(provider: CryptoProvider, host: String) -> Self { + Self { + provider, + host, + tofu_store: Arc::new(Mutex::new(None)), + } + } + + fn get_tofu_store(&self) -> Result, crate::Error> { + let mut store = self.tofu_store.lock().unwrap(); + if store.is_none() { + *store = Some(Arc::new( + TofuData::setup() + .map_err(|e| crate::Error::TofuPersistError(e.to_string()))?, + )); + } + Ok(store.as_ref().unwrap().clone()) + } + + fn verify_tofu(&self, cert_der: &[u8]) -> Result<(), crate::Error> { + let store = self.get_tofu_store()?; + match store + .getData(&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. + store + .setData(&self.host, cert_der.to_vec()) + .map_err(|e| crate::Error::TofuPersistError(e.to_string()))?; + } + } + + Ok(()) + } + } + + impl rustls::client::danger::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 +501,7 @@ impl RawClient { validate_domain, timeout ); + if validate_domain { socket_addrs.domain().ok_or(Error::MissingDomain)?; } @@ -431,6 +552,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)?; @@ -450,20 +572,28 @@ impl RawClient { .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()), + danger::TofuVerifier::new(rustls::crypto::aws_lc_rs::default_provider(), domain.clone()), #[cfg(feature = "use-rustls-ring")] - danger::NoCertificateVerification::new(rustls::crypto::ring::default_provider()), + danger::TofuVerifier::new(rustls::crypto::ring::default_provider(), domain.clone()), )) .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()) diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs new file mode 100644 index 0000000..a8686b9 --- /dev/null +++ b/src/tofu/mod.rs @@ -0,0 +1,52 @@ +use std::{fs::create_dir_all, path::PathBuf, sync::Mutex}; +use rusqlite::{Connection, OptionalExtension, Result as SqlResult, params}; + + +#[derive(Debug)] +pub struct TofuData { + db_path: PathBuf, + connection: Mutex +} + + + +impl TofuData { + pub fn setData(&self, host: &str, cert: Vec) -> SqlResult<()> { + let connection = self.connection.lock().unwrap(); + let sql = "INSERT INTO tofu (host, cert) VALUES (?1, ?2) + ON CONFLICT(host) DO UPDATE SET cert = excluded.cert"; + + connection.execute(sql, params![host, cert])?; + + Ok(()) + } + + pub fn getData(&self, host: &str) -> SqlResult>> { + let connection = self.connection.lock().unwrap(); + let sql = "SELECT cert FROM tofu WHERE host = ?1"; + + connection + .query_row(sql, params![host], |row| row.get(0)) + .optional() + } + + pub fn setup() -> SqlResult { + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push(".electrum-client"); + create_dir_all(&path).ok(); + path.push("tofu.sqlite"); + + let connection = Connection::open(&path)?; + let sql = "CREATE TABLE IF NOT EXISTS tofu( + host TEXT PRIMARY KEY, + cert BLOB NOT NULL + )"; + + connection.execute(sql, [])?; + + Ok(TofuData { + db_path: path, + connection: Mutex::new(connection) + }) + } +} 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), } } } From 2cbc38a19e83ac19f4f7e2e1ae2821759b54fc30 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Tue, 6 Jan 2026 22:05:51 -0300 Subject: [PATCH 2/5] test: add unit tests for TOFU certificate validation module - verifies initial certificate storage on first use - ensures stored certificates are retrieved correctly - tests certificate updates and replacement - confirms data persistence across store instances - tests handling of large certificates and empty certificates --- src/tofu/mod.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs index a8686b9..07a40c1 100644 --- a/src/tofu/mod.rs +++ b/src/tofu/mod.rs @@ -1,7 +1,6 @@ use std::{fs::create_dir_all, path::PathBuf, sync::Mutex}; use rusqlite::{Connection, OptionalExtension, Result as SqlResult, params}; - #[derive(Debug)] pub struct TofuData { db_path: PathBuf, @@ -12,6 +11,10 @@ pub struct TofuData { impl TofuData { pub fn setData(&self, host: &str, cert: Vec) -> SqlResult<()> { + if cert.is_empty() { + return Err(rusqlite::Error::InvalidQuery); + } + let connection = self.connection.lock().unwrap(); let sql = "INSERT INTO tofu (host, cert) VALUES (?1, ?2) ON CONFLICT(host) DO UPDATE SET cert = excluded.cert"; @@ -50,3 +53,165 @@ impl TofuData { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn setup_with_path(db_path: PathBuf) -> SqlResult { + if let Some(parent) = db_path.parent() { + create_dir_all(parent).ok(); + } + let connection = Connection::open(&db_path)?; + let sql = "CREATE TABLE IF NOT EXISTS tofu( + host TEXT PRIMARY KEY, + cert BLOB NOT NULL + )"; + + connection.execute(sql, [])?; + + Ok(TofuData { + db_path, + connection: Mutex::new(connection) + }) + } + + fn create_temp_db() -> (PathBuf, TofuData) { + let temp_dir = std::env::temp_dir(); + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let db_path = temp_dir.join(format!("tofu_test_{}_{}.sqlite", std::process::id(), counter)); + // Clean up any existing test database + let _ = fs::remove_file(&db_path); + let store = setup_with_path(db_path.clone()).unwrap(); + (db_path, store) + } + + fn cleanup_temp_db(db_path: &Path) { + // Small delay to ensure file handles are released + std::thread::sleep(std::time::Duration::from_millis(50)); + let _ = fs::remove_file(db_path); + } + + #[test] + fn test_tofu_first_use() { + let (db_path, store) = create_temp_db(); + + let host = "example.com"; + let cert = b"test certificate data".to_vec(); + + // First use: certificate should not exist + let result = store.getData(host).unwrap(); + assert!(result.is_none(), "Certificate should not exist on first use"); + + store.setData(host, cert.clone()).unwrap(); + + let stored = store.getData(host).unwrap(); + assert_eq!(stored, Some(cert), "Certificate should be stored"); + + drop(store); + cleanup_temp_db(&db_path); + } + + #[test] + fn test_tofu_certificate_match() { + let (db_path, store) = create_temp_db(); + + let host = "example.com"; + let cert = b"test certificate data".to_vec(); + + // Store certificate + store.setData(host, cert.clone()).unwrap(); + + // Retrieve and verify it matches + let stored = store.getData(host).unwrap(); + assert_eq!(stored, Some(cert), "Stored certificate should match"); + + drop(store); + cleanup_temp_db(&db_path); + } + + #[test] + fn test_tofu_certificate_change() { + let (db_path, store) = create_temp_db(); + + let host = "example.com"; + let cert1 = b"first certificate".to_vec(); + let cert2 = b"second certificate".to_vec(); + + // Store first certificate + store.setData(host, cert1.clone()).unwrap(); + let stored1 = store.getData(host).unwrap(); + assert_eq!(stored1, Some(cert1.clone()), "First certificate should be stored"); + + // Update with different certificate + store.setData(host, cert2.clone()).unwrap(); + let stored2 = store.getData(host).unwrap(); + assert_eq!(stored2, Some(cert2.clone()), "Second certificate should replace first"); + assert_ne!(stored2, Some(cert1), "Stored certificate should not match first"); + + drop(store); + cleanup_temp_db(&db_path); + } + + #[test] + fn test_tofu_persistence() { + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join(format!("tofu_persistence_test_{}.sqlite", std::process::id())); + let _ = fs::remove_file(&db_path); + + let host = "example.com"; + let cert = b"persistent certificate".to_vec(); + + // Create first store instance and save certificate + { + let store1 = setup_with_path(db_path.clone()).unwrap(); + store1.setData(host, cert.clone()).unwrap(); + } + + // Create second store instance and verify certificate persists + { + let store2 = setup_with_path(db_path.clone()).unwrap(); + let stored = store2.getData(host).unwrap(); + assert_eq!(stored, Some(cert), "Certificate should persist across instances"); + } + + cleanup_temp_db(&db_path); + } + + #[test] + fn test_tofu_empty_certificate() { + let (db_path, store) = create_temp_db(); + + let host = "example.com"; + let cert = vec![]; + + // Attempt to store empty certificate should fail + let result = store.setData(host, cert.clone()); + assert!(result.is_err(), "Storing empty certificate should return an error"); + + drop(store); + cleanup_temp_db(&db_path); + } + + #[test] + fn test_tofu_large_certificate() { + let (db_path, store) = create_temp_db(); + + let host = "example.com"; + // Create a large certificate (10KB) + let cert = vec![0x42; 10 * 1024]; + + // Store large certificate + store.setData(host, cert.clone()).unwrap(); + let stored = store.getData(host).unwrap(); + assert_eq!(stored, Some(cert), "Large certificate should be stored correctly"); + + drop(store); + cleanup_temp_db(&db_path); + } +} From 4004eedcf48038a3b586a12b0e968ec60c388b4e Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Sat, 10 Jan 2026 15:54:38 -0300 Subject: [PATCH 3/5] refactor: convert TOFU to trait-based architecture Remove concrete SQLite implementation and replace with TofuStore trait to allow flexible certificate storage backends. This provides better flexibility for users to implement custom certificate storage. This change: - Removes rusqlite dependency - Create a trait with get_certificate/set_certificate methods - Updates `raw_client.rs` to accept TofuStore implementations - Adds new_ssl_with_tofu constructors for SSL clients implementations - Refactors TofuVerifier to use trait instead of concrete implementation - Updates TOFU tests to use in-memory implementation --- Cargo.toml | 2 - src/lib.rs | 3 +- src/raw_client.rs | 201 ++++++++++++++++++++++++++++++++++----------- src/tofu/mod.rs | 203 ++++++++++++---------------------------------- 4 files changed, 205 insertions(+), 204 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9d80897..d3fe8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,6 @@ rustls = { version = "0.23.21", optional = true, default-features = false } webpki-roots = { version = "0.25", optional = true } byteorder = { version = "1.0", optional = true } -dirs = "6.0.0" -rusqlite = "0.38.0" [target.'cfg(unix)'.dependencies] libc = { version = "0.2", optional = true } diff --git a/src/lib.rs b/src/lib.rs index 070c59b..ee08b74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,4 +82,5 @@ pub use client::*; pub use config::{Config, ConfigBuilder, Socks5Config}; pub use types::*; -mod tofu; \ No newline at end of file +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 3d9251c..f5325c8 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"))] @@ -286,31 +289,58 @@ impl RawClient { .map_err(Error::SslHandshakeError)?; if !validate_domain { - let store = TofuData::setup() - .map_err(|e| Error::TofuPersistError(e.to_string()))?; + 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(), + )); + } - if let Some(peer_cert) = stream.ssl().peer_certificate() { - let der = peer_cert.to_der() - .map_err(|e| Error::TofuPersistError(e.to_string()))?; + Ok(stream.into()) + } - match store.getData(&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 - store - .setData(&domain, der) - .map_err(|e| Error::TofuPersistError(e.to_string()))?; + /// 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)); } } - } else { - return Err(Error::TofuPersistError( - "Peer Certificate not available".to_string(), - )); + 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()) @@ -329,12 +359,13 @@ impl RawClient { ))] mod danger { use crate::raw_client::ServerName; - use crate::tofu::TofuData; + use crate::TofuStore; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified}; use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::DigitallySignedStruct; - use std::sync::{Arc, Mutex}; + use rustls::client::danger::ServerCertVerifier; + use std::sync::Arc; #[derive(Debug)] pub struct NoCertificateVerification(CryptoProvider); @@ -380,37 +411,30 @@ mod danger { } } + /// A certificate verifier that uses TOFU (Trust On First Use) validation. #[derive(Debug)] pub struct TofuVerifier { provider: CryptoProvider, host: String, - tofu_store: Arc>>>, + tofu_store: Arc, } impl TofuVerifier { - pub fn new(provider: CryptoProvider, host: String) -> Self { + pub fn new( + provider: CryptoProvider, + host: String, + tofu_store: Arc, + ) -> Self { Self { provider, host, - tofu_store: Arc::new(Mutex::new(None)), - } - } - - fn get_tofu_store(&self) -> Result, crate::Error> { - let mut store = self.tofu_store.lock().unwrap(); - if store.is_none() { - *store = Some(Arc::new( - TofuData::setup() - .map_err(|e| crate::Error::TofuPersistError(e.to_string()))?, - )); + tofu_store, } - Ok(store.as_ref().unwrap().clone()) } fn verify_tofu(&self, cert_der: &[u8]) -> Result<(), crate::Error> { - let store = self.get_tofu_store()?; - match store - .getData(&self.host) + match self.tofu_store + .get_certificate(&self.host) .map_err(|e| crate::Error::TofuPersistError(e.to_string()))? { Some(saved_der) => { @@ -420,8 +444,8 @@ mod danger { } None => { // First time: persist certificate. - store - .setData(&self.host, cert_der.to_vec()) + self.tofu_store + .set_certificate(&self.host, cert_der.to_vec()) .map_err(|e| crate::Error::TofuPersistError(e.to_string()))?; } } @@ -430,7 +454,7 @@ mod danger { } } - impl rustls::client::danger::ServerCertVerifier for TofuVerifier { + impl ServerCertVerifier for TofuVerifier { fn verify_server_cert( &self, end_entity: &CertificateDer<'_>, @@ -525,8 +549,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")))] @@ -568,13 +590,17 @@ 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::TofuVerifier::new(rustls::crypto::aws_lc_rs::default_provider(), domain.clone()), - #[cfg(feature = "use-rustls-ring")] - danger::TofuVerifier::new(rustls::crypto::ring::default_provider(), domain.clone()), + 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() }; @@ -598,6 +624,85 @@ impl RawClient { 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()) + } } #[cfg(any(feature = "default", feature = "proxy"))] diff --git a/src/tofu/mod.rs b/src/tofu/mod.rs index 07a40c1..6d8374e 100644 --- a/src/tofu/mod.rs +++ b/src/tofu/mod.rs @@ -1,217 +1,114 @@ -use std::{fs::create_dir_all, path::PathBuf, sync::Mutex}; -use rusqlite::{Connection, OptionalExtension, Result as SqlResult, params}; - -#[derive(Debug)] -pub struct TofuData { - db_path: PathBuf, - connection: Mutex -} - - - -impl TofuData { - pub fn setData(&self, host: &str, cert: Vec) -> SqlResult<()> { - if cert.is_empty() { - return Err(rusqlite::Error::InvalidQuery); - } - - let connection = self.connection.lock().unwrap(); - let sql = "INSERT INTO tofu (host, cert) VALUES (?1, ?2) - ON CONFLICT(host) DO UPDATE SET cert = excluded.cert"; - - connection.execute(sql, params![host, cert])?; - - Ok(()) - } - - pub fn getData(&self, host: &str) -> SqlResult>> { - let connection = self.connection.lock().unwrap(); - let sql = "SELECT cert FROM tofu WHERE host = ?1"; - - connection - .query_row(sql, params![host], |row| row.get(0)) - .optional() - } - - pub fn setup() -> SqlResult { - let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - path.push(".electrum-client"); - create_dir_all(&path).ok(); - path.push("tofu.sqlite"); - - let connection = Connection::open(&path)?; - let sql = "CREATE TABLE IF NOT EXISTS tofu( - host TEXT PRIMARY KEY, - cert BLOB NOT NULL - )"; - - connection.execute(sql, [])?; - - Ok(TofuData { - db_path: path, - connection: Mutex::new(connection) - }) - } +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::fs; - use std::path::Path; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::collections::HashMap; + use std::sync::Mutex; - static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + #[derive(Debug)] + struct InMemoryTofuStore { + store: Mutex>>, + } - fn setup_with_path(db_path: PathBuf) -> SqlResult { - if let Some(parent) = db_path.parent() { - create_dir_all(parent).ok(); + impl InMemoryTofuStore { + fn new() -> Self { + Self { + store: Mutex::new(HashMap::new()), + } } - let connection = Connection::open(&db_path)?; - let sql = "CREATE TABLE IF NOT EXISTS tofu( - host TEXT PRIMARY KEY, - cert BLOB NOT NULL - )"; - - connection.execute(sql, [])?; - - Ok(TofuData { - db_path, - connection: Mutex::new(connection) - }) } - fn create_temp_db() -> (PathBuf, TofuData) { - let temp_dir = std::env::temp_dir(); - let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); - let db_path = temp_dir.join(format!("tofu_test_{}_{}.sqlite", std::process::id(), counter)); - // Clean up any existing test database - let _ = fs::remove_file(&db_path); - let store = setup_with_path(db_path.clone()).unwrap(); - (db_path, store) - } + impl TofuStore for InMemoryTofuStore { + fn get_certificate(&self, host: &str) -> Result>, Box> { + let store = self.store.lock().unwrap(); + Ok(store.get(host).cloned()) + } - fn cleanup_temp_db(db_path: &Path) { - // Small delay to ensure file handles are released - std::thread::sleep(std::time::Duration::from_millis(50)); - let _ = fs::remove_file(db_path); + 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 (db_path, store) = create_temp_db(); + 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.getData(host).unwrap(); + let result = store.get_certificate(host).unwrap(); assert!(result.is_none(), "Certificate should not exist on first use"); - store.setData(host, cert.clone()).unwrap(); + store.set_certificate(host, cert.clone()).unwrap(); - let stored = store.getData(host).unwrap(); + let stored = store.get_certificate(host).unwrap(); assert_eq!(stored, Some(cert), "Certificate should be stored"); - - drop(store); - cleanup_temp_db(&db_path); } #[test] fn test_tofu_certificate_match() { - let (db_path, store) = create_temp_db(); + let store = InMemoryTofuStore::new(); let host = "example.com"; let cert = b"test certificate data".to_vec(); // Store certificate - store.setData(host, cert.clone()).unwrap(); + store.set_certificate(host, cert.clone()).unwrap(); // Retrieve and verify it matches - let stored = store.getData(host).unwrap(); + let stored = store.get_certificate(host).unwrap(); assert_eq!(stored, Some(cert), "Stored certificate should match"); - - drop(store); - cleanup_temp_db(&db_path); } #[test] fn test_tofu_certificate_change() { - let (db_path, store) = create_temp_db(); + 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.setData(host, cert1.clone()).unwrap(); - let stored1 = store.getData(host).unwrap(); + 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.setData(host, cert2.clone()).unwrap(); - let stored2 = store.getData(host).unwrap(); + 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"); - - drop(store); - cleanup_temp_db(&db_path); - } - - #[test] - fn test_tofu_persistence() { - let temp_dir = std::env::temp_dir(); - let db_path = temp_dir.join(format!("tofu_persistence_test_{}.sqlite", std::process::id())); - let _ = fs::remove_file(&db_path); - - let host = "example.com"; - let cert = b"persistent certificate".to_vec(); - - // Create first store instance and save certificate - { - let store1 = setup_with_path(db_path.clone()).unwrap(); - store1.setData(host, cert.clone()).unwrap(); - } - - // Create second store instance and verify certificate persists - { - let store2 = setup_with_path(db_path.clone()).unwrap(); - let stored = store2.getData(host).unwrap(); - assert_eq!(stored, Some(cert), "Certificate should persist across instances"); - } - - cleanup_temp_db(&db_path); - } - - #[test] - fn test_tofu_empty_certificate() { - let (db_path, store) = create_temp_db(); - - let host = "example.com"; - let cert = vec![]; - - // Attempt to store empty certificate should fail - let result = store.setData(host, cert.clone()); - assert!(result.is_err(), "Storing empty certificate should return an error"); - - drop(store); - cleanup_temp_db(&db_path); } #[test] fn test_tofu_large_certificate() { - let (db_path, store) = create_temp_db(); + let store = InMemoryTofuStore::new(); let host = "example.com"; // Create a large certificate (10KB) let cert = vec![0x42; 10 * 1024]; // Store large certificate - store.setData(host, cert.clone()).unwrap(); - let stored = store.getData(host).unwrap(); + 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"); - - drop(store); - cleanup_temp_db(&db_path); } } From 63beb8a42af7a67380e03b83081bb79e71c2dc76 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Mon, 12 Jan 2026 23:30:21 -0300 Subject: [PATCH 4/5] feat: integrate TOFU certificate validation into the main struct This allows users to use the TOFU flow directly through the high-level API by providing a store implementation in the - Update to automatically establish TOFU-validated SSL connections when a store is provided in the configuration - Add the field to the struct to support Trust On First Use (TOFU) validation. Also change to allow injecting custom store implementations through the builder pattern --- src/client.rs | 19 ++++++++++++++----- src/config.rs | 18 ++++++++++++++++++ src/raw_client.rs | 14 ++++++++------ 3 files changed, 40 insertions(+), 11 deletions(-) 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/raw_client.rs b/src/raw_client.rs index f5325c8..e092d90 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -48,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, ) @@ -302,9 +304,9 @@ impl RawClient { /// 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( + pub fn new_ssl_with_tofu( socket_addrs: &dyn ToSocketAddrsDomain, - tofu_store: std::sync::Arc, + tofu_store: std::sync::Arc, stream: TcpStream, ) -> Result { let mut builder = @@ -420,10 +422,10 @@ mod danger { } impl TofuVerifier { - pub fn new( + pub fn new( provider: CryptoProvider, host: String, - tofu_store: Arc, + tofu_store: Arc, ) -> Self { Self { provider, @@ -628,9 +630,9 @@ impl RawClient { /// 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( + pub fn new_ssl_with_tofu( socket_addrs: A, - tofu_store: std::sync::Arc, + tofu_store: std::sync::Arc, timeout: Option, ) -> Result { From 9178ff9a0e555844b1c499642bb2bac4271a7277 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Tue, 13 Jan 2026 21:34:57 -0300 Subject: [PATCH 5/5] docs: add TOFU certificate validation example Add examples/tofu.rs, demonstrating Trust On First Use (TOFU) certificate validation for SSL connections. The example shows how to implement a custom TofuStore and how to configure a client to use TOFU for secure certificate verification. It uses a simple in-memory TofuStore implementation for demonstration purposes only. --- examples/tofu.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 examples/tofu.rs 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); +} + +