core: generic spawn_crypto helper; offload all slow KDF hashing (OPER pbkdf2 too, and MKPASSWD) off the core thread

This commit is contained in:
Jean Chevronnet 2026-08-12 13:05:29 +00:00
parent e246699fef
commit 7cb58586b4
4 changed files with 83 additions and 17 deletions

View file

@ -131,15 +131,20 @@ impl Command for Oper {
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
return CmdResult::Fail; return CmdResult::Fail;
}; };
// bcrypt is slow — verify it off the core thread (result arrives as OperAuth). // a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread
if hash.starts_with("$2") { // (result arrives as OperAuth), so it can't freeze the server or be a DoS.
if !s.spawn_auth(uid, hash, pass, level) { if crate::modules::password_hash::is_slow(&hash) {
let ok = s.spawn_crypto(move || {
let ok = crate::modules::password_hash::verify(&hash, &pass);
crate::ircd::Event::OperAuth { uid, ok, level }
});
if !ok {
s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again"); s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again");
return CmdResult::Fail; return CmdResult::Fail;
} }
return CmdResult::Ok; // pending; oper-up happens when the verify returns return CmdResult::Ok; // pending; oper-up happens when the verify returns
} }
// fast hashes (plaintext / sha* / pbkdf2) verify inline // fast hashes (plaintext / sha*) verify inline
if crate::modules::password_hash::verify(&hash, &pass) { if crate::modules::password_hash::verify(&hash, &pass) {
s.oper_up(uid); s.oper_up(uid);
crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection

View file

@ -53,13 +53,19 @@ pub enum Event {
uid: Uid, uid: Uid,
ident: Option<String>, ident: Option<String>,
}, },
/// A background OPER password verify finished. bcrypt is deliberately slow, so it /// A background OPER password verify finished. KDF hashes are deliberately slow,
/// runs on a worker thread (see `Server::spawn_auth`) instead of freezing the core. /// so they run on a worker thread (see `Server::spawn_crypto`), not on the core.
OperAuth { OperAuth {
uid: Uid, uid: Uid,
ok: bool, ok: bool,
level: u32, level: u32,
}, },
/// A background MKPASSWD hash finished (KDFs run off the core thread).
MkpasswdResult {
uid: Uid,
algo: String,
hash: Option<String>,
},
/// A module's async HTTP request finished. `tag` is `"<module>:<detail>"` /// A module's async HTTP request finished. `tag` is `"<module>:<detail>"`
/// so the core can route the reply back to the module that issued it (e.g. /// so the core can route the reply back to the module that issued it (e.g.
/// account registration, captcha verification). `status` is 0 on transport /// account registration, captcha verification). `status` is 0 on transport
@ -219,6 +225,22 @@ impl Ircd {
.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); .numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
} }
} }
Event::MkpasswdResult { uid, algo, hash } => {
let nick = self
.server
.users
.get(&uid)
.map(|u| u.nick.clone())
.unwrap_or_default();
let line = match hash {
Some(h) => format!(
":{} NOTICE {nick} :{algo} hashed password: {h}",
self.server.name
),
None => format!(":{} NOTICE {nick} :Could not hash with '{algo}'", self.server.name),
};
self.server.send(uid, line);
}
Event::HttpResult { Event::HttpResult {
uid, uid,
tag, tag,

View file

@ -129,6 +129,18 @@ fn make(algo: &str, plaintext: &str) -> Option<String> {
Some(format!("{}:{}", algo.to_ascii_lowercase(), hex(&d))) Some(format!("{}:{}", algo.to_ascii_lowercase(), hex(&d)))
} }
/// Whether *verifying* this stored credential is a deliberately-slow KDF (bcrypt or
/// pbkdf2) that should run off the core thread rather than inline.
pub fn is_slow(stored: &str) -> bool {
stored.starts_with("$2") || stored.starts_with("pbkdf2:")
}
/// Whether *producing* a hash with this algorithm name is a slow KDF (for MKPASSWD).
pub fn is_slow_algo(algo: &str) -> bool {
let a = algo.to_ascii_lowercase();
a == "bcrypt" || a.starts_with("bcrypt:") || a == "pbkdf2"
}
pub fn commands() -> Vec<Box<dyn Command>> { pub fn commands() -> Vec<Box<dyn Command>> {
vec![Box::new(MkPasswd)] vec![Box::new(MkPasswd)]
} }
@ -152,13 +164,28 @@ impl Command for MkPasswd {
); );
return CmdResult::Fail; return CmdResult::Fail;
} }
let (algo, pass) = (&params[0], &params[1]); let (algo, pass) = (params[0].clone(), params[1].clone());
let nick = s let nick = s
.users .users
.get(&uid) .get(&uid)
.map(|u| u.nick.clone()) .map(|u| u.nick.clone())
.unwrap_or_default(); .unwrap_or_default();
match make(algo, pass) { // a KDF (bcrypt / pbkdf2) is slow — hash it off the core thread (result comes
// back as MkpasswdResult) so an oper's MKPASSWD can't freeze the whole server.
if is_slow_algo(&algo) {
let started = s.spawn_crypto(move || {
let hash = make(&algo, &pass);
crate::ircd::Event::MkpasswdResult { uid, algo, hash }
});
if !started {
s.send(
uid,
format!(":{} NOTICE {nick} :Busy hashing, try again", s.name),
);
}
return CmdResult::Ok;
}
match make(&algo, &pass) {
Some(hashed) => { Some(hashed) => {
s.send( s.send(
uid, uid,

View file

@ -450,24 +450,36 @@ impl Server {
}); });
} }
/// Verify an OPER password on a worker thread, delivering the result back as /// Run an expensive credential operation (a KDF: bcrypt / pbkdf2) on a worker
/// `Event::OperAuth`. bcrypt is deliberately expensive (tens to hundreds of ms), /// thread and deliver its result back as the `Event` the closure builds. These
/// so running it inline would freeze the single-threaded core — and an OPER flood /// hashes are deliberately slow (tens to hundreds of ms), so running one inline
/// against a bcrypt block would be a trivial DoS. Bounded so the flood can't spawn /// would freeze the single-threaded core — and a flood of them (OPER, TITLE, …)
/// unlimited hash threads; returns `false` when at capacity. /// against a KDF credential would be a trivial DoS. Bounded so the flood can't
pub fn spawn_auth(&self, uid: Uid, hash: String, pass: String, level: u32) -> bool { /// spawn unlimited threads; returns `false` when at capacity (the caller then
/// rejects the attempt). A `Drop` guard keeps the counter correct even if the
/// closure panics.
pub fn spawn_crypto<F>(&self, f: F) -> bool
where
F: FnOnce() -> crate::ircd::Event + Send + 'static,
{
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
static ACTIVE: AtomicUsize = AtomicUsize::new(0); static ACTIVE: AtomicUsize = AtomicUsize::new(0);
const MAX_ACTIVE: usize = 16; const MAX_ACTIVE: usize = 16;
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
ACTIVE.fetch_sub(1, Ordering::Relaxed);
}
}
if ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_ACTIVE { if ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_ACTIVE {
ACTIVE.fetch_sub(1, Ordering::Relaxed); ACTIVE.fetch_sub(1, Ordering::Relaxed);
return false; return false;
} }
let tx = self.event_tx.clone(); let tx = self.event_tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let ok = crate::modules::password_hash::verify(&hash, &pass); let _guard = Guard; // decrements even on panic
ACTIVE.fetch_sub(1, Ordering::Relaxed); let ev = f();
let _ = tx.send(crate::ircd::Event::OperAuth { uid, ok, level }); let _ = tx.send(ev);
}); });
true true
} }