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 --- AGENTS.md | 3 + Cargo.toml | 2 +- docs/GUI-PLAN.md | 9 +- src/bin/client/connection.rs | 176 +++-------------- src/bin/client/main.rs | 1 - src/bin/client/tls.rs | 288 ---------------------------- src/client/mod.rs | 1 + src/client/transport.rs | 447 +++++++++++++++++++++++++++++++++++++++++++ tests/tofu.rs | 13 ++ 9 files changed, 498 insertions(+), 442 deletions(-) delete mode 100644 src/bin/client/tls.rs create mode 100644 src/client/transport.rs diff --git a/AGENTS.md b/AGENTS.md index e784624..72def0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,9 @@ sharing a login. - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... DO UPDATE SET last_used` — never `INSERT OR REPLACE`. +- The client transport lives in `client::transport`. It records the key during + the handshake, then asks the frontend (`TrustPrompt`) before sending `HELLO` + or credentials. A changed key is a hard error, and forgetting it is explicit. ## Versioning diff --git a/Cargo.toml b/Cargo.toml index 877ec86..076ffd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "4.0.3" +version = "4.0.4" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index c3d9b3f..eb0fd8e 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -28,10 +28,11 @@ existing game flows unchanged, new tests for version parsing and gating. **In progress.** `Command`/`View` exist and all four games are ported: the core builds a typed view, frontends send `Command`, and the TUI renders it. -Done so far: `Lobby` and `History` are structured views driven by -`Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the -connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with -the transport moved into the library, and removing `Key` from the core. +Done so far: every game plus `Lobby` and `History` are structured views; the +client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in +`client::transport` behind a trust callback. Remaining: the connection-facing +screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from +the core. - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, wheel animation, interactive prompts) into the TUI adapter. 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() - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index a79a460..456faa0 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -2,6 +2,7 @@ pub mod core; pub mod database; pub mod games; pub mod paths; +pub mod transport; pub mod tui; pub mod ui; pub mod view; diff --git a/src/client/transport.rs b/src/client/transport.rs new file mode 100644 index 0000000..97c9227 --- /dev/null +++ b/src/client/transport.rs @@ -0,0 +1,447 @@ +//! Client transport: TCP, rustls, TOFU and the HELLO handshake. +//! +//! The handshake records the server's SPKI fingerprint but does not act on it; +//! the trust decision happens after the handshake and before any credentials +//! are sent, so a frontend can ask the player. A changed key is a hard error. + +use std::fmt; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::DigitallySignedStruct; +use rusqlite::Connection; + +use crate::client::database; +use crate::protocol::LineBuffer; +use crate::tofu::{decide, spki_fingerprint, TrustDecision}; +use crate::version::{parse_hello, HelloReply, VERSION}; + +/// How long to wait for TCP + TLS before giving up. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// Read timeout once the stream is usable. +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// What a frontend shows when a server key is unknown. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustPrompt { + pub addr: String, + pub fingerprint: String, +} + +/// A usable connection after trust, version and optional auto-login. +pub struct Connected { + pub stream: TlsStream, + pub user: Option, + /// Set when stored credentials were rejected; the session is still usable. + pub auth_error: Option, + pub server_version: String, + pub latest: String, + pub url: String, + /// Round trip of the HELLO exchange. + pub elapsed: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + Timeout, + TrustDeclined, + KeyChanged { + expected: String, + observed: String, + }, + VersionMismatch(String), + Closed, + Unexpected(String), + Io(String), +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TransportError::Timeout => write!(f, "Connection timed out"), + TransportError::TrustDeclined => write!(f, "Certificate was not trusted"), + TransportError::KeyChanged { expected, observed } => write!( + f, + "HOST KEY CHANGED for this server!\n\ + expected fingerprint: {expected}\n\ + observed fingerprint: {observed}\n\ + If this is intentional (server reinstall), forget this host to reset trust." + ), + TransportError::VersionMismatch(message) => write!(f, "{message}"), + TransportError::Closed => write!(f, "Server closed the connection during HELLO"), + TransportError::Unexpected(message) => write!(f, "{message}"), + TransportError::Io(message) => write!(f, "Connection error: {message}"), + } + } +} + +impl std::error::Error for TransportError {} + +fn io_failure(error: std::io::Error) -> TransportError { + TransportError::Io(error.to_string()) +} + +/// Connect, verify the key, check versions and try the stored login. +/// +/// `trust` is called only for a server we have never seen; returning `false` +/// aborts before any application data is sent. +pub fn connect( + addr: &str, + conn: &Connection, + unsafe_mode: bool, + trust: &mut dyn FnMut(&TrustPrompt) -> bool, +) -> Result { + let stored = database::get_fingerprint(conn, addr); + let (tls, observed) = tls_connect(addr)?; + let observed = observed.ok_or_else(|| { + TransportError::Unexpected("the server presented no certificate fingerprint".to_string()) + })?; + + if !unsafe_mode { + match decide(stored.as_deref(), &observed) { + TrustDecision::Trusted => {} + TrustDecision::TrustNew => { + let prompt = TrustPrompt { + addr: addr.to_string(), + fingerprint: observed.clone(), + }; + if !trust(&prompt) { + return Err(TransportError::TrustDeclined); + } + database::save_fingerprint(conn, addr, &observed) + .map_err(|error| TransportError::Io(error.to_string()))?; + } + TrustDecision::Mismatch { expected, actual } => { + return Err(TransportError::KeyChanged { + expected, + observed: actual, + }) + } + } + } + + let mut tls = tls; + tls.set_read_timeout(Some(READ_TIMEOUT)) + .map_err(io_failure)?; + + let start = Instant::now(); + tls.write_line(&format!("HELLO {VERSION}")) + .map_err(io_failure)?; + let mut response = String::new(); + if tls.read_line(&mut response).map_err(io_failure)? == 0 { + return Err(TransportError::Closed); + } + let elapsed = start.elapsed(); + + let response = response.trim(); + if let Some(message) = response.strip_prefix("ERROR") { + return Err(TransportError::VersionMismatch(message.trim().to_string())); + } + let hello: HelloReply = + parse_hello(response).ok_or_else(|| TransportError::Unexpected(response.to_string()))?; + + // Credentials are keyed by the pinned fingerprint, so any address that + // presents this key can log in with the same stored account. + let mut user = None; + let mut auth_error = None; + if let Some((username, password)) = database::get_credentials(conn, &observed) { + tls.write_line(&format!("AUTH {username} {password}")) + .map_err(io_failure)?; + let mut auth = String::new(); + tls.read_line(&mut auth).map_err(io_failure)?; + if auth.trim().starts_with("OK") { + user = Some(username); + } else { + auth_error = Some(auth.trim().to_string()); + } + } + + Ok(Connected { + stream: tls, + user, + auth_error, + server_version: hello.version, + latest: hello.latest, + url: hello.url, + elapsed, + }) +} + +/// TCP + TLS on a thread so a blocked handshake can time out. +fn tls_connect(addr: &str) -> Result<(TlsStream, Option), TransportError> { + let (sender, receiver) = mpsc::channel(); + let addr = addr.to_string(); + let server_name = addr + .split(':') + .next() + .unwrap_or("127.0.0.1") + .to_string(); + + thread::spawn(move || { + let outcome = (|| -> Result<(TlsStream, Option), TransportError> { + let tcp = TcpStream::connect(&addr).map_err(io_failure)?; + let connector = TlsConnector::new(&server_name) + .map_err(|error| TransportError::Io(error.to_string()))?; + let stream = connector + .connect(tcp) + .map_err(|error| TransportError::Io(error.to_string()))?; + Ok((stream, connector.observed_fingerprint())) + })(); + let _ = sender.send(outcome); + }); + + match receiver.recv_timeout(CONNECT_TIMEOUT) { + Ok(outcome) => outcome, + Err(mpsc::RecvTimeoutError::Timeout) => Err(TransportError::Timeout), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(TransportError::Unexpected( + "connection attempt failed".to_string(), + )), + } +} + +struct TlsConnector { + config: Arc, + server_name: rustls::ServerName, + observed: Arc>>, +} + +impl TlsConnector { + fn new(server_name: &str) -> Result> { + let observed = Arc::new(Mutex::new(None)); + let verifier = Arc::new(RecordingVerifier { + 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, + }) + } + + fn connect( + &self, + stream: TcpStream, + ) -> Result> { + let mut connection = + rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; + let mut sock = stream; + connection.complete_io(&mut sock)?; + + Ok(TlsStream { + session: Arc::new(Mutex::new(Session { + sock, + tls: connection, + lines: LineBuffer::new(), + read_timeout: Some(READ_TIMEOUT), + })), + }) + } + + fn observed_fingerprint(&self) -> Option { + self.observed.lock().clone() + } +} + +/// Certificate verification is record-only: the trust decision is made after +/// the handshake, before anything is sent, so a frontend can ask first. +struct RecordingVerifier { + observed: Arc>>, +} + +impl ServerCertVerifier for RecordingVerifier { + 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 fingerprint = spki_fingerprint(&end_entity.0) + .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; + *self.observed.lock() = Some(fingerprint); + Ok(ServerCertVerified::assertion()) + } + + // Identity is pinned by the fingerprint check above; handshake signatures + // are not additionally validated 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<()> { + 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 chunk = [0u8; 4096]; + match s.tls.reader().read(&mut chunk) { + 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(&chunk[..n]), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(error) => return Err(error), + } + } + } + + pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { + let mut s = self.session.lock(); + s.tls.writer().write_all(message.as_bytes())?; + s.tls.writer().write_all(b"\n")?; + s.drive() + } + + 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(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(error) => return Err(error), + } + } + } +} + +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() + } +} diff --git a/tests/tofu.rs b/tests/tofu.rs index b644cc4..a26995f 100644 --- a/tests/tofu.rs +++ b/tests/tofu.rs @@ -133,3 +133,16 @@ fn fingerprint_roundtrip_in_client_db() { client_database::forget_host(&conn, "host1").unwrap(); assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); } + +#[test] +fn a_changed_key_error_names_both_fingerprints() { + let error = zagan_bet::client::transport::TransportError::KeyChanged { + expected: "aaaa".to_string(), + observed: "bbbb".to_string(), + }; + let text = error.to_string(); + + assert!(text.contains("HOST KEY CHANGED"), "{text}"); + assert!(text.contains("aaaa"), "{text}"); + assert!(text.contains("bbbb"), "{text}"); +} -- cgit v1.2.3