Skip to content

Commit 15eda69

Browse files
committed
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.
1 parent 19a7084 commit 15eda69

7 files changed

Lines changed: 616 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ edition = "2021"
1818
name = "electrum_client"
1919
path = "src/lib.rs"
2020

21+
[[example]]
22+
name = "tofu"
23+
required-features = ["tofu"]
24+
2125
[dependencies]
2226
log = "^0.4"
2327
bitcoin = { version = "0.32", features = ["serde"] }
@@ -43,6 +47,7 @@ proxy = ["byteorder", "winapi", "libc"]
4347
rustls = ["webpki-roots", "dep:rustls", "rustls/default"]
4448
rustls-ring = ["webpki-roots", "dep:rustls", "rustls/ring", "rustls/logging", "rustls/std", "rustls/tls12"]
4549
openssl = ["dep:openssl"]
50+
tofu = []
4651

4752
# Old feature names
4853
use-rustls = ["rustls"]

examples/tofu.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// cargo run --example tofu --features tofu
2+
extern crate electrum_client;
3+
4+
use electrum_client::{Client, Config, ElectrumApi, TofuStore};
5+
use std::collections::HashMap;
6+
use std::io;
7+
use std::sync::{Arc, Mutex};
8+
9+
#[derive(Debug, Default)]
10+
struct InMemoryTofuStore {
11+
certs: Mutex<HashMap<String, Vec<u8>>>,
12+
}
13+
14+
impl TofuStore for InMemoryTofuStore {
15+
fn get_certificate(&self, host: &str) -> io::Result<Option<Vec<u8>>> {
16+
Ok(self.certs.lock().unwrap().get(host).cloned())
17+
}
18+
19+
fn set_certificate(&self, host: &str, cert: Vec<u8>) -> io::Result<()> {
20+
self.certs.lock().unwrap().insert(host.to_string(), cert);
21+
Ok(())
22+
}
23+
}
24+
25+
fn main() {
26+
let store = Arc::new(InMemoryTofuStore::default());
27+
28+
let client = Client::from_config_with_tofu(
29+
"ssl://electrum.blockstream.info:50002",
30+
Config::default(),
31+
store,
32+
)
33+
.unwrap();
34+
35+
println!("{:#?}", client.server_features());
36+
}

src/client.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Electrum Client
22
3+
#[cfg(feature = "tofu")]
4+
use std::sync::Arc;
35
use std::{borrow::Borrow, sync::RwLock};
46

57
use log::{info, warn};
@@ -10,6 +12,8 @@ use crate::api::ElectrumApi;
1012
use crate::batch::Batch;
1113
use crate::config::Config;
1214
use crate::raw_client::*;
15+
#[cfg(feature = "tofu")]
16+
use crate::tofu::TofuStore;
1317
use crate::types::*;
1418
use std::convert::TryFrom;
1519

@@ -35,6 +39,8 @@ pub struct Client {
3539
client_type: RwLock<ClientType>,
3640
config: Config,
3741
url: String,
42+
#[cfg(feature = "tofu")]
43+
tofu_store: Option<Arc<dyn TofuStore>>,
3844
}
3945

