summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 18:14:16 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 18:14:16 +0100
commit55032d2de36b08692d39cc94c4f2da317b3e2eb7 (patch)
tree2ace8ef541e1ec211ff703c5050027c13ba56759
parentv4.0.3: lobby and history screens behind commands (diff)
downloadzagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.tar.gz
zagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.zip
v4.0.4: move the client transport and TOFU into the library
-rw-r--r--AGENTS.md3
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md9
-rw-r--r--src/bin/client/connection.rs176
-rw-r--r--src/bin/client/main.rs1
-rw-r--r--src/bin/client/tls.rs288
-rw-r--r--src/client/mod.rs1
-rw-r--r--src/client/transport.rs447
-rw-r--r--tests/tofu.rs13
9 files changed, 498 insertions, 442 deletions
diff --git a/AGENTS.md b/AGENTS.md
index e784624..72def0d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/Cargo.toml b/Cargo.toml
index 877ec86..076ffd6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.3" 3version = "4.0.4"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "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
30core builds a typed view, frontends send `Command`, and the TUI renders it. 30core builds a typed view, frontends send `Command`, and the TUI renders it.
31Done so far: `Lobby` and `History` are structured views driven by 31Done so far: every game plus `Lobby` and `History` are structured views; the
32`Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the 32client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in
33connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with 33`client::transport` behind a trust callback. Remaining: the connection-facing
34the transport moved into the library, and removing `Key` from the core. 34screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from
35the 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 @@
1use 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
2use parking_lot::Mutex; 7use parking_lot::Mutex;
3use rusqlite::Connection; 8use std::error::Error;
4use std::io::{Read, Write}; 9use zagan_bet::client::transport::{self, TrustPrompt};
5use std::net::TcpStream;
6use std::sync::mpsc;
7use std::thread;
8use std::time::{Duration, Instant};
9use zagan_bet::client::database;
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, parse_hello, VERSION}; 11use zagan_bet::version::{is_newer, VERSION};
12
13pub use zagan_bet::client::transport::TlsStream;
12 14
13static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); 15static 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
19type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>;
20
21pub fn connect( 21pub 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).
126struct TrustError(String);
127
128impl std::fmt::Display for TrustError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 write!(f, "{}", self.0)
131 }
132}
133impl std::fmt::Debug for TrustError {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 write!(f, "{self}")
136 }
137}
138impl std::error::Error for TrustError {}
139
140pub struct TlsStream {
141 inner: crate::tls::TlsStream,
142}
143
144impl 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
163impl Read for TlsStream {
164 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
165 self.inner.read(buf)
166 }
167}
168
169impl 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 @@
2mod macros; 2mod macros;
3mod connection; 3mod connection;
4mod repl; 4mod repl;
5mod tls;
6 5
7use repl::ClientState; 6use repl::ClientState;
8use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE}; 7use 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
15use std::io::{Read, Write};
16use std::net::TcpStream;
17use std::sync::Arc;
18
19use parking_lot::Mutex;
20use rustls::DigitallySignedStruct;
21use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
22use zagan_bet::protocol::LineBuffer;
23use zagan_bet::tofu::{TrustDecision, decide, spki_fingerprint};
24
25pub struct TlsConnector {
26 config: Arc<rustls::ClientConfig>,
27 server_name: rustls::ServerName,
28 observed: Arc<Mutex<Option<String>>>,
29}
30
31impl 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
81struct TofuVerifier {
82 stored: Option<String>,
83 bypass_tofu: bool,
84 observed: Arc<Mutex<Option<String>>>,
85}
86
87impl 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.
153struct Session {
154 sock: TcpStream,
155 tls: rustls::ClientConnection,
156 lines: LineBuffer,
157 read_timeout: Option<std::time::Duration>,
158}
159
160impl 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)]
167pub struct TlsStream {
168 session: Arc<Mutex<Session>>,
169}
170
171impl 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
264impl 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
277impl 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;
2pub mod database; 2pub mod database;
3pub mod games; 3pub mod games;
4pub mod paths; 4pub mod paths;
5pub mod transport;
5pub mod tui; 6pub mod tui;
6pub mod ui; 7pub mod ui;
7pub mod view; 8pub 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
7use std::fmt;
8use std::io::{Read, Write};
9use std::net::TcpStream;
10use std::sync::mpsc;
11use std::sync::Arc;
12use std::thread;
13use std::time::{Duration, Instant};
14
15use parking_lot::Mutex;
16use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
17use rustls::DigitallySignedStruct;
18use rusqlite::Connection;
19
20use crate::client::database;
21use crate::protocol::LineBuffer;
22use crate::tofu::{decide, spki_fingerprint, TrustDecision};
23use crate::version::{parse_hello, HelloReply, VERSION};
24
25/// How long to wait for TCP + TLS before giving up.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
27/// Read timeout once the stream is usable.
28const 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)]
32pub struct TrustPrompt {
33 pub addr: String,
34 pub fingerprint: String,
35}
36
37/// A usable connection after trust, version and optional auto-login.
38pub 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)]
51pub 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
64impl 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
84impl std::error::Error for TransportError {}
85
86fn 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.
94pub 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.
177fn 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
208struct TlsConnector {
209 config: Arc<rustls::ClientConfig>,
210 server_name: rustls::ServerName,
211 observed: Arc<Mutex<Option<String>>>,
212}
213
214impl 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.
257struct RecordingVerifier {
258 observed: Arc<Mutex<Option<String>>>,
259}
260
261impl 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.
313struct Session {
314 sock: TcpStream,
315 tls: rustls::ClientConnection,
316 lines: LineBuffer,
317 read_timeout: Option<Duration>,
318}
319
320impl Session {
321 fn drive(&mut self) -> std::io::Result<()> {
322 self.tls.complete_io(&mut self.sock).map(|_| ())
323 }
324}
325
326#[derive(Clone)]
327pub struct TlsStream {
328 session: Arc<Mutex<Session>>,
329}
330
331impl 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
423impl 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
436impl 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]
138fn 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}