nickserv/security: password policy, auth throttle, unguessable codes

Three brute-force / weak-secret gaps closed:
- REGISTER and SET PASSWORD now enforce a length range and reject a password
  equal to the nick (Anope's minpasslen/not-nick rules), with tests.
- IDENTIFY is throttled per account: a few free tries then an exponential
  backoff that clears on success, so a password can't be ground down. SASL had
  a limiter; interactive login had none.
- Emailed confirm/reset codes go from 6 digits to 8 base32 chars (~40 bits) and
  are burned after a few wrong guesses, closing a reset-code takeover window.
Throttle and code state are node-local and in-memory (not in the event log).
This commit is contained in:
Jean Chevronnet 2026-07-13 02:28:47 +00:00
parent 75ba9b35b4
commit f388e3d650
No known key found for this signature in database
8 changed files with 189 additions and 19 deletions

View file

@ -386,6 +386,10 @@ pub trait Store {
fn issue_code(&mut self, account: &str, kind: CodeKind) -> String; fn issue_code(&mut self, account: &str, kind: CodeKind) -> String;
fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool; fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool;
// Brute-force throttle for password authentication: how long the account is
// locked out (if at all), and a hook to record each attempt's outcome.
fn auth_lockout(&self, account: &str) -> Option<u64>;
fn note_auth(&mut self, account: &str, success: bool);
fn verify_account(&mut self, account: &str) -> Result<(), RegError>; fn verify_account(&mut self, account: &str) -> Result<(), RegError>;
fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError>; fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError>;
fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError>; fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError>;

View file

@ -3,7 +3,7 @@ use fedserv_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
// nick, so both the bare-password and account+password forms work. // nick, so both the bare-password and account+password forms work.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (account_name, password) = match (args.get(1), args.get(2)) { let (account_name, password) = match (args.get(1), args.get(2)) {
(Some(account), Some(password)) => (*account, *password), (Some(account), Some(password)) => (*account, *password),
(Some(password), None) => (from.nick, *password), (Some(password), None) => (from.nick, *password),
@ -17,14 +17,20 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered.")); ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered."));
return; return;
} }
match db.authenticate(account_name, password) { // Refuse while throttled, so a password can't be brute-forced.
if let Some(secs) = db.auth_lockout(account_name) {
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
return;
}
// Take the result as owned so the account-store borrow ends before note_auth.
match db.authenticate(account_name, password).map(str::to_string) {
Some(account) => { Some(account) => {
db.note_auth(account_name, true);
// Already identified to this account: don't re-fire the login. // Already identified to this account: don't re-fire the login.
if from.account == Some(account) { if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account)); ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
return; return;
} }
let account = account.to_string();
ctx.login(from.uid, &account); ctx.login(from.uid, &account);
ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account)); ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account));
// Apply the account's auto-join list (AJOIN). // Apply the account's auto-join list (AJOIN).
@ -32,6 +38,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.force_join(from.uid, &entry.channel, &entry.key); ctx.force_join(from.uid, &entry.channel, &entry.key);
} }
} }
None => ctx.notice(me, from.uid, "Invalid password. Please try again."), None => {
db.note_auth(account_name, false);
ctx.notice(me, from.uid, "Invalid password. Please try again.");
}
} }
} }

View file

