From 9a918839fe6af880ceafda724566682d6942d529 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 02:31:52 +0000 Subject: [PATCH] Derive registration credentials off-thread; rate-limit REGISTER; constant-time SCRAM compare --- Cargo.lock | 1 + Cargo.toml | 1 + src/engine/db.rs | 38 +++++++++-- src/engine/mod.rs | 132 ++++++++++++++++++++++++++++++++++----- src/engine/scram.rs | 4 +- src/engine/service.rs | 13 +++- src/link.rs | 28 ++++++++- src/proto/inspircd.rs | 2 + src/proto/mod.rs | 13 ++++ src/services/nickserv.rs | 18 +++--- 10 files changed, 214 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7865755..f49f334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tokio", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 1f886e9..df0387e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ base64 = "0.22" sha2 = "0.10" hmac = "0.12" pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +subtle = "2" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/engine/db.rs b/src/engine/db.rs index dfa2224..9c98841 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -38,6 +38,15 @@ pub enum RegError { Internal, } +// 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. +pub struct Credentials { + password_hash: String, + scram256: String, + scram512: String, +} + pub struct Db { accounts: HashMap, // keyed by casefolded name path: PathBuf, @@ -75,24 +84,43 @@ impl Db { self.accounts.contains_key(&key(name)) } - pub fn register(&mut self, name: &str, password: &str, email: Option) -> Result<(), RegError> { + /// Derive the password-bound half of an account. Pure and CPU-heavy (no + /// `&self`), so a caller can run it on a blocking thread; the cheap + /// `register_prepared` then commits the result. + pub fn derive_credentials(password: &str, iterations: u32) -> Option { + Some(Credentials { + password_hash: hash_password(password)?, + scram256: scram::make_verifier(Hash::Sha256, password, iterations), + scram512: scram::make_verifier(Hash::Sha512, password, iterations), + }) + } + + /// Commit pre-derived credentials as a new account. Cheap: no key stretching. + pub fn register_prepared(&mut self, name: &str, creds: Credentials, email: Option) -> Result<(), RegError> { if self.exists(name) { return Err(RegError::Exists); } - let password_hash = hash_password(password).ok_or(RegError::Internal)?; let account = Account { name: name.to_string(), - password_hash, + password_hash: creds.password_hash, email, ts: now(), - scram256: Some(scram::make_verifier(Hash::Sha256, password, self.scram_iterations)), - scram512: Some(scram::make_verifier(Hash::Sha512, password, self.scram_iterations)), + scram256: Some(creds.scram256), + scram512: Some(creds.scram512), }; self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); Ok(()) } + /// Register synchronously, deriving and committing in one call. A test helper; + /// the live paths derive off-thread via `derive_credentials` + `register_prepared`. + #[cfg(test)] + pub fn register(&mut self, name: &str, password: &str, email: Option) -> Result<(), RegError> { + let creds = Self::derive_credentials(password, self.scram_iterations).ok_or(RegError::Internal)?; + self.register_prepared(name, creds, email) + } + /// Check credentials; on success return the account's canonical name (its /// stored casing), else None. pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index a94c3b4..26a2ecd 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,10 +4,11 @@ pub mod service; pub mod state; use std::collections::HashMap; +use std::time::Instant; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use crate::proto::{NetAction, NetEvent}; +use crate::proto::{NetAction, NetEvent, RegReply}; use db::{Db, RegError}; use scram::Verifier; use service::{Sender, Service, ServiceCtx}; @@ -53,11 +54,23 @@ pub struct Engine { network: Network, db: Db, sasl_sessions: HashMap, // client uid -> in-progress exchange + reg_limiter: RegLimiter, } impl Engine { pub fn new(services: Vec>, db: Db) -> Self { - Self { services, network: Network::default(), db, sasl_sessions: HashMap::new() } + Self { + services, + network: Network::default(), + db, + sasl_sessions: HashMap::new(), + reg_limiter: RegLimiter::new(), + } + } + + // Cost of a fresh SCRAM verifier; read by the link layer for off-thread derivation. + pub fn scram_iterations(&self) -> u32 { + self.db.scram_iterations } // Sent right after the SERVER line: burst, introduce every service, endburst. @@ -215,26 +228,40 @@ impl Engine { } } - // Authority side of the IRCv3 account-registration relay: create the account - // (same store as classic NickServ REGISTER) and answer the requesting ircd. + // 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 { if !kind.eq_ignore_ascii_case("REGISTER") { return Vec::new(); // VERIFY / RESEND / STATUS: later } let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) }; - let (status, code, message) = match self.db.register(&account, &p3, email) { - Ok(()) => ("success", "*", "Account registered."), - Err(RegError::Exists) => ("error", "ACCOUNT_EXISTS", "That account name is already registered."), - Err(RegError::Internal) => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."), + vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }] + } + + // Cheap gate run before the expensive derivation: reject an already-taken name + // outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns + // the rejection response if refused, or None to proceed (spending a token). + pub fn pre_register_check(&mut self, account: &str, reply: &RegReply) -> Option> { + if self.db.exists(account) { + return Some(reg_reply(reply, RegOutcome::Exists, account)); + } + if !self.reg_limiter.allow() { + return Some(reg_reply(reply, RegOutcome::RateLimited, account)); + } + None + } + + // Commit credentials the link layer derived off-thread, then answer `reply`. + pub fn complete_register(&mut self, account: &str, creds: Option, email: Option, reply: RegReply) -> Vec { + let Some(creds) = creds else { + return reg_reply(&reply, RegOutcome::Internal, account); }; - vec![NetAction::AccountResponse { - reqid, - kind, - account, - status: status.to_string(), - code: code.to_string(), - message: message.to_string(), - }] + let outcome = match self.db.register_prepared(account, creds, email) { + Ok(()) => RegOutcome::Ok, + Err(RegError::Exists) => RegOutcome::Exists, + Err(RegError::Internal) => RegOutcome::Internal, + }; + reg_reply(&reply, outcome, account) } // Route a PRIVMSG addressed to a service (by uid or nick) into that service, @@ -277,6 +304,79 @@ fn sasl_success(agent: &str, client: &str, account: String) -> Vec { ] } +// Outcome of a registration attempt, rendered to the right wire response below. +enum RegOutcome { + Ok, + Exists, + RateLimited, + Internal, +} + +// Render a registration outcome for whichever origin asked (relay or NickServ), +// so both the pre-check rejection and the post-derivation result share one place. +fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec { + match reply { + RegReply::Relay { reqid, kind } => { + let (status, code, message) = match outcome { + RegOutcome::Ok => ("success", "*", "Account registered."), + RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already registered."), + RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."), + RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."), + }; + vec![NetAction::AccountResponse { + reqid: reqid.clone(), + kind: kind.clone(), + account: account.to_string(), + status: status.to_string(), + code: code.to_string(), + message: message.to_string(), + }] + } + RegReply::NickServ { agent, uid, nick } => { + let notice = |text: String| NetAction::Notice { from: agent.clone(), to: uid.clone(), text }; + match outcome { + RegOutcome::Ok => vec![ + // Registering identifies you to the nick right away (drives 900). + NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: nick.clone() }, + notice(format!("Nickname \x02{nick}\x02 is now registered.")), + ], + RegOutcome::Exists => vec![notice(format!("Nickname \x02{nick}\x02 is already registered."))], + RegOutcome::RateLimited => vec![notice("Too many registrations, please wait a moment.".to_string())], + RegOutcome::Internal => vec![notice("Registration failed, please try again later.".to_string())], + } + } + } +} + +// Token bucket bounding how often the expensive registration derivation may run, +// so a REGISTER flood degrades to a trickle instead of pinning a core. Global on +// purpose: it caps total work regardless of which user or server relayed it. +struct RegLimiter { + tokens: f64, + last: Instant, +} + +const REG_BURST: f64 = 8.0; // registrations allowed back-to-back from a full bucket +const REG_REFILL_PER_SEC: f64 = 0.5; // steady-state: one every two seconds + +impl RegLimiter { + fn new() -> Self { + Self { tokens: REG_BURST, last: Instant::now() } + } + + fn allow(&mut self) -> bool { + let now = Instant::now(); + self.tokens = (self.tokens + now.duration_since(self.last).as_secs_f64() * REG_REFILL_PER_SEC).min(REG_BURST); + self.last = now; + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/engine/scram.rs b/src/engine/scram.rs index a3c743f..3554ca8 100644 --- a/src/engine/scram.rs +++ b/src/engine/scram.rs @@ -13,6 +13,7 @@ use argon2::password_hash::rand_core::{OsRng, RngCore}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256, Sha512}; +use subtle::ConstantTimeEq; // Provisioning cost. High on purpose: the client bears the Hi() work each login, // the server only at REGISTER, and the verifier must not be a weaker @@ -227,7 +228,8 @@ pub fn verify_final( return None; } let client_key = xor(&proof, &client_signature); - if h(hash, &client_key) != v.stored_key { + // Constant-time: StoredKey is a secret, so leak nothing through compare timing. + if h(hash, &client_key).ct_eq(&v.stored_key).unwrap_u8() != 1 { return None; } let server_signature = hmac(hash, &v.server_key, auth_message.as_bytes()); diff --git a/src/engine/service.rs b/src/engine/service.rs index 45387d7..4247fc2 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -1,5 +1,5 @@ use crate::engine::db::Db; -use crate::proto::NetAction; +use crate::proto::{NetAction, RegReply}; // Who sent the command, resolved by the engine (UID + current nick). pub struct Sender<'a> { @@ -33,6 +33,17 @@ impl ServiceCtx { }); } + // Hand a registration to the engine to finish: its password derivation runs + // off the reactor, then the engine commits it and answers `reply`. + pub fn defer_register(&mut self, account: impl Into, password: impl Into, email: Option, reply: RegReply) { + self.actions.push(NetAction::DeferRegister { + account: account.into(), + password: password.into(), + email, + reply, + }); + } + // 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. diff --git a/src/link.rs b/src/link.rs index ffcf5ab..a08e15c 100644 --- a/src/link.rs +++ b/src/link.rs @@ -2,8 +2,9 @@ use anyhow::Result; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; +use crate::engine::db::Db; use crate::engine::Engine; -use crate::proto::Protocol; +use crate::proto::{NetAction, Protocol}; // One uplink session: connect, handshake + burst, then translate lines forever. pub async fn run(mut proto: Box, mut engine: Engine, addr: &str) -> Result<()> { @@ -24,8 +25,29 @@ pub async fn run(mut proto: Box, mut engine: Engine, addr: &str) - tracing::debug!(dir = "<<", %line); for event in proto.parse(&line) { for action in engine.handle(event) { - for out in proto.serialize(&action) { - send(&mut write, &out).await?; + let outs = match action { + // Registration: derive the password off the reactor so the + // ~1s of key stretching can't stall the link, after a cheap + // gate that rejects taken names and rate-limits floods. + NetAction::DeferRegister { account, password, email, reply } => { + match engine.pre_register_check(&account, &reply) { + Some(rejection) => rejection, + None => { + let iterations = engine.scram_iterations(); + let creds = tokio::task::spawn_blocking(move || { + Db::derive_credentials(&password, iterations) + }) + .await?; + engine.complete_register(&account, creds, email, reply) + } + } + } + action => vec![action], + }; + for act in outs { + for out in proto.serialize(&act) { + send(&mut write, &out).await?; + } } } } diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 3737022..5c63f0d 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -164,6 +164,8 @@ impl Protocol for InspIrcd { vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))] } NetAction::Raw(s) => vec![s.clone()], + // Internal: the link layer handles this before serialization. + NetAction::DeferRegister { .. } => vec![], } } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 73be7cb..63d90c1 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -35,6 +35,19 @@ pub enum NetAction { // advertised SASL mechanism list), otherwise a user uid (e.g. their account). Metadata { target: String, key: String, value: String }, Raw(String), + // Internal only, never serialized to the wire: a registration whose password + // still needs its (expensive) key derivation. The link layer runs the + // derivation off-thread, then calls Engine::complete_register. + DeferRegister { account: String, password: String, email: Option, reply: RegReply }, +} + +// How to answer a registration once its credentials have been derived. +#[derive(Debug, Clone)] +pub enum RegReply { + // IRCv3 account-registration relay: answer the requesting ircd. + Relay { reqid: String, kind: String }, + // NickServ REGISTER: NOTICE the requesting user, logging them in on success. + NickServ { agent: String, uid: String, nick: String }, } pub trait Protocol: Send { diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index ec4ee49..6868f71 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -1,5 +1,6 @@ -use crate::engine::db::{Db, RegError}; +use crate::engine::db::Db; use crate::engine::service::{Sender, Service, ServiceCtx}; +use crate::proto::RegReply; pub struct NickServ { pub uid: String, @@ -26,15 +27,12 @@ impl Service for NickServ { return; }; let email = args.get(2).map(|s| s.to_string()); - match db.register(from.nick, password, email) { - Ok(()) => { - // Registering identifies you to the nick right away (drives 900). - ctx.login(from.uid, from.nick); - ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)); - } - Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)), - Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."), - } + // The engine derives the password off-thread, commits, and answers. + ctx.defer_register(from.nick, *password, email, RegReply::NickServ { + agent: me.to_string(), + uid: from.uid.to_string(), + nick: from.nick.to_string(), + }); } Some("IDENTIFY") => { let Some(password) = args.get(1) else {