sasl external: request tls client cert, plumb sha256 certfp through to user, relay to services; advertise sasl=PLAIN,EXTERNAL on tls

This commit is contained in:
Jean Chevronnet 2026-08-08 19:36:48 +00:00
parent 8d31feb4e7
commit 02528df5e9
7 changed files with 75 additions and 9 deletions

View file

@ -24,6 +24,11 @@ bind_server = 0.0.0.0:7000
# link = <name> <ip> <port> <password> [autoconnect] (the password is a shared secret) # link = <name> <ip> <port> <password> [autoconnect] (the password is a shared secret)
# link = peer.example.net 203.0.113.5 7000 CHANGE_THIS_LINK_SECRET autoconnect # 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 = <name> <password> # IRC operators — oper = <name> <password>
oper = admin CHANGE_THIS_PASSWORD oper = admin CHANGE_THIS_PASSWORD

View file

@ -67,13 +67,18 @@ impl Command for Cap {
match params[0].to_ascii_uppercase().as_str() { match params[0].to_ascii_uppercase().as_str() {
"LS" => { "LS" => {
let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false); 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) { if let Some(u) = s.users.get_mut(&uid) {
u.cap = true; // hold registration until CAP END u.cap = true; // hold registration until CAP END
u.cap_302 |= cap302; u.cap_302 |= cap302;
} }
s.send( s.send(
uid, uid,
format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)), format!(
":{} CAP {who} LS :{}",
s.name,
Caps::ls_line(cap302, secure)
),
); );
} }
"REQ" => { "REQ" => {
@ -177,6 +182,28 @@ impl Command for Authenticate {
} }
s.send(uid, "AUTHENTICATE +".to_string()); s.send(uid, "AUTHENTICATE +".to_string());
CmdResult::Ok 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 { } else {
s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms"); s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms");
s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism"); s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism");

View file

