diff --git a/api/src/lib.rs b/api/src/lib.rs index bb74ec0..693adb3 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -122,6 +122,12 @@ pub enum NetAction { // Internal only: a password change awaiting the same off-thread derivation. // The link layer derives, then calls Engine::complete_password_change. DeferPassword { account: String, password: String, agent: String, uid: String }, + // Internal only: a password VERIFY (IDENTIFY / SASL PLAIN) awaiting the same + // off-thread work — the PBKDF2 that verify_plain runs is ~1s at production + // iteration counts, so it must never run under the engine lock. The link layer + // fetches `verifier` cheaply, runs verify_plain off-thread, then calls + // Engine::complete_authenticate with the boolean result and `then`. + DeferAuthenticate { verifier: String, password: String, then: AuthThen }, // Internal only: send an email (plaintext + optional HTML). The link layer // pipes it to the configured mail command off-thread; never serialized. SendEmail { to: String, subject: String, text: String, html: Option }, @@ -141,6 +147,18 @@ pub enum RegReply { NickServ { agent: String, uid: String, nick: String }, } +/// What to do once a deferred password verify (see [`NetAction::DeferAuthenticate`]) +/// completes, given the boolean result. The cheap pre-checks already ran; this is +/// only the success/failure finish. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthThen { + // NickServ IDENTIFY: `uid` logs in, `agent` (NickServ uid) sends the notices, + // `name` is what the user typed (for lockout/note_auth), `account` is canonical. + Identify { uid: String, agent: String, name: String, account: String }, + // SASL PLAIN: finish the SASL exchange for `client`, sourced from `agent`. + Sasl { agent: String, client: String, account: String }, +} + /// The ircd link layer. The engine only ever sees [`NetEvent`] / [`NetAction`]; /// raw server-to-server lines live entirely behind a `Protocol` impl, so a new /// ircd is one new module and the engine is untouched. @@ -317,6 +335,16 @@ impl ServiceCtx { }); } + // Hand a password verify (IDENTIFY / SASL PLAIN) to the engine to finish: the + // ~1s PBKDF2 runs off the reactor, then the engine completes it via `then`. + pub fn defer_authenticate(&mut self, verifier: impl Into, password: impl Into, then: AuthThen) { + self.actions.push(NetAction::DeferAuthenticate { + verifier: verifier.into(), + password: password.into(), + then, + }); + } + // 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. @@ -926,6 +954,10 @@ pub trait Store { fn accounts_by_email(&self, pattern: &str) -> Vec; // The canonical account name if the password is correct, else None. fn authenticate(&self, name: &str, password: &str) -> Option<&str>; + /// The account's canonical name and its SHA-256 verifier (owned), so the + /// caller can run the ~1s PBKDF2 verify OFF the engine lock via + /// `ctx.defer_authenticate` rather than the blocking `authenticate`. + fn scram_verifier(&self, name: &str) -> Option<(String, String)>; fn grouped_nicks(&self, account: &str) -> Vec; fn certfps(&self, account: &str) -> &[String]; fn is_verified(&self, account: &str) -> bool; diff --git a/modules/nickserv/src/identify.rs b/modules/nickserv/src/identify.rs index a199126..236dbed 100644 --- a/modules/nickserv/src/identify.rs +++ b/modules/nickserv/src/identify.rs @@ -1,4 +1,4 @@ -use echo_api::{human_time, Store}; +use echo_api::{human_time, AuthThen, Store}; use echo_api::{Sender, ServiceCtx}; // IDENTIFY [account] : log in. The account defaults to the current @@ -43,36 +43,27 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); return; } - // Take the result as owned so the account-store borrow ends before note_auth. - match db.authenticate(account_name, password).map(str::to_string) { - Some(account) => { - db.note_auth(account_name, true); - // Already identified to this account: don't re-fire the login. - if from.account == Some(account.as_str()) { - ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account)); - return; - } - ctx.login(from.uid, &account); - ctx.count("nickserv.identify"); - ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account)); - // Apply the account's auto-join list (AJOIN). - for entry in db.ajoin_list(&account) { - ctx.force_join(from.uid, &entry.channel, &entry.key); - } - // Apply the account's vhost (HostServ), if it has one. - if let Some(v) = db.vhost(&account) { - ctx.apply_vhost(from.uid, &v.host); - } - // Let them know about waiting memos. - let unread = db.unread_memos(&account); - if unread > 0 && db.memo_notify_on(&account) { - ctx.notice(me, from.uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02.")); - } - } + // Fetch the verifier cheaply and hand the (~1s) PBKDF2 verify to the engine to + // run off the reactor — never verify under the engine lock. The login finish + // happens in Engine::complete_authenticate. + match db.scram_verifier(account_name) { None => { + // Exists but has no verifier (e.g. cert-only) — a password can't match. db.note_auth(account_name, false); ctx.count("nickserv.identify_fail"); ctx.notice(me, from.uid, "Invalid password. Please try again."); } + Some((account, verifier)) => { + // Already identified to this account: skip the (wasted) verify. + if from.account == Some(account.as_str()) { + ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account)); + return; + } + ctx.defer_authenticate( + verifier, + password, + AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account }, + ); + } } } diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index f473827..d985043 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -396,7 +396,7 @@ impl Protocol for InspIrcd { NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. - NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } => vec![], + NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } => vec![], }; // A trailing parameter can carry free-form text (message bodies, kick // reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 532b4f3..c0526ed 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -33,6 +33,9 @@ impl Store for Db { fn authenticate(&self, name: &str, password: &str) -> Option<&str> { Db::authenticate(self, name, password) } + fn scram_verifier(&self, name: &str) -> Option<(String, String)> { + Db::scram_lookup(self, name, "SCRAM-SHA-256").map(|(a, v)| (a.to_string(), v.to_string())) + } fn grouped_nicks(&self, account: &str) -> Vec { Db::grouped_nicks(self, account) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4d81fa9..3013e65 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use tokio::sync::mpsc; -use crate::proto::{NetAction, NetEvent, RegReply}; +use crate::proto::{AuthThen, NetAction, NetEvent, RegReply}; use db::{Db, LogEntry, RegError}; use scram::Verifier; use echo_api::Privs; @@ -1110,6 +1110,21 @@ impl Engine { NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data), _ => Vec::new(), }; + // In tests there is no link layer to run DeferAuthenticate off-thread, so + // resolve it inline (test iteration counts are cheap) — the login finish is + // exactly what the link layer produces, and it must happen before + // track_accounts so the login is recorded within this handle(). + #[cfg(test)] + let evout: Vec = evout + .into_iter() + .flat_map(|a| match a { + NetAction::DeferAuthenticate { verifier, password, then } => { + let ok = crate::engine::scram::verify_plain(crate::engine::scram::Hash::Sha256, &verifier, &password); + self.complete_authenticate(ok, then) + } + other => vec![other], + }) + .collect(); self.track_accounts(&evout); out.extend(evout); // Give every user-removal a traceable incident id, stamped into its reason @@ -1415,15 +1430,17 @@ fn ci_hash(text: &str) -> u64 { h.finish() } -fn login_plain(b64: &str, db: &Db) -> Option { +// Decode a SASL PLAIN response into (authcid, password). The verify itself is +// deferred off the lock, so this no longer touches the store. +fn decode_plain(b64: &str) -> Option<(String, String)> { let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); if parts.len() != 3 { 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) + let authcid = std::str::from_utf8(parts[1]).ok()?.to_string(); + let passwd = std::str::from_utf8(parts[2]).ok()?.to_string(); + Some((authcid, passwd)) } // Report SASL failure to the ircd (drives 904). diff --git a/src/engine/register.rs b/src/engine/register.rs index 0802b9c..381da78 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -268,4 +268,51 @@ impl Engine { }; vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }] } + + /// Finish a deferred password verify once the off-thread `verify_plain` gave + /// `ok`. The cheap pre-checks (exists/suspended/lockout) already ran in the + /// caller; this is only the success/failure finish. IDENTIFY reuses the same + /// ctx helpers the inline path did, so its login side-effects (login, notice, + /// AJOIN, vhost, memo notice) stay identical. + pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec { + match then { + AuthThen::Identify { uid, agent, name, account } => { + self.db.note_auth(&name, ok); + let mut ctx = ServiceCtx::default(); + if !ok { + ctx.count("nickserv.identify_fail"); + ctx.notice(&agent, &uid, "Invalid password. Please try again."); + } else { + ctx.login(&uid, &account); + ctx.count("nickserv.identify"); + ctx.notice(&agent, &uid, format!("You're now identified as \x02{account}\x02. Welcome back!")); + for entry in self.db.ajoin_list(&account) { + ctx.force_join(&uid, &entry.channel, &entry.key); + } + let now = self.now_secs(); + let vhost = self.db.account(&account).and_then(|a| { + a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now)).map(|v| v.host.clone()) + }); + if let Some(host) = vhost { + ctx.apply_vhost(&uid, &host); + } + let unread = self.db.unread_memos(&account); + if unread > 0 && self.db.memo_notify_on(&account) { + ctx.notice(&agent, &uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02.")); + } + } + for key in std::mem::take(&mut ctx.stats) { + self.bump(&key); + } + ctx.actions + } + AuthThen::Sasl { agent, client, account } => { + if ok { + self.sasl_login(&agent, &client, account) + } else { + sasl_fail(&agent, &client) + } + } + } + } } diff --git a/src/engine/sasl.rs b/src/engine/sasl.rs index 7979038..3ad6a03 100644 --- a/src/engine/sasl.rs +++ b/src/engine/sasl.rs @@ -56,8 +56,17 @@ impl Engine { self.stash_sasl(client.clone(), SaslSession::Plain { response }); return Vec::new(); // more chunks still to come } - match login_plain(&response, &self.db) { - Some(account) => self.sasl_login(&agent, &client, account), + // Decode, then defer the verify off the lock (the login + // finish lands in Engine::complete_authenticate). + match decode_plain(&response) { + Some((authcid, passwd)) => match self.scram_verifier(&authcid) { + Some((account, verifier)) => vec![NetAction::DeferAuthenticate { + verifier, + password: passwd, + then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account }, + }], + None => mk("D", vec!["F".to_string()]), + }, None => mk("D", vec!["F".to_string()]), } } diff --git a/src/link.rs b/src/link.rs index e917dca..afdee7a 100644 --- a/src/link.rs +++ b/src/link.rs @@ -122,6 +122,15 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: .await?; engine.lock().await.complete_password_change(&account, creds, &agent, &uid) } + // Password verify (IDENTIFY / SASL PLAIN): run the ~1s + // PBKDF2 off the reactor, then finish under the lock. + NetAction::DeferAuthenticate { verifier, password, then } => { + let ok = tokio::task::spawn_blocking(move || { + crate::engine::scram::verify_plain(crate::engine::scram::Hash::Sha256, &verifier, &password) + }) + .await?; + engine.lock().await.complete_authenticate(ok, then) + } action => vec![action], }; for act in outs { diff --git a/src/proto.rs b/src/proto.rs index 59176ce..41f28d5 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,4 +1,4 @@ // The normalized protocol vocabulary lives in the echo-api SDK crate; // re-exported so the engine keeps referring to it as `crate::proto::*`. The // concrete ircd link (InspIRCd) is an external module crate, not part of core. -pub use echo_api::{NetAction, NetEvent, Protocol, RegReply}; +pub use echo_api::{AuthThen, NetAction, NetEvent, Protocol, RegReply};