diff --git a/Cargo.toml b/Cargo.toml index 7317c7a..aeecf1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ ahash = "0.8" mimalloc = "0.1" # SIMD byte search — accelerates the newline scan in the line framer. memchr = "2" +# Pure-Rust TLS backend, opt-in via `tls_backend = rustls` (default stays openssl). +# ring provider (no aws-lc-rs); its `unsafe` stays internal like every other crate. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-pemfile = "2" [dev-dependencies] # integration tests spawn the built binary and act as a TLS client against it diff --git a/src/lib.rs b/src/lib.rs index a0957d4..8b592b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod resolver; pub mod server; pub mod socketengine; pub mod tls; +pub mod tls_rustls; pub mod users; pub mod watch; pub mod websocket; diff --git a/src/main.rs b/src/main.rs index 6d8d67f..88cc497 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,16 +149,46 @@ fn main() { }) .unwrap_or_default(); match (&cfg.tls_cert, &cfg.tls_key) { - (Some(cert), Some(key)) => match OpensslBackend::new(cert, key, sni) { - Ok(backend) => { - let backend = Arc::new(backend); - // publish for REHASH-triggered cert reload - let _ = echoircd::tls::TLS_RELOAD.set(backend.clone()); - let backend: Arc = backend; + (Some(cert), Some(key)) => { + // pick the TLS backend: pure-Rust rustls (opt-in via tls_backend = + // rustls) or openssl (the default). Both satisfy the same trait. + let use_rustls = cfg + .raw + .get("tls_backend") + .and_then(|v| v.first()) + .is_some_and(|s| s.eq_ignore_ascii_case("rustls")); + let backend: Option> = if use_rustls { + match echoircd::tls_rustls::RustlsBackend::new(cert, key, sni) { + Ok(b) => { + let b = Arc::new(b); + let _ = echoircd::tls::TLS_RELOAD.set(b.clone()); + eprintln!("echoircd TLS backend: rustls"); + Some(b as Arc) + } + Err(e) => { + eprintln!("echoircd: TLS disabled (rustls cert/key error): {e}"); + None + } + } + } else { + match OpensslBackend::new(cert, key, sni) { + Ok(b) => { + let b = Arc::new(b); + let _ = echoircd::tls::TLS_RELOAD.set(b.clone()); // REHASH cert reload + eprintln!("echoircd TLS backend: openssl"); + Some(b as Arc) + } + Err(e) => { + eprintln!("echoircd: TLS disabled (cert/key error): {e}"); + None + } + } + }; + if let Some(backend) = backend { for bind_tls in &cfg.bind_tls { match TcpListener::bind(bind_tls) { Ok(tls_listener) => { - eprintln!("echoircd TLS on {bind_tls} (openssl)"); + eprintln!("echoircd TLS on {bind_tls}"); let tls_tx = tx.clone(); let tls_counter = counter.clone(); let tls_proxy_trust = proxy_trust.clone(); @@ -184,8 +214,7 @@ fn main() { } } } - Err(e) => eprintln!("echoircd: TLS disabled (cert/key error): {e}"), - }, + } _ => eprintln!("echoircd: bind_tls set but tls_cert/tls_key missing; TLS disabled"), } } diff --git a/src/tls_rustls.rs b/src/tls_rustls.rs new file mode 100644 index 0000000..836bc12 --- /dev/null +++ b/src/tls_rustls.rs @@ -0,0 +1,321 @@ +//! Pure-Rust TLS backend (rustls), an alternative to the openssl backend behind the +//! same [`TlsBackend`]/[`TlsConn`]/[`TlsSession`] traits. Opt in with +//! `tls_backend = rustls` in the config; the default stays openssl. No C/FFI in the +//! daemon itself — rustls keeps its `unsafe` internal like every other crate. +//! +//! Client certs are requested but never chain-validated (services identify a user by +//! the cert *fingerprint*, not a CA), mirroring the openssl backend's always-accept +//! verify callback. The client's CertificateVerify signature IS still checked, so +//! CertFP / SASL EXTERNAL keeps proving the client holds the matching private key. + +use std::io::{self, Read, Write}; +use std::net::{Shutdown, TcpStream}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use mio::net::TcpStream as MioStream; +use openssl::hash::{hash, MessageDigest}; + +use rustls::client::danger::HandshakeSignatureValid; +use rustls::crypto::CryptoProvider; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, UnixTime}; +use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use rustls::{ + DigitallySignedStruct, DistinguishedName, ServerConfig, ServerConnection, SignatureScheme, +}; + +use crate::map::HashMap; +use crate::tls::{CertReload, TlsBackend, TlsConn, TlsSession}; + +fn err(e: E) -> io::Error { + io::Error::other(e.to_string()) +} + +struct CertPaths { + cert: String, + key: String, +} + +pub struct RustlsBackend { + // Swapped by `reload` on REHASH so renewed certs apply without a restart; read + // only at connection-accept time (infrequent), so the lock is never hot. + config: RwLock>, + primary: CertPaths, + sni: Vec<(String, CertPaths)>, + provider: Arc, +} + +/// Load a PEM chain + private key into a rustls `CertifiedKey`. +fn load_key(cert: &str, key: &str, provider: &CryptoProvider) -> io::Result> { + let cert_pem = std::fs::read(cert)?; + let certs: Vec> = rustls_pemfile::certs(&mut &cert_pem[..]) + .collect::>() + .map_err(err)?; + if certs.is_empty() { + return Err(err(format!("no certificates in {cert}"))); + } + let key_pem = std::fs::read(key)?; + let key_der: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..]) + .map_err(err)? + .ok_or_else(|| err(format!("no private key in {key}")))?; + let signing_key = provider.key_provider.load_private_key(key_der).map_err(err)?; + Ok(Arc::new(CertifiedKey::new(certs, signing_key))) +} + +/// Per-hostname cert selection: the SNI name's cert, else the primary. Mirrors the +/// openssl backend's servername callback (no validation of the SNI cert here). +#[derive(Debug)] +struct SniResolver { + default: Arc, + by_host: HashMap>, +} + +impl ResolvesServerCert for SniResolver { + fn resolve(&self, hello: ClientHello) -> Option> { + if let Some(name) = hello.server_name() { + if let Some(ck) = self.by_host.get(&name.to_ascii_lowercase()) { + return Some(ck.clone()); + } + } + Some(self.default.clone()) + } +} + +/// Accept any client certificate (we fingerprint, never chain-validate) but still +/// verify the handshake signature so CertFP can't be spoofed without the key. +#[derive(Debug)] +struct AcceptAnyClientCert { + provider: Arc, +} + +impl ClientCertVerifier for AcceptAnyClientCert { + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + fn verify_client_cert( + &self, + _end: &CertificateDer, + _intermediates: &[CertificateDer], + _now: UnixTime, + ) -> Result { + Ok(ClientCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } + fn offer_client_auth(&self) -> bool { + true + } + fn client_auth_mandatory(&self) -> bool { + false + } +} + +/// SHA-256 fingerprint (lowercase hex) of the peer's leaf certificate, matching the +/// openssl backend's format so CertFP is identical across backends. +fn fp_of(certs: Option<&[CertificateDer<'_>]>) -> Option { + let cert = certs?.first()?; + let digest = hash(MessageDigest::sha256(), cert.as_ref()).ok()?; + Some(digest.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn build_config( + primary: &CertPaths, + sni: &[(String, CertPaths)], + provider: &Arc, +) -> io::Result> { + let default = load_key(&primary.cert, &primary.key, provider)?; + let mut by_host: HashMap> = HashMap::default(); + for (h, cp) in sni { + by_host.insert(h.to_ascii_lowercase(), load_key(&cp.cert, &cp.key, provider)?); + } + let resolver = Arc::new(SniResolver { default, by_host }); + let verifier = Arc::new(AcceptAnyClientCert { + provider: provider.clone(), + }); + let cfg = ServerConfig::builder_with_provider(provider.clone()) + .with_protocol_versions(rustls::ALL_VERSIONS) + .map_err(err)? + .with_client_cert_verifier(verifier) + .with_cert_resolver(resolver); + Ok(Arc::new(cfg)) +} + +impl RustlsBackend { + pub fn new(cert: &str, key: &str, sni: Vec<(String, String, String)>) -> io::Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let primary = CertPaths { + cert: cert.to_string(), + key: key.to_string(), + }; + let sni: Vec<(String, CertPaths)> = sni + .into_iter() + .map(|(h, c, k)| (h, CertPaths { cert: c, key: k })) + .collect(); + let config = build_config(&primary, &sni, &provider)?; + Ok(RustlsBackend { + config: RwLock::new(config), + primary, + sni, + provider, + }) + } + + pub fn reload(&self) -> io::Result<()> { + let fresh = build_config(&self.primary, &self.sni, &self.provider)?; + *self.config.write().unwrap() = fresh; + Ok(()) + } + + fn cfg(&self) -> Arc { + self.config.read().unwrap().clone() + } +} + +impl CertReload for RustlsBackend { + fn reload(&self) -> io::Result<()> { + RustlsBackend::reload(self) + } +} + +impl TlsBackend for RustlsBackend { + fn accept(&self, mut sock: TcpStream) -> io::Result> { + let mut conn = ServerConnection::new(self.cfg()).map_err(err)?; + // complete the handshake now, on the (blocking) socket, like openssl's accept + while conn.is_handshaking() { + conn.complete_io(&mut sock).map_err(err)?; + } + Ok(Box::new(RustlsConn { conn, sock })) + } + + fn start(&self, sock: MioStream) -> io::Result> { + let conn = ServerConnection::new(self.cfg()).map_err(err)?; + Ok(Box::new(RustlsSession { conn, sock })) + } +} + +// --- blocking connection (thread-per-conn path) ----------------------------- +struct RustlsConn { + conn: ServerConnection, + sock: TcpStream, +} + +impl TlsConn for RustlsConn { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + rustls::Stream::new(&mut self.conn, &mut self.sock).read(buf) + } + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + rustls::Stream::new(&mut self.conn, &mut self.sock).write_all(buf) + } + fn flush(&mut self) -> io::Result<()> { + rustls::Stream::new(&mut self.conn, &mut self.sock).flush() + } + fn set_read_timeout(&self, dur: Option) -> io::Result<()> { + self.sock.set_read_timeout(dur) + } + fn shutdown(&self) { + let _ = self.sock.shutdown(Shutdown::Both); + } + fn peer_cert_fp(&self) -> Option { + fp_of(self.conn.peer_certificates()) + } +} + +// --- non-blocking session (reactor path) ------------------------------------ +struct RustlsSession { + conn: ServerConnection, + sock: MioStream, +} + +impl RustlsSession { + /// Drain any decryptable TLS records the socket has for us, processing each. + /// `WouldBlock`/EOF just stop the loop — the caller checks state afterwards. + fn pump_read(&mut self) -> io::Result<()> { + loop { + match self.conn.read_tls(&mut self.sock) { + Ok(0) => return Ok(()), // socket EOF; reader() will report the close + Ok(_) => self.conn.process_new_packets().map_err(err)?, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e), + }; + } + } + /// Flush rustls's pending outbound TLS bytes to the socket; a full socket + /// (`WouldBlock`) just leaves them buffered for the next writable event. + fn pump_write(&mut self) -> io::Result<()> { + while self.conn.wants_write() { + match self.conn.write_tls(&mut self.sock) { + Ok(0) => break, + Ok(_) => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(()) + } +} + +impl TlsSession for RustlsSession { + fn accept(&mut self) -> io::Result { + self.pump_read()?; + self.pump_write()?; // handshake flight, and session tickets once it's done + Ok(!self.conn.is_handshaking()) + } + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.pump_read()?; + self.pump_write()?; // process_new_packets can queue writes (alerts, key updates) + match self.conn.reader().read(buf) { + Ok(n) => Ok(n), // Ok(0) = clean close_notify, like a plaintext EOF + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + Err(io::ErrorKind::WouldBlock.into()) + } + Err(e) => Err(e), + } + } + fn write(&mut self, buf: &[u8]) -> io::Result { + let n = self.conn.writer().write(buf)?; + self.pump_write()?; + Ok(n) + } + fn source(&mut self) -> &mut MioStream { + &mut self.sock + } + fn peer_cert_fp(&self) -> Option { + fp_of(self.conn.peer_certificates()) + } + fn shutdown(&mut self) { + self.conn.send_close_notify(); + let _ = self.pump_write(); + let _ = self.sock.shutdown(Shutdown::Both); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index cb873e3..18f1333 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -206,8 +206,10 @@ fn read_collect(s: &mut S, timeout: Duration) -> String { fn register(s: &mut S, nick: &str) { s.write_all(format!("NICK {nick}\r\nUSER {nick} 0 * :{nick}\r\n").as_bytes()) .unwrap(); + // Generous: read_until returns the instant 001 arrives, so a big deadline only + // buys patience on a box saturated by the parallel test servers, never latency. assert!( - read_until(s, " 001 ", Duration::from_secs(5)), + read_until(s, " 001 ", Duration::from_secs(15)), "no 001 welcome for {nick}" ); } @@ -299,11 +301,11 @@ fn register_with_cap(s: &mut S, nick: &str, cap: &str) { ) .unwrap(); assert!( - read_until(s, "ACK", Duration::from_secs(5)), + read_until(s, "ACK", Duration::from_secs(10)), "no CAP ACK for {cap}" ); assert!( - read_until(s, " 001 ", Duration::from_secs(5)), + read_until(s, " 001 ", Duration::from_secs(15)), "no 001 welcome for {nick}" ); } @@ -317,28 +319,31 @@ fn channel_rename_notifies_by_cap_and_needs_ops() { register_with_cap(&mut alice, "alice", "draft/channel-rename"); let mut bob = srv.plain_client("bob"); + // Generous timeouts: the full integration suite runs many echoircd processes in + // parallel, so a saturated CI box can slip past tight socket deadlines. + let t = Duration::from_secs(10); // Serialize the joins: alice must create #old (and become op) before bob joins, // or a reactor-scheduling race could make bob the creator instead. line(&mut alice, "JOIN #old"); // alice creates -> op - assert!(read_until(&mut alice, "JOIN #old", Duration::from_secs(2)), "alice join"); + assert!(read_until(&mut alice, "JOIN #old", t), "alice join"); line(&mut bob, "JOIN #old"); // joins the existing channel -> non-op - assert!(read_until(&mut bob, "JOIN #old", Duration::from_secs(2)), "bob join"); + assert!(read_until(&mut bob, "JOIN #old", t), "bob join"); // A non-op can't rename. line(&mut bob, "RENAME #old #nope"); assert!( - read_until(&mut bob, " 482 ", Duration::from_secs(3)), + read_until(&mut bob, " 482 ", t), "non-op RENAME should get 482 CHANOPRIVSNEEDED" ); // The op renames; alice (cap) gets a RENAME line, bob (no cap) is walked PART -> JOIN. line(&mut alice, "RENAME #old #new :moving"); assert!( - read_until(&mut alice, "RENAME #old #new", Duration::from_secs(3)), + read_until(&mut alice, "RENAME #old #new", t), "cap client did not receive RENAME" ); // bob's PART and JOIN arrive in one batch — collect and check both. - let bobseen = read_collect(&mut bob, Duration::from_secs(2)); + let bobseen = read_collect(&mut bob, Duration::from_secs(4)); assert!(bobseen.contains("PART #old"), "plain client not PARTed: {bobseen:?}"); assert!(bobseen.contains("JOIN #new"), "plain client not re-JOINed: {bobseen:?}"); assert!(!bobseen.contains("RENAME"), "plain client should not see RENAME: {bobseen:?}"); @@ -346,12 +351,12 @@ fn channel_rename_notifies_by_cap_and_needs_ops() { // The channel now answers under the new name (and not the old). line(&mut alice, "PRIVMSG #new :landed"); assert!( - read_until(&mut bob, "landed", Duration::from_secs(3)), + read_until(&mut bob, "landed", t), "message to the renamed channel didn't reach members" ); line(&mut alice, "NAMES #old"); assert!( - read_until(&mut alice, " 366 ", Duration::from_secs(3)), + read_until(&mut alice, " 366 ", t), "NAMES on the old name should just end (channel is gone)" ); }