@ -32,6 +32,8 @@ mod resetpass;
mod confirm; mod confirm;
#[path = "ajoin.rs"] #[path = "ajoin.rs"]
mod ajoin; mod ajoin;
#[path = "password.rs"]
mod password;
pub struct NickServ { pub struct NickServ {
pub uid: String, pub uid: String,

50
nickserv/src/password.rs Normal file
View file

@ -0,0 +1,50 @@
// Password policy for REGISTER and SET PASSWORD. Anope enforces a minimum
// length and forbids the password matching the nick; we do the same, plus an
// upper bound so a huge password can't be used to burn CPU in the key
// derivation. Length is measured in characters, the cap in bytes (what the
// hasher actually processes).
pub const MIN_PASSWORD_CHARS: usize = 8;
pub const MAX_PASSWORD_BYTES: usize = 300;
// Validate a new password for the account/nick it will protect. On rejection,
// returns the message to show the user.
pub fn validate_password(password: &str, owner: &str) -> Result<(), &'static str> {
if password.chars().count() < MIN_PASSWORD_CHARS {
return Err("That password is too short — please use at least 8 characters.");
}
if password.len() > MAX_PASSWORD_BYTES {
return Err("That password is too long.");
}
if password.eq_ignore_ascii_case(owner) {
return Err("Your password can't be the same as your nick. Please choose another.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_reasonable_password() {
assert!(validate_password("correct horse", "alice").is_ok());
}
#[test]
fn rejects_short_password() {
assert!(validate_password("short", "alice").is_err());
assert!(validate_password("1234567", "alice").is_err()); // 7 chars
assert!(validate_password("12345678", "alice").is_ok()); // 8 chars
}
#[test]
fn rejects_password_equal_to_nick() {
assert!(validate_password("Password", "password").is_err()); // case-insensitive
}
#[test]
fn rejects_overlong_password() {
assert!(validate_password(&"x".repeat(MAX_PASSWORD_BYTES + 1), "alice").is_err());
}
}

View file

@ -8,6 +8,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]"); ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
return; return;
}; };
if let Err(reason) = crate::password::validate_password(password, from.nick) {
ctx.notice(me, from.uid, reason);
return;
}
let email = args.get(2).map(|s| s.to_string()); let email = args.get(2).map(|s| s.to_string());
ctx.defer_register(from.nick, *password, email, RegReply::NickServ { ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
agent: me.to_string(), agent: me.to_string(),

View file

@ -13,6 +13,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>"); ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
return; return;
}; };
if let Err(reason) = crate::password::validate_password(password, account) {
ctx.notice(me, from.uid, reason);
return;
}
// The link layer derives the new password off-thread, then commits it. // The link layer derives the new password off-thread, then commits it.
ctx.defer_password(account, password, me, from.uid); ctx.defer_password(account, password, me, from.uid);
} }

View file

