From dfd31f631310a1d03618ae5490da4cf176147072 Mon Sep 17 00:00:00 2001 From: Christian Joergensen Date: Sun, 16 Aug 2026 06:15:54 -0400 Subject: [PATCH 1/4] feat: SASL authentication and IRCv3 capability negotiation The handshake was a bare NICK/USER pair, so a bot could not identify itself to services. Modern networks need this: +r channels refuse an unauthenticated bot, and the account cloak never applies. Registration now sends CAP LS 302 before NICK/USER, which makes the server hold registration open, and closes with CAP END. SASL PLAIN and SASL EXTERNAL run inside that window. A PASS server password is sent too, before NICK. Credentials go on Server, not behind a State builder. The generated new() connects immediately, so by the time a State exists to configure, the handshake is over. A refused SASL exchange fails the connection. If the network offers no sasl capability, refuses the mechanism, or rejects the credentials, connect returns an error. The bot does not continue unauthenticated. Lines that arrive during the exchange but do not belong to it are handed back to the read loop in arrival order. ERR_NICKNAMEINUSE is the one that matters: the server sends it as soon as it reads NICK, which is in the middle of the exchange, and dropping it would break the fallback nick. base64 is implemented here rather than added as a dependency. SASL needs a few dozen bytes encoded once per connection. PASS and AUTHENTICATE log a redacted form, and Debug redacts every credential, so a password cannot reach the protocol trace. --- ircbot/src/auth.rs | 280 +++++++++++++++++++++++ ircbot/src/bot.rs | 22 +- ircbot/src/connection.rs | 473 +++++++++++++++++++++++++++++++++++++-- ircbot/src/context.rs | 2 +- ircbot/src/lib.rs | 3 +- ircbot/src/server.rs | 145 ++++++++++++ 6 files changed, 905 insertions(+), 20 deletions(-) create mode 100644 ircbot/src/auth.rs diff --git a/ircbot/src/auth.rs b/ircbot/src/auth.rs new file mode 100644 index 0000000..8645c08 --- /dev/null +++ b/ircbot/src/auth.rs @@ -0,0 +1,280 @@ +//! Credentials presented to the server while registering. +//! +//! Everything here is configured through [`Server`](crate::Server) — see +//! [`Server::with_sasl_plain`](crate::Server::with_sasl_plain), +//! [`Server::with_sasl_external`](crate::Server::with_sasl_external), and +//! [`Server::with_password`](crate::Server::with_password). Credentials belong +//! to the server rather than to the running bot because they are needed during +//! the handshake, before a [`State`](crate::State) exists to configure. +//! +//! The exchange itself lives in [`crate::connection`]. + +use std::fmt; + +/// A SASL mechanism, together with the credentials it needs. +#[derive(Clone, PartialEq, Eq)] +pub(crate) enum Sasl { + /// `PLAIN`: a username and password sent over the connection. Only use it + /// on a TLS connection — the credentials are otherwise readable on the + /// wire. + Plain { + /// The account name to authenticate as (`authcid`). + user: String, + /// The account password. + password: String, + }, + /// `EXTERNAL`: the server derives the account from the TLS client + /// certificate already presented during the handshake (CertFP), so no + /// credentials are sent here. + External, +} + +impl Sasl { + /// The mechanism name as it appears in `AUTHENTICATE` and in the `sasl` + /// capability value. + pub(crate) fn mechanism(&self) -> &'static str { + match self { + Sasl::Plain { .. } => "PLAIN", + Sasl::External => "EXTERNAL", + } + } + + /// The base64 payload answering the server's `AUTHENTICATE +` challenge. + /// + /// `PLAIN` sends `authzid \0 authcid \0 passwd` with an empty `authzid`, + /// per RFC 4616. `EXTERNAL` sends an empty `authzid`, which base64-encodes + /// to the empty string; the caller turns that into the `+` the protocol + /// uses for "no data". + pub(crate) fn response(&self) -> String { + match self { + Sasl::Plain { user, password } => { + base64_encode(format!("\0{user}\0{password}").as_bytes()) + } + Sasl::External => String::new(), + } + } +} + +/// Redacts the password. `Sasl` is reachable from `Server`, which is exactly +/// the sort of value that ends up in a `tracing` field or a `{:?}` of +/// application config. +impl fmt::Debug for Sasl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Sasl::Plain { user, .. } => f + .debug_struct("Plain") + .field("user", user) + .field("password", &"") + .finish(), + Sasl::External => f.write_str("External"), + } + } +} + +/// How the bot authenticates to a server, and which IRCv3 capabilities it asks +/// for on the way. +/// +/// The default is no authentication and no capabilities, which makes the +/// handshake a bare `NICK`/`USER` exchange. +#[derive(Clone, Default, PartialEq, Eq)] +pub(crate) struct Auth { + /// Server password, sent as `PASS` before `NICK`. Distinct from SASL: it + /// authenticates to the *server*, not to its services. + pub(crate) password: Option, + /// SASL mechanism and credentials, or `None` to skip SASL. + pub(crate) sasl: Option, + /// Extra IRCv3 capabilities to request alongside `sasl`. Ones the server + /// does not advertise are skipped. + pub(crate) extra_caps: Vec, +} + +impl Auth { + /// The capabilities to request, in the order they should be asked for. + /// + /// Empty when there is nothing to negotiate, which is the signal to skip + /// `CAP` entirely and leave the handshake as it was before IRCv3. + pub(crate) fn wanted_caps(&self) -> Vec<&str> { + let mut caps = Vec::with_capacity(1 + self.extra_caps.len()); + if self.sasl.is_some() { + caps.push("sasl"); + } + caps.extend(self.extra_caps.iter().map(String::as_str)); + caps + } + + /// Whether the handshake needs a `CAP` exchange at all. + pub(crate) fn negotiates_caps(&self) -> bool { + !self.wanted_caps().is_empty() + } +} + +/// Redacts the server password; see the `Debug` impl for [`Sasl`]. +impl fmt::Debug for Auth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Auth") + .field("password", &self.password.as_ref().map(|_| "")) + .field("sasl", &self.sasl) + .field("extra_caps", &self.extra_caps) + .finish() + } +} + +/// The standard base64 alphabet (RFC 4648 §4). +const BASE64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode `input` as base64 with padding, per RFC 4648 §4. +/// +/// Hand-rolled rather than pulled in as a dependency: SASL needs a few dozen +/// bytes encoded once per connection, and the crate keeps its dependency +/// surface small. +fn base64_encode(input: &[u8]) -> String { + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let bytes = [ + chunk[0], + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + ]; + let group = (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]); + // Every group yields two characters; the third and fourth become `=` + // padding when the chunk was short. + out.push(char::from(BASE64_ALPHABET[(group >> 18) as usize & 0x3f])); + out.push(char::from(BASE64_ALPHABET[(group >> 12) as usize & 0x3f])); + out.push(if chunk.len() > 1 { + char::from(BASE64_ALPHABET[(group >> 6) as usize & 0x3f]) + } else { + '=' + }); + out.push(if chunk.len() > 2 { + char::from(BASE64_ALPHABET[group as usize & 0x3f]) + } else { + '=' + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── base64 ──────────────────────────────────────────────────────────────── + + /// The test vectors from RFC 4648 §10, which pin down both the alphabet and + /// the padding rules. + #[test] + fn base64_encodes_the_rfc_4648_vectors() { + let cases = [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ]; + for (input, expected) in cases { + assert_eq!( + base64_encode(input.as_bytes()), + expected, + "input: {input:?}" + ); + } + } + + /// Bytes outside ASCII must survive, since a password may hold any UTF-8. + #[test] + fn base64_encodes_high_bytes() { + assert_eq!(base64_encode(&[0xff, 0xfe, 0xfd]), "//79"); + } + + /// The NUL separators SASL PLAIN relies on must be encoded, not dropped. + #[test] + fn base64_encodes_nul_bytes() { + assert_eq!(base64_encode(b"\0a\0b"), "AGEAYg=="); + } + + // ── SASL ────────────────────────────────────────────────────────────────── + + /// `PLAIN` is `authzid \0 authcid \0 passwd` with an empty authzid, so + /// `bot` / `hunter2` must encode to this exact string (RFC 4616). + #[test] + fn plain_response_encodes_authzid_authcid_password() { + let sasl = Sasl::Plain { + user: "bot".to_string(), + password: "hunter2".to_string(), + }; + assert_eq!(sasl.response(), "AGJvdABodW50ZXIy"); + assert_eq!(sasl.mechanism(), "PLAIN"); + } + + #[test] + fn external_response_is_empty() { + assert_eq!(Sasl::External.response(), ""); + assert_eq!(Sasl::External.mechanism(), "EXTERNAL"); + } + + // ── redaction ───────────────────────────────────────────────────────────── + + /// A password must never reach a log line or a `{:?}` dump. + #[test] + fn debug_redacts_the_sasl_password() { + let sasl = Sasl::Plain { + user: "bot".to_string(), + password: "hunter2".to_string(), + }; + let rendered = format!("{sasl:?}"); + assert!(!rendered.contains("hunter2"), "password leaked: {rendered}"); + assert!(rendered.contains("bot"), "{rendered}"); + } + + #[test] + fn debug_redacts_the_server_password() { + let auth = Auth { + password: Some("s3cret".to_string()), + ..Auth::default() + }; + let rendered = format!("{auth:?}"); + assert!(!rendered.contains("s3cret"), "password leaked: {rendered}"); + } + + // ── capability selection ────────────────────────────────────────────────── + + #[test] + fn no_credentials_means_no_cap_exchange() { + assert!(!Auth::default().negotiates_caps()); + assert!(Auth::default().wanted_caps().is_empty()); + } + + /// A server password alone is sent as `PASS`; it needs no capability, so it + /// must not drag the connection into a `CAP` exchange. + #[test] + fn a_server_password_alone_needs_no_cap_exchange() { + let auth = Auth { + password: Some("s3cret".to_string()), + ..Auth::default() + }; + assert!(!auth.negotiates_caps()); + } + + #[test] + fn sasl_requests_the_sasl_capability_first() { + let auth = Auth { + sasl: Some(Sasl::External), + extra_caps: vec!["server-time".to_string()], + ..Auth::default() + }; + assert_eq!(auth.wanted_caps(), vec!["sasl", "server-time"]); + } + + #[test] + fn extra_capabilities_alone_still_negotiate() { + let auth = Auth { + extra_caps: vec!["server-time".to_string()], + ..Auth::default() + }; + assert!(auth.negotiates_caps()); + assert_eq!(auth.wanted_caps(), vec!["server-time"]); + } +} diff --git a/ircbot/src/bot.rs b/ircbot/src/bot.rs index b7da7fc..942f8e7 100644 --- a/ircbot/src/bot.rs +++ b/ircbot/src/bot.rs @@ -92,6 +92,7 @@ pub async fn run_bot_internal( }, reader, write_half, + pending_lines, #[cfg(unix)] raw_fd: _, } = state; @@ -228,13 +229,16 @@ pub async fn run_bot_internal( // Number of alternate-nick attempts made so far (after the initial NICK). let mut nick_attempt = 0u32; let mut lines = reader.lines(); + // Lines the capability exchange read ahead of this loop; drained before the + // socket so ordering is preserved. + let mut pending_lines = pending_lines.into_iter(); let mut keepalive_fail_rx = keepalive_fail_rx; // Run the read loop; collect any IO error so we can clean up first. let loop_result: Result<(), BoxError> = async { loop { tokio::select! { - result = lines.next_line() => { + result = next_line(&mut pending_lines, &mut lines) => { let Some(line) = result? else { break; }; let line = line.trim_end_matches('\r').to_string(); if line.is_empty() { @@ -362,6 +366,22 @@ pub async fn run_bot_internal( loop_result } +/// Yield the next line to dispatch: one the registration handshake read ahead +/// of the loop if any are left, otherwise the next one off the socket. +/// +/// Cancel-safe, as the `select!` in the read loop requires: the `pending` +/// branch returns without ever awaiting, so it cannot be dropped part-way and +/// lose a line, and `next_line` is cancel-safe in its own right. +async fn next_line( + pending: &mut std::vec::IntoIter, + lines: &mut tokio::io::Lines>, +) -> std::io::Result> { + match pending.next() { + Some(line) => Ok(Some(line)), + None => lines.next_line().await, + } +} + // ─── cron supervisor ───────────────────────────────────────────────────────── /// Drive every [`Trigger::Cron`] handler in `handlers`. diff --git a/ircbot/src/connection.rs b/ircbot/src/connection.rs index e226496..bb692ea 100644 --- a/ircbot/src/connection.rs +++ b/ircbot/src/connection.rs @@ -1,9 +1,15 @@ //! The live connection to an IRC server, and the settings that shape it. //! //! [`State`] is what [`State::connect`] returns: an open socket that has -//! finished the `NICK`/`USER` handshake, plus the channels to join. The `with_*` -//! methods on it configure keepalive, flood control, nick recovery, and roles -//! before the bot starts. +//! finished registering, plus the channels to join. The `with_*` methods on it +//! configure keepalive, flood control, nick recovery, and roles before the bot +//! starts. +//! +//! Registration is the `NICK`/`USER` handshake, and — when the [`Server`] +//! carries credentials — the `PASS` line, the IRCv3 capability exchange, and +//! SASL, all of which run before `CAP END` lets the server finish. Those +//! credentials live on the `Server` rather than behind a `with_*` method +//! because they are needed here, before any such method can be called. //! //! Each `DEFAULT_*` constant in this module gives the value the matching //! setting starts at. Nick recovery is the exception: it stays off until you @@ -12,11 +18,16 @@ use std::time::Duration; use irc_proto::chan::ChannelExt; -use tokio::io::{AsyncWriteExt, BufWriter}; +use irc_proto::CapSubCommand; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufWriter}; +use crate::auth::Auth; +use crate::context::sanitize; +use crate::irc::{Command, Message, Response}; use crate::server::Server; use crate::transport; use crate::types::{Channel, Nick}; +use crate::BoxError; /// Default interval between client-initiated keepalive pings. pub const DEFAULT_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); @@ -33,6 +44,27 @@ pub const DEFAULT_FLOOD_RATE: Duration = Duration::from_millis(500); /// enabled via [`State::with_keepnick`]. pub const DEFAULT_KEEPNICK_INTERVAL: Duration = Duration::from_secs(60); +/// How long the IRCv3 capability and SASL exchange may take before the +/// connection is given up on. +/// +/// Generous enough for a services daemon that is slow to answer, while still +/// bounding a server that stops replying part-way through: without a bound the +/// bot would wait for `CAP ACK` forever, never reaching the read loop and never +/// reconnecting. +pub const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Longest `AUTHENTICATE` payload the protocol allows in one line; longer +/// responses are split across several (IRCv3 SASL specification). +const SASL_CHUNK_LEN: usize = 400; + +/// Most lines the capability exchange will read before giving up. +/// +/// A real exchange takes under a dozen. The bound is what stops a server that +/// streams lines instead of answering from growing the buffered-line list +/// without limit — the timeout alone does not, since it bounds time rather than +/// memory. +const MAX_HANDSHAKE_LINES: usize = 1024; + /// Everything a caller configures through the `with_*` builders — that is, /// everything that must outlive the socket it was configured on. /// @@ -112,6 +144,324 @@ impl Blueprint { } } +// ─── registration handshake ────────────────────────────────────────────────── + +/// Write one line to the server, appending the IRC line terminator and logging +/// it on the protocol target. +async fn send( + writer: &mut BufWriter, + line: &str, +) -> Result<(), std::io::Error> { + send_secret(writer, line, line).await +} + +/// Write one line, logging `shown` in place of the line itself. +/// +/// `PASS` and `AUTHENTICATE` carry credentials, and the protocol log is a +/// `trace!` an operator may well have switched on — so what goes on the wire +/// and what goes in the log deliberately differ for those two. +async fn send_secret( + writer: &mut BufWriter, + line: &str, + shown: &str, +) -> Result<(), std::io::Error> { + tracing::trace!(target: crate::PROTOCOL_LOG_TARGET, dir = "send", line = %shown); + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\r\n").await?; + writer.flush().await +} + +/// Send a SASL response, split into the chunks the protocol allows. +/// +/// An empty response, and one whose length is an exact multiple of +/// [`SASL_CHUNK_LEN`], is followed by a lone `+` so the server can tell the +/// payload has ended rather than waiting for a continuation that never comes. +async fn send_sasl_response( + writer: &mut BufWriter, + response: &str, +) -> Result<(), std::io::Error> { + // base64 output is pure ASCII, so chunking by byte offset can never land + // mid-character. + for chunk in response.as_bytes().chunks(SASL_CHUNK_LEN) { + let chunk = String::from_utf8_lossy(chunk); + send_secret( + writer, + &format!("AUTHENTICATE {chunk}"), + "AUTHENTICATE ", + ) + .await?; + } + if response.len().is_multiple_of(SASL_CHUNK_LEN) { + send(writer, "AUTHENTICATE +").await?; + } + Ok(()) +} + +/// Split the payload of a `CAP … LS`/`ACK`/`NAK` line into "is this batch +/// continued?" and the capability list itself. +/// +/// `irc-proto` puts the list in the last populated parameter: a final +/// `CAP * LS :a b` parses with the list in `arg` and nothing trailing, while a +/// continued `CAP * LS * :a b` parses with the `*` marker in `arg` and the list +/// trailing. +fn cap_payload<'a>(arg: Option<&'a String>, trailing: Option<&'a String>) -> (bool, &'a str) { + match (arg, trailing) { + (Some(marker), Some(caps)) => (marker == "*", caps.as_str()), + (Some(caps), None) => (false, caps.as_str()), + (None, caps) => (false, caps.map_or("", String::as_str)), + } +} + +/// Look up `name` among the capabilities the server advertised, returning its +/// value (the part after `=`, empty when it has none). +/// +/// Capability names are case-sensitive per the IRCv3 specification. +fn advertised<'a>(available: &'a [String], name: &str) -> Option<&'a str> { + available.iter().find_map(|cap| { + let (cap_name, value) = cap.split_once('=').unwrap_or((cap.as_str(), "")); + (cap_name == name).then_some(value) + }) +} + +/// Run the IRCv3 capability exchange, and the SASL exchange inside it, until +/// the server is ready to complete registration. +/// +/// Returns the lines that arrived during the exchange but did not belong to it +/// — `ERR_NICKNAMEINUSE` above all, which the server sends as soon as it reads +/// our `NICK`. They are handed back in arrival order so the read loop can +/// process them as if they had come in normally. +/// +/// # Errors +/// +/// Returns an error if the connection drops or stalls mid-exchange, or if SASL +/// was configured and could not be completed — an unauthenticated connection is +/// not silently accepted in its place. +async fn negotiate( + reader: &mut tokio::io::BufReader, + writer: &mut BufWriter, + auth: &Auth, +) -> Result, BoxError> { + let mut pending: Vec = Vec::new(); + let wanted = auth.wanted_caps(); + if wanted.is_empty() { + return Ok(pending); + } + + // Capabilities advertised so far, each still in `name` or `name=value` + // form. `CAP LS` may be split across several lines, so they accumulate + // until the batch ends. + let mut available: Vec = Vec::new(); + // Whether an `AUTHENTICATE ` has gone out. A success numeric that + // arrives before one did not answer anything we sent, so it does not count. + let mut sasl_started = false; + let deadline = tokio::time::Instant::now() + REGISTRATION_TIMEOUT; + + for _ in 0..MAX_HANDSHAKE_LINES { + let mut line = String::new(); + let read = tokio::time::timeout_at(deadline, reader.read_line(&mut line)) + .await + .map_err(|_| -> BoxError { + format!( + "the server stopped responding during IRCv3 capability negotiation \ + (waited {REGISTRATION_TIMEOUT:?}). Make sure that the port speaks IRC and \ + that the network supports CAP. A server without CAP support answers with an \ + error, not with silence" + ) + .into() + })??; + if read == 0 { + return Err("the server closed the connection during IRCv3 capability \ + negotiation. A network does this when the server password passed to \ + Server::with_password is wrong" + .into()); + } + + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + continue; + } + tracing::trace!(target: crate::PROTOCOL_LOG_TARGET, dir = "recv", %line); + + let Ok(msg) = line.parse::() else { + pending.push(line.to_string()); + continue; + }; + + match &msg.command { + // A server may ping mid-handshake; an unanswered one gets us + // disconnected before registration ever completes. + Command::PING(server, _) => send(writer, &format!("PONG :{server}")).await?, + + Command::CAP(_, CapSubCommand::LS, arg, trailing) => { + let (more, caps) = cap_payload(arg.as_ref(), trailing.as_ref()); + available.extend(caps.split_whitespace().map(str::to_string)); + if more { + continue; + } + + let requested: Vec<&str> = wanted + .iter() + .copied() + .filter(|cap| advertised(&available, cap).is_some()) + .collect(); + + if let Some(sasl) = &auth.sasl { + let Some(mechanisms) = advertised(&available, "sasl") else { + return Err(format!( + "SASL authentication was configured but {} does not offer the sasl \ + capability. Drop the with_sasl_* call, or connect to a server that \ + supports SASL", + server_name(&msg) + ) + .into()); + }; + // With `CAP LS 302` the value lists the mechanisms; older + // servers advertise a bare `sasl` and accept any of them, + // so an empty value is not a rejection. + if !mechanisms.is_empty() + && !mechanisms + .split(',') + .any(|m| m.eq_ignore_ascii_case(sasl.mechanism())) + { + return Err(format!( + "the server does not support SASL {}. It offers {mechanisms}. Pick \ + a mechanism from that list", + sasl.mechanism() + ) + .into()); + } + } + + for cap in wanted.iter().filter(|c| !requested.contains(c)) { + tracing::debug!(capability = cap, "capability not advertised — skipping"); + } + + if requested.is_empty() { + send(writer, "CAP END").await?; + return Ok(pending); + } + send(writer, &format!("CAP REQ :{}", requested.join(" "))).await?; + } + + Command::CAP(_, CapSubCommand::ACK, arg, trailing) => { + let (_, caps) = cap_payload(arg.as_ref(), trailing.as_ref()); + tracing::debug!(capabilities = caps, "capabilities acknowledged"); + + let acked_sasl = caps.split_whitespace().any(|c| c == "sasl"); + match (&auth.sasl, acked_sasl) { + (Some(sasl), true) => { + send(writer, &format!("AUTHENTICATE {}", sasl.mechanism())).await?; + sasl_started = true; + } + (Some(sasl), false) => { + return Err(format!( + "the server acknowledged {caps} but not sasl, so the bot cannot \ + authenticate with SASL {}. Services are usually down when this \ + happens. Retry, or drop the with_sasl_* call to connect \ + unauthenticated", + sasl.mechanism() + ) + .into()); + } + (None, _) => { + send(writer, "CAP END").await?; + return Ok(pending); + } + } + } + + Command::CAP(_, CapSubCommand::NAK, arg, trailing) => { + let (_, caps) = cap_payload(arg.as_ref(), trailing.as_ref()); + if auth.sasl.is_some() && caps.split_whitespace().any(|c| c == "sasl") { + return Err(format!( + "the server refused the sasl capability ({caps}), so the bot cannot \ + authenticate. Services are usually down when this happens. Retry, or \ + drop the with_sasl_* call to connect unauthenticated" + ) + .into()); + } + tracing::warn!(capabilities = caps, "capabilities refused by the server"); + send(writer, "CAP END").await?; + return Ok(pending); + } + + // The server is ready for the mechanism's response. `+` means it + // sent no challenge of its own, which is all PLAIN and EXTERNAL + // ever see. + Command::AUTHENTICATE(_) => { + let Some(sasl) = &auth.sasl else { + pending.push(line.to_string()); + continue; + }; + send_sasl_response(writer, &sasl.response()).await?; + } + + Command::Response(Response::RPL_LOGGEDIN, args) => { + // " :You are now logged in as " + if let Some(account) = args.get(2) { + tracing::info!(%account, "authenticated with SASL"); + } + } + + Command::Response(Response::RPL_SASLSUCCESS, _) if sasl_started => { + send(writer, "CAP END").await?; + return Ok(pending); + } + + Command::Response( + response @ (Response::ERR_NICKLOCKED + | Response::ERR_SASLFAIL + | Response::ERR_SASLTOOLONG + | Response::ERR_SASLABORT + | Response::ERR_SASLALREADY), + args, + ) => { + let detail = args.last().map_or("no detail given", String::as_str); + return Err(format!( + "SASL authentication failed: {detail} ({response:?}). Correct the account \ + name and password passed to with_sasl_plain, or the client certificate \ + registered with the network for with_sasl_external" + ) + .into()); + } + + // A server old enough to have no CAP command answers this way. + Command::Response(Response::ERR_UNKNOWNCOMMAND, args) + if args.iter().any(|a| a.eq_ignore_ascii_case("CAP")) => + { + if auth.sasl.is_some() { + return Err( + "SASL authentication was configured but the server does not implement \ + CAP, so it cannot support SASL. Drop the with_sasl_* call, or connect to \ + a server that supports IRCv3" + .into(), + ); + } + tracing::warn!("server does not support CAP — continuing without capabilities"); + return Ok(pending); + } + + // Everything else belongs to the read loop, not to this exchange. + _ => pending.push(line.to_string()), + } + } + + Err(format!( + "the server sent more than {MAX_HANDSHAKE_LINES} lines without finishing IRCv3 \ + capability negotiation. Make sure that the address points at an IRC server and not at \ + another protocol" + ) + .into()) +} + +/// The server's own name, taken from a message's prefix, for use in an error. +fn server_name(msg: &Message) -> &str { + match msg.prefix.as_ref() { + Some(irc_proto::Prefix::ServerName(name)) => name.as_str(), + _ => "the server", + } +} + /// Holds the established connection to an IRC server plus join-on-connect metadata. pub struct State { /// The nick registered with the server during the handshake. @@ -127,6 +477,11 @@ pub struct State { /// The raw write half; `run_bot_internal` wraps this in a buffered writer and a /// dedicated write-loop task. pub(crate) write_half: transport::WriteHalf, + /// Lines that arrived during the capability exchange but were not part of + /// it. The read loop drains these before reading the socket, so a message + /// the server sent early — `ERR_NICKNAMEINUSE`, typically — is dispatched + /// in arrival order rather than lost. + pub(crate) pending_lines: Vec, /// The raw file descriptor of the underlying TCP socket, used by the /// hot-reload path to pass the live connection to a new binary. /// @@ -169,10 +524,16 @@ impl State { /// (`#`, `&`, `+`, `!`) will automatically be prefixed with `#`, so both /// `"general"` and `"#general"` are accepted. /// + /// When the `server` carries credentials, this also runs the IRCv3 + /// capability exchange and SASL before returning, so the connection is + /// already authenticated by the time the bot joins anything. + /// /// # Errors /// /// Returns an error if the TCP connection, the TLS handshake, or the - /// initial NICK/USER handshake fails. + /// registration handshake fails. A configured SASL exchange that the server + /// refuses counts as a failure: an unauthenticated connection is never + /// silently accepted in its place. pub async fn connect( nick: impl Into, server: impl Into, @@ -189,21 +550,28 @@ impl State { #[cfg(unix)] let raw_fd = connection.raw_fd; - let reader = tokio::io::BufReader::new(connection.reader); + let mut reader = tokio::io::BufReader::new(connection.reader); let mut writer = BufWriter::new(connection.writer); - let nick_line = format!("NICK {nick}\r\n"); - let user_line = format!("USER {nick} 0 * :{nick}\r\n"); - for line in [&nick_line, &user_line] { - tracing::trace!( - target: crate::PROTOCOL_LOG_TARGET, - dir = "send", - line = %line.trim_end_matches(['\r', '\n']), - ); + // `CAP LS` goes first: a server that sees it holds registration open + // until `CAP END`, which is the window SASL has to complete in. Sent + // after `NICK`/`USER` it would race the server's own completion of + // registration, and SASL is refused once registration is done. + if server.auth.negotiates_caps() { + send(&mut writer, "CAP LS 302").await?; } - writer.write_all(nick_line.as_bytes()).await?; - writer.write_all(user_line.as_bytes()).await?; - writer.flush().await?; + if let Some(password) = &server.auth.password { + send_secret( + &mut writer, + &format!("PASS :{}", sanitize(password)), + "PASS :", + ) + .await?; + } + send(&mut writer, &format!("NICK {nick}")).await?; + send(&mut writer, &format!("USER {nick} 0 * :{nick}")).await?; + + let pending_lines = negotiate(&mut reader, &mut writer, &server.auth).await?; // Recover the inner write half from the BufWriter. let write_half = writer.into_inner(); @@ -215,6 +583,7 @@ impl State { settings: Settings::default(), reader, write_half, + pending_lines, #[cfg(unix)] raw_fd, }) @@ -324,6 +693,9 @@ impl State { }, reader, write_half: connection.writer, + // An inherited connection is already registered, so no capability + // exchange runs and nothing can have been read ahead of the loop. + pending_lines: Vec::new(), raw_fd: connection.raw_fd, })) } @@ -458,6 +830,73 @@ mod tests { } } + // ── CAP line payloads ────────────────────────────────────────────────────── + // + // `cap_payload` exists because `irc-proto` places the capability list in a + // different parameter depending on whether the batch is continued. These + // tests parse real wire lines rather than constructing `Command::CAP` by + // hand, so they would catch that placement changing. + + fn parse_cap(line: &str) -> (bool, String) { + let msg: Message = line.parse().expect("a valid CAP line"); + let Command::CAP(_, _, arg, trailing) = &msg.command else { + panic!("not a CAP command: {line}"); + }; + let (more, caps) = cap_payload(arg.as_ref(), trailing.as_ref()); + (more, caps.to_string()) + } + + #[test] + fn a_final_cap_ls_yields_its_capability_list() { + let (more, caps) = parse_cap(":srv CAP * LS :sasl=PLAIN multi-prefix"); + assert!(!more); + assert_eq!(caps, "sasl=PLAIN multi-prefix"); + } + + #[test] + fn a_continued_cap_ls_is_flagged_and_still_yields_its_list() { + let (more, caps) = parse_cap(":srv CAP * LS * :sasl=PLAIN multi-prefix"); + assert!(more); + assert_eq!(caps, "sasl=PLAIN multi-prefix"); + } + + #[test] + fn a_cap_ack_yields_its_capability_list() { + let (more, caps) = parse_cap(":srv CAP * ACK :sasl"); + assert!(!more); + assert_eq!(caps, "sasl"); + } + + // ── advertised capabilities ──────────────────────────────────────────────── + + #[test] + fn advertised_returns_the_value_after_the_equals_sign() { + let caps = vec!["sasl=PLAIN,EXTERNAL".to_string(), "server-time".to_string()]; + assert_eq!(advertised(&caps, "sasl"), Some("PLAIN,EXTERNAL")); + } + + /// A pre-302 server advertises a bare `sasl` with no mechanism list. That + /// is "supported, mechanisms unknown", not "unsupported". + #[test] + fn advertised_returns_an_empty_value_for_a_valueless_capability() { + let caps = vec!["sasl".to_string()]; + assert_eq!(advertised(&caps, "sasl"), Some("")); + } + + #[test] + fn advertised_returns_none_when_absent() { + let caps = vec!["server-time".to_string()]; + assert_eq!(advertised(&caps, "sasl"), None); + } + + /// A capability whose name merely starts with the one we want must not + /// count as a match. + #[test] + fn advertised_does_not_match_a_name_prefix() { + let caps = vec!["sasl-not-really".to_string()]; + assert_eq!(advertised(&caps, "sasl"), None); + } + // ── builders / getters ───────────────────────────────────────────────────── /// Connect to an in-process loopback listener so a real `State` can be built diff --git a/ircbot/src/context.rs b/ircbot/src/context.rs index 9b9bf37..e65b972 100644 --- a/ircbot/src/context.rs +++ b/ircbot/src/context.rs @@ -47,7 +47,7 @@ pub struct Context { } /// Strip characters that could be used for IRC message injection. -fn sanitize(s: &str) -> String { +pub(crate) fn sanitize(s: &str) -> String { s.chars() .filter(|&c| c != '\r' && c != '\n' && c != '\0') .collect() diff --git a/ircbot/src/lib.rs b/ircbot/src/lib.rs index 8c2f0a6..091d240 100644 --- a/ircbot/src/lib.rs +++ b/ircbot/src/lib.rs @@ -2,6 +2,7 @@ #![warn(missing_docs)] mod args; +mod auth; pub mod bot; pub mod connection; pub mod context; @@ -17,7 +18,7 @@ pub mod types; pub use bot::HandlerSet; pub use connection::{ State, DEFAULT_FLOOD_BURST, DEFAULT_FLOOD_RATE, DEFAULT_KEEPALIVE_INTERVAL, - DEFAULT_KEEPALIVE_TIMEOUT, DEFAULT_KEEPNICK_INTERVAL, + DEFAULT_KEEPALIVE_TIMEOUT, DEFAULT_KEEPNICK_INTERVAL, REGISTRATION_TIMEOUT, }; pub use context::{make_messages, Context, User}; pub use handler::{BoxFuture, HandlerEntry, HandlerFn, Trigger}; diff --git a/ircbot/src/server.rs b/ircbot/src/server.rs index 38d8e82..1517782 100644 --- a/ircbot/src/server.rs +++ b/ircbot/src/server.rs @@ -29,12 +29,18 @@ use std::fmt; +use crate::auth::{Auth, Sasl}; + /// How to reach an IRC server. /// /// Build one with [`Server::plain`] or (with the `tls` feature) /// `Server::tls`. A `&str`, `String`, or `&String` holding a `"host:port"` /// address converts into a plaintext `Server` via [`From`], so anywhere a /// `Server` is accepted a bare address string works too. +/// +/// Credentials live here too — see [`with_sasl_plain`](Server::with_sasl_plain) +/// — because the handshake needs them before a [`State`](crate::State) exists +/// to configure. #[derive(Clone)] pub struct Server { /// The `host:port` address to connect to, also used for reconnects. @@ -42,6 +48,8 @@ pub struct Server { /// TLS configuration, or `None` for a plaintext connection. Without the /// `tls` feature [`TlsSettings`] is uninhabited, so this is always `None`. pub(crate) tls: Option, + /// Credentials and IRCv3 capabilities for the registration handshake. + pub(crate) auth: Auth, } impl Server { @@ -53,6 +61,7 @@ impl Server { Server { addr: addr.into(), tls: None, + auth: Auth::default(), } } @@ -71,6 +80,7 @@ impl Server { TlsServer { addr: addr.into(), settings: TlsSettings::default(), + auth: Auth::default(), } } @@ -103,6 +113,93 @@ impl Server { .and_then(|h| h.strip_suffix(']')) .unwrap_or(host) } + + /// Send a server password (`PASS`) before registering. + /// + /// This authenticates to the *server*, which is what a private or + /// password-gated network asks for. It is unrelated to services: to + /// identify to NickServ, use [`with_sasl_plain`](Server::with_sasl_plain) + /// instead. + /// + /// Calling this more than once replaces the previous password. + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.auth.password = Some(password.into()); + self + } + + /// Authenticate to services with SASL `PLAIN`, using an account name and + /// password. + /// + /// This is how a bot logs in to NickServ on a modern network. It happens + /// during registration, before any channel is joined, so the bot is already + /// identified when it arrives — which is what `+r` channels require and + /// what earns the account's cloak. + /// + /// The password is sent to the server in a reversible encoding, so **use + /// this only over TLS** (`Server::tls`). On a plaintext connection anyone + /// on the path can read it. Prefer + /// [`with_sasl_external`](Server::with_sasl_external) where the network + /// supports it: it sends no password at all. + /// + /// If the server does not offer SASL, or rejects these credentials, the + /// connection fails rather than continuing unauthenticated — a bot that + /// silently loses its identity is worse than one that stops. + /// + /// Calling this more than once replaces the previous mechanism. + #[must_use] + pub fn with_sasl_plain(mut self, user: impl Into, password: impl Into) -> Self { + self.auth.sasl = Some(Sasl::Plain { + user: user.into(), + password: password.into(), + }); + self + } + + /// Authenticate to services with SASL `EXTERNAL`, using the TLS client + /// certificate (CertFP). + /// + /// No password is sent: the server matches the certificate presented during + /// the TLS handshake against the fingerprint registered with your account. + /// Set the certificate with `TlsServer::with_client_cert_pem` (the `tls` + /// feature), and register its fingerprint with the network's services first + /// (`/msg NickServ CERT ADD` on most networks). + /// + /// This needs a TLS connection; on a plaintext one there is no certificate + /// to present and the server will reject the exchange. + /// + /// If the server does not offer SASL, or rejects the certificate, the + /// connection fails rather than continuing unauthenticated. + /// + /// Calling this more than once replaces the previous mechanism. + #[must_use] + pub fn with_sasl_external(mut self) -> Self { + self.auth.sasl = Some(Sasl::External); + self + } + + /// Request additional IRCv3 capabilities during registration. + /// + /// Capabilities the server does not advertise are skipped, so asking for + /// one a network lacks is harmless. What an acknowledged capability changes + /// on the wire reaches handlers through the raw message on + /// [`Context`](crate::Context); the framework does not interpret these + /// itself. + /// + /// `sasl` is requested automatically when a mechanism is configured; it + /// does not need to be listed here. + /// + /// May be called multiple times; capabilities accumulate. + #[must_use] + pub fn with_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.auth + .extra_caps + .extend(capabilities.into_iter().map(Into::into)); + self + } } impl fmt::Debug for Server { @@ -110,6 +207,7 @@ impl fmt::Debug for Server { f.debug_struct("Server") .field("addr", &self.addr) .field("tls", &self.tls) + .field("auth", &self.auth) .finish() } } @@ -154,10 +252,56 @@ impl From<&String> for Server { pub struct TlsServer { addr: String, settings: TlsSettings, + auth: Auth, } #[cfg(feature = "tls")] impl TlsServer { + /// Send a server password (`PASS`) before registering. + /// + /// See [`Server::with_password`]. + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.auth.password = Some(password.into()); + self + } + + /// Authenticate to services with SASL `PLAIN`. + /// + /// See [`Server::with_sasl_plain`]. + #[must_use] + pub fn with_sasl_plain(mut self, user: impl Into, password: impl Into) -> Self { + self.auth.sasl = Some(Sasl::Plain { + user: user.into(), + password: password.into(), + }); + self + } + + /// Authenticate to services with SASL `EXTERNAL`, using the client + /// certificate set by [`with_client_cert_pem`](TlsServer::with_client_cert_pem). + /// + /// See [`Server::with_sasl_external`]. + #[must_use] + pub fn with_sasl_external(mut self) -> Self { + self.auth.sasl = Some(Sasl::External); + self + } + + /// Request additional IRCv3 capabilities during registration. + /// + /// See [`Server::with_capabilities`]. + #[must_use] + pub fn with_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.auth + .extra_caps + .extend(capabilities.into_iter().map(Into::into)); + self + } + /// Override the hostname used for SNI and certificate verification. /// /// By default the host part of the address is used. Set this when @@ -234,6 +378,7 @@ impl From for Server { Server { addr: tls.addr, tls: Some(tls.settings), + auth: tls.auth, } } } From c6aef996784e51a950a9c6cee82154da0ac122b7 Mon Sep 17 00:00:00 2001 From: Christian Joergensen Date: Sun, 16 Aug 2026 06:16:03 -0400 Subject: [PATCH 2/4] fix: keep server credentials across a hot reload A reloaded process rebuilt its Server from the inherited environment, which holds only the address. The inherited socket is already registered, so the bot kept working. A later reconnect did not: it re-ran the handshake without credentials and arrived unauthenticated. The generated new() now keeps the Server the caller passed, which carries both the transport and the credentials. Nothing secret goes through the exec environment. --- ircbot-macros/src/lib.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ircbot-macros/src/lib.rs b/ircbot-macros/src/lib.rs index b4e86a6..5aab739 100644 --- a/ircbot-macros/src/lib.rs +++ b/ircbot-macros/src/lib.rs @@ -448,10 +448,12 @@ pub fn bot(attr: TokenStream, item: TokenStream) -> TokenStream { /// /// On Unix, if this process was started by `exec_reload` the live /// TCP connection is inherited from the parent binary and no new - /// connection is made. The `nick`, `server`, and `channels` - /// arguments are used only when no inherited connection is present. - /// A TLS connection is never inherited, so a reloaded TLS bot always - /// reconnects using the `server` given here. + /// connection is made. The `nick` and `channels` arguments are then + /// taken from the inherited session rather than from here, but + /// `server` is still kept: a later reconnect re-runs the handshake + /// and needs its transport and credentials. A TLS connection is + /// never inherited, so a reloaded TLS bot always reconnects using + /// the `server` given here. pub async fn new( nick: impl Into, server: impl Into, @@ -459,8 +461,12 @@ pub fn bot(attr: TokenStream, item: TokenStream) -> TokenStream { ) -> std::result::Result> { // On Unix, check for an inherited fd from a hot-reload exec. #[cfg(unix)] - if let Some(state) = ircbot::State::try_inherit_from_env()? { + if let Some(mut state) = ircbot::State::try_inherit_from_env()? { eprintln!("[ircbot] hot-reload: resumed on inherited connection"); + // The inherited socket is already registered, but a later + // reconnect is not: it re-runs the handshake and needs the + // credentials, which only the caller has. + state.server = server.into(); return Ok(#struct_name { __state: Some(state) #state_field_init }); } From 70217024f8effad9e3b559d658947254c47e8147 Mon Sep 17 00:00:00 2001 From: Christian Joergensen Date: Sun, 16 Aug 2026 06:16:03 -0400 Subject: [PATCH 3/4] test: cover the registration handshake Each test drives State::connect against a scripted server on loopback and asserts the bytes on the wire: the RFC 4616 payload for SASL PLAIN, the empty response for EXTERNAL, multi-line CAP LS reassembly, a PING during the exchange, a nick collision reaching the read loop, and the message of every failure mode. The tokio test-util feature runs the registration timeout on a paused clock, so that test costs no real time. --- ircbot/Cargo.toml | 3 + ircbot/tests/sasl.rs | 525 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 ircbot/tests/sasl.rs diff --git a/ircbot/Cargo.toml b/ircbot/Cargo.toml index d4d582b..969e332 100644 --- a/ircbot/Cargo.toml +++ b/ircbot/Cargo.toml @@ -39,6 +39,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = [ rustls-native-certs = { version = "0.8", optional = true } [dev-dependencies] +# `test-util` lets the SASL tests run the registration timeout on a paused +# clock instead of waiting REGISTRATION_TIMEOUT in real time. +tokio = { version = "1", features = ["full", "test-util"] } testcontainers = "0.28" irc = { version = "1", default-features = false } trybuild = "1.0" diff --git a/ircbot/tests/sasl.rs b/ircbot/tests/sasl.rs new file mode 100644 index 0000000..895226a --- /dev/null +++ b/ircbot/tests/sasl.rs @@ -0,0 +1,525 @@ +//! The registration handshake: `PASS`, IRCv3 capability negotiation, and SASL. +//! +//! Every test drives the real [`State::connect`] against a scripted server on +//! loopback, so what is asserted is the bytes that go on the wire. + +use std::time::Duration; + +use ircbot::{Server, State}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +/// How the fake server answers: when a client line starts with `.0`, it sends +/// back `.1`. The first matching rule wins, so put the more specific one first. +type Script = Vec<(&'static str, Vec<&'static str>)>; + +/// How long the fake server waits for another client line before deciding the +/// exchange is over. Only reached once the client is done talking, so it costs +/// this much per test and nothing more. +const IDLE: Duration = Duration::from_millis(300); + +/// Start a scripted IRC server on loopback. +/// +/// Returns its address and a receiver yielding every line the client sent, once +/// the client has gone quiet or disconnected. +async fn scripted_server(script: Script) -> (String, oneshot::Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind failed"); + let addr = listener + .local_addr() + .expect("local_addr failed") + .to_string(); + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let (sock, _) = listener.accept().await.expect("accept failed"); + let (read, mut write) = sock.into_split(); + let mut lines = BufReader::new(read).lines(); + let mut received: Vec = Vec::new(); + + loop { + let line = match tokio::time::timeout(IDLE, lines.next_line()).await { + Ok(Ok(Some(line))) => line, + // Idle, disconnected, or errored — the exchange is over. + _ => break, + }; + if let Some((_, replies)) = script.iter().find(|(p, _)| line.starts_with(p)) { + for reply in replies { + write + .write_all(format!("{reply}\r\n").as_bytes()) + .await + .expect("write failed"); + } + } + received.push(line); + } + + let _ = tx.send(received); + // Hold the socket open so a still-connected client never sees an EOF + // it did not ask for. Longer than the registration timeout, so the + // timeout test observes a silent server rather than a closed one. + tokio::time::sleep(Duration::from_secs(120)).await; + }); + + (addr, rx) +} + +/// The rules for a server that offers SASL and accepts whatever is sent. +/// +/// `advertisement` is the `CAP LS` line it answers with, which is what decides +/// the mechanisms on offer. +fn accepting_sasl_script(advertisement: &'static str) -> Script { + vec![ + ("CAP LS", vec![advertisement]), + ("CAP REQ", vec![":srv CAP * ACK :sasl"]), + ("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]), + ("AUTHENTICATE EXTERNAL", vec!["AUTHENTICATE +"]), + ( + "AUTHENTICATE", + vec![ + ":srv 900 bot bot!bot@host bot :You are now logged in as bot", + ":srv 903 bot :SASL authentication successful", + ], + ), + ] +} + +/// Connect expecting failure, and return the error message. +/// +/// `State` is not `Debug`, so `unwrap_err` is unavailable. +async fn connect_error(server: Server) -> String { + match State::connect("bot", server, vec![]).await { + Ok(_) => panic!("expected the connection to be rejected"), + Err(e) => e.to_string(), + } +} + +// ── no credentials ─────────────────────────────────────────────────────────── + +/// Given a server with no credentials configured, when the bot connects, then +/// the handshake is the bare `NICK`/`USER` pair it has always been — no `CAP` +/// line is sent to a network that may not understand one. +#[tokio::test] +async fn without_credentials_the_handshake_skips_cap_entirely() { + let (addr, rx) = scripted_server(vec![]).await; + + let _state = State::connect("bot", Server::plain(&addr), vec![]) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec!["NICK bot", "USER bot 0 * :bot"] + ); +} + +// ── server password ────────────────────────────────────────────────────────── + +/// `PASS` must precede `NICK`: a server reads it as part of registration and +/// rejects it afterwards. +#[tokio::test] +async fn a_server_password_is_sent_before_the_nick() { + let (addr, rx) = scripted_server(vec![]).await; + + let _state = State::connect("bot", Server::plain(&addr).with_password("s3cret"), vec![]) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec!["PASS :s3cret", "NICK bot", "USER bot 0 * :bot"] + ); +} + +/// A password carrying a line terminator must not be able to inject a second +/// command into the stream. +#[tokio::test] +async fn a_server_password_cannot_inject_a_second_command() { + let (addr, rx) = scripted_server(vec![]).await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_password("s3cret\r\nJOIN #evil"), + vec![], + ) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec!["PASS :s3cretJOIN #evil", "NICK bot", "USER bot 0 * :bot"] + ); +} + +// ── SASL PLAIN ─────────────────────────────────────────────────────────────── + +/// The full happy path. `AGJvdABodW50ZXIy` is base64 of `\0bot\0hunter2`, the +/// `authzid \0 authcid \0 passwd` form RFC 4616 specifies. +#[tokio::test] +async fn sasl_plain_authenticates_and_ends_the_capability_exchange() { + let (addr, rx) = scripted_server(accepting_sasl_script( + ":srv CAP * LS :sasl=PLAIN,EXTERNAL multi-prefix", + )) + .await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_sasl_plain("bot", "hunter2"), + vec![], + ) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec![ + "CAP LS 302", + "NICK bot", + "USER bot 0 * :bot", + "CAP REQ :sasl", + "AUTHENTICATE PLAIN", + "AUTHENTICATE AGJvdABodW50ZXIy", + "CAP END", + ] + ); +} + +/// A `CAP LS` batch split across lines is one advertisement, not two: the `sasl` +/// arriving in the second half must still be seen. +#[tokio::test] +async fn a_multi_line_capability_advertisement_is_reassembled() { + let mut script = accepting_sasl_script(":srv CAP * LS :sasl=PLAIN multi-prefix"); + script[0] = ( + "CAP LS", + vec![ + ":srv CAP * LS * :multi-prefix away-notify", + ":srv CAP * LS :sasl=PLAIN", + ], + ); + let (addr, rx) = scripted_server(script).await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_sasl_plain("bot", "hunter2"), + vec![], + ) + .await + .expect("connect failed"); + + let sent = rx.await.expect("server reported nothing"); + assert!( + sent.contains(&"CAP REQ :sasl".to_string()), + "sasl was not requested: {sent:?}" + ); +} + +// ── SASL EXTERNAL ──────────────────────────────────────────────────────────── + +/// `EXTERNAL` proves identity with the TLS client certificate, so the response +/// is empty — which the protocol spells `+`. No credential may appear. +#[tokio::test] +async fn sasl_external_sends_an_empty_response() { + let (addr, rx) = scripted_server(accepting_sasl_script( + ":srv CAP * LS :sasl=PLAIN,EXTERNAL multi-prefix", + )) + .await; + + let _state = State::connect("bot", Server::plain(&addr).with_sasl_external(), vec![]) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec![ + "CAP LS 302", + "NICK bot", + "USER bot 0 * :bot", + "CAP REQ :sasl", + "AUTHENTICATE EXTERNAL", + "AUTHENTICATE +", + "CAP END", + ] + ); +} + +// ── extra capabilities ─────────────────────────────────────────────────────── + +/// Capabilities the server never advertised must be dropped from the request: +/// asking for one is how a server is entitled to `NAK` the whole batch. +#[tokio::test] +async fn unadvertised_capabilities_are_not_requested() { + let (addr, rx) = scripted_server(vec![ + ("CAP LS", vec![":srv CAP * LS :server-time multi-prefix"]), + ("CAP REQ", vec![":srv CAP * ACK :server-time"]), + ]) + .await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_capabilities(["server-time", "away-notify"]), + vec![], + ) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec![ + "CAP LS 302", + "NICK bot", + "USER bot 0 * :bot", + "CAP REQ :server-time", + "CAP END", + ] + ); +} + +/// When nothing we asked for exists, the exchange still has to be closed — +/// otherwise the server holds registration open until it times us out. +#[tokio::test] +async fn a_capability_exchange_with_nothing_to_request_still_ends() { + let (addr, rx) = scripted_server(vec![("CAP LS", vec![":srv CAP * LS :multi-prefix"])]).await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_capabilities(["server-time"]), + vec![], + ) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec!["CAP LS 302", "NICK bot", "USER bot 0 * :bot", "CAP END"] + ); +} + +// ── failure modes ──────────────────────────────────────────────────────────── + +/// Connecting unauthenticated when authentication was asked for is the failure +/// SASL exists to prevent, so it must be an error rather than a warning. +#[tokio::test] +async fn a_server_without_sasl_fails_the_connection() { + let (addr, _rx) = scripted_server(vec![("CAP LS", vec![":srv CAP * LS :multi-prefix"])]).await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await; + + assert!( + err.contains("does not offer the sasl capability"), + "unhelpful error: {err}" + ); +} + +#[tokio::test] +async fn a_server_lacking_our_mechanism_fails_the_connection() { + let (addr, _rx) = scripted_server(vec![( + "CAP LS", + vec![":srv CAP * LS :sasl=EXTERNAL multi-prefix"], + )]) + .await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await; + + assert!( + err.contains("does not support SASL PLAIN"), + "unhelpful error: {err}" + ); + assert!( + err.contains("EXTERNAL"), + "error omits what is on offer: {err}" + ); +} + +#[tokio::test] +async fn rejected_credentials_fail_the_connection() { + let (addr, _rx) = scripted_server(vec![ + ("CAP LS", vec![":srv CAP * LS :sasl=PLAIN"]), + ("CAP REQ", vec![":srv CAP * ACK :sasl"]), + ("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]), + // Answers the credentials themselves, not the mechanism line above. + ( + "AUTHENTICATE", + vec![":srv 904 bot :SASL authentication failed"], + ), + ]) + .await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "wrong")).await; + + assert!( + err.contains("SASL authentication failed"), + "unhelpful error: {err}" + ); + assert!( + err.contains("with_sasl_plain"), + "error does not say what to fix: {err}" + ); +} + +#[tokio::test] +async fn a_refused_sasl_capability_fails_the_connection() { + let (addr, _rx) = scripted_server(vec![ + ("CAP LS", vec![":srv CAP * LS :sasl=PLAIN"]), + ("CAP REQ", vec![":srv CAP * NAK :sasl"]), + ]) + .await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await; + + assert!(err.contains("refused the sasl capability"), "{err}"); +} + +/// A pre-IRCv3 server answers `CAP` with `ERR_UNKNOWNCOMMAND`. With no SASL +/// configured that is survivable, and the bot should register anyway. +#[tokio::test] +async fn a_server_without_cap_support_still_registers() { + let (addr, rx) = + scripted_server(vec![("CAP LS", vec![":srv 421 bot CAP :Unknown command"])]).await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_capabilities(["server-time"]), + vec![], + ) + .await + .expect("connect failed"); + + assert_eq!( + rx.await.expect("server reported nothing"), + vec!["CAP LS 302", "NICK bot", "USER bot 0 * :bot"] + ); +} + +/// The same server, but with SASL configured, cannot authenticate at all. +#[tokio::test] +async fn a_server_without_cap_support_fails_a_sasl_connection() { + let (addr, _rx) = + scripted_server(vec![("CAP LS", vec![":srv 421 bot CAP :Unknown command"])]).await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await; + + assert!(err.contains("does not implement CAP"), "{err}"); +} + +/// A server that goes silent mid-exchange must not hang the bot forever: the +/// connection has to fail so the caller can reconnect. +#[tokio::test(start_paused = true)] +async fn a_silent_server_times_the_exchange_out() { + // No rules: the server accepts the connection and then says nothing. + let (addr, _rx) = scripted_server(vec![]).await; + + let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await; + + assert!(err.contains("stopped responding"), "unhelpful error: {err}"); +} + +// ── the PING that arrives mid-handshake ────────────────────────────────────── + +/// Some servers ping during registration; an unanswered ping gets the bot +/// disconnected before it ever finishes. +#[tokio::test] +async fn a_ping_during_negotiation_is_answered() { + let mut script = accepting_sasl_script(":srv CAP * LS :sasl=PLAIN multi-prefix"); + script[0] = ( + "CAP LS", + vec!["PING :handshake", ":srv CAP * LS :sasl=PLAIN"], + ); + let (addr, rx) = scripted_server(script).await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_sasl_plain("bot", "hunter2"), + vec![], + ) + .await + .expect("connect failed"); + + let sent = rx.await.expect("server reported nothing"); + assert!( + sent.contains(&"PONG :handshake".to_string()), + "the ping went unanswered: {sent:?}" + ); +} + +// ── lines that arrive during the exchange ──────────────────────────────────── + +/// The server answers `NICK` as soon as it reads it, which is in the middle of +/// the capability exchange. That `ERR_NICKNAMEINUSE` is not part of the +/// exchange, so it must reach the read loop rather than being swallowed — +/// otherwise a bot whose nick is taken keeps the fallback for ever. +#[tokio::test] +async fn a_nick_collision_during_negotiation_still_reaches_the_read_loop() { + let (addr, rx) = scripted_server(vec![ + ( + "CAP LS", + vec![ + ":srv CAP * LS :sasl=PLAIN", + ":srv 433 * bot :Nickname is already in use", + ], + ), + ("CAP REQ", vec![":srv CAP * ACK :sasl"]), + ("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]), + ( + "AUTHENTICATE", + vec![":srv 903 bot :SASL authentication successful"], + ), + ]) + .await; + + let state = State::connect( + "bot", + Server::plain(&addr).with_sasl_plain("bot", "hunter2"), + vec![], + ) + .await + .expect("connect failed"); + + let _bot = tokio::spawn(ircbot::internal::run_bot( + std::sync::Arc::new(()), + state, + vec![], + )); + + let sent = rx.await.expect("server reported nothing"); + assert!( + sent.contains(&"NICK bot_".to_string()), + "the nick collision was dropped: {sent:?}" + ); +} + +/// A success numeric that arrives before the bot has sent anything to succeed +/// at proves nothing, so the exchange must carry on rather than treat the +/// connection as authenticated. +#[tokio::test] +async fn an_unprompted_success_numeric_does_not_end_the_exchange() { + let (addr, rx) = scripted_server(vec![ + ( + "CAP LS", + vec![ + ":srv CAP * LS :sasl=PLAIN", + ":srv 903 bot :SASL authentication successful", + ], + ), + ("CAP REQ", vec![":srv CAP * ACK :sasl"]), + ("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]), + ( + "AUTHENTICATE", + vec![":srv 903 bot :SASL authentication successful"], + ), + ]) + .await; + + let _state = State::connect( + "bot", + Server::plain(&addr).with_sasl_plain("bot", "hunter2"), + vec![], + ) + .await + .expect("connect failed"); + + let sent = rx.await.expect("server reported nothing"); + assert!( + sent.contains(&"AUTHENTICATE AGJvdABodW50ZXIy".to_string()), + "the exchange ended before authenticating: {sent:?}" + ); +} From d2a9229a5f3638a125d7a570bc52bf62a49eb547 Mon Sep 17 00:00:00 2001 From: Christian Joergensen Date: Sun, 16 Aug 2026 06:16:03 -0400 Subject: [PATCH 4/4] docs: document authentication Add an Authentication section to the README, and show both SASL mechanisms in the TLS example. --- README.md | 49 ++++++++++++++++++++++++++++++++++++-- ircbot/README.md | 49 ++++++++++++++++++++++++++++++++++++-- ircbot/examples/tls_bot.rs | 19 ++++++++++++--- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ca79598..b168a86 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ async fn main() -> Result<(), Box> { - **Access control** — define hostmask-based roles with `.with_role("admin", ["*!*@trusted.host"])` and gate commands with `#[command("op", role = "admin")]`; unauthorized senders are silently ignored. - **Message accessors** — `ctx.nick()`, `ctx.is_from_self()`, `ctx.mentions_me()` to inspect who sent a message and what it says. - **Keepalive & auto-reconnect** — periodic `PING`/`PONG` monitoring; reconnects and re-joins on drop. If the configured nick is already in use, the bot automatically retries with a suffixed alternative (`bot`, `bot_`, …). +- **Authentication** — SASL `PLAIN` and `EXTERNAL` (CertFP) during registration, a `PASS` server password, and IRCv3 capability negotiation. A rejected login fails the connection instead of continuing unauthenticated. - **TLS** (optional) — `Server::tls("irc.libera.chat:6697")` behind the `tls` feature, with certificate verification against the platform root store, private-CA and self-signed support, and client certificates for CertFP. - **Hot reload** (Unix) — `SIGHUP` execs the new binary with the live TCP socket inherited; no reconnect, no missed messages. - **Flood protection** — token-bucket rate limiter (default: burst 4, 1 msg / 500 ms). @@ -80,6 +81,49 @@ tokio = { version = "1", features = ["full"] } See the [`basic_bot` example](ircbot/examples/basic_bot.rs) and the [docs](https://docs.rs/ircbot) for the complete API, hot-reload guide, testing helpers, and lower-level `State` / `internal` APIs. +## Authentication + +Most networks want a bot to identify itself. The credentials live on `Server`, +because the handshake needs them before the bot exists: + +```rust,ignore +use ircbot::Server; + +MyBot::new( + "mybot", + Server::tls("irc.libera.chat:6697").with_sasl_plain("mybot", &password), + ["rust"], +) +``` + +The bot authenticates during registration, before it joins a channel. It is +therefore already identified when it arrives, which is what `+r` channels +require and what earns the account its cloak. + +Three methods are available: + +- `with_sasl_plain(account, password)` — an account name and a password. The + password travels in a reversible encoding, so use it only over TLS. +- `with_sasl_external()` — the server reads the account from the TLS client + certificate (CertFP), and no password is sent. Set the certificate with + `Server::tls(..).with_client_cert_pem(..)`, and register its fingerprint with + the network first. +- `with_password(password)` — a server password (`PASS`). This authenticates to + the server itself, not to its services. + +**A failed SASL exchange fails the connection.** If the network offers no SASL, +or rejects the credentials, `connect` returns an error. The bot does not +continue unauthenticated: a bot that loses its identity without saying so is the +fault SASL exists to prevent. + +`with_capabilities` asks for further IRCv3 capabilities, for example +`server-time` or `multi-prefix`. The framework requests the ones the server +advertises and skips the rest. It does not interpret them itself — what they +change reaches handlers on the raw message. + +A bot with no credentials sends the same bare `NICK`/`USER` handshake as before, +and no `CAP` line at all. + ## TLS TLS is behind the optional `tls` feature, which pulls in @@ -114,8 +158,9 @@ Server::tls("irc.internal.example:6697") .with_extra_root_pem(std::fs::read("ca.pem")?) ``` -`with_client_cert_pem` presents a client certificate for CertFP / SASL EXTERNAL, -and `with_sni` overrides the verified hostname when connecting by IP. +`with_client_cert_pem` presents a client certificate for CertFP, which +`with_sasl_external` then authenticates with. `with_sni` overrides the verified +hostname when connecting by IP. `danger_accept_invalid_certs` disables verification entirely — it is meant for a development server on `localhost`, leaves the connection unauthenticated, and logs a warning on every connect. diff --git a/ircbot/README.md b/ircbot/README.md index ca79598..b168a86 100644 --- a/ircbot/README.md +++ b/ircbot/README.md @@ -60,6 +60,7 @@ async fn main() -> Result<(), Box> { - **Access control** — define hostmask-based roles with `.with_role("admin", ["*!*@trusted.host"])` and gate commands with `#[command("op", role = "admin")]`; unauthorized senders are silently ignored. - **Message accessors** — `ctx.nick()`, `ctx.is_from_self()`, `ctx.mentions_me()` to inspect who sent a message and what it says. - **Keepalive & auto-reconnect** — periodic `PING`/`PONG` monitoring; reconnects and re-joins on drop. If the configured nick is already in use, the bot automatically retries with a suffixed alternative (`bot`, `bot_`, …). +- **Authentication** — SASL `PLAIN` and `EXTERNAL` (CertFP) during registration, a `PASS` server password, and IRCv3 capability negotiation. A rejected login fails the connection instead of continuing unauthenticated. - **TLS** (optional) — `Server::tls("irc.libera.chat:6697")` behind the `tls` feature, with certificate verification against the platform root store, private-CA and self-signed support, and client certificates for CertFP. - **Hot reload** (Unix) — `SIGHUP` execs the new binary with the live TCP socket inherited; no reconnect, no missed messages. - **Flood protection** — token-bucket rate limiter (default: burst 4, 1 msg / 500 ms). @@ -80,6 +81,49 @@ tokio = { version = "1", features = ["full"] } See the [`basic_bot` example](ircbot/examples/basic_bot.rs) and the [docs](https://docs.rs/ircbot) for the complete API, hot-reload guide, testing helpers, and lower-level `State` / `internal` APIs. +## Authentication + +Most networks want a bot to identify itself. The credentials live on `Server`, +because the handshake needs them before the bot exists: + +```rust,ignore +use ircbot::Server; + +MyBot::new( + "mybot", + Server::tls("irc.libera.chat:6697").with_sasl_plain("mybot", &password), + ["rust"], +) +``` + +The bot authenticates during registration, before it joins a channel. It is +therefore already identified when it arrives, which is what `+r` channels +require and what earns the account its cloak. + +Three methods are available: + +- `with_sasl_plain(account, password)` — an account name and a password. The + password travels in a reversible encoding, so use it only over TLS. +- `with_sasl_external()` — the server reads the account from the TLS client + certificate (CertFP), and no password is sent. Set the certificate with + `Server::tls(..).with_client_cert_pem(..)`, and register its fingerprint with + the network first. +- `with_password(password)` — a server password (`PASS`). This authenticates to + the server itself, not to its services. + +**A failed SASL exchange fails the connection.** If the network offers no SASL, +or rejects the credentials, `connect` returns an error. The bot does not +continue unauthenticated: a bot that loses its identity without saying so is the +fault SASL exists to prevent. + +`with_capabilities` asks for further IRCv3 capabilities, for example +`server-time` or `multi-prefix`. The framework requests the ones the server +advertises and skips the rest. It does not interpret them itself — what they +change reaches handlers on the raw message. + +A bot with no credentials sends the same bare `NICK`/`USER` handshake as before, +and no `CAP` line at all. + ## TLS TLS is behind the optional `tls` feature, which pulls in @@ -114,8 +158,9 @@ Server::tls("irc.internal.example:6697") .with_extra_root_pem(std::fs::read("ca.pem")?) ``` -`with_client_cert_pem` presents a client certificate for CertFP / SASL EXTERNAL, -and `with_sni` overrides the verified hostname when connecting by IP. +`with_client_cert_pem` presents a client certificate for CertFP, which +`with_sasl_external` then authenticates with. `with_sni` overrides the verified +hostname when connecting by IP. `danger_accept_invalid_certs` disables verification entirely — it is meant for a development server on `localhost`, leaves the connection unauthenticated, and logs a warning on every connect. diff --git a/ircbot/examples/tls_bot.rs b/ircbot/examples/tls_bot.rs index 69b5ab8..2503957 100644 --- a/ircbot/examples/tls_bot.rs +++ b/ircbot/examples/tls_bot.rs @@ -33,13 +33,26 @@ async fn main() -> std::result::Result<(), Box