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") => {