@ -475,10 +475,33 @@ pub struct Db {
email_brand: String, email_brand: String,
email_accent: String, email_accent: String,
email_logo: String, email_logo: String,
// Node-local, non-persisted email codes: account -> (purpose, code, expiry). // Node-local, non-persisted email codes, keyed by account.
codes: HashMap<String, (CodeKind, String, Instant)>, codes: HashMap<String, PendingCode>,
// Node-local, non-persisted brute-force throttle for password authentication.
auth_fails: HashMap<String, AuthThrottle>,
} }
// A pending emailed code and how many wrong guesses remain before it is burned.
struct PendingCode {
kind: CodeKind,
code: String,
deadline: Instant,
tries_left: u8,
}
// Failed-authentication state for one account (in memory only, reset on restart).
struct AuthThrottle {
fails: u32,
locked_until: Option<Instant>,
}
// Wrong-code guesses tolerated before a code is invalidated (defence in depth on
// top of the code's own entropy).
const CODE_TRIES: u8 = 5;
// Free password attempts before the exponential backoff kicks in, and its cap.
const AUTH_FREE_TRIES: u32 = 3;
const AUTH_MAX_BACKOFF_SECS: u64 = 300;
fn key(name: &str) -> String { fn key(name: &str) -> String {
name.to_ascii_lowercase() name.to_ascii_lowercase()
} }
@ -497,7 +520,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, event); apply(&mut accounts, &mut channels, &mut grouped, event);
} }
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new() } Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new() }
} }
/// Fold an entry authored by another node into the store — the services-side /// Fold an entry authored by another node into the store — the services-side
@ -755,20 +778,57 @@ impl Db {
/// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes. /// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes.
pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String { pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String {
let code = gen_code(); let code = gen_code();
self.codes.insert(key(account), (kind, code.clone(), Instant::now() + Duration::from_secs(900))); self.codes.insert(
key(account),
PendingCode { kind, code: code.clone(), deadline: Instant::now() + Duration::from_secs(900), tries_left: CODE_TRIES },
);
code code
} }
/// Consume a code for `account`: true if the purpose and code match and it /// Consume a code for `account`: true if the purpose and code match and it
/// hasn't expired. /// hasn't expired. A wrong guess burns a try and the code is dropped once
/// they run out, so it can't be ground down online.
pub fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool { pub fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool {
let k = key(account); let k = key(account);
match self.codes.get(&k) { let Some(pc) = self.codes.get_mut(&k) else { return false };
Some((ki, c, deadline)) if *ki == kind && c == code && *deadline > Instant::now() => { if pc.deadline <= Instant::now() {
self.codes.remove(&k); self.codes.remove(&k);
true return false;
} }
_ => false, if pc.kind == kind && pc.code == code {
self.codes.remove(&k);
return true;
}
pc.tries_left = pc.tries_left.saturating_sub(1);
if pc.tries_left == 0 {
self.codes.remove(&k);
}
false
}
/// Seconds the caller must wait before another password attempt on `account`,
/// or None if it is not currently throttled.
pub fn auth_lockout(&self, account: &str) -> Option<u64> {
let until = self.auth_fails.get(&key(account))?.locked_until?;
let now = Instant::now();
(until > now).then(|| (until - now).as_secs() + 1)
}
/// Record the outcome of a password attempt against `account`. Success clears
/// the counter; each failure past a few free tries grows an exponential
/// backoff, throttling guessing without a hard lockout a griefer could abuse.
pub fn note_auth(&mut self, account: &str, success: bool) {
let k = key(account);
if success {
self.auth_fails.remove(&k);
return;
}
let t = self.auth_fails.entry(k).or_insert(AuthThrottle { fails: 0, locked_until: None });
t.fails += 1;
if t.fails > AUTH_FREE_TRIES {
let shift = (t.fails - AUTH_FREE_TRIES - 1).min(9);
let secs = (1u64 << shift).min(AUTH_MAX_BACKOFF_SECS);
t.locked_until = Some(Instant::now() + Duration::from_secs(secs));
} }
} }
@ -1211,10 +1271,15 @@ fn glob_match(pattern: &str, text: &str) -> bool {
} }
// A random 6-digit code for email verification / password reset. // A random 6-digit code for email verification / password reset.
// An unguessable emailed code: 8 characters from a 32-symbol unambiguous
// alphabet (~40 bits). 256 is an exact multiple of 32, so the byte->symbol map
// is bias-free. Long enough that it can't be brute-forced inside its 15-minute
// window even without the per-code attempt limit.
fn gen_code() -> String { fn gen_code() -> String {
let mut b = [0u8; 4]; const ALPHABET: &[u8; 32] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // no 0/O/1/I
let mut b = [0u8; 8];
OsRng.fill_bytes(&mut b); OsRng.fill_bytes(&mut b);
format!("{:06}", u32::from_le_bytes(b) % 1_000_000) b.iter().map(|x| ALPHABET[(*x % 32) as usize] as char).collect()
} }
fn hash_password(password: &str) -> Option<String> { fn hash_password(password: &str) -> Option<String> {
@ -1286,6 +1351,12 @@ impl Store for Db {
fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool { fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool {
Db::take_code(self, account, kind, code) Db::take_code(self, account, kind, code)
} }
fn auth_lockout(&self, account: &str) -> Option<u64> {
Db::auth_lockout(self, account)
}
fn note_auth(&mut self, account: &str, success: bool) {
Db::note_auth(self, account, success)
}
fn verify_account(&mut self, account: &str) -> Result<(), RegError> { fn verify_account(&mut self, account: &str) -> Result<(), RegError> {
Db::verify_account(self, account) Db::verify_account(self, account)
} }
@ -1478,6 +1549,32 @@ mod tests {
assert_eq!(db.ajoin_list("alice")[0].channel, "#dev"); assert_eq!(db.ajoin_list("alice")[0].channel, "#dev");
} }
// A wrong code is tolerated a few times, then the code is burned so it can't
// be ground down online even though it is short.
#[test]
fn code_burns_after_too_many_wrong_guesses() {
let mut db = Db::open(&tmp("code"), "N1");
db.scram_iterations = 4096;
db.register("alice", "password", None).unwrap();
let code = db.issue_code("alice", CodeKind::Reset);
for _ in 0..CODE_TRIES {
assert!(!db.take_code("alice", CodeKind::Reset, "WRONG000"), "wrong guess fails");
}
assert!(!db.take_code("alice", CodeKind::Reset, &code), "the real code is now burned too");
}
// Password auth backs off after a few failures and the throttle clears on success.
#[test]
fn auth_throttle_locks_then_clears() {
let mut db = Db::open(&tmp("throttle"), "N1");
for _ in 0..=AUTH_FREE_TRIES {
db.note_auth("mallory", false);
}
assert!(db.auth_lockout("mallory").is_some(), "locked out after the free tries");
db.note_auth("mallory", true);
assert!(db.auth_lockout("mallory").is_none(), "a success clears the throttle");
}
// Locally-authored events get an incrementing per-origin seq and a ticking // Locally-authored events get an incrementing per-origin seq and a ticking
// Lamport clock. // Lamport clock.
#[test] #[test]

View file

@ -1411,7 +1411,7 @@ mod tests {
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "newbie".into(), host: "h".into() }); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "newbie".into(), host: "h".into() });
// REGISTER with an email defers; complete it as the link layer would. // REGISTER with an email defers; complete it as the link layer would.
let reg = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "REGISTER pw newbie@example.org".into() }); let reg = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "REGISTER correcthorse newbie@example.org".into() });
let (account, password, email, reply) = reg.iter().find_map(|a| match a { let (account, password, email, reply) = reg.iter().find_map(|a| match a {
NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())), NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())),
_ => None, _ => None,