auth: offload IDENTIFY + SASL PLAIN verify off the engine lock
All checks were successful
CI / check (push) Successful in 3m47s
All checks were successful
CI / check (push) Successful in 3m47s
Both ran scram verify_plain (~1s PBKDF2 at 1.2M iters) inline under the engine lock, freezing the whole daemon per login. Add NetAction::DeferAuthenticate + AuthThen continuation + ctx.defer_authenticate, mirroring DeferPassword: the module/SASL path fetches the verifier cheaply and defers; the link layer runs verify_plain on spawn_blocking, then Engine::complete_authenticate finishes the login (IDENTIFY reuses the same ctx helpers, so its login/AJOIN/vhost/memo side-effects are unchanged). Tests resolve the defer inline (cfg(test)). The gRPC web-login path was already fixed; no login path stalls services now. (GHOST/DROP/CERT/GROUP still verify inline but are rare account ops, not logins.)
This commit is contained in:
parent
63fc2fb2c0
commit
2f9790feac
9 changed files with 144 additions and 36 deletions
|
|
@ -122,6 +122,12 @@ pub enum NetAction {
|
||||||
// Internal only: a password change awaiting the same off-thread derivation.
|
// Internal only: a password change awaiting the same off-thread derivation.
|
||||||
// The link layer derives, then calls Engine::complete_password_change.
|
// The link layer derives, then calls Engine::complete_password_change.
|
||||||
DeferPassword { account: String, password: String, agent: String, uid: String },
|
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
|
// Internal only: send an email (plaintext + optional HTML). The link layer
|
||||||
// pipes it to the configured mail command off-thread; never serialized.
|
// pipes it to the configured mail command off-thread; never serialized.
|
||||||
SendEmail { to: String, subject: String, text: String, html: Option<String> },
|
SendEmail { to: String, subject: String, text: String, html: Option<String> },
|
||||||
|
|
@ -141,6 +147,18 @@ pub enum RegReply {
|
||||||
NickServ { agent: String, uid: String, nick: String },
|
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`];
|
/// 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
|
/// raw server-to-server lines live entirely behind a `Protocol` impl, so a new
|
||||||
/// ircd is one new module and the engine is untouched.
|
/// 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<String>, password: impl Into<String>, 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
|
// 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
|
// RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the
|
||||||
// SASL agent applies. Used after a successful REGISTER / IDENTIFY.
|
// SASL agent applies. Used after a successful REGISTER / IDENTIFY.
|
||||||
|
|
@ -926,6 +954,10 @@ pub trait Store {
|
||||||
fn accounts_by_email(&self, pattern: &str) -> Vec<String>;
|
fn accounts_by_email(&self, pattern: &str) -> Vec<String>;
|
||||||
// The canonical account name if the password is correct, else None.
|
// The canonical account name if the password is correct, else None.
|
||||||
fn authenticate(&self, name: &str, password: &str) -> Option<&str>;
|
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<String>;
|
fn grouped_nicks(&self, account: &str) -> Vec<String>;
|
||||||
fn certfps(&self, account: &str) -> &[String];
|
fn certfps(&self, account: &str) -> &[String];
|
||||||
fn is_verified(&self, account: &str) -> bool;
|
fn is_verified(&self, account: &str) -> bool;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use echo_api::{human_time, Store};
|
use echo_api::{human_time, AuthThen, Store};
|
||||||
use echo_api::{Sender, ServiceCtx};
|
use echo_api::{Sender, ServiceCtx};
|
||||||
|
|
||||||
// IDENTIFY [account] <password>: log in. The account defaults to the current
|
// IDENTIFY [account] <password>: 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."));
|
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Take the result as owned so the account-store borrow ends before note_auth.
|
// Fetch the verifier cheaply and hand the (~1s) PBKDF2 verify to the engine to
|
||||||
match db.authenticate(account_name, password).map(str::to_string) {
|
// run off the reactor — never verify under the engine lock. The login finish
|
||||||
Some(account) => {
|
// happens in Engine::complete_authenticate.
|
||||||
db.note_auth(account_name, true);
|
match db.scram_verifier(account_name) {
|
||||||
// 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."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
None => {
|
||||||
|
// Exists but has no verifier (e.g. cert-only) — a password can't match.
|
||||||
db.note_auth(account_name, false);
|
db.note_auth(account_name, false);
|
||||||
ctx.count("nickserv.identify_fail");
|
ctx.count("nickserv.identify_fail");
|
||||||
ctx.notice(me, from.uid, "Invalid password. Please try again.");
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,7 @@ impl Protocol for InspIrcd {
|
||||||
NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))],
|
NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))],
|
||||||
NetAction::Raw(s) => vec![s.clone()],
|
NetAction::Raw(s) => vec![s.clone()],
|
||||||
// Internal: the link layer handles these before serialization.
|
// 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
|
// A trailing parameter can carry free-form text (message bodies, kick
|
||||||
// reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point
|
// reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ impl Store for Db {
|
||||||
fn authenticate(&self, name: &str, password: &str) -> Option<&str> {
|
fn authenticate(&self, name: &str, password: &str) -> Option<&str> {
|
||||||
Db::authenticate(self, name, password)
|
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<String> {
|
fn grouped_nicks(&self, account: &str) -> Vec<String> {
|
||||||
Db::grouped_nicks(self, account)
|
Db::grouped_nicks(self, account)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use std::time::{Duration, Instant};
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::proto::{NetAction, NetEvent, RegReply};
|
use crate::proto::{AuthThen, NetAction, NetEvent, RegReply};
|
||||||
use db::{Db, LogEntry, RegError};
|
use db::{Db, LogEntry, RegError};
|
||||||
use scram::Verifier;
|
use scram::Verifier;
|
||||||
use echo_api::Privs;
|
use echo_api::Privs;
|
||||||
|
|
@ -1110,6 +1110,21 @@ impl Engine {
|
||||||
NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data),
|
NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data),
|
||||||
_ => Vec::new(),
|
_ => 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<NetAction> = 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);
|
self.track_accounts(&evout);
|
||||||
out.extend(evout);
|
out.extend(evout);
|
||||||
// Give every user-removal a traceable incident id, stamped into its reason
|
// Give every user-removal a traceable incident id, stamped into its reason
|
||||||
|
|
@ -1415,15 +1430,17 @@ fn ci_hash(text: &str) -> u64 {
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
// 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 raw = STANDARD.decode(b64).ok()?;
|
||||||
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let authcid = std::str::from_utf8(parts[1]).ok()?;
|
let authcid = std::str::from_utf8(parts[1]).ok()?.to_string();
|
||||||
let passwd = std::str::from_utf8(parts[2]).ok()?;
|
let passwd = std::str::from_utf8(parts[2]).ok()?.to_string();
|
||||||
db.authenticate(authcid, passwd).map(str::to_string)
|
Some((authcid, passwd))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report SASL failure to the ircd (drives 904).
|
// Report SASL failure to the ircd (drives 904).
|
||||||
|
|
|
||||||
|
|
@ -268,4 +268,51 @@ impl Engine {
|
||||||
};
|
};
|
||||||
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
|
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<NetAction> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,17 @@ impl Engine {
|
||||||
self.stash_sasl(client.clone(), SaslSession::Plain { response });
|
self.stash_sasl(client.clone(), SaslSession::Plain { response });
|
||||||
return Vec::new(); // more chunks still to come
|
return Vec::new(); // more chunks still to come
|
||||||
}
|
}
|
||||||
match login_plain(&response, &self.db) {
|
// Decode, then defer the verify off the lock (the login
|
||||||
Some(account) => self.sasl_login(&agent, &client, account),
|
// 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()]),
|
None => mk("D", vec!["F".to_string()]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,15 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
||||||
.await?;
|
.await?;
|
||||||
engine.lock().await.complete_password_change(&account, creds, &agent, &uid)
|
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],
|
action => vec![action],
|
||||||
};
|
};
|
||||||
for act in outs {
|
for act in outs {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// The normalized protocol vocabulary lives in the echo-api SDK crate;
|
// The normalized protocol vocabulary lives in the echo-api SDK crate;
|
||||||
// re-exported so the engine keeps referring to it as `crate::proto::*`. The
|
// 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.
|
// 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};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue