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 /src/bin/client/tls.rs | |
| 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
Diffstat (limited to '')
| -rw-r--r-- | src/bin/client/tls.rs | 240 |
1 files changed, 153 insertions, 87 deletions
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 | } |
