diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
| commit | 8218fd584e4053848738a310258893e14c4ee10e (patch) | |
| tree | 2c92997ba0b6aa260fe72c88190f5f8b590457cb | |
| parent | TLS transport and games framework on client and server (diff) | |
| download | zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.tar.gz zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.zip | |
TOFU host trust: SPKI fingerprint pinning, tests, forget-host
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 174 | ||||
| -rw-r--r-- | src/bin/client/database.rs | 46 | ||||
| -rw-r--r-- | src/bin/client/main.rs | 1 | ||||
| -rw-r--r-- | src/bin/client/mod.rs | 1 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 27 | ||||
| -rw-r--r-- | src/bin/client/tls.rs | 240 | ||||
| -rw-r--r-- | src/bin/server/database.rs | 5 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 6 | ||||
| -rw-r--r-- | src/lib.rs | 10 | ||||
| -rw-r--r-- | src/tofu.rs | 114 | ||||
| -rw-r--r-- | tests/storage.rs | 61 | ||||
| -rw-r--r-- | tests/tofu.rs | 139 |
13 files changed, 641 insertions, 186 deletions
| @@ -12,6 +12,7 @@ aes-gcm = "0.10" | |||
| 12 | pbkdf2 = "0.12" | 12 | pbkdf2 = "0.12" |
| 13 | rand = "0.8" | 13 | rand = "0.8" |
| 14 | once_cell = "1.18" | 14 | once_cell = "1.18" |
| 15 | parking_lot = "0.12" | ||
| 15 | hmac = "0.12" | 16 | hmac = "0.12" |
| 16 | sha2 = "0.10" | 17 | sha2 = "0.10" |
| 17 | base64 = "0.21" | 18 | base64 = "0.21" |
| @@ -19,5 +20,5 @@ ctrlc = "3.4" | |||
| 19 | serde = { version = "1.0", features = ["derive"] } | 20 | serde = { version = "1.0", features = ["derive"] } |
| 20 | uuid = { version = "1.0", features = ["v4"] } | 21 | uuid = { version = "1.0", features = ["v4"] } |
| 21 | native-tls = "0.2" | 22 | native-tls = "0.2" |
| 22 | rustls = "0.21" | 23 | rustls = { version = "0.21", features = ["dangerous_configuration"] } |
| 23 | rustls-pemfile = "1.0" \ No newline at end of file | 24 | rustls-pemfile = "1.0" \ No newline at end of file |
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 @@ | |||
| 1 | use crate::database; | 1 | use crate::database; |
| 2 | use native_tls::TlsConnector; | 2 | use crate::tls::TlsConnector; |
| 3 | use rusqlite::Connection; | 3 | use rusqlite::Connection; |
| 4 | use std::io::{Read, Write}; | 4 | use std::io::{Read, Write}; |
| 5 | use std::net::TcpStream; | 5 | use std::net::TcpStream; |
| 6 | use std::sync::mpsc; | 6 | use std::sync::mpsc; |
| 7 | use std::sync::Arc; | 7 | use parking_lot::Mutex; |
| 8 | use std::sync::Mutex; | ||
| 9 | use std::thread; | 8 | use std::thread; |
| 10 | use std::time::{Duration, Instant}; | 9 | use std::time::{Duration, Instant}; |
| 11 | 10 | ||
| 12 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); | 11 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); |
| 13 | 12 | ||
| 14 | pub fn set_unsafe_mode(unsafe_mode: bool) { | 13 | pub fn set_unsafe_mode(unsafe_mode: bool) { |
| 15 | let mut mode = UNSAFE_MODE.lock().unwrap(); | 14 | *UNSAFE_MODE.lock() = unsafe_mode; |
| 16 | *mode = unsafe_mode; | ||
| 17 | } | 15 | } |
| 18 | 16 | ||
| 17 | type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>; | ||
| 18 | |||
| 19 | pub fn connect( | 19 | pub fn connect( |
| 20 | addr: &str, | 20 | addr: &str, |
| 21 | conn: &Connection, | 21 | conn: &Connection, |
| 22 | ) -> Result<(TlsStream, Option<String>), Box<dyn std::error::Error>> { | 22 | ) -> Result<(TlsStream, Option<String>), Box<dyn std::error::Error>> { |
| 23 | let (tx, rx) = mpsc::channel(); | 23 | let stored_fingerprint = database::get_fingerprint(conn, addr); |
| 24 | let unsafe_mode = *UNSAFE_MODE.lock(); | ||
| 25 | |||
| 26 | let (tx, rx) = mpsc::channel::<ConnectResult>(); | ||
| 24 | let addr_str = addr.to_string(); | 27 | let addr_str = addr.to_string(); |
| 25 | let server_name = addr_str | 28 | let server_name = addr_str |
| 26 | .split(':') | 29 | .split(':') |
| 27 | .next() | 30 | .next() |
| 28 | .unwrap_or("127.0.0.1") | 31 | .unwrap_or("127.0.0.1") |
| 29 | .to_string(); | 32 | .to_string(); |
| 30 | thread::spawn(move || match TcpStream::connect(&addr_str) { | 33 | |
| 31 | Ok(s) => tx.send(Ok(s)).unwrap(), | 34 | thread::spawn(move || { |
| 32 | Err(e) => tx.send(Err(e)).unwrap(), | 35 | let outcome = (|| -> ConnectResult { |
| 36 | let tcp = TcpStream::connect(&addr_str)?; | ||
| 37 | let connector = TlsConnector::new(&server_name, stored_fingerprint.clone(), unsafe_mode)?; | ||
| 38 | let tls_stream = connector.connect(tcp)?; | ||
| 39 | let fp = connector.observed_fingerprint(); | ||
| 40 | Ok((TlsStream { inner: tls_stream }, fp)) | ||
| 41 | })(); | ||
| 42 | let _ = tx.send(outcome); | ||
| 33 | }); | 43 | }); |
| 34 | match rx.recv_timeout(Duration::from_secs(3)) { | 44 | |
| 35 | Ok(Ok(s)) => { | 45 | // Channel carries (stream, observed fingerprint). |
| 36 | let unsafe_mode = *UNSAFE_MODE.lock().unwrap(); | 46 | let (tls, observed) = match rx.recv_timeout(Duration::from_secs(3)) { |
| 37 | 47 | Ok(Ok((tls, fp))) => (tls, fp), | |
| 38 | let connector = if unsafe_mode { | 48 | Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), |
| 39 | TlsConnector::builder() | 49 | Err(mpsc::RecvTimeoutError::Timeout) => { |
| 40 | .danger_accept_invalid_certs(true) | 50 | return Err("Connection timed out after 3 seconds".into()) |
| 41 | .danger_accept_invalid_hostnames(true) | 51 | } |
| 42 | .build()? | 52 | Err(_) => return Err("Connection attempt failed".into()), |
| 43 | } else { | 53 | }; |
| 44 | TlsConnector::builder().build()? | 54 | |
| 45 | }; | 55 | // TOFU: persist the fingerprint on first contact. |
| 46 | 56 | if !unsafe_mode { | |
| 47 | let tls_stream = connector.connect(&server_name, s)?; | 57 | if let Some(fp) = observed { |
| 48 | let mut tls = TlsStream::new(tls_stream); | 58 | if database::get_fingerprint(conn, addr).is_none() { |
| 49 | 59 | database::save_fingerprint(conn, addr, &fp)?; | |
| 50 | let start = Instant::now(); | 60 | println!( |
| 51 | tls.write_line("PING")?; | 61 | "First contact with {addr}: trusting fingerprint {fp}\n\ |
| 52 | let mut response = String::new(); | 62 | Future connections must present the same certificate key." |
| 53 | tls.read_line(&mut response)?; | 63 | ); |
| 54 | let elapsed = start.elapsed(); | ||
| 55 | println!( | ||
| 56 | "\n\rConnected to {} with ping delay: {:?} (TLS)", | ||
| 57 | addr, elapsed | ||
| 58 | ); | ||
| 59 | |||
| 60 | if let Some((username, password)) = database::get_credentials(conn, addr) { | ||
| 61 | let auth_msg = format!("AUTH {} {}", username, password); | ||
| 62 | tls.write_line(&auth_msg)?; | ||
| 63 | let mut auth_response = String::new(); | ||
| 64 | tls.read_line(&mut auth_response)?; | ||
| 65 | if auth_response.trim().starts_with("OK") { | ||
| 66 | println!("Authenticated as {}", username); | ||
| 67 | Ok((tls, Some(username))) | ||
| 68 | } else { | ||
| 69 | println!("Authentication failed: {}", auth_response.trim()); | ||
| 70 | Ok((tls, None)) | ||
| 71 | } | ||
| 72 | } else { | ||
| 73 | Ok((tls, None)) | ||
| 74 | } | 64 | } |
| 75 | } | 65 | } |
| 76 | Ok(Err(e)) => Err(Box::new(e)), | ||
| 77 | Err(mpsc::RecvTimeoutError::Timeout) => Err("Connection timed out after 3 seconds".into()), | ||
| 78 | Err(_) => Err("Connection attempt failed".into()), | ||
| 79 | } | 66 | } |
| 80 | } | ||
| 81 | 67 | ||
| 82 | pub struct TlsStream { | 68 | let mut tls = tls; |
| 83 | stream: Arc<Mutex<native_tls::TlsStream<TcpStream>>>, | 69 | |
| 70 | let start = Instant::now(); | ||
| 71 | tls.write_line("PING")?; | ||
| 72 | let mut response = String::new(); | ||
| 73 | tls.read_line(&mut response)?; | ||
| 74 | let elapsed = start.elapsed(); | ||
| 75 | println!("\n\rConnected to {} with ping delay: {:?} (TLS)", addr, elapsed); | ||
| 76 | |||
| 77 | if let Some((username, password)) = database::get_credentials(conn, addr) { | ||
| 78 | let auth_msg = format!("AUTH {} {}", username, password); | ||
| 79 | tls.write_line(&auth_msg)?; | ||
| 80 | let mut auth_response = String::new(); | ||
| 81 | tls.read_line(&mut auth_response)?; | ||
| 82 | if auth_response.trim().starts_with("OK") { | ||
| 83 | println!("Authenticated as {}", username); | ||
| 84 | Ok((tls, Some(username))) | ||
| 85 | } else { | ||
| 86 | println!("Authentication failed: {}", auth_response.trim()); | ||
| 87 | Ok((tls, None)) | ||
| 88 | } | ||
| 89 | } else { | ||
| 90 | Ok((tls, None)) | ||
| 91 | } | ||
| 84 | } | 92 | } |
| 85 | 93 | ||
| 86 | unsafe impl Send for TlsStream {} | 94 | /// Wrapper so TLS handshake failures surface as errors with their message |
| 87 | unsafe impl Sync for TlsStream {} | 95 | /// intact (rustls errors already carry the mismatch details). |
| 96 | struct TrustError(String); | ||
| 88 | 97 | ||
| 89 | impl TlsStream { | 98 | impl std::fmt::Display for TrustError { |
| 90 | pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { | 99 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 91 | Self { | 100 | write!(f, "{}", self.0) |
| 92 | stream: Arc::new(Mutex::new(stream)), | ||
| 93 | } | ||
| 94 | } | 101 | } |
| 102 | } | ||
| 103 | impl std::fmt::Debug for TrustError { | ||
| 104 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 105 | write!(f, "{self}") | ||
| 106 | } | ||
| 107 | } | ||
| 108 | impl std::error::Error for TrustError {} | ||
| 109 | |||
| 110 | pub struct TlsStream { | ||
| 111 | inner: crate::tls::TlsStream, | ||
| 112 | } | ||
| 95 | 113 | ||
| 114 | impl TlsStream { | ||
| 96 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | 115 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { |
| 97 | let mut byte_buf = [0u8; 1024]; | 116 | self.inner.read_line(buf) |
| 98 | let mut stream = self.stream.lock().unwrap(); | ||
| 99 | loop { | ||
| 100 | let n = stream.read(&mut byte_buf)?; | ||
| 101 | if n == 0 { | ||
| 102 | return Ok(0); | ||
| 103 | } | ||
| 104 | let chunk = std::str::from_utf8(&byte_buf[..n]).unwrap_or(""); | ||
| 105 | buf.push_str(chunk); | ||
| 106 | if buf.contains('\n') { | ||
| 107 | return Ok(n); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | } | 117 | } |
| 111 | 118 | ||
| 112 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | 119 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { |
| 113 | let mut stream = self.stream.lock().unwrap(); | 120 | self.inner.write_line(msg) |
| 114 | stream.write_all(msg.as_bytes())?; | ||
| 115 | stream.write_all(b"\n")?; | ||
| 116 | stream.flush()?; | ||
| 117 | Ok(()) | ||
| 118 | } | 121 | } |
| 119 | 122 | ||
| 120 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { | 123 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { |
| 121 | Ok(TlsStream { | 124 | Ok(TlsStream { |
| 122 | stream: Arc::clone(&self.stream), | 125 | inner: self.inner.try_clone()?, |
| 123 | }) | 126 | }) |
| 124 | } | 127 | } |
| 125 | } | 128 | } |
| 126 | 129 | ||
| 127 | impl Read for TlsStream { | 130 | impl Read for TlsStream { |
| 128 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { | 131 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { |
| 129 | let mut stream = self.stream.lock().unwrap(); | 132 | self.inner.read(buf) |
| 130 | stream.read(buf) | ||
| 131 | } | 133 | } |
| 132 | } | 134 | } |
| 133 | 135 | ||
| 134 | impl Write for TlsStream { | 136 | impl Write for TlsStream { |
| 135 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | 137 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { |
| 136 | let mut stream = self.stream.lock().unwrap(); | 138 | self.inner.write(buf) |
| 137 | stream.write(buf) | ||
| 138 | } | 139 | } |
| 139 | 140 | ||
| 140 | fn flush(&mut self) -> std::io::Result<()> { | 141 | fn flush(&mut self) -> std::io::Result<()> { |
| 141 | let mut stream = self.stream.lock().unwrap(); | 142 | self.inner.flush() |
| 142 | stream.flush() | ||
| 143 | } | 143 | } |
| 144 | } | 144 | } |
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 @@ | |||
| 1 | use rusqlite::Connection; | 1 | use rusqlite::Connection; |
| 2 | 2 | ||
| 3 | pub fn init_db() -> Connection { | 3 | pub fn init_db(conn: &Connection) { |
| 4 | let conn = Connection::open("client.db").unwrap(); | ||
| 5 | conn.execute( | 4 | conn.execute( |
| 6 | "CREATE TABLE IF NOT EXISTS hosts ( | 5 | "CREATE TABLE IF NOT EXISTS hosts ( |
| 7 | host TEXT PRIMARY KEY, | 6 | host TEXT PRIMARY KEY, |
| 8 | last_used DATETIME DEFAULT CURRENT_TIMESTAMP | 7 | last_used DATETIME DEFAULT CURRENT_TIMESTAMP, |
| 8 | fingerprint TEXT | ||
| 9 | )", | 9 | )", |
| 10 | [], | 10 | [], |
| 11 | ) | 11 | ) |
| 12 | .unwrap(); | 12 | .unwrap(); |
| 13 | // Migration for databases created before TOFU trust. | ||
| 14 | let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); | ||
| 13 | conn.execute( | 15 | conn.execute( |
| 14 | "CREATE TABLE IF NOT EXISTS credentials ( | 16 | "CREATE TABLE IF NOT EXISTS credentials ( |
| 15 | host TEXT PRIMARY KEY, | 17 | host TEXT PRIMARY KEY, |
| @@ -19,7 +21,6 @@ pub fn init_db() -> Connection { | |||
| 19 | [], | 21 | [], |
| 20 | ) | 22 | ) |
| 21 | .unwrap(); | 23 | .unwrap(); |
| 22 | conn | ||
| 23 | } | 24 | } |
| 24 | 25 | ||
| 25 | pub fn get_last_host(conn: &Connection) -> Option<String> { | 26 | pub fn get_last_host(conn: &Connection) -> Option<String> { |
| @@ -36,7 +37,8 @@ pub fn get_last_host(conn: &Connection) -> Option<String> { | |||
| 36 | 37 | ||
| 37 | pub fn save_host(conn: &Connection, host: &str) { | 38 | pub fn save_host(conn: &Connection, host: &str) { |
| 38 | conn.execute( | 39 | conn.execute( |
| 39 | "INSERT OR REPLACE INTO hosts (host, last_used) VALUES (?1, CURRENT_TIMESTAMP)", | 40 | "INSERT INTO hosts (host) VALUES (?1) |
| 41 | ON CONFLICT(host) DO UPDATE SET last_used = CURRENT_TIMESTAMP", | ||
| 40 | [host], | 42 | [host], |
| 41 | ) | 43 | ) |
| 42 | .unwrap(); | 44 | .unwrap(); |
| @@ -68,3 +70,37 @@ pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String) | |||
| 68 | None | 70 | None |
| 69 | } | 71 | } |
| 70 | } | 72 | } |
| 73 | |||
| 74 | /// SHA-256 SPKI fingerprint of a host's certificate, if trusted. | ||
| 75 | pub fn get_fingerprint(conn: &Connection, host: &str) -> Option<String> { | ||
| 76 | let mut stmt = conn | ||
| 77 | .prepare("SELECT fingerprint FROM hosts WHERE host = ?1") | ||
| 78 | .ok()?; | ||
| 79 | let mut rows = stmt.query([host]).ok()?; | ||
| 80 | match rows.next() { | ||
| 81 | Ok(Some(row)) => row.get(0).ok(), | ||
| 82 | _ => None, | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | pub fn save_fingerprint( | ||
| 87 | conn: &Connection, | ||
| 88 | host: &str, | ||
| 89 | fingerprint: &str, | ||
| 90 | ) -> Result<(), rusqlite::Error> { | ||
| 91 | // Hosts may not exist yet (fingerprint can be saved before first | ||
| 92 | // successful connect completes save_host). | ||
| 93 | conn.execute("INSERT OR IGNORE INTO hosts (host) VALUES (?1)", [host])?; | ||
| 94 | conn.execute( | ||
| 95 | "UPDATE hosts SET fingerprint = ?2 WHERE host = ?1", | ||
| 96 | (host, fingerprint), | ||
| 97 | )?; | ||
| 98 | Ok(()) | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Remove all trust and credentials for a host ("forget this host"). | ||
| 102 | pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { | ||
| 103 | conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; | ||
| 104 | conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; | ||
| 105 | Ok(()) | ||
| 106 | } | ||
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; | |||
| 4 | mod database; | 4 | mod database; |
| 5 | mod games; | 5 | mod games; |
| 6 | mod repl; | 6 | mod repl; |
| 7 | mod tls; | ||
| 7 | 8 | ||
| 8 | use repl::ClientState; | 9 | use repl::ClientState; |
| 9 | 10 | ||
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; | |||
| 2 | mod connection; | 2 | mod connection; |
| 3 | mod games; | 3 | mod games; |
| 4 | pub mod platform; | 4 | pub mod platform; |
| 5 | pub mod tls; | ||
| 5 | pub mod repl; | 6 | 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 { | |||
| 31 | 31 | ||
| 32 | impl ClientState { | 32 | impl ClientState { |
| 33 | pub fn new() -> Self { | 33 | pub fn new() -> Self { |
| 34 | let conn = database::init_db(); | 34 | let conn = rusqlite::Connection::open( |
| 35 | std::env::var("CLIENT_DB").unwrap_or_else(|_| "client.db".to_string()), | ||
| 36 | ) | ||
| 37 | .unwrap(); | ||
| 38 | database::init_db(&conn); | ||
| 35 | let default_ip = "127.0.0.1".to_string(); | 39 | let default_ip = "127.0.0.1".to_string(); |
| 36 | let default_port = 11525; | 40 | let default_port = 11525; |
| 37 | let last_host = database::get_last_host(&conn); | 41 | let last_host = database::get_last_host(&conn); |
| @@ -559,6 +563,25 @@ impl ClientState { | |||
| 559 | break 'outer; | 563 | break 'outer; |
| 560 | } | 564 | } |
| 561 | } | 565 | } |
| 566 | "forget-host" => { | ||
| 567 | let target = if parts.len() > 1 { | ||
| 568 | parts[1].to_string() | ||
| 569 | } else if let Some(addr) = &self.addr { | ||
| 570 | addr.clone() | ||
| 571 | } else { | ||
| 572 | println!( | ||
| 573 | "\nUsage: forget-host <ip>:<port> (or connect first)" | ||
| 574 | ); | ||
| 575 | continue 'outer; | ||
| 576 | }; | ||
| 577 | match database::forget_host(&self.conn, &target) { | ||
| 578 | Ok(_) => println!( | ||
| 579 | "\nCleared trust and stored credentials for {}.", | ||
| 580 | target | ||
| 581 | ), | ||
| 582 | Err(e) => println!("\nFailed to forget host: {}", e), | ||
| 583 | } | ||
| 584 | } | ||
| 562 | _ => help(), | 585 | _ => help(), |
| 563 | } | 586 | } |
| 564 | break; | 587 | break; |
| @@ -713,6 +736,7 @@ fn get_commands() -> Vec<String> { | |||
| 713 | "play".to_string(), | 736 | "play".to_string(), |
| 714 | "help".to_string(), | 737 | "help".to_string(), |
| 715 | "quit".to_string(), | 738 | "quit".to_string(), |
| 739 | "forget-host".to_string(), | ||
| 716 | ] | 740 | ] |
| 717 | } | 741 | } |
| 718 | 742 | ||
| @@ -730,6 +754,7 @@ fn help() { | |||
| 730 | info - show login info\r | 754 | info - show login info\r |
| 731 | play <game> [bet] - start a game (e.g., play slots 50)\r | 755 | play <game> [bet] - start a game (e.g., play slots 50)\r |
| 732 | help - show this message\r | 756 | help - show this message\r |
| 757 | forget-host [ip:port] - clear saved trust fingerprint and credentials\r | ||
| 733 | quit - quit the program" | 758 | quit - quit the program" |
| 734 | ); | 759 | ); |
| 735 | } | 760 | } |
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 @@ | |||
| 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 | |||
| 1 | use std::io::{Read, Write}; | 15 | use std::io::{Read, Write}; |
| 2 | use std::net::TcpStream; | 16 | use std::net::TcpStream; |
| 3 | use std::sync::mpsc; | 17 | use std::sync::Arc; |
| 4 | use std::thread; | 18 | |
| 5 | use std::time::{Duration, Instant}; | 19 | use parking_lot::Mutex; |
| 20 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 21 | use rustls::DigitallySignedStruct; | ||
| 22 | use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; | ||
| 6 | 23 | ||
| 7 | pub struct TlsConnector { | 24 | pub struct TlsConnector { |
| 8 | connector: rustls::ClientConnection, | 25 | config: Arc<rustls::ClientConfig>, |
| 9 | server_name: String, | 26 | server_name: rustls::ServerName, |
| 27 | observed: Arc<Mutex<Option<String>>>, | ||
| 10 | } | 28 | } |
| 11 | 29 | ||
| 12 | impl TlsConnector { | 30 | impl TlsConnector { |
| 13 | pub fn new(server_name: &str) -> Result<Self, Box<dyn std::error::Error>> { | 31 | /// `stored` is the previously trusted fingerprint for this host, if any. |
| 32 | /// With `bypass_tofu` (unsafe mode) any certificate is accepted. | ||
| 33 | pub fn new( | ||
| 34 | server_name: &str, | ||
| 35 | stored: Option<String>, | ||
| 36 | bypass_tofu: bool, | ||
| 37 | ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { | ||
| 38 | let observed = Arc::new(Mutex::new(None)); | ||
| 39 | let verifier = Arc::new(TofuVerifier { | ||
| 40 | stored, | ||
| 41 | bypass_tofu, | ||
| 42 | observed: Arc::clone(&observed), | ||
| 43 | }); | ||
| 14 | let config = rustls::ClientConfig::builder() | 44 | let config = rustls::ClientConfig::builder() |
| 15 | .dangerous() | 45 | .with_safe_defaults() |
| 16 | .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) | 46 | .with_custom_certificate_verifier(verifier) |
| 17 | .with_no_client_auth(); | 47 | .with_no_client_auth(); |
| 18 | |||
| 19 | let connector = | ||
| 20 | rustls::ClientConnection::new(std::sync::Arc::new(config), server_name.try_into()?); | ||
| 21 | |||
| 22 | Ok(Self { | 48 | Ok(Self { |
| 23 | connector, | 49 | config: Arc::new(config), |
| 24 | server_name: server_name.to_string(), | 50 | server_name: server_name.try_into()?, |
| 51 | observed, | ||
| 25 | }) | 52 | }) |
| 26 | } | 53 | } |
| 27 | 54 | ||
| 28 | pub fn connect( | 55 | pub fn connect(&self, stream: TcpStream) -> Result<TlsStream, Box<dyn std::error::Error + Send + Sync>> { |
| 29 | &self, | 56 | let mut conn = |
| 30 | stream: TcpStream, | 57 | rustls::ClientConnection::new(Arc::clone(&self.config), self.server_name.clone())?; |
| 31 | ) -> Result<TlsClientStream, Box<dyn std::error::Error>> { | 58 | let mut sock = stream; |
| 32 | let mut conn = rustls::ClientConnection::new( | 59 | conn.complete_io(&mut sock)?; |
| 33 | std::sync::Arc::new( | 60 | |
| 34 | rustls::ClientConfig::builder() | 61 | Ok(TlsStream { |
| 35 | .dangerous() | 62 | session: Arc::new(Mutex::new(Session { sock, tls: conn })), |
| 36 | .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) | ||
| 37 | .with_no_client_auth(), | ||
| 38 | ), | ||
| 39 | self.server_name.clone().try_into()?, | ||
| 40 | )?; | ||
| 41 | |||
| 42 | conn.complete_io(&mut std::io::BufReader::new(&stream))?; | ||
| 43 | |||
| 44 | Ok(TlsClientStream { | ||
| 45 | stream, | ||
| 46 | conn: std::sync::Arc::new(conn), | ||
| 47 | }) | 63 | }) |
| 48 | } | 64 | } |
| 65 | |||
| 66 | /// Fingerprint of the certificate seen during the last `connect`. | ||
| 67 | pub fn observed_fingerprint(&self) -> Option<String> { | ||
| 68 | self.observed.lock().clone() | ||
| 69 | } | ||
| 49 | } | 70 | } |
| 50 | 71 | ||
| 51 | struct InsecureVerifier; | 72 | struct TofuVerifier { |
| 73 | stored: Option<String>, | ||
| 74 | bypass_tofu: bool, | ||
| 75 | observed: Arc<Mutex<Option<String>>>, | ||
| 76 | } | ||
| 52 | 77 | ||
| 53 | impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { | 78 | impl ServerCertVerifier for TofuVerifier { |
| 54 | fn verify_server_cert( | 79 | fn verify_server_cert( |
| 55 | &self, | 80 | &self, |
| 56 | _end_entity: &rustls::pki_types::CertificateDer<'_>, | 81 | end_entity: &rustls::Certificate, |
| 57 | _intermediates: &[rustls::pki_types::CertificateDer<'_>], | 82 | _intermediates: &[rustls::Certificate], |
| 58 | _server_name: &rustls::pki_types::ServerName<'_>, | 83 | _server_name: &rustls::ServerName, |
| 84 | _scts: &mut dyn Iterator<Item = &[u8]>, | ||
| 59 | _ocsp_response: &[u8], | 85 | _ocsp_response: &[u8], |
| 60 | _now: std::time::SystemTime, | 86 | _now: std::time::SystemTime, |
| 61 | ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { | 87 | ) -> Result<ServerCertVerified, rustls::Error> { |
| 62 | Ok(rustls::client::danger::ServerCertVerified::assertion()) | 88 | let fp = spki_fingerprint(&end_entity.0) |
| 89 | .map_err(|e| rustls::Error::General(format!("certificate parse failed: {e}")))?; | ||
| 90 | *self.observed.lock() = Some(fp.clone()); | ||
| 91 | |||
| 92 | if self.bypass_tofu { | ||
| 93 | return Ok(ServerCertVerified::assertion()); | ||
| 94 | } | ||
| 95 | |||
| 96 | match decide(self.stored.as_deref(), &fp) { | ||
| 97 | TrustDecision::TrustNew | TrustDecision::Trusted => Ok(ServerCertVerified::assertion()), | ||
| 98 | TrustDecision::Mismatch { expected, actual } => Err(rustls::Error::General(format!( | ||
| 99 | "HOST KEY CHANGED for this server!\n\ | ||
| 100 | expected fingerprint: {expected}\n\ | ||
| 101 | observed fingerprint: {actual}\n\ | ||
| 102 | If this is intentional (server reinstall), use the client's\n\ | ||
| 103 | forget-host command to reset trust for this address." | ||
| 104 | ))), | ||
| 105 | } | ||
| 63 | } | 106 | } |
| 64 | 107 | ||
| 108 | // Identity is pinned via fingerprint comparison above; handshake | ||
| 109 | // signatures are not additionally verified against a trust store. | ||
| 65 | fn verify_tls12_signature( | 110 | fn verify_tls12_signature( |
| 66 | &self, | 111 | &self, |
| 67 | _message: &[u8], | 112 | _message: &[u8], |
| 68 | _cert: &rustls::pki_types::CertificateDer<'_>, | 113 | _cert: &rustls::Certificate, |
| 69 | _dss: &rustls::DigitallySignedStruct, | 114 | _dss: &DigitallySignedStruct, |
| 70 | ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { | 115 | ) -> Result<HandshakeSignatureValid, rustls::Error> { |
| 71 | Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) | 116 | Ok(HandshakeSignatureValid::assertion()) |
| 72 | } | 117 | } |
| 73 | 118 | ||
| 74 | fn verify_tls13_signature( | 119 | fn verify_tls13_signature( |
| 75 | &self, | 120 | &self, |
| 76 | _message: &[u8], | 121 | _message: &[u8], |
| 77 | _cert: &rustls::pki_types::CertificateDer<'_>, | 122 | _cert: &rustls::Certificate, |
| 78 | _dss: &rustls::DigitallySignedStruct, | 123 | _dss: &DigitallySignedStruct, |
| 79 | ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { | 124 | ) -> Result<HandshakeSignatureValid, rustls::Error> { |
| 80 | Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) | 125 | Ok(HandshakeSignatureValid::assertion()) |
| 81 | } | 126 | } |
| 82 | 127 | ||
| 83 | fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { | 128 | fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { |
| @@ -91,60 +136,81 @@ impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { | |||
| 91 | rustls::SignatureScheme::ECDSA_NISTP256_SHA256, | 136 | rustls::SignatureScheme::ECDSA_NISTP256_SHA256, |
| 92 | rustls::SignatureScheme::ECDSA_NISTP384_SHA384, | 137 | rustls::SignatureScheme::ECDSA_NISTP384_SHA384, |
| 93 | rustls::SignatureScheme::ECDSA_NISTP521_SHA512, | 138 | rustls::SignatureScheme::ECDSA_NISTP521_SHA512, |
| 94 | rustls::SignatureScheme::RSA_PSS_SHA256_LEGACY, | ||
| 95 | rustls::SignatureScheme::RSA_PSS_SHA384_LEGACY, | ||
| 96 | rustls::SignatureScheme::RSA_PSS_SHA512_LEGACY, | ||
| 97 | ] | 139 | ] |
| 98 | } | 140 | } |
| 99 | } | 141 | } |
| 100 | 142 | ||
| 101 | pub struct TlsClientStream { | 143 | /// TCP socket + TLS session behind one mutex so clones share the stream. |
| 102 | stream: TcpStream, | 144 | struct Session { |
| 103 | conn: std::sync::Arc<rustls::ClientConnection>, | 145 | sock: TcpStream, |
| 146 | tls: rustls::ClientConnection, | ||
| 147 | } | ||
| 148 | |||
| 149 | impl Session { | ||
| 150 | fn drive(&mut self) -> std::io::Result<()> { | ||
| 151 | Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?) | ||
| 152 | } | ||
| 104 | } | 153 | } |
| 105 | 154 | ||
| 106 | impl TlsClientStream { | 155 | #[derive(Clone)] |
| 156 | pub struct TlsStream { | ||
| 157 | session: Arc<Mutex<Session>>, | ||
| 158 | } | ||
| 159 | |||
| 160 | impl TlsStream { | ||
| 107 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | 161 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { |
| 108 | let mut byte_buf = [0u8; 1024]; | 162 | let mut byte_buf = [0u8; 1024]; |
| 163 | let mut s = self.session.lock(); | ||
| 109 | loop { | 164 | loop { |
| 110 | let mut tls_reader = std::io::BufReader::new(self.conn.clone()); | 165 | match s.tls.reader().read(&mut byte_buf) { |
| 111 | let n = tls_reader.read(&mut byte_buf)?; | 166 | Ok(0) => return Ok(0), |
| 112 | if n == 0 { | 167 | Ok(n) => { |
| 113 | return Ok(0); | 168 | buf.push_str(std::str::from_utf8(&byte_buf[..n]).unwrap_or("")); |
| 114 | } | 169 | if buf.contains('\n') { |
| 115 | let chunk = std::str::from_utf8(&byte_buf[..n]).unwrap_or(""); | 170 | return Ok(n); |
| 116 | buf.push_str(chunk); | 171 | } |
| 117 | if buf.contains('\n') { | 172 | } |
| 118 | return Ok(n); | 173 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, |
| 174 | Err(e) => return Err(e), | ||
| 119 | } | 175 | } |
| 120 | } | 176 | } |
| 121 | } | 177 | } |
| 122 | 178 | ||
| 123 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | 179 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { |
| 124 | use std::io::Write; | 180 | let mut s = self.session.lock(); |
| 125 | self.conn.writer().write_all(msg.as_bytes())?; | 181 | s.tls.writer().write_all(msg.as_bytes())?; |
| 126 | self.conn.writer().write_all(b"\n")?; | 182 | s.tls.writer().write_all(b"\n")?; |
| 127 | self.conn.writer().flush()?; | 183 | s.drive() |
| 128 | Ok(()) | ||
| 129 | } | 184 | } |
| 130 | 185 | ||
| 131 | pub fn try_clone(&self) -> std::io::Result<TlsClientStream> { | 186 | /// Clones share the same underlying TLS session. |
| 132 | let new_stream = self.stream.try_clone()?; | 187 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { |
| 133 | let mut conn = rustls::ClientConnection::new( | 188 | Ok(self.clone()) |
| 134 | std::sync::Arc::new( | 189 | } |
| 135 | rustls::ClientConfig::builder() | 190 | } |
| 136 | .dangerous() | 191 | |
| 137 | .with_custom_verifier(rustls::client::danger::ServerCertVerifier::DEFAULT) | 192 | impl Read for TlsStream { |
| 138 | .with_no_client_auth(), | 193 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { |
| 139 | ), | 194 | let mut s = self.session.lock(); |
| 140 | self.server_name.clone().try_into().unwrap(), | 195 | loop { |
| 141 | )?; | 196 | match s.tls.reader().read(buf) { |
| 142 | 197 | Ok(n) => return Ok(n), | |
| 143 | conn.complete_io(&mut std::io::BufReader::new(&new_stream))?; | 198 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, |
| 144 | 199 | Err(e) => return Err(e), | |
| 145 | Ok(TlsClientStream { | 200 | } |
| 146 | stream: new_stream, | 201 | } |
| 147 | conn: std::sync::Arc::new(conn), | 202 | } |
| 148 | }) | 203 | } |
| 204 | |||
| 205 | impl Write for TlsStream { | ||
| 206 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 207 | let mut s = self.session.lock(); | ||
| 208 | s.tls.writer().write(buf) | ||
| 209 | } | ||
| 210 | |||
| 211 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 212 | let mut s = self.session.lock(); | ||
| 213 | s.tls.writer().flush()?; | ||
| 214 | s.drive() | ||
| 149 | } | 215 | } |
| 150 | } | 216 | } |
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 @@ | |||
| 1 | use rusqlite::Connection; | 1 | use rusqlite::Connection; |
| 2 | 2 | ||
| 3 | pub fn init_db() -> Connection { | 3 | pub fn init_db(conn: &Connection) { |
| 4 | let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); | ||
| 5 | let conn = Connection::open(&db_path).unwrap(); | ||
| 6 | conn.execute( | 4 | conn.execute( |
| 7 | "CREATE TABLE IF NOT EXISTS users ( | 5 | "CREATE TABLE IF NOT EXISTS users ( |
| 8 | username TEXT PRIMARY KEY, | 6 | username TEXT PRIMARY KEY, |
| @@ -19,7 +17,6 @@ pub fn init_db() -> Connection { | |||
| 19 | "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", | 17 | "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", |
| 20 | [], | 18 | [], |
| 21 | ); | 19 | ); |
| 22 | conn | ||
| 23 | } | 20 | } |
| 24 | 21 | ||
| 25 | pub fn add_user( | 22 | 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}; | |||
| 8 | use std::net::TcpStream; | 8 | use std::net::TcpStream; |
| 9 | use std::sync::Mutex; | 9 | use std::sync::Mutex; |
| 10 | 10 | ||
| 11 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| Mutex::new(database::init_db())); | 11 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { |
| 12 | let conn = Connection::open(std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string())).unwrap(); | ||
| 13 | database::init_db(&conn); | ||
| 14 | Mutex::new(conn) | ||
| 15 | }); | ||
| 12 | 16 | ||
| 13 | #[allow(dead_code)] | 17 | #[allow(dead_code)] |
| 14 | pub fn handle_client(stream: TcpStream) { | 18 | 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 @@ | |||
| 1 | //! Shared library for zagan-bet: exposes client/server storage layers and the | ||
| 2 | //! TOFU trust module to integration tests in `tests/`. | ||
| 3 | |||
| 4 | pub mod tofu; | ||
| 5 | |||
| 6 | #[path = "bin/client/database.rs"] | ||
| 7 | pub mod client_database; | ||
| 8 | |||
| 9 | #[path = "bin/server/database.rs"] | ||
| 10 | 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 @@ | |||
| 1 | //! Trust-On-First-Use host verification. | ||
| 2 | //! | ||
| 3 | //! Model (SSH known_hosts style): | ||
| 4 | //! - First contact with a host: record the SHA-256 fingerprint of the | ||
| 5 | //! certificate's SubjectPublicKeyInfo (SPKI, not the whole certificate, so | ||
| 6 | //! routine cert renewal with the same key does not break clients). | ||
| 7 | //! - Later contacts must present the same key or the connection is rejected. | ||
| 8 | |||
| 9 | use sha2::{Digest, Sha256}; | ||
| 10 | use std::ops::Range; | ||
| 11 | |||
| 12 | /// Outcome of comparing an observed fingerprint against stored trust state. | ||
| 13 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 14 | pub enum TrustDecision { | ||
| 15 | /// Host never seen: accept and store `fingerprint`. | ||
| 16 | TrustNew, | ||
| 17 | /// Stored fingerprint matches: proceed. | ||
| 18 | Trusted, | ||
| 19 | /// Key changed since first use: reject with details. | ||
| 20 | Mismatch { | ||
| 21 | expected: String, | ||
| 22 | actual: String, | ||
| 23 | }, | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Hex-encoded SHA-256 of the SPKI inside a DER-encoded X.509 certificate. | ||
| 27 | /// | ||
| 28 | /// Fails if `cert_der` is not parseable as a certificate. | ||
| 29 | pub fn spki_fingerprint(cert_der: &[u8]) -> Result<String, String> { | ||
| 30 | let spki = der_spki(cert_der)?; | ||
| 31 | Ok(hex_encode(&Sha256::digest(&cert_der[spki]))) | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Decide what to do with an observed fingerprint given stored state | ||
| 35 | /// (`None` when the host has never been trusted). | ||
| 36 | pub fn decide(stored: Option<&str>, observed: &str) -> TrustDecision { | ||
| 37 | match stored { | ||
| 38 | None => TrustDecision::TrustNew, | ||
| 39 | Some(expected) if expected == observed => TrustDecision::Trusted, | ||
| 40 | Some(expected) => TrustDecision::Mismatch { | ||
| 41 | expected: expected.to_string(), | ||
| 42 | actual: observed.to_string(), | ||
| 43 | }, | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | fn hex_encode(bytes: &[u8]) -> String { | ||
| 48 | bytes.iter().map(|b| format!("{b:02x}")).collect() | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Locate the complete DER encoding of the certificate's SubjectPublicKeyInfo | ||
| 52 | /// (TLV including tag and length bytes — the same bytes | ||
| 53 | /// `openssl pkey -pubin -outform DER` produces). | ||
| 54 | fn der_spki(der: &[u8]) -> Result<Range<usize>, String> { | ||
| 55 | let mut pos = 0; | ||
| 56 | |||
| 57 | // Certificate ::= SEQUENCE { tbsCertificate, ... } | ||
| 58 | let (_, _cert_full, cert_body) = read_tlv(der, &mut pos)?; | ||
| 59 | |||
| 60 | // TBSCertificate ::= SEQUENCE { version [0] OPTIONAL, serial, signature, | ||
| 61 | // issuer, validity, subject, subjectPublicKeyInfo, ... } | ||
| 62 | let mut pos = cert_body.start; | ||
| 63 | let (_, _tbs_full, tbs) = read_tlv(der, &mut pos)?; | ||
| 64 | |||
| 65 | let mut pos = tbs.start; | ||
| 66 | // Slots before SPKI: [0] version (optional), serial, signature, | ||
| 67 | let mut next = || read_tlv(der, &mut pos); | ||
| 68 | let (first_tag, _, _) = next()?; | ||
| 69 | let skip = if first_tag == 0xA0 { 5 } else { 4 }; | ||
| 70 | for _ in 0..skip { | ||
| 71 | next()?; | ||
| 72 | } | ||
| 73 | let (spki_tag, spki_full, _) = next()?; | ||
| 74 | if spki_tag != 0x30 { | ||
| 75 | return Err(format!("expected SPKI SEQUENCE, got tag {spki_tag:#x}")); | ||
| 76 | } | ||
| 77 | Ok(spki_full) | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Read one DER TLV at *pos. Returns (tag, full range incl. header, | ||
| 81 | /// content range) and advances pos past the element. | ||
| 82 | fn read_tlv(der: &[u8], pos: &mut usize) -> Result<(u8, Range<usize>, Range<usize>), String> { | ||
| 83 | let elem_start = *pos; | ||
| 84 | if *pos + 2 > der.len() { | ||
| 85 | return Err("truncated DER".into()); | ||
| 86 | } | ||
| 87 | let tag = der[*pos]; | ||
| 88 | *pos += 1; | ||
| 89 | let first = der[*pos]; | ||
| 90 | *pos += 1; | ||
| 91 | let content_len = if first < 0x80 { | ||
| 92 | first as usize | ||
| 93 | } else { | ||
| 94 | let n = (first & 0x7F) as usize; | ||
| 95 | if n == 0 || n > 4 || *pos + n > der.len() { | ||
| 96 | return Err("unsupported DER length".into()); | ||
| 97 | } | ||
| 98 | let mut v = 0usize; | ||
| 99 | for b in &der[*pos..*pos + n] { | ||
| 100 | v = (v << 8) | *b as usize; | ||
| 101 | } | ||
| 102 | *pos += n; | ||
| 103 | v | ||
| 104 | }; | ||
| 105 | let content_start = *pos; | ||
| 106 | let end = content_start | ||
| 107 | .checked_add(content_len) | ||
| 108 | .ok_or("DER length overflow")?; | ||
| 109 | if end > der.len() { | ||
| 110 | return Err("truncated DER element".into()); | ||
| 111 | } | ||
| 112 | *pos = end; | ||
| 113 | Ok((tag, elem_start..end, content_start..end)) | ||
| 114 | } | ||
diff --git a/tests/storage.rs b/tests/storage.rs new file mode 100644 index 0000000..275d47d --- /dev/null +++ b/tests/storage.rs | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | //! Tests for storage layers that already exist. These must PASS. | ||
| 2 | |||
| 3 | use std::path::PathBuf; | ||
| 4 | |||
| 5 | use zagan_bet::client_database; | ||
| 6 | use zagan_bet::server_database; | ||
| 7 | |||
| 8 | fn tmp_db(name: &str) -> (String, rusqlite::Connection) { | ||
| 9 | let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 10 | .join(format!("storage-{name}.db")) | ||
| 11 | .to_string_lossy() | ||
| 12 | .into_owned(); | ||
| 13 | let _ = std::fs::remove_file(&path); | ||
| 14 | let conn = rusqlite::Connection::open(&path).unwrap(); | ||
| 15 | (path, conn) | ||
| 16 | } | ||
| 17 | |||
| 18 | #[test] | ||
| 19 | fn client_credentials_roundtrip() { | ||
| 20 | let (_, conn) = tmp_db("creds"); | ||
| 21 | client_database::init_db(&conn); | ||
| 22 | |||
| 23 | assert_eq!(client_database::get_credentials(&conn, "h1"), None); | ||
| 24 | client_database::save_credentials(&conn, "h1", "alice", "pw").unwrap(); | ||
| 25 | assert_eq!( | ||
| 26 | client_database::get_credentials(&conn, "h1"), | ||
| 27 | Some(("alice".to_string(), "pw".to_string())) | ||
| 28 | ); | ||
| 29 | // Overwrite works. | ||
| 30 | client_database::save_credentials(&conn, "h1", "alice", "pw2").unwrap(); | ||
| 31 | assert_eq!( | ||
| 32 | client_database::get_credentials(&conn, "h1"), | ||
| 33 | Some(("alice".to_string(), "pw2".to_string())) | ||
| 34 | ); | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | ||
| 38 | fn client_last_host_tracking() { | ||
| 39 | let (_, conn) = tmp_db("hosts"); | ||
| 40 | client_database::init_db(&conn); | ||
| 41 | |||
| 42 | assert_eq!(client_database::get_last_host(&conn), None); | ||
| 43 | client_database::save_host(&conn, "example.com:9000"); | ||
| 44 | assert_eq!( | ||
| 45 | client_database::get_last_host(&conn), | ||
| 46 | Some("example.com:9000".to_string()) | ||
| 47 | ); | ||
| 48 | } | ||
| 49 | |||
| 50 | #[test] | ||
| 51 | fn server_user_registration_and_auth_lookup() { | ||
| 52 | let (_, conn) = tmp_db("users"); | ||
| 53 | server_database::init_db(&conn); | ||
| 54 | |||
| 55 | server_database::add_user(&conn, "bob", "cafebabe").unwrap(); | ||
| 56 | assert_eq!( | ||
| 57 | server_database::get_hashed_password(&conn, "bob"), | ||
| 58 | Some("cafebabe".to_string()) | ||
| 59 | ); | ||
| 60 | assert_eq!(server_database::get_hashed_password(&conn, "nobody"), None); | ||
| 61 | } | ||
diff --git a/tests/tofu.rs b/tests/tofu.rs new file mode 100644 index 0000000..260e1b6 --- /dev/null +++ b/tests/tofu.rs | |||
| @@ -0,0 +1,139 @@ | |||
| 1 | //! TOFU trust tests. | ||
| 2 | //! | ||
| 3 | //! These pin the not-yet-implemented TOFU behavior: every test here is | ||
| 4 | //! expected to FAIL until the corresponding feature lands in `src/tofu.rs` | ||
| 5 | //! and the client storage layer. | ||
| 6 | |||
| 7 | use std::path::PathBuf; | ||
| 8 | use std::process::Command; | ||
| 9 | |||
| 10 | use zagan_bet::client_database; | ||
| 11 | use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; | ||
| 12 | |||
| 13 | /// Generate a self-signed certificate under target/ and return its DER bytes. | ||
| 14 | fn make_cert(tag: &str) -> Vec<u8> { | ||
| 15 | let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); | ||
| 16 | std::fs::create_dir_all(&dir).unwrap(); | ||
| 17 | let key = dir.join(format!("{tag}.key")); | ||
| 18 | let pem = dir.join(format!("{tag}.pem")); | ||
| 19 | if !pem.exists() { | ||
| 20 | let status = Command::new("openssl") | ||
| 21 | .args([ | ||
| 22 | "req", | ||
| 23 | "-x509", | ||
| 24 | "-newkey", | ||
| 25 | "rsa:2048", | ||
| 26 | "-keyout", | ||
| 27 | key.to_str().unwrap(), | ||
| 28 | "-out", | ||
| 29 | pem.to_str().unwrap(), | ||
| 30 | "-days", | ||
| 31 | "365", | ||
| 32 | "-nodes", | ||
| 33 | "-subj", | ||
| 34 | "/CN=zagan-test", | ||
| 35 | ]) | ||
| 36 | .status() | ||
| 37 | .expect("openssl must be available for tests"); | ||
| 38 | assert!(status.success(), "openssl failed"); | ||
| 39 | } | ||
| 40 | // Convert PEM to DER. | ||
| 41 | let status = Command::new("openssl") | ||
| 42 | .args([ | ||
| 43 | "x509", | ||
| 44 | "-in", | ||
| 45 | pem.to_str().unwrap(), | ||
| 46 | "-outform", | ||
| 47 | "DER", | ||
| 48 | "-out", | ||
| 49 | dir.join(format!("{tag}.der")).to_str().unwrap(), | ||
| 50 | ]) | ||
| 51 | .status() | ||
| 52 | .unwrap(); | ||
| 53 | assert!(status.success()); | ||
| 54 | std::fs::read(dir.join(format!("{tag}.der"))).unwrap() | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Independent expected value computed with openssl, not with our code: | ||
| 58 | /// SHA-256 over the SPKI DER of the certificate. | ||
| 59 | fn openssl_spki_sha256(pem_path: &str) -> String { | ||
| 60 | let out = Command::new("sh") | ||
| 61 | .arg("-c") | ||
| 62 | .arg(format!( | ||
| 63 | "openssl x509 -in {pem} -pubkey -noout | \ | ||
| 64 | openssl pkey -pubin -outform DER | sha256sum", | ||
| 65 | pem = pem_path | ||
| 66 | )) | ||
| 67 | .output() | ||
| 68 | .unwrap(); | ||
| 69 | assert!(out.status.success()); | ||
| 70 | let s = String::from_utf8(out.stdout).unwrap(); | ||
| 71 | s.split_whitespace().next().unwrap().to_string() | ||
| 72 | } | ||
| 73 | |||
| 74 | #[test] | ||
| 75 | fn fingerprint_is_sha256_of_spki() { | ||
| 76 | let der = make_cert("primary"); | ||
| 77 | let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("tofu-certs"); | ||
| 78 | let expected = openssl_spki_sha256(dir.join("primary.pem").to_str().unwrap()); | ||
| 79 | |||
| 80 | let fp = spki_fingerprint(&der).expect("must parse the test certificate"); | ||
| 81 | assert_eq!(fp, expected, "fingerprint must equal openssl's SPKI SHA-256"); | ||
| 82 | } | ||
| 83 | |||
| 84 | #[test] | ||
| 85 | fn fingerprint_rejects_garbage() { | ||
| 86 | assert!(spki_fingerprint(b"not a certificate").is_err()); | ||
| 87 | } | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn first_contact_trusts_and_records() { | ||
| 91 | let observed = "ab12cd34"; | ||
| 92 | assert_eq!(decide(None, observed), TrustDecision::TrustNew); | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn matching_key_is_trusted() { | ||
| 97 | assert_eq!( | ||
| 98 | decide(Some("ab12cd34"), "ab12cd34"), | ||
| 99 | TrustDecision::Trusted | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn changed_key_is_mismatch_with_details() { | ||
| 105 | let d = decide(Some("ab12cd34"), "ef56ab78"); | ||
| 106 | match d { | ||
| 107 | TrustDecision::Mismatch { expected, actual } => { | ||
| 108 | assert_eq!(expected, "ab12cd34"); | ||
| 109 | assert_eq!(actual, "ef56ab78"); | ||
| 110 | } | ||
| 111 | other => panic!("expected Mismatch, got {:?}", other), | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | /// Fingerprint persistence roundtrip in the client database. | ||
| 116 | #[test] | ||
| 117 | fn fingerprint_roundtrip_in_client_db() { | ||
| 118 | let db_path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 119 | .join("tofu-roundtrip.db") | ||
| 120 | .to_string_lossy() | ||
| 121 | .into_owned(); | ||
| 122 | let _ = std::fs::remove_file(&db_path); | ||
| 123 | let conn = rusqlite::Connection::open(&db_path).unwrap(); | ||
| 124 | client_database::init_db(&conn); | ||
| 125 | |||
| 126 | // Host starts unknown. | ||
| 127 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); | ||
| 128 | |||
| 129 | // Save, read back. | ||
| 130 | client_database::save_fingerprint(&conn, "host1", "deadbeef").unwrap(); | ||
| 131 | assert_eq!( | ||
| 132 | client_database::get_fingerprint(&conn, "host1"), | ||
| 133 | Some("deadbeef".to_string()) | ||
| 134 | ); | ||
| 135 | |||
| 136 | // Forget clears it. | ||
| 137 | client_database::forget_host(&conn, "host1").unwrap(); | ||
| 138 | assert_eq!(client_database::get_fingerprint(&conn, "host1"), None); | ||
| 139 | } | ||
