From 65deeef2f7b0442c81709403b2906607a0add5db Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 00:36:51 +0000 Subject: [PATCH] Complete IRCv3 SASL 3.2 login for PLAIN Turn the PLAIN verification exchange into a full account login: - Advertise the mechanism list to the uplink as `saslmechlist` network metadata, so clients see `sasl=PLAIN` in CAP LS (SASL 3.2). - On a valid response set the client's `accountname` metadata (drives RPL_LOGGEDIN 900) before reporting success (D S, drives 903); a bad credential or unknown mechanism reports failure (D F, drives 904). - Reassemble the base64 response from the uplink's 400-byte AUTHENTICATE chunks, ending on a short chunk or a lone "+", with an upper bound to cap a pre-auth client's buffer. - Log the user in on NickServ REGISTER and IDENTIFY too, through the same accountname metadata, and return the canonical account name from the store so the login keeps its registered casing. Verified against irctest server_tests/sasl.py (PLAIN success/failure, non-ASCII, no-authzid, chunked payloads, unknown mechanism, too-long, retry) on InspIRCd + fedserv. --- src/engine/db.rs | 10 ++-- src/engine/mod.rs | 100 ++++++++++++++++++++++++++++++++------- src/engine/service.rs | 11 +++++ src/proto/inspircd.rs | 4 ++ src/proto/mod.rs | 3 ++ src/services/nickserv.rs | 17 +++++-- 6 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/engine/db.rs b/src/engine/db.rs index 438035a..e1f8ad9 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -76,11 +76,11 @@ impl Db { Ok(()) } - pub fn verify(&self, name: &str, password: &str) -> bool { - match self.accounts.get(&key(name)) { - Some(a) => verify_password(password, &a.password_hash), - None => false, - } + /// Check credentials; on success return the account's canonical name (its + /// stored casing), else None. + pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> { + let account = self.accounts.get(&key(name))?; + verify_password(password, &account.password_hash).then_some(account.name.as_str()) } fn append(&self, event: &Event) -> std::io::Result<()> { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 26600e2..e518882 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -9,11 +9,30 @@ use db::{Db, RegError}; use service::{Sender, Service, ServiceCtx}; use state::Network; +// SASL mechanisms we offer. Advertised to the uplink as the `sasl=` capability +// value (IRCv3 SASL 3.2 mechanism list) and the set we accept in the exchange. +const SASL_MECHS: &str = "PLAIN"; + +// A client's base64 response is split into chunks of this length; a chunk +// shorter than this (or a lone "+") marks the end of the response (SASL 3.1). +const MAX_AUTHENTICATE: usize = 400; + +// Upper bound on a reassembled response, to cap a pre-auth client's buffer. +const MAX_SASL_RESPONSE: usize = 8 * 1024; + +// A client's in-progress SASL exchange: the chosen mechanism and the base64 +// response reassembled from the uplink's fixed-size AUTHENTICATE chunks. +#[derive(Default)] +struct SaslSession { + mech: String, + response: String, +} + pub struct Engine { services: Vec>, network: Network, db: Db, - sasl_sessions: HashMap, // client uid -> in-progress mechanism + sasl_sessions: HashMap, // client uid -> in-progress exchange } impl Engine { @@ -33,6 +52,13 @@ impl Engine { gecos: svc.gecos().to_string(), }); } + // Advertise our SASL mechanisms so the uplink can offer `sasl=PLAIN` to + // clients in CAP LS (IRCv3 SASL 3.2). + out.push(NetAction::Metadata { + target: "*".to_string(), + key: "saslmechlist".to_string(), + value: SASL_MECHS.to_string(), + }); out.push(NetAction::EndBurst); out } @@ -57,10 +83,10 @@ impl Engine { } } - // SASL agent side of the exchange the ircd relays to us (modes H/S/C/D). - // PLAIN only for now: verify the credentials against the account store. NOTE: - // this proves the password; wiring the account login (metadata) + advertising - // the mechanism list to the ircd are the remaining integration steps. + // SASL agent side of the exchange the ircd relays to us (modes H/S/C/D), + // per IRCv3 SASL 3.2. PLAIN only: on a valid client response we set the + // client's account (drives 900) then report success (drives 903); a bad + // credential or unknown mechanism reports failure (drives 904). fn sasl(&mut self, client: String, mode: String, data: Vec) -> Vec { let agent = match self.services.first() { Some(s) => s.uid().to_string(), @@ -73,15 +99,54 @@ impl Engine { "H" => Vec::new(), // host info "S" => match data.first().map(String::as_str) { Some("PLAIN") => { - self.sasl_sessions.insert(client.clone(), "PLAIN".to_string()); + self.sasl_sessions.insert( + client.clone(), + SaslSession { mech: "PLAIN".to_string(), response: String::new() }, + ); mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload } _ => mk("D", vec!["F".to_string()]), // unsupported mechanism }, "C" => { - let is_plain = self.sasl_sessions.remove(&client).as_deref() == Some("PLAIN"); - let ok = is_plain && verify_plain(&data, &self.db); - mk("D", vec![if ok { "S".to_string() } else { "F".to_string() }]) + // Reassemble the base64 response: append each chunk until one is + // shorter than a full chunk (or a lone "+"), which ends it. + let chunk = data.first().map(String::as_str).unwrap_or(""); + let overflowed = match self.sasl_sessions.get_mut(&client) { + Some(s) => { + if chunk != "+" { + s.response.push_str(chunk); + } + s.response.len() > MAX_SASL_RESPONSE + } + None => return mk("D", vec!["F".to_string()]), + }; + if overflowed { + self.sasl_sessions.remove(&client); + return mk("D", vec!["F".to_string()]); + } + if chunk.len() >= MAX_AUTHENTICATE { + return Vec::new(); // more chunks still to come + } + let session = self.sasl_sessions.remove(&client).unwrap_or_default(); + let account = (session.mech == "PLAIN") + .then(|| login_plain(&session.response, &self.db)) + .flatten(); + match account { + Some(account) => vec![ + NetAction::Metadata { + target: client.clone(), + key: "accountname".to_string(), + value: account, + }, + NetAction::Sasl { + agent: agent.clone(), + client: client.clone(), + mode: "D".to_string(), + data: vec!["S".to_string()], + }, + ], + None => mk("D", vec!["F".to_string()]), + } } "D" => { self.sasl_sessions.remove(&client); @@ -131,17 +196,16 @@ impl Engine { } } -// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and verify it. -fn verify_plain(data: &[String], db: &Db) -> bool { +// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate +// it, returning the canonical account name on success. +fn login_plain(b64: &str, db: &Db) -> Option { use base64::{engine::general_purpose::STANDARD, Engine}; - let Some(b64) = data.first() else { return false }; - let Ok(raw) = STANDARD.decode(b64) else { return false }; + let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); if parts.len() != 3 { - return false; - } - match (std::str::from_utf8(parts[1]), std::str::from_utf8(parts[2])) { - (Ok(authcid), Ok(passwd)) => db.verify(authcid, passwd), - _ => false, + return None; } + let authcid = std::str::from_utf8(parts[1]).ok()?; + let passwd = std::str::from_utf8(parts[2]).ok()?; + db.authenticate(authcid, passwd).map(str::to_string) } diff --git a/src/engine/service.rs b/src/engine/service.rs index f8262b5..45387d7 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -32,4 +32,15 @@ impl ServiceCtx { text: text.into(), }); } + + // Log a user into an account: sets the accountname the ircd turns into + // RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the + // SASL agent applies. Used after a successful REGISTER / IDENTIFY. + pub fn login(&mut self, uid: &str, account: &str) { + self.actions.push(NetAction::Metadata { + target: uid.to_string(), + key: "accountname".to_string(), + value: account.to_string(), + }); + } } diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 393361f..3737022 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -159,6 +159,10 @@ impl Protocol for InspIrcd { } vec![self.from_us(line)] } + // METADATA : — "*" is server-global metadata. + NetAction::Metadata { target, key, value } => { + vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))] + } NetAction::Raw(s) => vec![s.clone()], } } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 0e7bb5c..73be7cb 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -31,6 +31,9 @@ pub enum NetAction { AccountResponse { reqid: String, kind: String, account: String, status: String, code: String, message: String }, // A SASL exchange step back to the ircd, sourced from our SASL agent. mode = C/D. Sasl { agent: String, client: String, mode: String, data: Vec }, + // Publish network state to the uplink: target "*" is server-global (e.g. the + // advertised SASL mechanism list), otherwise a user uid (e.g. their account). + Metadata { target: String, key: String, value: String }, Raw(String), } diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index 9dfe03e..ec4ee49 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -27,7 +27,11 @@ impl Service for NickServ { }; let email = args.get(2).map(|s| s.to_string()); match db.register(from.nick, password, email) { - Ok(()) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)), + Ok(()) => { + // Registering identifies you to the nick right away (drives 900). + ctx.login(from.uid, from.nick); + ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)); + } Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)), Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."), } @@ -37,10 +41,13 @@ impl Service for NickServ { ctx.notice(me, from.uid, "Syntax: IDENTIFY "); return; }; - if db.verify(from.nick, password) { - ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", from.nick)); - } else { - ctx.notice(me, from.uid, "Invalid password."); + match db.authenticate(from.nick, password) { + Some(account) => { + let account = account.to_string(); + ctx.login(from.uid, &account); + ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", account)); + } + None => ctx.notice(me, from.uid, "Invalid password."), } } Some("HELP") => {