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:
parent
fa84ba9f53
commit
f462fecac3
7 changed files with 75 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ pub enum Event {
|
||||||
out: OutSink,
|
out: OutSink,
|
||||||
sock: Option<TcpStream>,
|
sock: Option<TcpStream>,
|
||||||
secure: bool,
|
secure: bool,
|
||||||
link: bool, // a server-to-server connection, not a client
|
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
|
||||||
outbound: bool, // (link) we dialed them
|
link: bool, // a server-to-server connection, not a client
|
||||||
|
outbound: bool, // (link) we dialed them
|
||||||
},
|
},
|
||||||
Line {
|
Line {
|
||||||
uid: Uid,
|
uid: Uid,
|
||||||
|
|
@ -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 } => {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
15
src/tls.rs
15
src/tls.rs
|
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/users.rs
19
src/users.rs
|
|
@ -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 {
|
||||||
"sasl=PLAIN".to_string()
|
if secure {
|
||||||
|
"sasl=PLAIN,EXTERNAL".to_string()
|
||||||
|
} else {
|
||||||
|
"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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue