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-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 }); } 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/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 &'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, } } } 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:?}" + ); +}