From 55032d2de36b08692d39cc94c4f2da317b3e2eb7 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 18:14:16 +0100 Subject: v4.0.4: move the client transport and TOFU into the library --- src/bin/client/connection.rs | 176 +++++--------------------- src/bin/client/main.rs | 1 - src/bin/client/tls.rs | 288 ------------------------------------------- 3 files changed, 28 insertions(+), 437 deletions(-) delete mode 100644 src/bin/client/tls.rs (limited to 'src/bin/client') diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 6bc9b09..f88f62b 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -1,14 +1,16 @@ -use crate::tls::TlsConnector; +//! Terminal adapter around the shared client transport. +//! +//! All protocol work (TLS, TOFU, HELLO, auto-login) lives in +//! `zagan_bet::client::transport`; this file only decides how to ask a +//! terminal player about a new certificate and how to print the result. + use parking_lot::Mutex; -use rusqlite::Connection; -use std::io::{Read, Write}; -use std::net::TcpStream; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; -use zagan_bet::client::database; +use std::error::Error; +use zagan_bet::client::transport::{self, TrustPrompt}; use zagan_bet::client::ui::first_contact_lines; -use zagan_bet::version::{is_newer, parse_hello, VERSION}; +use zagan_bet::version::{is_newer, VERSION}; + +pub use zagan_bet::client::transport::TlsStream; static UNSAFE_MODE: Mutex = Mutex::new(false); @@ -16,162 +18,40 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { *UNSAFE_MODE.lock() = unsafe_mode; } -type ConnectResult = Result<(TlsStream, Option), Box>; - pub fn connect( addr: &str, - conn: &Connection, -) -> Result<(TlsStream, Option), Box> { - let stored_fingerprint = database::get_fingerprint(conn, addr); + conn: &rusqlite::Connection, +) -> Result<(TlsStream, Option), Box> { let unsafe_mode = *UNSAFE_MODE.lock(); - let (tx, rx) = mpsc::channel::(); - let addr_str = addr.to_string(); - let server_name = addr_str - .split(':') - .next() - .unwrap_or("127.0.0.1") - .to_string(); - - thread::spawn(move || { - let outcome = (|| -> ConnectResult { - let tcp = TcpStream::connect(&addr_str)?; - let connector = - TlsConnector::new(&server_name, stored_fingerprint.clone(), unsafe_mode)?; - let tls_stream = connector.connect(tcp)?; - let fp = connector.observed_fingerprint(); - Ok((TlsStream { inner: tls_stream }, fp)) - })(); - let _ = tx.send(outcome); - }); - - // Channel carries (stream, observed fingerprint). - let (tls, observed) = match rx.recv_timeout(Duration::from_secs(10)) { - Ok(Ok((tls, fp))) => (tls, fp), - Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), - Err(mpsc::RecvTimeoutError::Timeout) => { - return Err("Connection timed out after 10 seconds".into()); + // The terminal has no dialog: print the first-contact notice and trust. + let mut trust = |prompt: &TrustPrompt| { + for line in first_contact_lines(&prompt.addr, &prompt.fingerprint) { + println!("{line}"); } - Err(_) => return Err("Connection attempt failed".into()), + true }; - // TOFU: persist the fingerprint on first contact. - if !unsafe_mode { - if let Some(fp) = observed.as_deref() { - if database::get_fingerprint(conn, addr).is_none() { - database::save_fingerprint(conn, addr, fp)?; - for line in first_contact_lines(addr, fp) { - println!("{}", line); - } - } - } - } - - let mut tls = tls; - tls.set_read_timeout(Some(Duration::from_secs(10)))?; - - // Version lockstep: the first line after TLS is the handshake, and no - // credentials are sent until the server accepts it. - let start = Instant::now(); - tls.write_line(&format!("HELLO {VERSION}"))?; - let mut response = String::new(); - if tls.read_line(&mut response)? == 0 { - return Err("server closed the connection during HELLO".into()); - } - let elapsed = start.elapsed(); - let response = response.trim(); - - if let Some(message) = response.strip_prefix("ERROR") { - return Err(message.trim().to_string().into()); - } - let Some(hello) = parse_hello(response) else { - return Err(format!("unexpected HELLO reply: {response}").into()); - }; + let connected = transport::connect(addr, conn, unsafe_mode, &mut trust) + .map_err(|error| Box::new(error) as Box)?; println!( "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", addr, - elapsed.as_secs_f64() * 1000.0 + connected.elapsed.as_secs_f64() * 1000.0 ); - if is_newer(&hello.latest, VERSION) { + if is_newer(&connected.latest, VERSION) { println!( "A newer version is available: {} ({})", - hello.latest, hello.url + connected.latest, connected.url ); } - - // Credentials are stored per server identity, so any address presenting - // this certificate key can log in with the same stored account. - let identity = observed - .clone() - .or_else(|| database::get_fingerprint(conn, addr)); - if let Some(identity) = identity { - if let Some((username, password)) = database::get_credentials(conn, &identity) { - let auth_msg = format!("AUTH {} {}", username, password); - tls.write_line(&auth_msg)?; - let mut auth_response = String::new(); - tls.read_line(&mut auth_response)?; - if auth_response.trim().starts_with("OK") { - println!("Authenticated as {}", username); - return Ok((tls, Some(username))); - } - println!("Authentication failed: {}", auth_response.trim()); - } - } - Ok((tls, None)) -} - -/// Wrapper so TLS handshake failures surface as errors with their message -/// intact (rustls errors already carry the mismatch details). -struct TrustError(String); - -impl std::fmt::Display for TrustError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} -impl std::fmt::Debug for TrustError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self}") - } -} -impl std::error::Error for TrustError {} - -pub struct TlsStream { - inner: crate::tls::TlsStream, -} - -impl TlsStream { - pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { - self.inner.read_line(buf) - } - - pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - self.inner.write_line(msg) + if let Some(user) = &connected.user { + println!("Authenticated as {user}"); } - - /// One line if the socket already has one, without blocking. - pub fn read_available_line(&mut self) -> std::io::Result> { - self.inner.read_available_line() + if let Some(error) = &connected.auth_error { + println!("Authentication failed: {error}"); } - pub fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { - self.inner.set_read_timeout(duration) - } -} - -impl Read for TlsStream { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.inner.read(buf) - } -} - -impl Write for TlsStream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.inner.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.inner.flush() - } + Ok((connected.stream, connected.user)) } diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs index 3407380..d983e58 100644 --- a/src/bin/client/main.rs +++ b/src/bin/client/main.rs @@ -2,7 +2,6 @@ mod macros; mod connection; mod repl; -mod tls; use repl::ClientState; use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE}; diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs deleted file mode 100644 index e14ed98..0000000 --- a/src/bin/client/tls.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! 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::Arc; - -use parking_lot::Mutex; -use rustls::DigitallySignedStruct; -use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -use zagan_bet::protocol::LineBuffer; -use zagan_bet::tofu::{TrustDecision, decide, spki_fingerprint}; - -pub struct TlsConnector { - config: Arc, - server_name: rustls::ServerName, - observed: Arc>>, -} - -impl TlsConnector { - /// `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() - .with_safe_defaults() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth(); - Ok(Self { - config: Arc::new(config), - server_name: server_name.try_into()?, - observed, - }) - } - - 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, - lines: LineBuffer::new(), - read_timeout: Some(std::time::Duration::from_secs(10)), - })), - }) - } - - /// Fingerprint of the certificate seen during the last `connect`. - pub fn observed_fingerprint(&self) -> Option { - self.observed.lock().clone() - } -} - -struct TofuVerifier { - stored: Option, - bypass_tofu: bool, - observed: Arc>>, -} - -impl ServerCertVerifier for TofuVerifier { - fn verify_server_cert( - &self, - end_entity: &rustls::Certificate, - _intermediates: &[rustls::Certificate], - _server_name: &rustls::ServerName, - _scts: &mut dyn Iterator, - _ocsp_response: &[u8], - _now: std::time::SystemTime, - ) -> 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::Certificate, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &rustls::Certificate, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - ] - } -} - -/// TCP socket + TLS session behind one mutex so clones share the stream. -struct Session { - sock: TcpStream, - tls: rustls::ClientConnection, - lines: LineBuffer, - read_timeout: Option, -} - -impl Session { - fn drive(&mut self) -> std::io::Result<()> { - Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?) - } -} - -#[derive(Clone)] -pub struct TlsStream { - session: Arc>, -} - -impl TlsStream { - pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { - let mut s = self.session.lock(); - loop { - if let Some(line) = s.lines.next_line() { - buf.push_str(&line); - buf.push('\n'); - return Ok(line.len() + 1); - } - - let mut byte_buf = [0u8; 4096]; - match s.tls.reader().read(&mut byte_buf) { - Ok(0) => { - if let Some(rest) = s.lines.take_rest() { - let read = rest.len(); - buf.push_str(&rest); - return Ok(read); - } - return Ok(0); - } - Ok(n) => s.lines.push(&byte_buf[..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<()> { - let mut s = self.session.lock(); - s.tls.writer().write_all(msg.as_bytes())?; - s.tls.writer().write_all(b"\n")?; - s.drive() - } - - /// Apply a read timeout to the underlying TCP socket. - pub fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { - let mut s = self.session.lock(); - s.read_timeout = duration; - s.sock.set_read_timeout(duration) - } - - /// Return one complete line if the socket already has one, without - /// blocking. Used to pick up server pushes between requests. - pub fn read_available_line(&mut self) -> std::io::Result> { - let mut s = self.session.lock(); - if let Some(line) = s.lines.next_line() { - return Ok(Some(line)); - } - - // `set_read_timeout(Some(0))` is rejected by std, so poll with the - // shortest timeout the socket accepts instead of a nonblocking mode. - s.sock - .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; - let drained = (|| -> std::io::Result<()> { - loop { - let mut buf = [0u8; 4096]; - match s.tls.reader().read(&mut buf) { - Ok(0) => return Ok(()), - Ok(n) => s.lines.push(&buf[..n]), - // The reader only drains plaintext rustls has already - // decrypted; `drive` moves bytes off the socket. - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - match s.drive() { - Ok(()) => continue, - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - return Ok(()); - } - Err(error) => return Err(error), - } - } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - return Ok(()); - } - Err(error) => return Err(error), - } - } - })(); - s.sock.set_read_timeout(s.read_timeout)?; - drained?; - Ok(s.lines.next_line()) - } -} - -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