diff --git a/Cargo.lock b/Cargo.lock index 7781370..c5ac6bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -107,6 +113,7 @@ version = "0.0.1" dependencies = [ "anyhow", "argon2", + "base64", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index a7dd2b0..9fbfbe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" argon2 = { version = "0.5", features = ["std"] } +base64 = "0.22" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 288343c..26600e2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -2,6 +2,8 @@ pub mod db; pub mod service; pub mod state; +use std::collections::HashMap; + use crate::proto::{NetAction, NetEvent}; use db::{Db, RegError}; use service::{Sender, Service, ServiceCtx}; @@ -11,11 +13,12 @@ pub struct Engine { services: Vec>, network: Network, db: Db, + sasl_sessions: HashMap, // client uid -> in-progress mechanism } impl Engine { pub fn new(services: Vec>, db: Db) -> Self { - Self { services, network: Network::default(), db } + Self { services, network: Network::default(), db, sasl_sessions: HashMap::new() } } // Sent right after the SERVER line: burst, introduce every service, endburst. @@ -49,6 +52,41 @@ impl Engine { NetEvent::AccountRequest { reqid, kind, account, p2, p3, .. } => { self.account_request(reqid, kind, account, p2, p3) } + NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data), + _ => Vec::new(), + } + } + + // 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. + fn sasl(&mut self, client: String, mode: String, data: Vec) -> Vec { + let agent = match self.services.first() { + Some(s) => s.uid().to_string(), + None => return Vec::new(), + }; + let mk = |mode: &str, d: Vec| { + vec![NetAction::Sasl { agent: agent.clone(), client: client.clone(), mode: mode.to_string(), data: d }] + }; + match mode.as_str() { + "H" => Vec::new(), // host info + "S" => match data.first().map(String::as_str) { + Some("PLAIN") => { + self.sasl_sessions.insert(client.clone(), "PLAIN".to_string()); + 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() }]) + } + "D" => { + self.sasl_sessions.remove(&client); + Vec::new() + } _ => Vec::new(), } } @@ -92,3 +130,18 @@ impl Engine { ctx.actions } } + +// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and verify it. +fn verify_plain(data: &[String], db: &Db) -> bool { + 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 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, + } +} diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 166a202..393361f 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -98,6 +98,27 @@ impl Protocol for InspIrcd { vec![] } } + // ENCAP … — we only care about relayed SASL: + // ENCAP SASL [data…] + "ENCAP" => { + let _target = tokens.next(); + match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("SASL") => { + let p: Vec<&str> = tokens.collect(); + if p.len() >= 3 { + vec![NetEvent::Sasl { + client: p[0].to_string(), + agent: p[1].to_string(), + mode: p[2].to_string(), + data: p[3..].iter().map(|s| s.to_string()).collect(), + }] + } else { + vec![] + } + } + _ => vec![NetEvent::Unknown { line: line.to_string() }], + } + } _ => vec![NetEvent::Unknown { line: line.to_string() }], } } @@ -129,6 +150,15 @@ impl Protocol for InspIrcd { reqid, kind, account, status, code, message ))] } + // ENCAP * SASL [data…] + NetAction::Sasl { agent, client, mode, data } => { + let mut line = format!("ENCAP * SASL {} {} {}", agent, client, mode); + for d in data { + line.push(' '); + line.push_str(d); + } + vec![self.from_us(line)] + } NetAction::Raw(s) => vec![s.clone()], } } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 4bdd21d..0e7bb5c 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -14,6 +14,8 @@ pub enum NetEvent { Quit { uid: String }, // An ircd relaying an IRCv3 account-registration request to us as the authority. AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String }, + // An ircd relaying a SASL exchange step to us (the SASL agent). mode = H/S/C/D. + Sasl { client: String, agent: String, mode: String, data: Vec }, Unknown { line: String }, } @@ -27,6 +29,8 @@ pub enum NetAction { Privmsg { from: String, to: String, text: String }, Notice { from: String, to: String, text: String }, 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 }, Raw(String), }