summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
committersalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
commit8218fd584e4053848738a310258893e14c4ee10e (patch)
tree2c92997ba0b6aa260fe72c88190f5f8b590457cb
parentTLS transport and games framework on client and server (diff)
downloadzagan-bet-8218fd584e4053848738a310258893e14c4ee10e.tar.gz
zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.zip
TOFU host trust: SPKI fingerprint pinning, tests, forget-host
Diffstat (limited to '')
-rw-r--r--Cargo.toml3
-rw-r--r--src/bin/client/connection.rs174
-rw-r--r--src/bin/client/database.rs46
-rw-r--r--src/bin/client/main.rs1
-rw-r--r--src/bin/client/mod.rs1
-rw-r--r--src/bin/client/repl.rs27
-rw-r--r--src/bin/client/tls.rs240
-rw-r--r--src/bin/server/database.rs5
-rw-r--r--src/bin/server/handler.rs6
-rw-r--r--src/lib.rs10
-rw-r--r--src/tofu.rs114
-rw-r--r--tests/storage.rs61
-rw-r--r--tests/tofu.rs139
13 files changed, 641 insertions, 186 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 9f72128..beaaf02 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,7 @@ aes-gcm = "0.10"
12pbkdf2 = "0.12" 12pbkdf2 = "0.12"
13rand = "0.8" 13rand = "0.8"
14once_cell = "1.18" 14once_cell = "1.18"
15parking_lot = "0.12"
15hmac = "0.12" 16hmac = "0.12"
16sha2 = "0.10" 17sha2 = "0.10"
17base64 = "0.21" 18base64 = "0.21"
@@ -19,5 +20,5 @@ ctrlc = "3.4"
19serde = { version = "1.0", features = ["derive"] } 20serde = { version = "1.0", features = ["derive"] }
20uuid = { version = "1.0", features = ["v4"] } 21uuid = { version = "1.0", features = ["v4"] }
21native-tls = "0.2" 22native-tls = "0.2"
22rustls = "0.21" 23rustls = { version = "0.21", features = ["dangerous_configuration"] }
23rustls-pemfile = "1.0" \ No newline at end of file 24rustls-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 @@
1use crate::database; 1use crate::database;
2use native_tls::TlsConnector; 2use crate::tls::TlsConnector;
3use rusqlite::Connection; 3use rusqlite::Connection;
4use std::io::{Read, Write}; 4use std::io::{Read, Write};
5use std::net::TcpStream; 5use std::net::TcpStream;
6use std::sync::mpsc; 6use std::sync::mpsc;
7use std::sync::Arc; 7use parking_lot::Mutex;
8use std::sync::Mutex;
9use std::thread; 8use std::thread;
10use std::time::{Duration, Instant}; 9use std::time::{Duration, Instant};
11 10
12static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); 11static UNSAFE_MODE: Mutex<bool> = Mutex::new(false);
13 12
14pub fn set_unsafe_mode(unsafe_mode: bool) { 13pub 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
17type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>;
18
19pub fn connect( 19pub 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
82pub 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
86unsafe impl Send for TlsStream {} 94/// Wrapper so TLS handshake failures surface as errors with their message
87unsafe impl Sync for TlsStream {} 95/// intact (rustls errors already carry the mismatch details).
96struct TrustError(String);
88 97
89impl TlsStream { 98impl 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}
103impl std::fmt::Debug for TrustError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 write!(f, "{self}")
106 }
107}
108impl std::error::Error for TrustError {}
109
110pub struct TlsStream {
111 inner: crate::tls::TlsStream,
112}
95 113
114impl 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
127impl Read for TlsStream { 130impl 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
134impl Write for TlsStream { 136impl 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 @@
1use rusqlite::Connection; 1use rusqlite::Connection;
2 2
3pub fn init_db() -> Connection { 3pub 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
25pub fn get_last_host(conn: &Connection) -> Option<String> { 26pub fn get_last_host(conn: &Connection) -> Option<String> {
@@ -36,7 +37,8 @@ pub fn get_last_host(conn: &Connection) -> Option<String> {
36 37
37pub fn save_host(conn: &Connection, host: &str) { 38pub 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.
75pub 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
86pub 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").
102pub 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;
4mod database; 4mod database;
5mod games; 5mod games;
6mod repl; 6mod repl;
7mod tls;
7 8
8use repl::ClientState; 9use 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;
2mod connection; 2mod connection;
3mod games; 3mod games;
4pub mod platform; 4pub mod platform;
5pub mod tls;
5pub mod repl; 6pub 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
32impl ClientState { 32impl 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
1use std::io::{Read, Write}; 15use std::io::{Read, Write};
2use std::net::TcpStream; 16use std::net::TcpStream;
3use std::sync::mpsc; 17use std::sync::Arc;
4use std::thread; 18
5use std::time::{Duration, Instant}; 19use parking_lot::Mutex;
20use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
21use rustls::DigitallySignedStruct;
22use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision};
6 23
7pub struct TlsConnector { 24pub 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
12impl TlsConnector { 30impl 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
51struct InsecureVerifier; 72struct TofuVerifier {
73 stored: Option<String>,
74 bypass_tofu: bool,
75 observed: Arc<Mutex<Option<String>>>,
76}
52 77
53impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { 78impl 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
101pub struct TlsClientStream { 143/// TCP socket + TLS session behind one mutex so clones share the stream.
102 stream: TcpStream, 144struct Session {
103 conn: std::sync::Arc<rustls::ClientConnection>, 145 sock: TcpStream,
146 tls: rustls::ClientConnection,
147}
148
149impl Session {
150 fn drive(&mut self) -> std::io::Result<()> {
151 Ok(self.tls.complete_io(&mut self.sock).map(|_| ())?)
152 }
104} 153}
105 154
106impl TlsClientStream { 155#[derive(Clone)]
156pub struct TlsStream {
157 session: Arc<Mutex<Session>>,
158}
159
160impl 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) 192impl 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
205impl 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 @@
1use rusqlite::Connection; 1use rusqlite::Connection;
2 2
3pub fn init_db() -> Connection { 3pub 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
25pub fn add_user( 22pub 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};
8use std::net::TcpStream; 8use std::net::TcpStream;
9use std::sync::Mutex; 9use std::sync::Mutex;
10 10
11static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| Mutex::new(database::init_db())); 11static 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)]
14pub fn handle_client(stream: TcpStream) { 18pub 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
4pub mod tofu;
5
6#[path = "bin/client/database.rs"]
7pub mod client_database;
8
9#[path = "bin/server/database.rs"]
10pub 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
9use sha2::{Digest, Sha256};
10use std::ops::Range;
11
12/// Outcome of comparing an observed fingerprint against stored trust state.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub 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.
29pub 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).
36pub 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
47fn 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).
54fn 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.
82fn 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
3use std::path::PathBuf;
4
5use zagan_bet::client_database;
6use zagan_bet::server_database;
7
8fn 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]
19fn 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]
38fn 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]
51fn 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
7use std::path::PathBuf;
8use std::process::Command;
9
10use zagan_bet::client_database;
11use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision};
12
13/// Generate a self-signed certificate under target/ and return its DER bytes.
14fn 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.
59fn 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]
75fn 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]
85fn fingerprint_rejects_garbage() {
86 assert!(spki_fingerprint(b"not a certificate").is_err());
87}
88
89#[test]
90fn first_contact_trusts_and_records() {
91 let observed = "ab12cd34";
92 assert_eq!(decide(None, observed), TrustDecision::TrustNew);
93}
94
95#[test]
96fn matching_key_is_trusted() {
97 assert_eq!(
98 decide(Some("ab12cd34"), "ab12cd34"),
99 TrustDecision::Trusted
100 );
101}
102
103#[test]
104fn 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]
117fn 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}