Derive registration credentials off-thread; rate-limit REGISTER; constant-time SCRAM compare
This commit is contained in:
parent
496c0f99a6
commit
9a918839fe
10 changed files with 214 additions and 36 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue