diff --git a/Cargo.toml b/Cargo.toml index ef312ef..e89df7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ memchr = "2" # 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" +# Reads the negotiated TLS group name (SSL_get0_group_name) for the WHOIS 671 line; +# isolates the one unsafe FFI call the safe openssl crate lacks, so the daemon stays +# `#![forbid(unsafe_code)]`. +sslgroup = { path = "sslgroup" } [dev-dependencies] # integration tests spawn the built binary and act as a TLS client against it diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 07325e0..d4d6ae5 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -200,6 +200,7 @@ impl Command for Whois { last_active: u64, signon: u64, certfp: Option, + tls_info: Option, swhois: Option, showwhois: bool, } @@ -219,6 +220,7 @@ impl Command for Whois { last_active, signon, certfp, + tls_info, swhois, showwhois, } = { @@ -239,6 +241,7 @@ impl Command for Whois { last_active: u.last_active, signon: u.signon, certfp: u.certfp.clone(), + tls_info: u.tls_info.clone(), swhois: u .ext .get::() @@ -399,12 +402,18 @@ impl Command for Whois { &format!("{nick} {acct} :is logged in as"), ); } - // sslinfo: advertise a secure (TLS) connection + // sslinfo: advertise a secure (TLS) connection, with the negotiated + // version/group/cipher (e.g. TLSv1.3/X25519MLKEM768/TLS_CHACHA20_POLY1305_SHA256) + // when the backend could report it. if secure && !(hide && crate::modules::hidewhois::hide_secure(s)) { + let detail = match &tls_info { + Some(t) if !t.is_empty() => format!(" [{t}]"), + _ => String::new(), + }; s.numeric( uid, RPL_WHOISSECURE, - &format!("{nick} :is using a secure connection"), + &format!("{nick} :is using a secure connection{detail}"), ); } // client-cert fingerprint (CertFP) — shown to the user themselves and opers diff --git a/src/ircd.rs b/src/ircd.rs index 2b472c0..48f0d0c 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -28,6 +28,7 @@ pub enum Event { sock: Option, secure: bool, certfp: Option, // TLS client-cert fingerprint (clients only) + tls_info: Option, // negotiated TLS version/group/cipher (WHOIS 671) local_port: u16, // the listener port the client connected to link: bool, // a server-to-server connection, not a client outbound: bool, // (link) we dialed them @@ -189,6 +190,7 @@ impl Ircd { sock, secure, certfp, + tls_info, local_port, link, outbound, @@ -198,7 +200,7 @@ impl Ircd { self.server.add_link(uid, addr, out, sock, outbound); } else { self.server - .add_conn(uid, addr, out, sock, secure, certfp, local_port); + .add_conn(uid, addr, out, sock, secure, certfp, tls_info, local_port); if websocket { if let Some(u) = self.server.users.get_mut(&uid) { u.flags.via_websocket = true; diff --git a/src/link.rs b/src/link.rs index b917d8e..8309bc3 100644 --- a/src/link.rs +++ b/src/link.rs @@ -2827,6 +2827,7 @@ mod tests { vhost: None, secure: false, certfp: None, + tls_info: None, account: None, signon: 0, nick_ts: 0, @@ -2916,6 +2917,7 @@ mod tests { vhost: None, secure: false, certfp: None, + tls_info: None, account: None, signon: 0, nick_ts: 0, diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index cd26f2f..b6a411d 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -374,6 +374,7 @@ mod tests { vhost: None, secure: false, certfp: None, + tls_info: None, account: Some("reverse".into()), signon: 0, nick_ts: 0, diff --git a/src/s2s_sim.rs b/src/s2s_sim.rs index bb00010..f8a5c68 100644 --- a/src/s2s_sim.rs +++ b/src/s2s_sim.rs @@ -74,6 +74,7 @@ impl Node { vhost: None, secure: false, certfp: None, + tls_info: None, account: None, signon: 0, nick_ts: 0, diff --git a/src/server.rs b/src/server.rs index 7bd5810..5c48399 100644 --- a/src/server.rs +++ b/src/server.rs @@ -387,6 +387,7 @@ impl Server { sock: Option, secure: bool, certfp: Option, + tls_info: Option, local_port: u16, ) { let uuid = self.next_uuid(); @@ -405,6 +406,7 @@ impl Server { vhost: None, secure, certfp, + tls_info, account: None, signon: now(), nick_ts: now(), @@ -1544,6 +1546,7 @@ mod tests { vhost: None, secure: false, certfp: None, + tls_info: None, account: None, signon: 0, nick_ts: 0, diff --git a/src/socketengine.rs b/src/socketengine.rs index 9007cb0..fd8deef 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -656,6 +656,7 @@ fn reactor_loop( sock: None, secure: false, certfp: None, + tls_info: None, local_port: a.local_port, link: false, outbound: false, @@ -788,7 +789,8 @@ fn try_handshake( core: &Sender, ) -> bool { let mut close = false; - let mut connect: Option<(Uid, SocketAddr, u16, Option, OutSink)> = None; + let mut connect: Option<(Uid, SocketAddr, u16, Option, Option, OutSink)> = + None; if let Some(c) = conns.get_mut(&t) { if !c.handshaking { return true; @@ -798,10 +800,11 @@ fn try_handshake( Ok(true) => { c.handshaking = false; let certfp = sess.peer_cert_fp(); + let tls_info = sess.tls_info(); connect = c .pending_out .take() - .map(|out| (c.uid, c.addr, c.local_port, certfp, out)); + .map(|out| (c.uid, c.addr, c.local_port, certfp, tls_info, out)); set_interest(poll, c, t); // handshake done: drop the extra WRITABLE } Ok(false) => return false, // still negotiating @@ -813,7 +816,7 @@ fn try_handshake( } else { return false; } - if let Some((uid, addr, local_port, certfp, out)) = connect { + if let Some((uid, addr, local_port, certfp, tls_info, out)) = connect { let _ = core.send(Event::Connect { uid, addr, @@ -821,6 +824,7 @@ fn try_handshake( sock: None, secure: true, certfp, + tls_info, local_port, link: false, outbound: false, @@ -943,6 +947,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: sock: None, secure, certfp, + tls_info: None, local_port, link: false, outbound: false, @@ -1096,6 +1101,7 @@ pub fn accept_loop( sock: Some(shutdown), secure: false, certfp: None, + tls_info: None, local_port, link, outbound: false, @@ -1180,6 +1186,7 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc, ma sock: Some(shutdown), secure: false, certfp: None, + tls_info: None, local_port: 0, link: true, outbound: true, @@ -1287,6 +1294,7 @@ fn tls_conn( } }; let certfp = conn.peer_cert_fp(); + let tls_info = conn.tls_info(); let (out_tx, out_rx) = mpsc::channel::(); if core .send(Event::Connect { @@ -1296,6 +1304,7 @@ fn tls_conn( sock: Some(shutdown), secure: true, certfp, + tls_info, local_port, link, outbound: false, diff --git a/src/tls.rs b/src/tls.rs index 31cb4c6..852fc47 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -15,7 +15,7 @@ use mio::net::TcpStream as MioStream; use openssl::hash::MessageDigest; use openssl::ssl::{ ErrorCode, NameType, SniError, Ssl, SslAcceptor, SslAcceptorBuilder, SslContext, SslFiletype, - SslMethod, SslMode, SslStream, SslVerifyMode, + SslMethod, SslMode, SslRef, SslStream, SslVerifyMode, }; /// A hot-reloadable TLS certificate source (implemented by the openssl backend); @@ -41,6 +41,11 @@ pub trait TlsConn: Send { /// SHA-256 fingerprint (lowercase hex) of the peer's certificate, if it sent /// one. Drives SASL EXTERNAL / CertFP. fn peer_cert_fp(&self) -> Option; + /// `//` summary of the session for the WHOIS 671 + /// sslinfo line, if the backend can report it. + fn tls_info(&self) -> Option { + None + } } /// A non-blocking TLS session the reactor drives itself over a mio socket. The @@ -70,6 +75,10 @@ pub trait TlsSession: Send { fn source(&mut self) -> &mut MioStream; /// SHA-256 fingerprint of the peer certificate (CertFP / SASL EXTERNAL), if any. fn peer_cert_fp(&self) -> Option; + /// `//` summary of the session for WHOIS 671, if any. + fn tls_info(&self) -> Option { + None + } fn shutdown(&mut self); } @@ -204,6 +213,19 @@ fn ssl_io_err(e: openssl::ssl::Error) -> io::Error { } } +/// `//` for the WHOIS 671 sslinfo line, e.g. +/// `TLSv1.3/X25519MLKEM768/TLS_CHACHA20_POLY1305_SHA256`. The key-exchange group +/// comes from `sslgroup` (SSL_get0_group_name); it's omitted when OpenSSL can't +/// report it (TLS 1.2, or before the handshake completes). +fn openssl_tls_info(ssl: &SslRef) -> Option { + let cipher = ssl.current_cipher()?.name(); + let ver = ssl.version_str(); + match sslgroup::group_name(ssl) { + Some(g) if !g.is_empty() => Some(format!("{ver}/{g}/{cipher}")), + _ => Some(format!("{ver}/{cipher}")), + } +} + impl TlsSession for OpensslSession { fn accept(&mut self) -> io::Result { match self.0.accept() { @@ -233,6 +255,9 @@ impl TlsSession for OpensslSession { let digest = cert.digest(MessageDigest::sha256()).ok()?; Some(digest.iter().map(|b| format!("{b:02x}")).collect()) } + fn tls_info(&self) -> Option { + openssl_tls_info(self.0.ssl()) + } fn shutdown(&mut self) { // best-effort TLS close_notify, then close the socket. Non-blocking, so a // WouldBlock just means the alert is queued — we don't wait for the peer's. @@ -264,4 +289,7 @@ impl TlsConn for OpensslConn { let digest = cert.digest(MessageDigest::sha256()).ok()?; Some(digest.iter().map(|b| format!("{b:02x}")).collect()) } + fn tls_info(&self) -> Option { + openssl_tls_info(self.0.ssl()) + } } diff --git a/src/users.rs b/src/users.rs index 3a6f092..8866e06 100644 --- a/src/users.rs +++ b/src/users.rs @@ -230,6 +230,7 @@ pub struct User { pub vhost: Option, // displayed-host override (CHGHOST/SETHOST vhost) pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo) pub certfp: Option, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP) + pub tls_info: Option, // negotiated TLS version/group/cipher (WHOIS 671) pub account: Option, // logged-in account name (set by services) pub signon: u64, // unix secs at registration (WHOIS 317) pub nick_ts: u64, // unix secs the current nick was taken (nick-collision arbitration) diff --git a/src/websocket.rs b/src/websocket.rs index d32c5bc..3fbc0a1 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -278,6 +278,7 @@ fn ws_session( sock: Some(shutdown), secure, certfp: None, + tls_info: None, local_port, link: false, outbound: false, diff --git a/sslgroup/Cargo.toml b/sslgroup/Cargo.toml new file mode 100644 index 0000000..5449fc3 --- /dev/null +++ b/sslgroup/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sslgroup" +version = "0.1.0" +edition = "2021" +description = "Read the negotiated TLS key-exchange group name via SSL_get0_group_name (not exposed by the safe openssl crate)." +license = "MIT" + +[dependencies] +# Same versions echoircd resolves, so the SslRef / SSL pointer types match. +openssl = "0.10" +openssl-sys = "0.9" +foreign-types = "0.3" diff --git a/sslgroup/src/lib.rs b/sslgroup/src/lib.rs new file mode 100644 index 0000000..f3d7b5f --- /dev/null +++ b/sslgroup/src/lib.rs @@ -0,0 +1,23 @@ +//! Reads the negotiated TLS key-exchange group name (e.g. `X25519MLKEM768`) via +//! OpenSSL's `SSL_get0_group_name`, which the safe `openssl` crate does not expose. +//! Isolated in its own crate so the daemon can stay `#![forbid(unsafe_code)]` — the +//! single `unsafe` FFI call lives here, exactly like the FFI inside `openssl`/`ring`. + +use foreign_types::ForeignTypeRef; +use openssl::ssl::SslRef; +use std::ffi::CStr; + +/// The TLS key-exchange group name for an accepted session (OpenSSL 3.2+), or +/// `None` if unavailable (before the handshake, or an OpenSSL without the API). +pub fn group_name(ssl: &SslRef) -> Option { + // SAFETY: `ssl` is a live, accepted `SSL`. `SSL_get0_group_name` returns a + // NUL-terminated string OpenSSL owns for the session's lifetime; we copy it out + // here, so the borrow does not escape. + unsafe { + let name = openssl_sys::SSL_get0_group_name(ssl.as_ptr()); + if name.is_null() { + return None; + } + CStr::from_ptr(name).to_str().ok().map(String::from) + } +}