Derive registration credentials off-thread; rate-limit REGISTER; constant-time SCRAM compare

This commit is contained in:
Jean Chevronnet 2026-07-12 02:31:52 +00:00
parent 496c0f99a6
commit 9a918839fe
No known key found for this signature in database
10 changed files with 214 additions and 36 deletions

View file

@ -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<String, Account>, // 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<String>) -> 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<Credentials> {
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<String>) -> 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<String>) -> 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> {

View file

@ -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<String, SaslSession>, // client uid -> in-progress exchange
reg_limiter: RegLimiter,
}
impl Engine {
pub fn new(services: Vec<Box<dyn Service>>, 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<NetAction> {
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<Vec<NetAction>> {
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<db::Credentials>, email: Option<String>, reply: RegReply) -> Vec<NetAction> {
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<NetAction> {
]
}
// 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<NetAction> {
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::*;

View file

@ -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());

View file

@ -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<String>, password: impl Into<String>, email: Option<String>, 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.