From 8218fd584e4053848738a310258893e14c4ee10e Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 24 Aug 2026 15:27:06 +0200 Subject: TOFU host trust: SPKI fingerprint pinning, tests, forget-host --- src/bin/client/tls.rs | 240 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 153 insertions(+), 87 deletions(-) (limited to 'src/bin/client/tls.rs') diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs index 8ab5c7c..ca99838 100644 --- a/src/bin/client/tls.rs +++ b/src/bin/client/tls.rs @@ -1,83 +1,128 @@ +//! rustls-based client TLS with Trust-On-First-Use (TOFU) host verification. +//! +//! Model (SSH known_hosts style): +//! - First contact: accept the certificate and record the SHA-256 of its +//! SubjectPublicKeyInfo (SPKI). Hashing the SPKI rather than the whole +//! certificate means routine renewal with the same key does not break +//! clients; key rotation does — by design. +//! - Later contacts: the presented key must hash to the stored fingerprint, +//! otherwise the handshake fails with a loud error. +//! +//! Handshake signatures are accepted without webpki validation; identity is +//! established purely by the pinned fingerprint. An impostor cannot present a +//! different certificate without triggering the mismatch rejection. + use std::io::{Read, Write}; use std::net::TcpStream; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; +use std::sync::Arc; + +use parking_lot::Mutex; +use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::DigitallySignedStruct; +use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; pub struct TlsConnector { - connector: rustls::ClientConnection, - server_name: String, + config: Arc, + server_name: rustls::ServerName, + observed: Arc>>, } impl TlsConnector { - pub fn new(server_name: &str) -> Result> { + /// `stored` is the previously trusted fingerprint for this host, if any. + /// With `bypass_tofu` (unsafe mode) any certificate is accepted. + pub fn new( + server_name: &str, + stored: Option, + bypass_tofu: bool, + ) -> Result> { + let observed = Arc::new(Mutex::new(None)); + let verifier = Arc::new(TofuVerifier { + stored, + bypass_tofu, + observed: Arc::clone(&observed), + }); let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) + .with_safe_defaults() + .with_custom_certificate_verifier(verifier) .with_no_client_auth(); - - let connector = - rustls::ClientConnection::new(std::sync::Arc::new(config), server_name.try_into()?); - Ok(Self { - connector, - server_name: server_name.to_string(), + config: Arc::new(config), + server_name: server_name.try_into()?, + observed, }) } - pub fn connect( - &self, - stream: TcpStream, - ) -> Result> { - let mut conn = rustls::ClientConnection::new( - std::sync::Arc::new( - rustls::ClientConfig::builder() - .dangerous() - .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) - .with_no_client_auth(), - ), - self.server_name.clone().try_into()?, - )?; - - conn.complete_io(&mut std::io::BufReader::new(&stream))?; - - Ok(TlsClientStream { - stream, - conn: std::sync::Arc::new(conn), + pub fn connect(&self, stream: TcpStream) -> Result> { + let mut conn = + rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; + let mut sock = stream; + conn.complete_io(&mut sock)?; + + Ok(TlsStream { + session: Arc::new(Mutex::new(Session { sock, tls: conn })), }) } + + /// Fingerprint of the certificate seen during the last `connect`. + pub fn observed_fingerprint(&self) -> Option { + self.observed.lock().clone() + } } -struct InsecureVerifier; +struct TofuVerifier { + stored: Option, + bypass_tofu: bool, + observed: Arc>>, +} -impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { +impl ServerCertVerifier for TofuVerifier { fn verify_server_cert( &self, - _end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, + end_entity: &rustls::Certificate, + _intermediates: &[rustls::Certificate], + _server_name: &rustls::ServerName, + _scts: &mut dyn Iterator, _ocsp_response: &[u8], _now: std::time::SystemTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) + ) -> Result { + let fp = spki_fingerprint(&end_entity.0) + .map_err(|e| rustls::Error::General(format!("certificate parse failed: {e}")))?; + *self.observed.lock() = Some(fp.clone()); + + if self.bypass_tofu { + return Ok(ServerCertVerified::assertion()); + } + + match decide(self.stored.as_deref(), &fp) { + TrustDecision::TrustNew | TrustDecision::Trusted => Ok(ServerCertVerified::assertion()), + TrustDecision::Mismatch { expected, actual } => Err(rustls::Error::General(format!( + "HOST KEY CHANGED for this server!\n\ + expected fingerprint: {expected}\n\ + observed fingerprint: {actual}\n\ + If this is intentional (server reinstall), use the client's\n\ + forget-host command to reset trust for this address." + ))), + } } + // Identity is pinned via fingerprint comparison above; handshake + // signatures are not additionally verified against a trust store. fn verify_tls12_signature( &self, _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + _cert: &rustls::Certificate, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + _cert: &rustls::Certificate, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { @@ -91,60 +136,81 @@ impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { rustls::SignatureScheme::ECDSA_NISTP256_SHA256, rustls::SignatureScheme::ECDSA_NISTP384_SHA384, rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256_LEGACY, - rustls::SignatureScheme::RSA_PSS_SHA384_LEGACY, - rustls::SignatureScheme::RSA_PSS_SHA512_LEGACY, ] } } -pub struct TlsClientStream { - stream: TcpStream, - conn: std::sync::Arc, +/// TCP socket + TLS session behind one mutex so clones share the stream. +struct Session { + sock: TcpStream, + tls: rustls::ClientConnection, +} + +impl Session { + fn drive(&mut self) -> std::io::Result<()> { + Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?) + } } -impl TlsClientStream { +#[derive(Clone)] +pub struct TlsStream { + session: Arc>, +} + +impl TlsStream { pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { let mut byte_buf = [0u8; 1024]; + let mut s = self.session.lock(); loop { - let mut tls_reader = std::io::BufReader::new(self.conn.clone()); - let n = tls_reader.read(&mut byte_buf)?; - if n == 0 { - return Ok(0); - } - let chunk = std::str::from_utf8(&byte_buf[..n]).unwrap_or(""); - buf.push_str(chunk); - if buf.contains('\n') { - return Ok(n); + match s.tls.reader().read(&mut byte_buf) { + Ok(0) => return Ok(0), + Ok(n) => { + buf.push_str(std::str::from_utf8(&byte_buf[..n]).unwrap_or("")); + if buf.contains('\n') { + return Ok(n); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(e) => return Err(e), } } } pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - use std::io::Write; - self.conn.writer().write_all(msg.as_bytes())?; - self.conn.writer().write_all(b"\n")?; - self.conn.writer().flush()?; - Ok(()) + let mut s = self.session.lock(); + s.tls.writer().write_all(msg.as_bytes())?; + s.tls.writer().write_all(b"\n")?; + s.drive() } - pub fn try_clone(&self) -> std::io::Result { - let new_stream = self.stream.try_clone()?; - let mut conn = rustls::ClientConnection::new( - std::sync::Arc::new( - rustls::ClientConfig::builder() - .dangerous() - .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) - .with_no_client_auth(), - ), - self.server_name.clone().try_into().unwrap(), - )?; - - conn.complete_io(&mut std::io::BufReader::new(&new_stream))?; - - Ok(TlsClientStream { - stream: new_stream, - conn: std::sync::Arc::new(conn), - }) + /// Clones share the same underlying TLS session. + pub fn try_clone(&self) -> std::io::Result { + Ok(self.clone()) + } +} + +impl Read for TlsStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut s = self.session.lock(); + loop { + match s.tls.reader().read(buf) { + Ok(n) => return Ok(n), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(e) => return Err(e), + } + } + } +} + +impl Write for TlsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut s = self.session.lock(); + s.tls.writer().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut s = self.session.lock(); + s.tls.writer().flush()?; + s.drive() } } -- cgit v1.2.3