diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 18:14:16 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 18:14:16 +0100 |
| commit | 55032d2de36b08692d39cc94c4f2da317b3e2eb7 (patch) | |
| tree | 2ace8ef541e1ec211ff703c5050027c13ba56759 | |
| parent | v4.0.3: lobby and history screens behind commands (diff) | |
| download | zagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.tar.gz zagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.zip | |
v4.0.4: move the client transport and TOFU into the library
| -rw-r--r-- | AGENTS.md | 3 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 9 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 176 | ||||
| -rw-r--r-- | src/bin/client/main.rs | 1 | ||||
| -rw-r--r-- | src/bin/client/tls.rs | 288 | ||||
| -rw-r--r-- | src/client/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/transport.rs | 447 | ||||
| -rw-r--r-- | tests/tofu.rs | 13 |
9 files changed, 498 insertions, 442 deletions
| @@ -104,6 +104,9 @@ | |||
| 104 | sharing a login. | 104 | sharing a login. |
| 105 | - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... | 105 | - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... |
| 106 | DO UPDATE SET last_used` — never `INSERT OR REPLACE`. | 106 | DO UPDATE SET last_used` — never `INSERT OR REPLACE`. |
| 107 | - The client transport lives in `client::transport`. It records the key during | ||
| 108 | the handshake, then asks the frontend (`TrustPrompt`) before sending `HELLO` | ||
| 109 | or credentials. A changed key is a hard error, and forgetting it is explicit. | ||
| 107 | 110 | ||
| 108 | ## Versioning | 111 | ## Versioning |
| 109 | 112 | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.3" | 3 | version = "4.0.4" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
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. | |||
| 28 | 28 | ||
| 29 | **In progress.** `Command`/`View` exist and all four games are ported: the | 29 | **In progress.** `Command`/`View` exist and all four games are ported: the |
| 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. | 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. |
| 31 | Done so far: `Lobby` and `History` are structured views driven by | 31 | Done so far: every game plus `Lobby` and `History` are structured views; the |
| 32 | `Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the | 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in |
| 33 | connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with | 33 | `client::transport` behind a trust callback. Remaining: the connection-facing |
| 34 | the transport moved into the library, and removing `Key` from the core. | 34 | screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from |
| 35 | the core. | ||
| 35 | 36 | ||
| 36 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 37 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 37 | wheel animation, interactive prompts) into the TUI adapter. | 38 | 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 @@ | |||
| 1 | use crate::tls::TlsConnector; | 1 | //! Terminal adapter around the shared client transport. |
| 2 | //! | ||
| 3 | //! All protocol work (TLS, TOFU, HELLO, auto-login) lives in | ||
| 4 | //! `zagan_bet::client::transport`; this file only decides how to ask a | ||
| 5 | //! terminal player about a new certificate and how to print the result. | ||
| 6 | |||
| 2 | use parking_lot::Mutex; | 7 | use parking_lot::Mutex; |
| 3 | use rusqlite::Connection; | 8 | use std::error::Error; |
| 4 | use std::io::{Read, Write}; | 9 | use zagan_bet::client::transport::{self, TrustPrompt}; |
| 5 | use std::net::TcpStream; | ||
| 6 | use std::sync::mpsc; | ||
| 7 | use std::thread; | ||
| 8 | use std::time::{Duration, Instant}; | ||
| 9 | use zagan_bet::client::database; | ||
| 10 | use zagan_bet::client::ui::first_contact_lines; | 10 | use zagan_bet::client::ui::first_contact_lines; |
| 11 | use zagan_bet::version::{is_newer, parse_hello, VERSION}; | 11 | use zagan_bet::version::{is_newer, VERSION}; |
| 12 | |||
| 13 | pub use zagan_bet::client::transport::TlsStream; | ||
| 12 | 14 | ||
| 13 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); | 15 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); |
| 14 | 16 | ||
| @@ -16,162 +18,40 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { | |||
| 16 | *UNSAFE_MODE.lock() = unsafe_mode; | 18 | *UNSAFE_MODE.lock() = unsafe_mode; |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>; | ||
| 20 | |||
| 21 | pub fn connect( | 21 | pub fn connect( |
| 22 | addr: &str, | 22 | addr: &str, |
| 23 | conn: &Connection, | 23 | conn: &rusqlite::Connection, |
| 24 | ) -> Result<(TlsStream, Option<String>), Box<dyn std::error::Error>> { | 24 | ) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { |
| 25 | let stored_fingerprint = database::get_fingerprint(conn, addr); | ||
| 26 | let unsafe_mode = *UNSAFE_MODE.lock(); | 25 | let unsafe_mode = *UNSAFE_MODE.lock(); |
| 27 | 26 | ||
| 28 | let (tx, rx) = mpsc::channel::<ConnectResult>(); | 27 | // The terminal has no dialog: print the first-contact notice and trust. |
| 29 | let addr_str = addr.to_string(); | 28 | let mut trust = |prompt: &TrustPrompt| { |
| 30 | let server_name = addr_str | 29 | for line in first_contact_lines(&prompt.addr, &prompt.fingerprint) { |
| 31 | .split(':') | 30 | println!("{line}"); |
| 32 | .next() | ||
| 33 | .unwrap_or("127.0.0.1") | ||
| 34 | .to_string(); | ||
| 35 | |||
| 36 | thread::spawn(move || { | ||
| 37 | let outcome = (|| -> ConnectResult { | ||
| 38 | let tcp = TcpStream::connect(&addr_str)?; | ||
| 39 | let connector = | ||
| 40 | TlsConnector::new(&server_name, stored_fingerprint.clone(), unsafe_mode)?; | ||
| 41 | let tls_stream = connector.connect(tcp)?; | ||
| 42 | let fp = connector.observed_fingerprint(); | ||
| 43 | Ok((TlsStream { inner: tls_stream }, fp)) | ||
| 44 | })(); | ||
| 45 | let _ = tx.send(outcome); | ||
| 46 | }); | ||
| 47 | |||
| 48 | // Channel carries (stream, observed fingerprint). | ||
| 49 | let (tls, observed) = match rx.recv_timeout(Duration::from_secs(10)) { | ||
| 50 | Ok(Ok((tls, fp))) => (tls, fp), | ||
| 51 | Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), | ||
| 52 | Err(mpsc::RecvTimeoutError::Timeout) => { | ||
| 53 | return Err("Connection timed out after 10 seconds".into()); | ||
| 54 | } | 31 | } |
| 55 | Err(_) => return Err("Connection attempt failed".into()), | 32 | true |
| 56 | }; | 33 | }; |
| 57 | 34 | ||
| 58 | // TOFU: persist the fingerprint on first contact. | 35 | let connected = transport::connect(addr, conn, unsafe_mode, &mut trust) |
| 59 | if !unsafe_mode { | 36 | .map_err(|error| Box::new(error) as Box<dyn Error>)?; |
| 60 | if let Some(fp) = observed.as_deref() { | ||
| 61 | if database::get_fingerprint(conn, addr).is_none() { | ||
| 62 | database::save_fingerprint(conn, addr, fp)?; | ||
| 63 | for line in first_contact_lines(addr, fp) { | ||
| 64 | println!("{}", line); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | let mut tls = tls; | ||
| 71 | tls.set_read_timeout(Some(Duration::from_secs(10)))?; | ||
| 72 | |||
| 73 | // Version lockstep: the first line after TLS is the handshake, and no | ||
| 74 | // credentials are sent until the server accepts it. | ||
| 75 | let start = Instant::now(); | ||
| 76 | tls.write_line(&format!("HELLO {VERSION}"))?; | ||
| 77 | let mut response = String::new(); | ||
| 78 | if tls.read_line(&mut response)? == 0 { | ||
| 79 | return Err("server closed the connection during HELLO".into()); | ||
| 80 | } | ||
| 81 | let elapsed = start.elapsed(); | ||
| 82 | let response = response.trim(); | ||
| 83 | |||
| 84 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 85 | return Err(message.trim().to_string().into()); | ||
| 86 | } | ||
| 87 | let Some(hello) = parse_hello(response) else { | ||
| 88 | return Err(format!("unexpected HELLO reply: {response}").into()); | ||
| 89 | }; | ||
| 90 | 37 | ||
| 91 | println!( | 38 | println!( |
| 92 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", | 39 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", |
| 93 | addr, | 40 | addr, |
| 94 | elapsed.as_secs_f64() * 1000.0 | 41 | connected.elapsed.as_secs_f64() * 1000.0 |
| 95 | ); | 42 | ); |
| 96 | if is_newer(&hello.latest, VERSION) { | 43 | if is_newer(&connected.latest, VERSION) { |
| 97 | println!( | 44 | println!( |
| 98 | "A newer version is available: {} ({})", | 45 | "A newer version is available: {} ({})", |
| 99 | hello.latest, hello.url | 46 | connected.latest, connected.url |
| 100 | ); | 47 | ); |
| 101 | } | 48 | } |
| 102 | 49 | if let Some(user) = &connected.user { | |
| 103 | // Credentials are stored per server identity, so any address presenting | 50 | println!("Authenticated as {user}"); |
| 104 | // this certificate key can log in with the same stored account. | ||
| 105 | let identity = observed | ||
| 106 | .clone() | ||
| 107 | .or_else(|| database::get_fingerprint(conn, addr)); | ||
| 108 | if let Some(identity) = identity { | ||
| 109 | if let Some((username, password)) = database::get_credentials(conn, &identity) { | ||
| 110 | let auth_msg = format!("AUTH {} {}", username, password); | ||
| 111 | tls.write_line(&auth_msg)?; | ||
| 112 | let mut auth_response = String::new(); | ||
| 113 | tls.read_line(&mut auth_response)?; | ||
| 114 | if auth_response.trim().starts_with("OK") { | ||
| 115 | println!("Authenticated as {}", username); | ||
| 116 | return Ok((tls, Some(username))); | ||
| 117 | } | ||
| 118 | println!("Authentication failed: {}", auth_response.trim()); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | Ok((tls, None)) | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Wrapper so TLS handshake failures surface as errors with their message | ||
| 125 | /// intact (rustls errors already carry the mismatch details). | ||
| 126 | struct TrustError(String); | ||
| 127 | |||
| 128 | impl std::fmt::Display for TrustError { | ||
| 129 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 130 | write!(f, "{}", self.0) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | impl std::fmt::Debug for TrustError { | ||
| 134 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 135 | write!(f, "{self}") | ||
| 136 | } | ||
| 137 | } | ||
| 138 | impl std::error::Error for TrustError {} | ||
| 139 | |||
| 140 | pub struct TlsStream { | ||
| 141 | inner: crate::tls::TlsStream, | ||
| 142 | } | ||
| 143 | |||
| 144 | impl TlsStream { | ||
| 145 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | ||
| 146 | self.inner.read_line(buf) | ||
| 147 | } | ||
| 148 | |||
| 149 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | ||
| 150 | self.inner.write_line(msg) | ||
| 151 | } | 51 | } |
| 152 | 52 | if let Some(error) = &connected.auth_error { | |
| 153 | /// One line if the socket already has one, without blocking. | 53 | println!("Authentication failed: {error}"); |
| 154 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 155 | self.inner.read_available_line() | ||
| 156 | } | 54 | } |
| 157 | 55 | ||
| 158 | pub fn set_read_timeout(&self, duration: Option<std::time::Duration>) -> std::io::Result<()> { | 56 | Ok((connected.stream, connected.user)) |
| 159 | self.inner.set_read_timeout(duration) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | impl Read for TlsStream { | ||
| 164 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { | ||
| 165 | self.inner.read(buf) | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | impl Write for TlsStream { | ||
| 170 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 171 | self.inner.write(buf) | ||
| 172 | } | ||
| 173 | |||
| 174 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 175 | self.inner.flush() | ||
| 176 | } | ||
| 177 | } | 57 | } |
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 @@ | |||
| 2 | mod macros; | 2 | mod macros; |
| 3 | mod connection; | 3 | mod connection; |
| 4 | mod repl; | 4 | mod repl; |
| 5 | mod tls; | ||
| 6 | 5 | ||
| 7 | use repl::ClientState; | 6 | use repl::ClientState; |
| 8 | use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE}; | 7 | 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 @@ | |||
| 1 | //! rustls-based client TLS with Trust-On-First-Use (TOFU) host verification. | ||
| 2 | //! | ||
| 3 | //! Model (SSH known_hosts style): | ||
| 4 | //! - First contact: accept the certificate and record the SHA-256 of its | ||
| 5 | //! SubjectPublicKeyInfo (SPKI). Hashing the SPKI rather than the whole | ||
| 6 | //! certificate means routine renewal with the same key does not break | ||
| 7 | //! clients; key rotation does — by design. | ||
| 8 | //! - Later contacts: the presented key must hash to the stored fingerprint, | ||
| 9 | //! otherwise the handshake fails with a loud error. | ||
| 10 | //! | ||
| 11 | //! Handshake signatures are accepted without webpki validation; identity is | ||
| 12 | //! established purely by the pinned fingerprint. An impostor cannot present a | ||
| 13 | //! different certificate without triggering the mismatch rejection. | ||
| 14 | |||
| 15 | use std::io::{Read, Write}; | ||
| 16 | use std::net::TcpStream; | ||
| 17 | use std::sync::Arc; | ||
| 18 | |||
| 19 | use parking_lot::Mutex; | ||
| 20 | use rustls::DigitallySignedStruct; | ||
| 21 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 22 | use zagan_bet::protocol::LineBuffer; | ||
| 23 | use zagan_bet::tofu::{TrustDecision, decide, spki_fingerprint}; | ||
| 24 | |||
| 25 | pub struct TlsConnector { | ||
| 26 | config: Arc<rustls::ClientConfig>, | ||
| 27 | server_name: rustls::ServerName, | ||
| 28 | observed: Arc<Mutex<Option<String>>>, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl TlsConnector { | ||
| 32 | /// `stored` is the previously trusted fingerprint for this host, if any. | ||
| 33 | /// With `bypass_tofu` (unsafe mode) any certificate is accepted. | ||
| 34 | pub fn new( | ||
| 35 | server_name: &str, | ||
| 36 | stored: Option<String>, | ||
| 37 | bypass_tofu: bool, | ||
| 38 | ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { | ||
| 39 | let observed = Arc::new(Mutex::new(None)); | ||
| 40 | let verifier = Arc::new(TofuVerifier { | ||
| 41 | stored, | ||
| 42 | bypass_tofu, | ||
| 43 | observed: Arc::clone(&observed), | ||
| 44 | }); | ||
| 45 | let config = rustls::ClientConfig::builder() | ||
| 46 | .with_safe_defaults() | ||
| 47 | .with_custom_certificate_verifier(verifier) | ||
| 48 | .with_no_client_auth(); | ||
| 49 | Ok(Self { | ||
| 50 | config: Arc::new(config), | ||
| 51 | server_name: server_name.try_into()?, | ||
| 52 | observed, | ||
| 53 | }) | ||
| 54 | } | ||
| 55 | |||
| 56 | pub fn connect( | ||
| 57 | &self, | ||
| 58 | stream: TcpStream, | ||
| 59 | ) -> Result<TlsStream, Box<dyn std::error::Error + Send + Sync>> { | ||
| 60 | let mut conn = | ||
| 61 | rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; | ||
| 62 | let mut sock = stream; | ||
| 63 | conn.complete_io(&mut sock)?; | ||
| 64 | |||
| 65 | Ok(TlsStream { | ||
| 66 | session: Arc::new(Mutex::new(Session { | ||
| 67 | sock, | ||
| 68 | tls: conn, | ||
| 69 | lines: LineBuffer::new(), | ||
| 70 | read_timeout: Some(std::time::Duration::from_secs(10)), | ||
| 71 | })), | ||
| 72 | }) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Fingerprint of the certificate seen during the last `connect`. | ||
| 76 | pub fn observed_fingerprint(&self) -> Option<String> { | ||
| 77 | self.observed.lock().clone() | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | struct TofuVerifier { | ||
| 82 | stored: Option<String>, | ||
| 83 | bypass_tofu: bool, | ||
| 84 | observed: Arc<Mutex<Option<String>>>, | ||
| 85 | } | ||
| 86 | |||
| 87 | impl ServerCertVerifier for TofuVerifier { | ||
| 88 | fn verify_server_cert( | ||
| 89 | &self, | ||
| 90 | end_entity: &rustls::Certificate, | ||
| 91 | _intermediates: &[rustls::Certificate], | ||
| 92 | _server_name: &rustls::ServerName, | ||
| 93 | _scts: &mut dyn Iterator<Item = &[u8]>, | ||
| 94 | _ocsp_response: &[u8], | ||
| 95 | _now: std::time::SystemTime, | ||
| 96 | ) -> Result<ServerCertVerified, rustls::Error> { | ||
| 97 | let fp = spki_fingerprint(&end_entity.0) | ||
| 98 | .map_err(|e| rustls::Error::General(format!("certificate parse failed: {e}")))?; | ||
| 99 | *self.observed.lock() = Some(fp.clone()); | ||
| 100 | |||
| 101 | if self.bypass_tofu { | ||
| 102 | return Ok(ServerCertVerified::assertion()); | ||
| 103 | } | ||
| 104 | |||
| 105 | match decide(self.stored.as_deref(), &fp) { | ||
| 106 | TrustDecision::TrustNew | TrustDecision::Trusted => Ok(ServerCertVerified::assertion()), | ||
| 107 | TrustDecision::Mismatch { expected, actual } => Err(rustls::Error::General(format!( | ||
| 108 | "HOST KEY CHANGED for this server!\n\ | ||
| 109 | expected fingerprint: {expected}\n\ | ||
| 110 | observed fingerprint: {actual}\n\ | ||
| 111 | If this is intentional (server reinstall), use the client's\n\ | ||
| 112 | forget-host command to reset trust for this address." | ||
| 113 | ))), | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | // Identity is pinned via fingerprint comparison above; handshake | ||
| 118 | // signatures are not additionally verified against a trust store. | ||
| 119 | fn verify_tls12_signature( | ||
| 120 | &self, | ||
| 121 | _message: &[u8], | ||
| 122 | _cert: &rustls::Certificate, | ||
| 123 | _dss: &DigitallySignedStruct, | ||
| 124 | ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| 125 | Ok(HandshakeSignatureValid::assertion()) | ||
| 126 | } | ||
| 127 | |||
| 128 | fn verify_tls13_signature( | ||
| 129 | &self, | ||
| 130 | _message: &[u8], | ||
| 131 | _cert: &rustls::Certificate, | ||
| 132 | _dss: &DigitallySignedStruct, | ||
| 133 | ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| 134 | Ok(HandshakeSignatureValid::assertion()) | ||
| 135 | } | ||
| 136 | |||
| 137 | fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { | ||
| 138 | vec![ | ||
| 139 | rustls::SignatureScheme::RSA_PKCS1_SHA256, | ||
| 140 | rustls::SignatureScheme::RSA_PKCS1_SHA384, | ||
| 141 | rustls::SignatureScheme::RSA_PKCS1_SHA512, | ||
| 142 | rustls::SignatureScheme::RSA_PSS_SHA256, | ||
| 143 | rustls::SignatureScheme::RSA_PSS_SHA384, | ||
| 144 | rustls::SignatureScheme::RSA_PSS_SHA512, | ||
| 145 | rustls::SignatureScheme::ECDSA_NISTP256_SHA256, | ||
| 146 | rustls::SignatureScheme::ECDSA_NISTP384_SHA384, | ||
| 147 | rustls::SignatureScheme::ECDSA_NISTP521_SHA512, | ||
| 148 | ] | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | /// TCP socket + TLS session behind one mutex so clones share the stream. | ||
| 153 | struct Session { | ||
| 154 | sock: TcpStream, | ||
| 155 | tls: rustls::ClientConnection, | ||
| 156 | lines: LineBuffer, | ||
| 157 | read_timeout: Option<std::time::Duration>, | ||
| 158 | } | ||
| 159 | |||
| 160 | impl Session { | ||
| 161 | fn drive(&mut self) -> std::io::Result<()> { | ||
| 162 | Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?) | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | #[derive(Clone)] | ||
| 167 | pub struct TlsStream { | ||
| 168 | session: Arc<Mutex<Session>>, | ||
| 169 | } | ||
| 170 | |||
| 171 | impl TlsStream { | ||
| 172 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | ||
| 173 | let mut s = self.session.lock(); | ||
| 174 | loop { | ||
| 175 | if let Some(line) = s.lines.next_line() { | ||
| 176 | buf.push_str(&line); | ||
| 177 | buf.push('\n'); | ||
| 178 | return Ok(line.len() + 1); | ||
| 179 | } | ||
| 180 | |||
| 181 | let mut byte_buf = [0u8; 4096]; | ||
| 182 | match s.tls.reader().read(&mut byte_buf) { | ||
| 183 | Ok(0) => { | ||
| 184 | if let Some(rest) = s.lines.take_rest() { | ||
| 185 | let read = rest.len(); | ||
| 186 | buf.push_str(&rest); | ||
| 187 | return Ok(read); | ||
| 188 | } | ||
| 189 | return Ok(0); | ||
| 190 | } | ||
| 191 | Ok(n) => s.lines.push(&byte_buf[..n]), | ||
| 192 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | ||
| 193 | Err(e) => return Err(e), | ||
| 194 | } | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | ||
| 199 | let mut s = self.session.lock(); | ||
| 200 | s.tls.writer().write_all(msg.as_bytes())?; | ||
| 201 | s.tls.writer().write_all(b"\n")?; | ||
| 202 | s.drive() | ||
| 203 | } | ||
| 204 | |||
| 205 | /// Apply a read timeout to the underlying TCP socket. | ||
| 206 | pub fn set_read_timeout(&self, duration: Option<std::time::Duration>) -> std::io::Result<()> { | ||
| 207 | let mut s = self.session.lock(); | ||
| 208 | s.read_timeout = duration; | ||
| 209 | s.sock.set_read_timeout(duration) | ||
| 210 | } | ||
| 211 | |||
| 212 | /// Return one complete line if the socket already has one, without | ||
| 213 | /// blocking. Used to pick up server pushes between requests. | ||
| 214 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 215 | let mut s = self.session.lock(); | ||
| 216 | if let Some(line) = s.lines.next_line() { | ||
| 217 | return Ok(Some(line)); | ||
| 218 | } | ||
| 219 | |||
| 220 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the | ||
| 221 | // shortest timeout the socket accepts instead of a nonblocking mode. | ||
| 222 | s.sock | ||
| 223 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; | ||
| 224 | let drained = (|| -> std::io::Result<()> { | ||
| 225 | loop { | ||
| 226 | let mut buf = [0u8; 4096]; | ||
| 227 | match s.tls.reader().read(&mut buf) { | ||
| 228 | Ok(0) => return Ok(()), | ||
| 229 | Ok(n) => s.lines.push(&buf[..n]), | ||
| 230 | // The reader only drains plaintext rustls has already | ||
| 231 | // decrypted; `drive` moves bytes off the socket. | ||
| 232 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { | ||
| 233 | match s.drive() { | ||
| 234 | Ok(()) => continue, | ||
| 235 | Err(error) | ||
| 236 | if matches!( | ||
| 237 | error.kind(), | ||
| 238 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 239 | ) => | ||
| 240 | { | ||
| 241 | return Ok(()); | ||
| 242 | } | ||
| 243 | Err(error) => return Err(error), | ||
| 244 | } | ||
| 245 | } | ||
| 246 | Err(error) | ||
| 247 | if matches!( | ||
| 248 | error.kind(), | ||
| 249 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 250 | ) => | ||
| 251 | { | ||
| 252 | return Ok(()); | ||
| 253 | } | ||
| 254 | Err(error) => return Err(error), | ||
| 255 | } | ||
| 256 | } | ||
| 257 | })(); | ||
| 258 | s.sock.set_read_timeout(s.read_timeout)?; | ||
| 259 | drained?; | ||
| 260 | Ok(s.lines.next_line()) | ||
| 261 | } | ||
| 262 | } | ||
| 263 | |||
| 264 | impl Read for TlsStream { | ||
| 265 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { | ||
| 266 | let mut s = self.session.lock(); | ||
| 267 | loop { | ||
| 268 | match s.tls.reader().read(buf) { | ||
| 269 | Ok(n) => return Ok(n), | ||
| 270 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | ||
| 271 | Err(e) => return Err(e), | ||
| 272 | } | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | impl Write for TlsStream { | ||
| 278 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 279 | let mut s = self.session.lock(); | ||
| 280 | s.tls.writer().write(buf) | ||
| 281 | } | ||
| 282 | |||
| 283 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 284 | let mut s = self.session.lock(); | ||
| 285 | s.tls.writer().flush()?; | ||
| 286 | s.drive() | ||
| 287 | } | ||
| 288 | } | ||
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; | |||
| 2 | pub mod database; | 2 | pub mod database; |
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod paths; | 4 | pub mod paths; |
| 5 | pub mod transport; | ||
| 5 | pub mod tui; | 6 | pub mod tui; |
| 6 | pub mod ui; | 7 | pub mod ui; |
| 7 | pub mod view; | 8 | 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 @@ | |||
| 1 | //! Client transport: TCP, rustls, TOFU and the HELLO handshake. | ||
| 2 | //! | ||
| 3 | //! The handshake records the server's SPKI fingerprint but does not act on it; | ||
| 4 | //! the trust decision happens after the handshake and before any credentials | ||
| 5 | //! are sent, so a frontend can ask the player. A changed key is a hard error. | ||
| 6 | |||
| 7 | use std::fmt; | ||
| 8 | use std::io::{Read, Write}; | ||
| 9 | use std::net::TcpStream; | ||
| 10 | use std::sync::mpsc; | ||
| 11 | use std::sync::Arc; | ||
| 12 | use std::thread; | ||
| 13 | use std::time::{Duration, Instant}; | ||
| 14 | |||
| 15 | use parking_lot::Mutex; | ||
| 16 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 17 | use rustls::DigitallySignedStruct; | ||
| 18 | use rusqlite::Connection; | ||
| 19 | |||
| 20 | use crate::client::database; | ||
| 21 | use crate::protocol::LineBuffer; | ||
| 22 | use crate::tofu::{decide, spki_fingerprint, TrustDecision}; | ||
| 23 | use crate::version::{parse_hello, HelloReply, VERSION}; | ||
| 24 | |||
| 25 | /// How long to wait for TCP + TLS before giving up. | ||
| 26 | const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); | ||
| 27 | /// Read timeout once the stream is usable. | ||
| 28 | const READ_TIMEOUT: Duration = Duration::from_secs(10); | ||
| 29 | |||
| 30 | /// What a frontend shows when a server key is unknown. | ||
| 31 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 32 | pub struct TrustPrompt { | ||
| 33 | pub addr: String, | ||
| 34 | pub fingerprint: String, | ||
| 35 | } | ||
| 36 | |||
| 37 | /// A usable connection after trust, version and optional auto-login. | ||
| 38 | pub struct Connected { | ||
| 39 | pub stream: TlsStream, | ||
| 40 | pub user: Option<String>, | ||
| 41 | /// Set when stored credentials were rejected; the session is still usable. | ||
| 42 | pub auth_error: Option<String>, | ||
| 43 | pub server_version: String, | ||
| 44 | pub latest: String, | ||
| 45 | pub url: String, | ||
| 46 | /// Round trip of the HELLO exchange. | ||
| 47 | pub elapsed: Duration, | ||
| 48 | } | ||
| 49 | |||
| 50 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 51 | pub enum TransportError { | ||
| 52 | Timeout, | ||
| 53 | TrustDeclined, | ||
| 54 | KeyChanged { | ||
| 55 | expected: String, | ||
| 56 | observed: String, | ||
| 57 | }, | ||
| 58 | VersionMismatch(String), | ||
| 59 | Closed, | ||
| 60 | Unexpected(String), | ||
| 61 | Io(String), | ||
| 62 | } | ||
| 63 | |||
| 64 | impl fmt::Display for TransportError { | ||
| 65 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 66 | match self { | ||
| 67 | TransportError::Timeout => write!(f, "Connection timed out"), | ||
| 68 | TransportError::TrustDeclined => write!(f, "Certificate was not trusted"), | ||
| 69 | TransportError::KeyChanged { expected, observed } => write!( | ||
| 70 | f, | ||
| 71 | "HOST KEY CHANGED for this server!\n\ | ||
| 72 | expected fingerprint: {expected}\n\ | ||
| 73 | observed fingerprint: {observed}\n\ | ||
| 74 | If this is intentional (server reinstall), forget this host to reset trust." | ||
| 75 | ), | ||
| 76 | TransportError::VersionMismatch(message) => write!(f, "{message}"), | ||
| 77 | TransportError::Closed => write!(f, "Server closed the connection during HELLO"), | ||
| 78 | TransportError::Unexpected(message) => write!(f, "{message}"), | ||
| 79 | TransportError::Io(message) => write!(f, "Connection error: {message}"), | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | impl std::error::Error for TransportError {} | ||
| 85 | |||
| 86 | fn io_failure(error: std::io::Error) -> TransportError { | ||
| 87 | TransportError::Io(error.to_string()) | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Connect, verify the key, check versions and try the stored login. | ||
| 91 | /// | ||
| 92 | /// `trust` is called only for a server we have never seen; returning `false` | ||
| 93 | /// aborts before any application data is sent. | ||
| 94 | pub fn connect( | ||
| 95 | addr: &str, | ||
| 96 | conn: &Connection, | ||
| 97 | unsafe_mode: bool, | ||
| 98 | trust: &mut dyn FnMut(&TrustPrompt) -> bool, | ||
| 99 | ) -> Result<Connected, TransportError> { | ||
| 100 | let stored = database::get_fingerprint(conn, addr); | ||
| 101 | let (tls, observed) = tls_connect(addr)?; | ||
| 102 | let observed = observed.ok_or_else(|| { | ||
| 103 | TransportError::Unexpected("the server presented no certificate fingerprint".to_string()) | ||
| 104 | })?; | ||
| 105 | |||
| 106 | if !unsafe_mode { | ||
| 107 | match decide(stored.as_deref(), &observed) { | ||
| 108 | TrustDecision::Trusted => {} | ||
| 109 | TrustDecision::TrustNew => { | ||
| 110 | let prompt = TrustPrompt { | ||
| 111 | addr: addr.to_string(), | ||
| 112 | fingerprint: observed.clone(), | ||
| 113 | }; | ||
| 114 | if !trust(&prompt) { | ||
| 115 | return Err(TransportError::TrustDeclined); | ||
| 116 | } | ||
| 117 | database::save_fingerprint(conn, addr, &observed) | ||
| 118 | .map_err(|error| TransportError::Io(error.to_string()))?; | ||
| 119 | } | ||
| 120 | TrustDecision::Mismatch { expected, actual } => { | ||
| 121 | return Err(TransportError::KeyChanged { | ||
| 122 | expected, | ||
| 123 | observed: actual, | ||
| 124 | }) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | let mut tls = tls; | ||
| 130 | tls.set_read_timeout(Some(READ_TIMEOUT)) | ||
| 131 | .map_err(io_failure)?; | ||
| 132 | |||
| 133 | let start = Instant::now(); | ||
| 134 | tls.write_line(&format!("HELLO {VERSION}")) | ||
| 135 | .map_err(io_failure)?; | ||
| 136 | let mut response = String::new(); | ||
| 137 | if tls.read_line(&mut response).map_err(io_failure)? == 0 { | ||
| 138 | return Err(TransportError::Closed); | ||
| 139 | } | ||
| 140 | let elapsed = start.elapsed(); | ||
| 141 | |||
| 142 | let response = response.trim(); | ||
| 143 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 144 | return Err(TransportError::VersionMismatch(message.trim().to_string())); | ||
| 145 | } | ||
| 146 | let hello: HelloReply = | ||
| 147 | parse_hello(response).ok_or_else(|| TransportError::Unexpected(response.to_string()))?; | ||
| 148 | |||
| 149 | // Credentials are keyed by the pinned fingerprint, so any address that | ||
| 150 | // presents this key can log in with the same stored account. | ||
| 151 | let mut user = None; | ||
| 152 | let mut auth_error = None; | ||
| 153 | if let Some((username, password)) = database::get_credentials(conn, &observed) { | ||
| 154 | tls.write_line(&format!("AUTH {username} {password}")) | ||
| 155 | .map_err(io_failure)?; | ||
| 156 | let mut auth = String::new(); | ||
| 157 | tls.read_line(&mut auth).map_err(io_failure)?; | ||
| 158 | if auth.trim().starts_with("OK") { | ||
| 159 | user = Some(username); | ||
| 160 | } else { | ||
| 161 | auth_error = Some(auth.trim().to_string()); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | Ok(Connected { | ||
| 166 | stream: tls, | ||
| 167 | user, | ||
| 168 | auth_error, | ||
| 169 | server_version: hello.version, | ||
| 170 | latest: hello.latest, | ||
| 171 | url: hello.url, | ||
| 172 | elapsed, | ||
| 173 | }) | ||
| 174 | } | ||
| 175 | |||
| 176 | /// TCP + TLS on a thread so a blocked handshake can time out. | ||
| 177 | fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { | ||
| 178 | let (sender, receiver) = mpsc::channel(); | ||
| 179 | let addr = addr.to_string(); | ||
| 180 | let server_name = addr | ||
| 181 | .split(':') | ||
| 182 | .next() | ||
| 183 | .unwrap_or("127.0.0.1") | ||
| 184 | .to_string(); | ||
| 185 | |||
| 186 | thread::spawn(move || { | ||
| 187 | let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { | ||
| 188 | let tcp = TcpStream::connect(&addr).map_err(io_failure)?; | ||
| 189 | let connector = TlsConnector::new(&server_name) | ||
| 190 | .map_err(|error| TransportError::Io(error.to_string()))?; | ||
| 191 | let stream = connector | ||
| 192 | .connect(tcp) | ||
| 193 | .map_err(|error| TransportError::Io(error.to_string()))?; | ||
| 194 | Ok((stream, connector.observed_fingerprint())) | ||
| 195 | })(); | ||
| 196 | let _ = sender.send(outcome); | ||
| 197 | }); | ||
| 198 | |||
| 199 | match receiver.recv_timeout(CONNECT_TIMEOUT) { | ||
| 200 | Ok(outcome) => outcome, | ||
| 201 | Err(mpsc::RecvTimeoutError::Timeout) => Err(TransportError::Timeout), | ||
| 202 | Err(mpsc::RecvTimeoutError::Disconnected) => Err(TransportError::Unexpected( | ||
| 203 | "connection attempt failed".to_string(), | ||
| 204 | )), | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | struct TlsConnector { | ||
| 209 | config: Arc<rustls::ClientConfig>, | ||
| 210 | server_name: rustls::ServerName, | ||
| 211 | observed: Arc<Mutex<Option<String>>>, | ||
| 212 | } | ||
| 213 | |||
| 214 | impl TlsConnector { | ||
| 215 | fn new(server_name: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { | ||
| 216 | let observed = Arc::new(Mutex::new(None)); | ||
| 217 | let verifier = Arc::new(RecordingVerifier { | ||
| 218 | observed: Arc::clone(&observed), | ||
| 219 | }); | ||
| 220 | let config = rustls::ClientConfig::builder() | ||
| 221 | .with_safe_defaults() | ||
| 222 | .with_custom_certificate_verifier(verifier) | ||
| 223 | .with_no_client_auth(); | ||
| 224 | Ok(Self { | ||
| 225 | config: Arc::new(config), | ||
| 226 | server_name: server_name.try_into()?, | ||
| 227 | observed, | ||
| 228 | }) | ||
| 229 | } | ||
| 230 | |||
| 231 | fn connect( | ||
| 232 | &self, | ||
| 233 | stream: TcpStream, | ||
| 234 | ) -> Result<TlsStream, Box<dyn std::error::Error + Send + Sync>> { | ||
| 235 | let mut connection = | ||
| 236 | rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; | ||
| 237 | let mut sock = stream; | ||
| 238 | connection.complete_io(&mut sock)?; | ||
| 239 | |||
| 240 | Ok(TlsStream { | ||
| 241 | session: Arc::new(Mutex::new(Session { | ||
| 242 | sock, | ||
| 243 | tls: connection, | ||
| 244 | lines: LineBuffer::new(), | ||
| 245 | read_timeout: Some(READ_TIMEOUT), | ||
| 246 | })), | ||
| 247 | }) | ||
| 248 | } | ||
| 249 | |||
| 250 | fn observed_fingerprint(&self) -> Option<String> { | ||
| 251 | self.observed.lock().clone() | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | /// Certificate verification is record-only: the trust decision is made after | ||
| 256 | /// the handshake, before anything is sent, so a frontend can ask first. | ||
| 257 | struct RecordingVerifier { | ||
| 258 | observed: Arc<Mutex<Option<String>>>, | ||
| 259 | } | ||
| 260 | |||
| 261 | impl ServerCertVerifier for RecordingVerifier { | ||
| 262 | fn verify_server_cert( | ||
| 263 | &self, | ||
| 264 | end_entity: &rustls::Certificate, | ||
| 265 | _intermediates: &[rustls::Certificate], | ||
| 266 | _server_name: &rustls::ServerName, | ||
| 267 | _scts: &mut dyn Iterator<Item = &[u8]>, | ||
| 268 | _ocsp_response: &[u8], | ||
| 269 | _now: std::time::SystemTime, | ||
| 270 | ) -> Result<ServerCertVerified, rustls::Error> { | ||
| 271 | let fingerprint = spki_fingerprint(&end_entity.0) | ||
| 272 | .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; | ||
| 273 | *self.observed.lock() = Some(fingerprint); | ||
| 274 | Ok(ServerCertVerified::assertion()) | ||
| 275 | } | ||
| 276 | |||
| 277 | // Identity is pinned by the fingerprint check above; handshake signatures | ||
| 278 | // are not additionally validated against a trust store. | ||
| 279 | fn verify_tls12_signature( | ||
| 280 | &self, | ||
| 281 | _message: &[u8], | ||
| 282 | _cert: &rustls::Certificate, | ||
| 283 | _dss: &DigitallySignedStruct, | ||
| 284 | ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| 285 | Ok(HandshakeSignatureValid::assertion()) | ||
| 286 | } | ||
| 287 | |||
| 288 | fn verify_tls13_signature( | ||
| 289 | &self, | ||
| 290 | _message: &[u8], | ||
| 291 | _cert: &rustls::Certificate, | ||
| 292 | _dss: &DigitallySignedStruct, | ||
| 293 | ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| 294 | Ok(HandshakeSignatureValid::assertion()) | ||
| 295 | } | ||
| 296 | |||
| 297 | fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { | ||
| 298 | vec![ | ||
| 299 | rustls::SignatureScheme::RSA_PKCS1_SHA256, | ||
| 300 | rustls::SignatureScheme::RSA_PKCS1_SHA384, | ||
| 301 | rustls::SignatureScheme::RSA_PKCS1_SHA512, | ||
| 302 | rustls::SignatureScheme::RSA_PSS_SHA256, | ||
| 303 | rustls::SignatureScheme::RSA_PSS_SHA384, | ||
| 304 | rustls::SignatureScheme::RSA_PSS_SHA512, | ||
| 305 | rustls::SignatureScheme::ECDSA_NISTP256_SHA256, | ||
| 306 | rustls::SignatureScheme::ECDSA_NISTP384_SHA384, | ||
| 307 | rustls::SignatureScheme::ECDSA_NISTP521_SHA512, | ||
| 308 | ] | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | /// TCP socket + TLS session behind one mutex so clones share the stream. | ||
| 313 | struct Session { | ||
| 314 | sock: TcpStream, | ||
| 315 | tls: rustls::ClientConnection, | ||
| 316 | lines: LineBuffer, | ||
| 317 | read_timeout: Option<Duration>, | ||
| 318 | } | ||
| 319 | |||
| 320 | impl Session { | ||
| 321 | fn drive(&mut self) -> std::io::Result<()> { | ||
| 322 | self.tls.complete_io(&mut self.sock).map(|_| ()) | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | #[derive(Clone)] | ||
| 327 | pub struct TlsStream { | ||
| 328 | session: Arc<Mutex<Session>>, | ||
| 329 | } | ||
| 330 | |||
| 331 | impl TlsStream { | ||
| 332 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | ||
| 333 | let mut s = self.session.lock(); | ||
| 334 | loop { | ||
| 335 | if let Some(line) = s.lines.next_line() { | ||
| 336 | buf.push_str(&line); | ||
| 337 | buf.push('\n'); | ||
| 338 | return Ok(line.len() + 1); | ||
| 339 | } | ||
| 340 | |||
| 341 | let mut chunk = [0u8; 4096]; | ||
| 342 | match s.tls.reader().read(&mut chunk) { | ||
| 343 | Ok(0) => { | ||
| 344 | if let Some(rest) = s.lines.take_rest() { | ||
| 345 | let read = rest.len(); | ||
| 346 | buf.push_str(&rest); | ||
| 347 | return Ok(read); | ||
| 348 | } | ||
| 349 | return Ok(0); | ||
| 350 | } | ||
| 351 | Ok(n) => s.lines.push(&chunk[..n]), | ||
| 352 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | ||
| 353 | Err(error) => return Err(error), | ||
| 354 | } | ||
| 355 | } | ||
| 356 | } | ||
| 357 | |||
| 358 | pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { | ||
| 359 | let mut s = self.session.lock(); | ||
| 360 | s.tls.writer().write_all(message.as_bytes())?; | ||
| 361 | s.tls.writer().write_all(b"\n")?; | ||
| 362 | s.drive() | ||
| 363 | } | ||
| 364 | |||
| 365 | pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { | ||
| 366 | let mut s = self.session.lock(); | ||
| 367 | s.read_timeout = duration; | ||
| 368 | s.sock.set_read_timeout(duration) | ||
| 369 | } | ||
| 370 | |||
| 371 | /// Return one complete line if the socket already has one, without | ||
| 372 | /// blocking. Used to pick up server pushes between requests. | ||
| 373 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 374 | let mut s = self.session.lock(); | ||
| 375 | if let Some(line) = s.lines.next_line() { | ||
| 376 | return Ok(Some(line)); | ||
| 377 | } | ||
| 378 | |||
| 379 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the | ||
| 380 | // shortest timeout the socket accepts instead of a nonblocking mode. | ||
| 381 | s.sock | ||
| 382 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; | ||
| 383 | let drained = (|| -> std::io::Result<()> { | ||
| 384 | loop { | ||
| 385 | let mut buf = [0u8; 4096]; | ||
| 386 | match s.tls.reader().read(&mut buf) { | ||
| 387 | Ok(0) => return Ok(()), | ||
| 388 | Ok(n) => s.lines.push(&buf[..n]), | ||
| 389 | // The reader only drains plaintext rustls has already | ||
| 390 | // decrypted; `drive` moves bytes off the socket. | ||
| 391 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { | ||
| 392 | match s.drive() { | ||
| 393 | Ok(()) => continue, | ||
| 394 | Err(error) | ||
| 395 | if matches!( | ||
| 396 | error.kind(), | ||
| 397 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 398 | ) => | ||
| 399 | { | ||
| 400 | return Ok(()); | ||
| 401 | } | ||
| 402 | Err(error) => return Err(error), | ||
| 403 | } | ||
| 404 | } | ||
| 405 | Err(error) | ||
| 406 | if matches!( | ||
| 407 | error.kind(), | ||
| 408 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 409 | ) => | ||
| 410 | { | ||
| 411 | return Ok(()); | ||
| 412 | } | ||
| 413 | Err(error) => return Err(error), | ||
| 414 | } | ||
| 415 | } | ||
| 416 | })(); | ||
| 417 | s.sock.set_read_timeout(s.read_timeout)?; | ||
| 418 | drained?; | ||
| 419 | Ok(s.lines.next_line()) | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | impl Read for TlsStream { | ||
| 424 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { | ||
| 425 | let mut s = self.session.lock(); | ||
| 426 | loop { | ||
| 427 | match s.tls.reader().read(buf) { | ||
| 428 | Ok(n) => return Ok(n), | ||
| 429 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | ||
| 430 | Err(error) => return Err(error), | ||
| 431 | } | ||
| 432 | } | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | impl Write for TlsStream { | ||
| 437 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 438 | let mut s = self.session.lock(); | ||
| 439 | s.tls.writer().write(buf) | ||
| 440 | } | ||
| 441 | |||
| 442 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 443 | let mut s = self.session.lock(); | ||
| 444 | s.tls.writer().flush()?; | ||
| 445 | s.drive() | ||
| 446 | } | ||
| 447 | } | ||
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() { | |||
| 133 | client_database::forget_host(&conn, "host1").unwrap(); | 133 | client_database::forget_host(&conn, "host1").unwrap(); |
| 134 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); | 134 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); |
| 135 | } | 135 | } |
| 136 | |||
| 137 | #[test] | ||
| 138 | fn a_changed_key_error_names_both_fingerprints() { | ||
| 139 | let error = zagan_bet::client::transport::TransportError::KeyChanged { | ||
| 140 | expected: "aaaa".to_string(), | ||
| 141 | observed: "bbbb".to_string(), | ||
| 142 | }; | ||
| 143 | let text = error.to_string(); | ||
| 144 | |||
| 145 | assert!(text.contains("HOST KEY CHANGED"), "{text}"); | ||
| 146 | assert!(text.contains("aaaa"), "{text}"); | ||
| 147 | assert!(text.contains("bbbb"), "{text}"); | ||
| 148 | } | ||
