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

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