Add the SASL PLAIN verification exchange

fedserv now handles the SASL exchange an ircd relays over ENCAP (modes H/S/C/D):
on PLAIN it answers the empty challenge, decodes the client payload, and verifies
the credentials against the account store, replying D S or D F. Wiring the
account login on success and advertising the mechanism list to the ircd are the
remaining integration steps.
This commit is contained in:
Jean Chevronnet 2026-07-11 21:18:42 +00:00
parent 18a28ed49b
commit 63028d99e5
No known key found for this signature in database
5 changed files with 96 additions and 1 deletions

View file

@ -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<Box<dyn Service>>,
network: Network,
db: Db,
sasl_sessions: HashMap<String, String>, // client uid -> in-progress mechanism
}
impl Engine {
pub fn new(services: Vec<Box<dyn Service>>, 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<String>) -> Vec<NetAction> {
let agent = match self.services.first() {
Some(s) => s.uid().to_string(),
None => return Vec::new(),
};
let mk = |mode: &str, d: Vec<String>| {
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,
}
}

View file

@ -98,6 +98,27 @@ impl Protocol for InspIrcd {
vec![]
}
}
// ENCAP <target> <subcmd> … — we only care about relayed SASL:
// ENCAP <target> SASL <client> <agent> <mode> [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 <agent> <client> <mode> [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()],
}
}

View file

@ -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<String> },
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<String> },
Raw(String),
}