@ -24,6 +24,7 @@ pub enum Event {
out: OutSink, out: OutSink,
sock: Option<TcpStream>, sock: Option<TcpStream>,
secure: bool, secure: bool,
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
link: bool, // a server-to-server connection, not a client link: bool, // a server-to-server connection, not a client
outbound: bool, // (link) we dialed them outbound: bool, // (link) we dialed them
}, },
@ -70,13 +71,14 @@ impl Ircd {
out, out,
sock, sock,
secure, secure,
certfp,
link, link,
outbound, outbound,
} => { } => {
if link { if link {
self.server.add_link(uid, addr, out, sock, outbound); self.server.add_link(uid, addr, out, sock, outbound);
} else { } else {
self.server.add_conn(uid, addr, out, sock, secure); self.server.add_conn(uid, addr, out, sock, secure, certfp);
} }
} }
Event::Line { uid, line } => { Event::Line { uid, line } => {

View file

@ -182,6 +182,7 @@ impl Server {
out: OutSink, out: OutSink,
sock: Option<TcpStream>, sock: Option<TcpStream>,
secure: bool, secure: bool,
certfp: Option<String>,
) { ) {
let uuid = self.next_uuid(); let uuid = self.next_uuid();
self.uuid_local.insert(uuid.clone(), uid); self.uuid_local.insert(uuid.clone(), uid);
@ -198,6 +199,7 @@ impl Server {
cloak: String::new(), cloak: String::new(),
vhost: None, vhost: None,
secure, secure,
certfp,
account: None, account: None,
signon: now(), signon: now(),
addr, addr,
@ -666,6 +668,7 @@ mod tests {
cloak: String::new(), cloak: String::new(),
vhost: None, vhost: None,
secure: false, secure: false,
certfp: None,
account: None, account: None,
signon: 0, signon: 0,
addr: "127.0.0.1:1".parse().unwrap(), addr: "127.0.0.1:1".parse().unwrap(),

View file

@ -184,6 +184,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
out, out,
sock: None, sock: None,
secure: false, secure: false,
certfp: None,
link: false, link: false,
outbound: false, outbound: false,
}) })
@ -380,6 +381,7 @@ pub fn accept_loop(
out: OutSink::Thread(out_tx), out: OutSink::Thread(out_tx),
sock: Some(shutdown), sock: Some(shutdown),
secure: false, secure: false,
certfp: None,
link, link,
outbound: false, outbound: false,
}) })
@ -427,6 +429,7 @@ pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
out: OutSink::Thread(out_tx), out: OutSink::Thread(out_tx),
sock: Some(shutdown), sock: Some(shutdown),
secure: false, secure: false,
certfp: None,
link: true, link: true,
outbound: true, outbound: true,
}) })
@ -501,6 +504,7 @@ fn tls_conn(
return; // handshake failed return; // handshake failed
} }
}; };
let certfp = conn.peer_cert_fp();
let (out_tx, out_rx) = mpsc::channel::<String>(); let (out_tx, out_rx) = mpsc::channel::<String>();
if core if core
.send(Event::Connect { .send(Event::Connect {
@ -509,6 +513,7 @@ fn tls_conn(
out: OutSink::Thread(out_tx), out: OutSink::Thread(out_tx),
sock: Some(shutdown), sock: Some(shutdown),
secure: true, secure: true,
certfp,
link, link,
outbound: false, outbound: false,
}) })

View file

@ -12,7 +12,8 @@ use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream}; use std::net::{Shutdown, TcpStream};
use std::time::Duration; 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 /// 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 /// 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 flush(&mut self) -> io::Result<()>;
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>; fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>;
fn shutdown(&self); 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<String>;
} }
/// A TLS backend: performs the server-side handshake on an accepted socket. /// 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_private_key_file(key, SslFiletype::PEM).map_err(err)?;
b.set_certificate_chain_file(cert).map_err(err)?; b.set_certificate_chain_file(cert).map_err(err)?;
b.check_private_key().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 { Ok(OpensslBackend {
acceptor: b.build(), acceptor: b.build(),
}) })
@ -78,4 +86,9 @@ impl TlsConn for OpensslConn {
fn shutdown(&self) { fn shutdown(&self) {
let _ = self.0.get_ref().shutdown(Shutdown::Both); let _ = self.0.get_ref().shutdown(Shutdown::Both);
} }
fn peer_cert_fp(&self) -> Option<String> {
let cert = self.0.ssl().peer_certificate()?;
let digest = cert.digest(MessageDigest::sha256()).ok()?;
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
}
} }

View file

@ -127,12 +127,17 @@ impl Caps {
} }
/// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients. /// 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 SUPPORTED_CAPS
.iter() .iter()
.map(|c| { .map(|c| {
if *c == "sasl" && cap302 { if *c == "sasl" && cap302 {
if secure {
"sasl=PLAIN,EXTERNAL".to_string()
} else {
"sasl=PLAIN".to_string() "sasl=PLAIN".to_string()
}
} else { } else {
(*c).to_string() (*c).to_string()
} }
@ -209,6 +214,7 @@ pub struct User {
pub cloak: String, // masked host shown under +x ("" until computed) pub cloak: String, // masked host shown under +x ("" until computed)
pub vhost: Option<String>, // displayed-host override (CHGHOST/SETHOST vhost) pub vhost: Option<String>, // displayed-host override (CHGHOST/SETHOST vhost)
pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo) pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo)
pub certfp: Option<String>, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP)
pub account: Option<String>, // logged-in account name (set by services) pub account: Option<String>, // logged-in account name (set by services)
pub signon: u64, // unix secs at registration (WHOIS 317) pub signon: u64, // unix secs at registration (WHOIS 317)
pub addr: SocketAddr, pub addr: SocketAddr,
@ -465,8 +471,13 @@ mod tests {
assert!(!c.set("bogus-cap", true)); // unknown cap rejected assert!(!c.set("bogus-cap", true)); // unknown cap rejected
assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl")); assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl"));
assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order 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(true, false).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("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); c.set("server-time", false);
assert!(!c.has("server-time")); assert!(!c.has("server-time"));
} }