From 8218fd584e4053848738a310258893e14c4ee10e Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 24 Aug 2026 15:27:06 +0200 Subject: TOFU host trust: SPKI fingerprint pinning, tests, forget-host --- src/bin/client/connection.rs | 174 +++++++++++++++---------------- src/bin/client/database.rs | 46 ++++++++- src/bin/client/main.rs | 1 + src/bin/client/mod.rs | 1 + src/bin/client/repl.rs | 27 ++++- src/bin/client/tls.rs | 240 +++++++++++++++++++++++++++---------------- src/bin/server/database.rs | 5 +- src/bin/server/handler.rs | 6 +- src/lib.rs | 10 ++ src/tofu.rs | 114 ++++++++++++++++++++ 10 files changed, 439 insertions(+), 185 deletions(-) create mode 100644 src/lib.rs create mode 100644 src/tofu.rs (limited to 'src') diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index c5d506d..6aff321 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -1,144 +1,144 @@ use crate::database; -use native_tls::TlsConnector; +use crate::tls::TlsConnector; use rusqlite::Connection; use std::io::{Read, Write}; use std::net::TcpStream; use std::sync::mpsc; -use std::sync::Arc; -use std::sync::Mutex; +use parking_lot::Mutex; use std::thread; use std::time::{Duration, Instant}; static UNSAFE_MODE: Mutex = Mutex::new(false); pub fn set_unsafe_mode(unsafe_mode: bool) { - let mut mode = UNSAFE_MODE.lock().unwrap(); - *mode = unsafe_mode; + *UNSAFE_MODE.lock() = unsafe_mode; } +type ConnectResult = Result<(TlsStream, Option), Box>; + pub fn connect( addr: &str, conn: &Connection, ) -> Result<(TlsStream, Option), Box> { - let (tx, rx) = mpsc::channel(); + let stored_fingerprint = database::get_fingerprint(conn, addr); + 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 || match TcpStream::connect(&addr_str) { - Ok(s) => tx.send(Ok(s)).unwrap(), - Err(e) => tx.send(Err(e)).unwrap(), + + 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); }); - match rx.recv_timeout(Duration::from_secs(3)) { - Ok(Ok(s)) => { - let unsafe_mode = *UNSAFE_MODE.lock().unwrap(); - - let connector = if unsafe_mode { - TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build()? - } else { - TlsConnector::builder().build()? - }; - - let tls_stream = connector.connect(&server_name, s)?; - let mut tls = TlsStream::new(tls_stream); - - let start = Instant::now(); - tls.write_line("PING")?; - let mut response = String::new(); - tls.read_line(&mut response)?; - let elapsed = start.elapsed(); - println!( - "\n\rConnected to {} with ping delay: {:?} (TLS)", - addr, elapsed - ); - - if let Some((username, password)) = database::get_credentials(conn, addr) { - 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); - Ok((tls, Some(username))) - } else { - println!("Authentication failed: {}", auth_response.trim()); - Ok((tls, None)) - } - } else { - Ok((tls, None)) + + // Channel carries (stream, observed fingerprint). + let (tls, observed) = match rx.recv_timeout(Duration::from_secs(3)) { + 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 3 seconds".into()) + } + Err(_) => return Err("Connection attempt failed".into()), + }; + + // TOFU: persist the fingerprint on first contact. + if !unsafe_mode { + if let Some(fp) = observed { + if database::get_fingerprint(conn, addr).is_none() { + database::save_fingerprint(conn, addr, &fp)?; + println!( + "First contact with {addr}: trusting fingerprint {fp}\n\ + Future connections must present the same certificate key." + ); } } - Ok(Err(e)) => Err(Box::new(e)), - Err(mpsc::RecvTimeoutError::Timeout) => Err("Connection timed out after 3 seconds".into()), - Err(_) => Err("Connection attempt failed".into()), } -} -pub struct TlsStream { - stream: Arc>>, + let mut tls = tls; + + let start = Instant::now(); + tls.write_line("PING")?; + let mut response = String::new(); + tls.read_line(&mut response)?; + let elapsed = start.elapsed(); + println!("\n\rConnected to {} with ping delay: {:?} (TLS)", addr, elapsed); + + if let Some((username, password)) = database::get_credentials(conn, addr) { + 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); + Ok((tls, Some(username))) + } else { + println!("Authentication failed: {}", auth_response.trim()); + Ok((tls, None)) + } + } else { + Ok((tls, None)) + } } -unsafe impl Send for TlsStream {} -unsafe impl Sync for TlsStream {} +/// Wrapper so TLS handshake failures surface as errors with their message +/// intact (rustls errors already carry the mismatch details). +struct TrustError(String); -impl TlsStream { - pub fn new(stream: native_tls::TlsStream) -> Self { - Self { - stream: Arc::new(Mutex::new(stream)), - } +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 { - let mut byte_buf = [0u8; 1024]; - let mut stream = self.stream.lock().unwrap(); - loop { - let n = stream.read(&mut byte_buf)?; - if n == 0 { - return Ok(0); - } - let chunk = std::str::from_utf8(&byte_buf[..n]).unwrap_or(""); - buf.push_str(chunk); - if buf.contains('\n') { - return Ok(n); - } - } + self.inner.read_line(buf) } pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - let mut stream = self.stream.lock().unwrap(); - stream.write_all(msg.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - Ok(()) + self.inner.write_line(msg) } pub fn try_clone(&self) -> std::io::Result { Ok(TlsStream { - stream: Arc::clone(&self.stream), + inner: self.inner.try_clone()?, }) } } impl Read for TlsStream { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let mut stream = self.stream.lock().unwrap(); - stream.read(buf) + self.inner.read(buf) } } impl Write for TlsStream { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut stream = self.stream.lock().unwrap(); - stream.write(buf) + self.inner.write(buf) } fn flush(&mut self) -> std::io::Result<()> { - let mut stream = self.stream.lock().unwrap(); - stream.flush() + self.inner.flush() } } diff --git a/src/bin/client/database.rs b/src/bin/client/database.rs index a274f31..55051fb 100644 --- a/src/bin/client/database.rs +++ b/src/bin/client/database.rs @@ -1,15 +1,17 @@ use rusqlite::Connection; -pub fn init_db() -> Connection { - let conn = Connection::open("client.db").unwrap(); +pub fn init_db(conn: &Connection) { conn.execute( "CREATE TABLE IF NOT EXISTS hosts ( host TEXT PRIMARY KEY, - last_used DATETIME DEFAULT CURRENT_TIMESTAMP + last_used DATETIME DEFAULT CURRENT_TIMESTAMP, + fingerprint TEXT )", [], ) .unwrap(); + // Migration for databases created before TOFU trust. + let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); conn.execute( "CREATE TABLE IF NOT EXISTS credentials ( host TEXT PRIMARY KEY, @@ -19,7 +21,6 @@ pub fn init_db() -> Connection { [], ) .unwrap(); - conn } pub fn get_last_host(conn: &Connection) -> Option { @@ -36,7 +37,8 @@ pub fn get_last_host(conn: &Connection) -> Option { pub fn save_host(conn: &Connection, host: &str) { conn.execute( - "INSERT OR REPLACE INTO hosts (host, last_used) VALUES (?1, CURRENT_TIMESTAMP)", + "INSERT INTO hosts (host) VALUES (?1) + ON CONFLICT(host) DO UPDATE SET last_used = CURRENT_TIMESTAMP", [host], ) .unwrap(); @@ -68,3 +70,37 @@ pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String) None } } + +/// SHA-256 SPKI fingerprint of a host's certificate, if trusted. +pub fn get_fingerprint(conn: &Connection, host: &str) -> Option { + let mut stmt = conn + .prepare("SELECT fingerprint FROM hosts WHERE host = ?1") + .ok()?; + let mut rows = stmt.query([host]).ok()?; + match rows.next() { + Ok(Some(row)) => row.get(0).ok(), + _ => None, + } +} + +pub fn save_fingerprint( + conn: &Connection, + host: &str, + fingerprint: &str, +) -> Result<(), rusqlite::Error> { + // Hosts may not exist yet (fingerprint can be saved before first + // successful connect completes save_host). + conn.execute("INSERT OR IGNORE INTO hosts (host) VALUES (?1)", [host])?; + conn.execute( + "UPDATE hosts SET fingerprint = ?2 WHERE host = ?1", + (host, fingerprint), + )?; + Ok(()) +} + +/// Remove all trust and credentials for a host ("forget this host"). +pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { + conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; + conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; + Ok(()) +} diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs index c95880d..4e03381 100644 --- a/src/bin/client/main.rs +++ b/src/bin/client/main.rs @@ -4,6 +4,7 @@ mod connection; mod database; mod games; mod repl; +mod tls; use repl::ClientState; diff --git a/src/bin/client/mod.rs b/src/bin/client/mod.rs index 21d7f27..5921c41 100644 --- a/src/bin/client/mod.rs +++ b/src/bin/client/mod.rs @@ -2,4 +2,5 @@ pub mod database; mod connection; mod games; pub mod platform; +pub mod tls; pub mod repl; diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 194860b..91fd10e 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -31,7 +31,11 @@ pub struct ClientState { impl ClientState { pub fn new() -> Self { - let conn = database::init_db(); + let conn = rusqlite::Connection::open( + std::env::var("CLIENT_DB").unwrap_or_else(|_| "client.db".to_string()), + ) + .unwrap(); + database::init_db(&conn); let default_ip = "127.0.0.1".to_string(); let default_port = 11525; let last_host = database::get_last_host(&conn); @@ -559,6 +563,25 @@ impl ClientState { break 'outer; } } + "forget-host" => { + let target = if parts.len() > 1 { + parts[1].to_string() + } else if let Some(addr) = &self.addr { + addr.clone() + } else { + println!( + "\nUsage: forget-host : (or connect first)" + ); + continue 'outer; + }; + match database::forget_host(&self.conn, &target) { + Ok(_) => println!( + "\nCleared trust and stored credentials for {}.", + target + ), + Err(e) => println!("\nFailed to forget host: {}", e), + } + } _ => help(), } break; @@ -713,6 +736,7 @@ fn get_commands() -> Vec { "play".to_string(), "help".to_string(), "quit".to_string(), + "forget-host".to_string(), ] } @@ -730,6 +754,7 @@ fn help() { info - show login info\r play [bet] - start a game (e.g., play slots 50)\r help - show this message\r + forget-host [ip:port] - clear saved trust fingerprint and credentials\r quit - quit the program" ); } diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs index 8ab5c7c..ca99838 100644 --- a/src/bin/client/tls.rs +++ b/src/bin/client/tls.rs @@ -1,83 +1,128 @@ +//! rustls-based client TLS with Trust-On-First-Use (TOFU) host verification. +//! +//! Model (SSH known_hosts style): +//! - First contact: accept the certificate and record the SHA-256 of its +//! SubjectPublicKeyInfo (SPKI). Hashing the SPKI rather than the whole +//! certificate means routine renewal with the same key does not break +//! clients; key rotation does — by design. +//! - Later contacts: the presented key must hash to the stored fingerprint, +//! otherwise the handshake fails with a loud error. +//! +//! Handshake signatures are accepted without webpki validation; identity is +//! established purely by the pinned fingerprint. An impostor cannot present a +//! different certificate without triggering the mismatch rejection. + use std::io::{Read, Write}; use std::net::TcpStream; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; +use std::sync::Arc; + +use parking_lot::Mutex; +use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::DigitallySignedStruct; +use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; pub struct TlsConnector { - connector: rustls::ClientConnection, - server_name: String, + config: Arc, + server_name: rustls::ServerName, + observed: Arc>>, } impl TlsConnector { - pub fn new(server_name: &str) -> Result> { + /// `stored` is the previously trusted fingerprint for this host, if any. + /// With `bypass_tofu` (unsafe mode) any certificate is accepted. + pub fn new( + server_name: &str, + stored: Option, + bypass_tofu: bool, + ) -> Result> { + let observed = Arc::new(Mutex::new(None)); + let verifier = Arc::new(TofuVerifier { + stored, + bypass_tofu, + observed: Arc::clone(&observed), + }); let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) + .with_safe_defaults() + .with_custom_certificate_verifier(verifier) .with_no_client_auth(); - - let connector = - rustls::ClientConnection::new(std::sync::Arc::new(config), server_name.try_into()?); - Ok(Self { - connector, - server_name: server_name.to_string(), + config: Arc::new(config), + server_name: server_name.try_into()?, + observed, }) } - pub fn connect( - &self, - stream: TcpStream, - ) -> Result> { - let mut conn = rustls::ClientConnection::new( - std::sync::Arc::new( - rustls::ClientConfig::builder() - .dangerous() - .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) - .with_no_client_auth(), - ), - self.server_name.clone().try_into()?, - )?; - - conn.complete_io(&mut std::io::BufReader::new(&stream))?; - - Ok(TlsClientStream { - stream, - conn: std::sync::Arc::new(conn), + pub fn connect(&self, stream: TcpStream) -> Result> { + let mut conn = + rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; + let mut sock = stream; + conn.complete_io(&mut sock)?; + + Ok(TlsStream { + session: Arc::new(Mutex::new(Session { sock, tls: conn })), }) } + + /// Fingerprint of the certificate seen during the last `connect`. + pub fn observed_fingerprint(&self) -> Option { + self.observed.lock().clone() + } } -struct InsecureVerifier; +struct TofuVerifier { + stored: Option, + bypass_tofu: bool, + observed: Arc>>, +} -impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { +impl ServerCertVerifier for TofuVerifier { fn verify_server_cert( &self, - _end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, + end_entity: &rustls::Certificate, + _intermediates: &[rustls::Certificate], + _server_name: &rustls::ServerName, + _scts: &mut dyn Iterator, _ocsp_response: &[u8], _now: std::time::SystemTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) + ) -> Result { + let fp = spki_fingerprint(&end_entity.0) + .map_err(|e| rustls::Error::General(format!("certificate parse failed: {e}")))?; + *self.observed.lock() = Some(fp.clone()); + + if self.bypass_tofu { + return Ok(ServerCertVerified::assertion()); + } + + match decide(self.stored.as_deref(), &fp) { + TrustDecision::TrustNew | TrustDecision::Trusted => Ok(ServerCertVerified::assertion()), + TrustDecision::Mismatch { expected, actual } => Err(rustls::Error::General(format!( + "HOST KEY CHANGED for this server!\n\ + expected fingerprint: {expected}\n\ + observed fingerprint: {actual}\n\ + If this is intentional (server reinstall), use the client's\n\ + forget-host command to reset trust for this address." + ))), + } } + // Identity is pinned via fingerprint comparison above; handshake + // signatures are not additionally verified against a trust store. fn verify_tls12_signature( &self, _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + _cert: &rustls::Certificate, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + _cert: &rustls::Certificate, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { @@ -91,60 +136,81 @@ impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { rustls::SignatureScheme::ECDSA_NISTP256_SHA256, rustls::SignatureScheme::ECDSA_NISTP384_SHA384, rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256_LEGACY, - rustls::SignatureScheme::RSA_PSS_SHA384_LEGACY, - rustls::SignatureScheme::RSA_PSS_SHA512_LEGACY, ] } } -pub struct TlsClientStream { - stream: TcpStream, - conn: std::sync::Arc, +/// TCP socket + TLS session behind one mutex so clones share the stream. +struct Session { + sock: TcpStream, + tls: rustls::ClientConnection, +} + +impl Session { + fn drive(&mut self) -> std::io::Result<()> { + Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?) + } } -impl TlsClientStream { +#[derive(Clone)] +pub struct TlsStream { + session: Arc>, +} + +impl TlsStream { pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { let mut byte_buf = [0u8; 1024]; + let mut s = self.session.lock(); loop { - let mut tls_reader = std::io::BufReader::new(self.conn.clone()); - let n = tls_reader.read(&mut byte_buf)?; - if n == 0 { - return Ok(0); - } - let chunk = std::str::from_utf8(&byte_buf[..n]).unwrap_or(""); - buf.push_str(chunk); - if buf.contains('\n') { - return Ok(n); + match s.tls.reader().read(&mut byte_buf) { + Ok(0) => return Ok(0), + Ok(n) => { + buf.push_str(std::str::from_utf8(&byte_buf[..n]).unwrap_or("")); + if buf.contains('\n') { + return Ok(n); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(e) => return Err(e), } } } pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - use std::io::Write; - self.conn.writer().write_all(msg.as_bytes())?; - self.conn.writer().write_all(b"\n")?; - self.conn.writer().flush()?; - Ok(()) + let mut s = self.session.lock(); + s.tls.writer().write_all(msg.as_bytes())?; + s.tls.writer().write_all(b"\n")?; + s.drive() } - pub fn try_clone(&self) -> std::io::Result { - let new_stream = self.stream.try_clone()?; - let mut conn = rustls::ClientConnection::new( - std::sync::Arc::new( - rustls::ClientConfig::builder() - .dangerous() - .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) - .with_no_client_auth(), - ), - self.server_name.clone().try_into().unwrap(), - )?; - - conn.complete_io(&mut std::io::BufReader::new(&new_stream))?; - - Ok(TlsClientStream { - stream: new_stream, - conn: std::sync::Arc::new(conn), - }) + /// Clones share the same underlying TLS session. + pub fn try_clone(&self) -> std::io::Result { + Ok(self.clone()) + } +} + +impl Read for TlsStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut s = self.session.lock(); + loop { + match s.tls.reader().read(buf) { + Ok(n) => return Ok(n), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, + Err(e) => return Err(e), + } + } + } +} + +impl Write for TlsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut s = self.session.lock(); + s.tls.writer().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut s = self.session.lock(); + s.tls.writer().flush()?; + s.drive() } } diff --git a/src/bin/server/database.rs b/src/bin/server/database.rs index 1653071..c73aec5 100644 --- a/src/bin/server/database.rs +++ b/src/bin/server/database.rs @@ -1,8 +1,6 @@ use rusqlite::Connection; -pub fn init_db() -> Connection { - let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); - let conn = Connection::open(&db_path).unwrap(); +pub fn init_db(conn: &Connection) { conn.execute( "CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, @@ -19,7 +17,6 @@ pub fn init_db() -> Connection { "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", [], ); - conn } pub fn add_user( diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 867910e..48e9d3a 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs @@ -8,7 +8,11 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; use std::sync::Mutex; -static DB_CONN: Lazy> = Lazy::new(|| Mutex::new(database::init_db())); +static DB_CONN: Lazy> = Lazy::new(|| { + let conn = Connection::open(std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string())).unwrap(); + database::init_db(&conn); + Mutex::new(conn) +}); #[allow(dead_code)] pub fn handle_client(stream: TcpStream) { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..77533d7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +//! Shared library for zagan-bet: exposes client/server storage layers and the +//! TOFU trust module to integration tests in `tests/`. + +pub mod tofu; + +#[path = "bin/client/database.rs"] +pub mod client_database; + +#[path = "bin/server/database.rs"] +pub mod server_database; diff --git a/src/tofu.rs b/src/tofu.rs new file mode 100644 index 0000000..6308813 --- /dev/null +++ b/src/tofu.rs @@ -0,0 +1,114 @@ +//! Trust-On-First-Use host verification. +//! +//! Model (SSH known_hosts style): +//! - First contact with a host: record the SHA-256 fingerprint of the +//! certificate's SubjectPublicKeyInfo (SPKI, not the whole certificate, so +//! routine cert renewal with the same key does not break clients). +//! - Later contacts must present the same key or the connection is rejected. + +use sha2::{Digest, Sha256}; +use std::ops::Range; + +/// Outcome of comparing an observed fingerprint against stored trust state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustDecision { + /// Host never seen: accept and store `fingerprint`. + TrustNew, + /// Stored fingerprint matches: proceed. + Trusted, + /// Key changed since first use: reject with details. + Mismatch { + expected: String, + actual: String, + }, +} + +/// Hex-encoded SHA-256 of the SPKI inside a DER-encoded X.509 certificate. +/// +/// Fails if `cert_der` is not parseable as a certificate. +pub fn spki_fingerprint(cert_der: &[u8]) -> Result { + let spki = der_spki(cert_der)?; + Ok(hex_encode(&Sha256::digest(&cert_der[spki]))) +} + +/// Decide what to do with an observed fingerprint given stored state +/// (`None` when the host has never been trusted). +pub fn decide(stored: Option<&str>, observed: &str) -> TrustDecision { + match stored { + None => TrustDecision::TrustNew, + Some(expected) if expected == observed => TrustDecision::Trusted, + Some(expected) => TrustDecision::Mismatch { + expected: expected.to_string(), + actual: observed.to_string(), + }, + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Locate the complete DER encoding of the certificate's SubjectPublicKeyInfo +/// (TLV including tag and length bytes — the same bytes +/// `openssl pkey -pubin -outform DER` produces). +fn der_spki(der: &[u8]) -> Result, String> { + let mut pos = 0; + + // Certificate ::= SEQUENCE { tbsCertificate, ... } + let (_, _cert_full, cert_body) = read_tlv(der, &mut pos)?; + + // TBSCertificate ::= SEQUENCE { version [0] OPTIONAL, serial, signature, + // issuer, validity, subject, subjectPublicKeyInfo, ... } + let mut pos = cert_body.start; + let (_, _tbs_full, tbs) = read_tlv(der, &mut pos)?; + + let mut pos = tbs.start; + // Slots before SPKI: [0] version (optional), serial, signature, + let mut next = || read_tlv(der, &mut pos); + let (first_tag, _, _) = next()?; + let skip = if first_tag == 0xA0 { 5 } else { 4 }; + for _ in 0..skip { + next()?; + } + let (spki_tag, spki_full, _) = next()?; + if spki_tag != 0x30 { + return Err(format!("expected SPKI SEQUENCE, got tag {spki_tag:#x}")); + } + Ok(spki_full) +} + +/// Read one DER TLV at *pos. Returns (tag, full range incl. header, +/// content range) and advances pos past the element. +fn read_tlv(der: &[u8], pos: &mut usize) -> Result<(u8, Range, Range), String> { + let elem_start = *pos; + if *pos + 2 > der.len() { + return Err("truncated DER".into()); + } + let tag = der[*pos]; + *pos += 1; + let first = der[*pos]; + *pos += 1; + let content_len = if first < 0x80 { + first as usize + } else { + let n = (first & 0x7F) as usize; + if n == 0 || n > 4 || *pos + n > der.len() { + return Err("unsupported DER length".into()); + } + let mut v = 0usize; + for b in &der[*pos..*pos + n] { + v = (v << 8) | *b as usize; + } + *pos += n; + v + }; + let content_start = *pos; + let end = content_start + .checked_add(content_len) + .ok_or("DER length overflow")?; + if end > der.len() { + return Err("truncated DER element".into()); + } + *pos = end; + Ok((tag, elem_start..end, content_start..end)) +} -- cgit v1.2.3