4046
macro_rules! impl_inner_call {
@@ -74,7 +80,7 @@ macro_rules! impl_inner_call {
7480
if let Ok(mut write_client) = $self.client_type.try_write() {
7581
loop {
7682
std::thread::sleep(std::time::Duration::from_secs((1 << errors.len()).min(30) as u64));
77-
match ClientType::from_config(&$self.url, &$self.config) {
83+
match $self.client_type_adapter() {
7884
Ok(new_client) => {
7985
info!("Succesfully created new client");
8086
*write_client = new_client;
@@ -179,6 +185,61 @@ impl ClientType {
179185
Ok(client)
180186
}
181187
}
188+
189+
/// Constructor that supports multiple backends and allows configuration through
190+
/// the Config, enabling TOFU certificate checks for SSL connections
191+
#[cfg(feature = "tofu")]
192+
pub fn from_config_with_tofu(
193+
url: &str,
194+
config: &Config,
195+
tofu_store: Arc<dyn TofuStore>,
196+
) -> Result<Self, Error> {
197+
let auth_provider = config.authorization_provider().cloned();
198+
199+
#[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))]
200+
if url.starts_with("ssl://") {
201+
let url = url.replacen("ssl://", "", 1);
202+
#[cfg(feature = "proxy")]
203+
let raw_client = match config.socks5() {
204+
Some(socks5) => RawClient::new_proxy_ssl_with_tofu(
205+
url.as_str(),
206+
config.validate_domain(),
207+
socks5,
208+
config.timeout(),
209+
tofu_store,
210+
auth_provider,
211+
)?,
212+
None => RawClient::new_ssl_with_tofu(
213+
url.as_str(),
214+
config.validate_domain(),
215+
config.timeout(),
216+
tofu_store,
217+
auth_provider,
218+
)?,
219+
};
220+
#[cfg(not(feature = "proxy"))]
221+
let raw_client = RawClient::new_ssl_with_tofu(
222+
url.as_str(),
223+
config.validate_domain(),
224+
config.timeout(),
225+
tofu_store,
226+
auth_provider,
227+
)?;
228+
229+
return Ok(ClientType::SSL(raw_client));
230+
}
231+
232+
#[cfg(not(any(feature = "openssl", feature = "rustls", feature = "rustls-ring")))]
233+
if url.starts_with("ssl://") {
234+
return Err(Error::Message(
235+
"SSL connections require one of the following features to be enabled: openssl, rustls, or rustls-ring".to_string()
236+
));
237+
}
238+
239+
Err(Error::Message(
240+
"TOFU validation is available only for SSL connections".to_string(),
241+
))
242+
}
182243
}
183244

184245
impl Client {
@@ -204,8 +265,45 @@ impl Client {
204265
client_type,
205266
config,
206267
url: url.to_string(),
268+
#[cfg(feature = "tofu")]
269+
tofu_store: None,
270+
})
271+
}
272+
273+
/// Creates a new client with TOFU (Trust On First Use) certificate validation.
274+
/// This constructor requires an SSL URL and stores/verifies server
275+
/// certificates using the provided store
276+
#[cfg(feature = "tofu")]
277+
pub fn from_config_with_tofu(
278+
url: &str,
279+
config: Config,
280+
tofu_store: Arc<dyn TofuStore>,
281+
) -> Result<Self, Error> {
282+
let client_type = RwLock::new(ClientType::from_config_with_tofu(
283+
url,
284+
&config,
285+
tofu_store.clone(),
286+
)?);
287+
288+
Ok(Client {
289+
client_type,
290+
config,
291+
url: url.to_string(),
292+
tofu_store: Some(tofu_store),
207293
})
208294
}
295+
296+
// Recreate the client using the same strategy as the original constructor
297+
fn client_type_adapter(&self) -> Result<ClientType, Error> {
298+
#[cfg(feature = "tofu")]
299+
{
300+
if let Some(store) = self.tofu_store.as_ref() {
301+
return ClientType::from_config_with_tofu(&self.url, &self.config, store.clone());
302+
}
303+
}
304+
305+
ClientType::from_config(&self.url, &self.config)
306+
}
209307
}
210308

211309
impl ElectrumApi for Client {

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@ mod config;
5252

5353
pub mod raw_client;
5454
mod stream;
55+
#[cfg(feature = "tofu")]
56+
mod tofu;
5557
mod types;
5658
pub mod utils;
5759

5860
pub use api::ElectrumApi;
5961
pub use batch::Batch;
6062
pub use client::*;
6163
pub use config::{AuthProvider, Config, ConfigBuilder, Socks5Config};
64+
#[cfg(feature = "tofu")]
65+
pub use tofu::TofuStore;
6266
pub use types::*;

0 commit comments

Comments
 (0)