From a382ac8ad3b6fbc20745b26a0047ebae233be026 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 03:34:46 +0000 Subject: [PATCH] Add SASL EXTERNAL --- src/engine/db.rs | 77 +++++++++++++++++++++++++ src/engine/mod.rs | 120 ++++++++++++++++++++++++++++++++++++++- src/services/nickserv.rs | 72 +++++++++++++++++++++-- 3 files changed, 264 insertions(+), 5 deletions(-) diff --git a/src/engine/db.rs b/src/engine/db.rs index 9c98841..e086fab 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -22,6 +22,10 @@ pub struct Account { pub scram256: Option, #[serde(default)] pub scram512: Option, + // TLS client-certificate fingerprints (lowercase hex) that may log in to + // this account via SASL EXTERNAL. Each fingerprint maps to one account. + #[serde(default)] + pub certfps: Vec, } // Event-sourced persistence: every change is an Event appended to a JSONL log, @@ -31,13 +35,31 @@ pub struct Account { #[serde(tag = "event")] pub enum Event { AccountRegistered(Account), + CertAdded { account: String, fp: String }, + CertRemoved { account: String, fp: String }, } +#[derive(Debug)] pub enum RegError { Exists, Internal, } +#[derive(Debug)] +pub enum CertError { + Invalid, // not a plausible fingerprint + InUse, // already registered (to any account) + NoAccount, // target account does not exist + Internal, // persistence failed +} + +// A fingerprint is hex (hash digest), optionally colon-separated. Bound the +// length so a junk value can't bloat an account. +fn valid_fp(fp: &str) -> bool { + let hex = fp.chars().filter(|c| *c != ':').count(); + (32..=128).contains(&hex) && fp.chars().all(|c| c.is_ascii_hexdigit() || c == ':') +} + // The expensive, password-derived half of an account, computed once at // registration. Split out from `register` so the derivation (argon2 + two SCRAM // verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking. @@ -72,6 +94,16 @@ impl Db { Ok(Event::AccountRegistered(a)) => { accounts.insert(key(&a.name), a); } + Ok(Event::CertAdded { account, fp }) => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.certfps.push(fp); + } + } + Ok(Event::CertRemoved { account, fp }) => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.certfps.retain(|c| *c != fp); + } + } Err(e) => tracing::warn!(%e, "skipping malformed event log line"), } } @@ -107,6 +139,7 @@ impl Db { ts: now(), scram256: Some(creds.scram256), scram512: Some(creds.scram512), + certfps: Vec::new(), }; self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -139,6 +172,50 @@ impl Db { Some((account.name.as_str(), verifier)) } + /// The canonical name of the account owning `fp`, if any. Fingerprints are + /// globally unique, so this is unambiguous. + pub fn certfp_owner(&self, fp: &str) -> Option<&str> { + let fp = fp.to_ascii_lowercase(); + self.accounts.values().find(|a| a.certfps.iter().any(|c| *c == fp)).map(|a| a.name.as_str()) + } + + /// The fingerprints registered to an account (empty if unknown/none). + pub fn certfps(&self, account: &str) -> &[String] { + self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice()) + } + + /// Register `fp` to `account`. Fingerprints are one-to-one with accounts. + pub fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError> { + let fp = fp.to_ascii_lowercase(); + if !valid_fp(&fp) { + return Err(CertError::Invalid); + } + if self.certfp_owner(&fp).is_some() { + return Err(CertError::InUse); + } + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(CertError::NoAccount); + } + self.append(&Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?; + self.accounts.get_mut(&k).unwrap().certfps.push(fp); + Ok(()) + } + + /// Remove `fp` from `account`. Ok(false) if the account had no such fingerprint. + pub fn certfp_del(&mut self, account: &str, fp: &str) -> Result { + let fp = fp.to_ascii_lowercase(); + let k = key(account); + match self.accounts.get(&k) { + None => return Err(CertError::NoAccount), + Some(a) if !a.certfps.iter().any(|c| *c == fp) => return Ok(false), + Some(_) => {} + } + self.append(&Event::CertRemoved { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?; + self.accounts.get_mut(&k).unwrap().certfps.retain(|c| *c != fp); + Ok(true) + } + fn append(&self, event: &Event) -> std::io::Result<()> { let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?; writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default()) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index fea997b..45bc148 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -16,7 +16,7 @@ use state::Network; // SASL mechanisms we offer, strongest first. Advertised to the uplink as the // `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept. -const SASL_MECHS: &str = "SCRAM-SHA-512,SCRAM-SHA-256,PLAIN"; +const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,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). @@ -31,6 +31,8 @@ enum SaslSession { Plain { response: String }, // SCRAM: which hash, and where we are in the challenge/response. Scram { hash: scram::Hash, step: ScramStep }, + // EXTERNAL: the TLS cert fingerprints the ircd relayed for this client. + External { fingerprints: Vec }, } // An in-progress exchange with its last-activity stamp, so abandoned sessions @@ -169,6 +171,12 @@ impl Engine { self.stash_sasl(client.clone(), SaslSession::Plain { response: String::new() }); mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload } + Some("EXTERNAL") => { + // The ircd appends the client's TLS cert fingerprints after the mechanism. + let fingerprints = data.iter().skip(1).map(|f| f.to_ascii_lowercase()).collect(); + self.stash_sasl(client.clone(), SaslSession::External { fingerprints }); + mk("C", vec!["+".to_string()]) // client sends its authzid (or "+") + } Some(mech) if scram::Hash::from_mech(mech).is_some() => { let hash = scram::Hash::from_mech(mech).unwrap(); self.stash_sasl(client.clone(), SaslSession::Scram { hash, step: ScramStep::ClientFirst }); @@ -200,6 +208,7 @@ impl Engine { } } Some(SaslSession::Scram { hash, step }) => self.sasl_scram(&agent, &client, hash, step, chunk), + Some(SaslSession::External { fingerprints }) => self.sasl_external(&agent, &client, fingerprints, chunk), } } "D" => { @@ -263,6 +272,27 @@ impl Engine { } } + // SASL EXTERNAL: the credential is the client's TLS cert fingerprint, which + // the ircd handed us in the S message (already validated at the handshake). + // Match it to an account; an authzid, if given, must name that same account. + fn sasl_external(&self, agent: &str, client: &str, fingerprints: Vec, chunk: &str) -> Vec { + let authzid = if chunk == "+" || chunk.is_empty() { + String::new() + } else { + match STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok()) { + Some(a) => a, + None => return sasl_fail(agent, client), + } + }; + match fingerprints.iter().find_map(|fp| self.db.certfp_owner(fp)) { + Some(account) if authzid.is_empty() || authzid.eq_ignore_ascii_case(account) => { + let account = account.to_string(); + sasl_success(agent, client, account) + } + _ => sasl_fail(agent, client), + } + } + // Authority side of the IRCv3 account-registration relay. Hands the work to // the link layer (which derives the password off-thread) via DeferRegister. fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec { @@ -330,6 +360,11 @@ fn login_plain(b64: &str, db: &Db) -> Option { db.authenticate(authcid, passwd).map(str::to_string) } +// Report SASL failure to the ircd (drives 904). +fn sasl_fail(agent: &str, client: &str) -> Vec { + vec![NetAction::Sasl { agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()] }] +} + // The two actions that complete any mechanism: set the account (drives 900), // then report SASL success (drives 903). fn sasl_success(agent: &str, client: &str, account: String) -> Vec { @@ -603,4 +638,87 @@ mod tests { e.handle(NetEvent::Quit { uid: "ZZZ".into() }); assert!(!e.sasl_sessions.contains_key("ZZZ"), "quit should drop the exchange"); } + + // Drive a SASL step with a multi-field data vector (EXTERNAL carries the + // mechanism plus the ircd-supplied fingerprints in one message). + fn sasl_multi(e: &mut Engine, mode: &str, data: &[&str]) -> Vec { + e.handle(NetEvent::Sasl { + client: "000AAAAAB".into(), + agent: "*".into(), + mode: mode.into(), + data: data.iter().map(|s| s.to_string()).collect(), + }) + } + + // EXTERNAL with a registered fingerprint logs in; matching is case-insensitive. + #[test] + fn external_success_with_registered_cert() { + let mut e = engine_with("extok", "foo", "sesame"); + e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap(); + assert_eq!(datum(&sasl_multi(&mut e, "S", &["EXTERNAL", "AABBCCDDEEFF00112233445566778899"])), ("C", "+")); + assert!(is_success(&sasl(&mut e, "C", "+")), "empty authzid should log in"); + } + + // An authzid, if sent, must name the cert's own account. + #[test] + fn external_authzid_must_match() { + let mut e = engine_with("extaz", "foo", "sesame"); + e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap(); + sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]); + assert!(is_success(&sasl(&mut e, "C", &STANDARD.encode("foo"))), "matching authzid ok"); + + let mut e = engine_with("extaz2", "foo", "sesame"); + e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap(); + sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]); + assert_eq!(datum(&sasl(&mut e, "C", &STANDARD.encode("bar"))), ("D", "F"), "foreign authzid rejected"); + } + + // An unknown fingerprint, or no fingerprint at all (plaintext client), fails. + #[test] + fn external_without_registered_cert_fails() { + let mut e = engine_with("extno", "foo", "sesame"); + sasl_multi(&mut e, "S", &["EXTERNAL", "0011223344556677889900aabbccddee"]); + assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "unknown fp rejected"); + + sasl_multi(&mut e, "S", &["EXTERNAL"]); // no fingerprint supplied + assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "no cert rejected"); + } + + // End to end: enrol a cert with NickServ CERT ADD, then log in with EXTERNAL. + #[test] + fn nickserv_cert_add_enables_external() { + let mut e = engine_with("nscert", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + let fp = "aabbccddeeff00112233445566778899"; + + let out = e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), + to: "42SAAAAAA".into(), + text: format!("CERT ADD sesame {fp}"), + }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))), "{out:?}"); + + sasl_multi(&mut e, "S", &["EXTERNAL", fp]); + assert!(is_success(&sasl(&mut e, "C", "+")), "external should work after CERT ADD"); + } + + // A wrong password can't enrol a cert, and a fingerprint is one account only. + #[test] + fn cert_add_is_guarded_and_unique() { + let mut e = engine_with("certguard", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + let fp = "aabbccddeeff00112233445566778899"; + + let bad = e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), + to: "42SAAAAAA".into(), + text: format!("CERT ADD wrongpw {fp}"), + }); + assert!(bad.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid password"))), "{bad:?}"); + assert!(e.db.certfp_owner(fp).is_none(), "bad password must not enrol"); + + e.db.certfp_add("foo", fp).unwrap(); + e.db.register("bar", "sesame", None).unwrap(); + assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account"); + } } diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index 6868f71..cf61ea8 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -1,4 +1,4 @@ -use crate::engine::db::Db; +use crate::engine::db::{CertError, Db}; use crate::engine::service::{Sender, Service, ServiceCtx}; use crate::proto::RegReply; @@ -48,11 +48,75 @@ impl Service for NickServ { None => ctx.notice(me, from.uid, "Invalid password."), } } - Some("HELP") => { - ctx.notice(me, from.uid, "Commands: REGISTER [email], IDENTIFY .") - } + Some("CERT") => self.cert(from, &args, ctx, db), + Some("HELP") => ctx.notice( + me, + from.uid, + "Commands: REGISTER [email], IDENTIFY , CERT ADD|DEL|LIST [fingerprint].", + ), Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)), None => {} } } } + +impl NickServ { + // CERT ADD|DEL|LIST [fingerprint]: manage the TLS certificate + // fingerprints that may log in to your account via SASL EXTERNAL. Gated by + // the account password, so it needs no separate identified-session state. + fn cert(&self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let me = self.uid.as_str(); + // All subcommands authenticate the sender's nick's account by password. + let auth = |db: &Db, password: &str| db.authenticate(from.nick, password).map(str::to_string); + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("LIST") => { + let Some(password) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: CERT LIST "); + return; + }; + let Some(account) = auth(db, password) else { + ctx.notice(me, from.uid, "Invalid password."); + return; + }; + let fps = db.certfps(&account); + if fps.is_empty() { + ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account."); + } else { + ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", "))); + } + } + Some("ADD") => { + let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else { + ctx.notice(me, from.uid, "Syntax: CERT ADD "); + return; + }; + let Some(account) = auth(db, password) else { + ctx.notice(me, from.uid, "Invalid password."); + return; + }; + match db.certfp_add(&account, fp) { + Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")), + Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."), + Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."), + Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."), + } + } + Some("DEL") | Some("REMOVE") => { + let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else { + ctx.notice(me, from.uid, "Syntax: CERT DEL "); + return; + }; + let Some(account) = auth(db, password) else { + ctx.notice(me, from.uid, "Invalid password."); + return; + }; + match db.certfp_del(&account, fp) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")), + Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."), + Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."), + } + } + _ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST [fingerprint]"), + } + } +}