From 02528df5e90a0a37c54f63f11fdc3638efebeb5e Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 19:36:48 +0000 Subject: [PATCH] sasl external: request tls client cert, plumb sha256 certfp through to user, relay to services; advertise sasl=PLAIN,EXTERNAL on tls --- echoircd.conf.example | 5 +++++ src/coremods/core_user.rs | 29 ++++++++++++++++++++++++++++- src/ircd.rs | 8 +++++--- src/server.rs | 3 +++ src/socketengine.rs | 5 +++++ src/tls.rs | 15 ++++++++++++++- src/users.rs | 19 +++++++++++++++---- 7 files changed, 75 insertions(+), 9 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index dcbd29e..5d92456 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -24,6 +24,11 @@ bind_server = 0.0.0.0:7000 # link = [autoconnect] (the password is a shared secret) # link = peer.example.net 203.0.113.5 7000 CHANGE_THIS_LINK_SECRET autoconnect +# services: the linked server that handles SASL (client AUTHENTICATE is relayed to +# it). Leave unset to disable SASL. SASL EXTERNAL additionally needs the client on +# TLS with a client certificate (its fingerprint is sent to services). +# sasl_server = services.example.net + # IRC operators — oper = oper = admin CHANGE_THIS_PASSWORD diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 26e9a4b..0d6615f 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -67,13 +67,18 @@ impl Command for Cap { match params[0].to_ascii_uppercase().as_str() { "LS" => { let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false); + let secure = s.users.get(&uid).map(|u| u.secure).unwrap_or(false); if let Some(u) = s.users.get_mut(&uid) { u.cap = true; // hold registration until CAP END u.cap_302 |= cap302; } s.send( uid, - format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)), + format!( + ":{} CAP {who} LS :{}", + s.name, + Caps::ls_line(cap302, secure) + ), ); } "REQ" => { @@ -177,6 +182,28 @@ impl Command for Authenticate { } s.send(uid, "AUTHENTICATE +".to_string()); CmdResult::Ok + } else if arg.eq_ignore_ascii_case("EXTERNAL") { + // CertFP: only works on TLS with a client cert; the fingerprint + // goes to services, which map it to an account. + let certfp = s.users.get(&uid).and_then(|u| u.certfp.clone()); + match certfp { + Some(fp) if have_services => { + if let Some(u) = s.users.get_mut(&uid) { + u.sasl_mech = Some("EXTERNAL".to_string()); + } + s.sasl_relay(uid, &format!("S EXTERNAL {fp}")); + s.send(uid, "AUTHENTICATE +".to_string()); + CmdResult::Ok + } + _ => { + s.numeric( + uid, + ERR_SASLFAIL, + ":SASL EXTERNAL requires a client certificate", + ); + CmdResult::Fail + } + } } else { s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms"); s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism"); diff --git a/src/ircd.rs b/src/ircd.rs index e7740ef..9c4de9c 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -24,8 +24,9 @@ pub enum Event { out: OutSink, sock: Option, secure: bool, - link: bool, // a server-to-server connection, not a client - outbound: bool, // (link) we dialed them + certfp: Option, // TLS client-cert fingerprint (clients only) + link: bool, // a server-to-server connection, not a client + outbound: bool, // (link) we dialed them }, Line { uid: Uid, @@ -70,13 +71,14 @@ impl Ircd { out, sock, secure, + certfp, link, outbound, } => { if link { self.server.add_link(uid, addr, out, sock, outbound); } else { - self.server.add_conn(uid, addr, out, sock, secure); + self.server.add_conn(uid, addr, out, sock, secure, certfp); } } Event::Line { uid, line } => { diff --git a/src/server.rs b/src/server.rs index 949f5da..531f21f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -182,6 +182,7 @@ impl Server { out: OutSink, sock: Option, secure: bool, + certfp: Option, ) { let uuid = self.next_uuid(); self.uuid_local.insert(uuid.clone(), uid); @@ -198,6 +199,7 @@ impl Server { cloak: String::new(), vhost: None, secure, + certfp, account: None, signon: now(), addr, @@ -666,6 +668,7 @@ mod tests { cloak: String::new(), vhost: None, secure: false, + certfp: None, account: None, signon: 0, addr: "127.0.0.1:1".parse().unwrap(), diff --git a/src/socketengine.rs b/src/socketengine.rs index 290eb66..4531e04 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -184,6 +184,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< out, sock: None, secure: false, + certfp: None, link: false, outbound: false, }) @@ -380,6 +381,7 @@ pub fn accept_loop( out: OutSink::Thread(out_tx), sock: Some(shutdown), secure: false, + certfp: None, link, outbound: false, }) @@ -427,6 +429,7 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc) { out: OutSink::Thread(out_tx), sock: Some(shutdown), secure: false, + certfp: None, link: true, outbound: true, }) @@ -501,6 +504,7 @@ fn tls_conn( return; // handshake failed } }; + let certfp = conn.peer_cert_fp(); let (out_tx, out_rx) = mpsc::channel::(); if core .send(Event::Connect { @@ -509,6 +513,7 @@ fn tls_conn( out: OutSink::Thread(out_tx), sock: Some(shutdown), secure: true, + certfp, link, outbound: false, }) diff --git a/src/tls.rs b/src/tls.rs index 321ed19..d0a0de6 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -12,7 +12,8 @@ use std::io::{self, Read, Write}; use std::net::{Shutdown, TcpStream}; use std::time::Duration; -use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream}; +use openssl::hash::MessageDigest; +use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream, SslVerifyMode}; /// A live TLS connection: read/write plaintext, tune the read timeout (the /// socket engine polls with one to interleave reads and queued writes), and shut @@ -23,6 +24,9 @@ pub trait TlsConn: Send { fn flush(&mut self) -> io::Result<()>; fn set_read_timeout(&self, dur: Option) -> io::Result<()>; fn shutdown(&self); + /// SHA-256 fingerprint (lowercase hex) of the peer's certificate, if it sent + /// one. Drives SASL EXTERNAL / CertFP. + fn peer_cert_fp(&self) -> Option; } /// A TLS backend: performs the server-side handshake on an accepted socket. @@ -47,6 +51,10 @@ impl OpensslBackend { b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?; b.set_certificate_chain_file(cert).map_err(err)?; b.check_private_key().map_err(err)?; + // Request (but don't require) a client cert so SASL EXTERNAL / CertFP can + // read its fingerprint. We never validate the chain — services match the + // fingerprint to an account — so the callback always accepts. + b.set_verify_callback(SslVerifyMode::PEER, |_valid, _ctx| true); Ok(OpensslBackend { acceptor: b.build(), }) @@ -78,4 +86,9 @@ impl TlsConn for OpensslConn { fn shutdown(&self) { let _ = self.0.get_ref().shutdown(Shutdown::Both); } + fn peer_cert_fp(&self) -> Option { + let cert = self.0.ssl().peer_certificate()?; + let digest = cert.digest(MessageDigest::sha256()).ok()?; + Some(digest.iter().map(|b| format!("{b:02x}")).collect()) + } } diff --git a/src/users.rs b/src/users.rs index 73feb9b..46e755e 100644 --- a/src/users.rs +++ b/src/users.rs @@ -127,12 +127,17 @@ impl Caps { } /// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients. - pub fn ls_line(cap302: bool) -> String { + /// EXTERNAL is only offered on TLS connections (it needs a client cert). + pub fn ls_line(cap302: bool, secure: bool) -> String { SUPPORTED_CAPS .iter() .map(|c| { if *c == "sasl" && cap302 { - "sasl=PLAIN".to_string() + if secure { + "sasl=PLAIN,EXTERNAL".to_string() + } else { + "sasl=PLAIN".to_string() + } } else { (*c).to_string() } @@ -209,6 +214,7 @@ pub struct User { pub cloak: String, // masked host shown under +x ("" until computed) 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 account: Option, // logged-in account name (set by services) pub signon: u64, // unix secs at registration (WHOIS 317) pub addr: SocketAddr, @@ -465,8 +471,13 @@ mod tests { assert!(!c.set("bogus-cap", true)); // unknown cap rejected assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl")); assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order - assert!(Caps::ls_line(true).contains("sasl=PLAIN")); // 302 shows mechs - assert!(Caps::ls_line(false).contains("sasl") && !Caps::ls_line(false).contains("sasl=")); + assert!(Caps::ls_line(true, false).contains("sasl=PLAIN")); // 302 shows mechs + assert!(!Caps::ls_line(true, false).contains("EXTERNAL")); // plaintext: no EXTERNAL + assert!(Caps::ls_line(true, true).contains("sasl=PLAIN,EXTERNAL")); // TLS offers it + assert!( + Caps::ls_line(false, false).contains("sasl") + && !Caps::ls_line(false, false).contains("sasl=") + ); c.set("server-time", false); assert!(!c.has("server-time")); }