Throttle every password-verify path (GROUP/GHOST/CERT/DROP/SASL) and key the lockout by resolved account
This commit is contained in:
parent
1ecb76affb
commit
660362a34b
7 changed files with 79 additions and 11 deletions
|
|
@ -5,15 +5,13 @@ use echo_api::{Sender, ServiceCtx};
|
||||||
// fingerprints that may log in to your account via SASL EXTERNAL. Each
|
// fingerprints that may log in to your account via SASL EXTERNAL. Each
|
||||||
// subcommand is password-gated, so it needs no identified-session state.
|
// subcommand is password-gated, so it needs no identified-session state.
|
||||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string);
|
|
||||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
Some("LIST") => {
|
Some("LIST") => {
|
||||||
let Some(password) = args.get(2) else {
|
let Some(password) = args.get(2) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
|
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(account) = auth(db, password) else {
|
let Some(account) = verify(me, from, ctx, db, password) else {
|
||||||
ctx.notice(me, from.uid, "Invalid password.");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let fps = db.certfps(&account);
|
let fps = db.certfps(&account);
|
||||||
|
|
@ -28,8 +26,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
|
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(account) = auth(db, password) else {
|
let Some(account) = verify(me, from, ctx, db, password) else {
|
||||||
ctx.notice(me, from.uid, "Invalid password.");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match db.certfp_add(&account, fp) {
|
match db.certfp_add(&account, fp) {
|
||||||
|
|
@ -44,8 +41,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
|
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(account) = auth(db, password) else {
|
let Some(account) = verify(me, from, ctx, db, password) else {
|
||||||
ctx.notice(me, from.uid, "Invalid password.");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match db.certfp_del(&account, fp) {
|
match db.certfp_del(&account, fp) {
|
||||||
|
|
@ -57,3 +53,26 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
|
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the caller's password against the account matching their current nick,
|
||||||
|
// throttled and recorded like IDENTIFY — so CERT can't be used as an unthrottled
|
||||||
|
// guessing oracle (a user can `/nick victim` then CERT LIST <guess>) and each ~1s
|
||||||
|
// verify can't be spammed to stall the engine.
|
||||||
|
fn verify(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, password: &str) -> Option<String> {
|
||||||
|
if let Some(secs) = db.auth_lockout(from.nick) {
|
||||||
|
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match db.authenticate(from.nick, password).map(str::to_string) {
|
||||||
|
Some(account) => {
|
||||||
|
db.note_auth(from.nick, true);
|
||||||
|
Some(account)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
db.note_auth(from.nick, false);
|
||||||
|
ctx.auth_report(false, Some(from.nick), "NickServ CERT", from.uid, Some("bad password"));
|
||||||
|
ctx.notice(me, from.uid, "Invalid password.");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,18 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
||||||
ctx.notice(me, from.uid, "Syntax: DROP <password>");
|
ctx.notice(me, from.uid, "Syntax: DROP <password>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Throttle the confirmation verify like IDENTIFY so the ~1s check can't be
|
||||||
|
// spammed to stall the engine, and record the attempt.
|
||||||
|
if let Some(secs) = db.auth_lockout(account) {
|
||||||
|
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if db.authenticate(account, password).is_none() {
|
if db.authenticate(account, password).is_none() {
|
||||||
|
db.note_auth(account, false);
|
||||||
ctx.notice(me, from.uid, "Invalid password.");
|
ctx.notice(me, from.uid, "Invalid password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
db.note_auth(account, true);
|
||||||
// A channel with a successor is inherited; the rest are dropped and released.
|
// A channel with a successor is inherited; the rest are dropped and released.
|
||||||
let (inherited, dropped) = db.release_founded_channels(account);
|
let (inherited, dropped) = db.release_founded_channels(account);
|
||||||
for chan in &dropped {
|
for chan in &dropped {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use echo_api::NetView;
|
||||||
// it. Every argument is a distinct input the guest-rename needs, so the count is
|
// it. Every argument is a distinct input the guest-rename needs, so the count is
|
||||||
// inherent.
|
// inherent.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store, regain: bool) {
|
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, regain: bool) {
|
||||||
let cmd = if regain { "RECOVER" } else { "GHOST" };
|
let cmd = if regain { "RECOVER" } else { "GHOST" };
|
||||||
let Some(&target) = args.get(1) else {
|
let Some(&target) = args.get(1) else {
|
||||||
ctx.notice(me, from.uid, format!("Syntax: {cmd} <nick> [password]"));
|
ctx.notice(me, from.uid, format!("Syntax: {cmd} <nick> [password]"));
|
||||||
|
|
@ -18,7 +18,25 @@ pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ar
|
||||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
|
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
|
// Ownership by an identified session, or by password — throttled and recorded
|
||||||
|
// like IDENTIFY so GHOST can't be an unthrottled guessing oracle against any
|
||||||
|
// registered nick, and each ~1s verify can't be spammed to stall the engine.
|
||||||
|
let owns = if from.account == Some(account.as_str()) {
|
||||||
|
true
|
||||||
|
} else if let Some(&pw) = args.get(2) {
|
||||||
|
if let Some(secs) = db.auth_lockout(&account) {
|
||||||
|
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = db.authenticate(&account, pw).is_some();
|
||||||
|
db.note_auth(&account, ok);
|
||||||
|
if !ok {
|
||||||
|
ctx.auth_report(false, Some(&account), "NickServ GHOST", from.uid, Some("bad password"));
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
if !owns {
|
if !owns {
|
||||||
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
|
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,20 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
|
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Throttle + record the attempt like IDENTIFY, or GROUP is an unthrottled
|
||||||
|
// password-guessing oracle against any account (and each ~1s verify blocks the
|
||||||
|
// engine). Also feeds the auth audit feed.
|
||||||
|
if let Some(secs) = db.auth_lockout(account) {
|
||||||
|
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
|
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
|
||||||
|
db.note_auth(account, false);
|
||||||
|
ctx.auth_report(false, Some(account), "NickServ GROUP", from.uid, Some("bad password"));
|
||||||
ctx.notice(me, from.uid, "Invalid account or password.");
|
ctx.notice(me, from.uid, "Invalid account or password.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
db.note_auth(account, true);
|
||||||
if db.account(from.nick).is_some() {
|
if db.account(from.nick).is_some() {
|
||||||
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
|
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,10 @@ impl Db {
|
||||||
/// Seconds the caller must wait before another password attempt on `account`,
|
/// Seconds the caller must wait before another password attempt on `account`,
|
||||||
/// or None if it is not currently throttled.
|
/// or None if it is not currently throttled.
|
||||||
pub fn auth_lockout(&self, account: &str) -> Option<u64> {
|
pub fn auth_lockout(&self, account: &str) -> Option<u64> {
|
||||||
let until = self.auth_fails.get(&key(account))?.locked_until?;
|
// Key the throttle by the resolved account, so every grouped-nick alias of
|
||||||
|
// one account shares a single lockout bucket (else each spelling is its own).
|
||||||
|
let k = self.resolved_key(account).unwrap_or_else(|| key(account));
|
||||||
|
let until = self.auth_fails.get(&k)?.locked_until?;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
(until > now).then(|| (until - now).as_secs() + 1)
|
(until > now).then(|| (until - now).as_secs() + 1)
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +314,7 @@ impl Db {
|
||||||
/// the counter; each failure past a few free tries grows an exponential
|
/// the counter; each failure past a few free tries grows an exponential
|
||||||
/// backoff, throttling guessing without a hard lockout a griefer could abuse.
|
/// backoff, throttling guessing without a hard lockout a griefer could abuse.
|
||||||
pub fn note_auth(&mut self, account: &str, success: bool) {
|
pub fn note_auth(&mut self, account: &str, success: bool) {
|
||||||
let k = key(account);
|
let k = self.resolved_key(account).unwrap_or_else(|| key(account));
|
||||||
if success {
|
if success {
|
||||||
self.auth_fails.remove(&k);
|
self.auth_fails.remove(&k);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,9 @@ impl Engine {
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
AuthThen::Sasl { agent, client, account } => {
|
AuthThen::Sasl { agent, client, account } => {
|
||||||
|
// Feed the same brute-force throttle IDENTIFY uses: success clears it,
|
||||||
|
// a failure grows the backoff — so SASL isn't a throttle-free door.
|
||||||
|
self.db.note_auth(&account, ok);
|
||||||
if ok {
|
if ok {
|
||||||
self.sasl_login("SASL PLAIN", &agent, &client, account)
|
self.sasl_login("SASL PLAIN", &agent, &client, account)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,13 @@ impl Engine {
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
Some((authcid, passwd)) => match self.scram_verifier(&authcid) {
|
Some((authcid, passwd)) => match self.scram_verifier(&authcid) {
|
||||||
|
// Refuse while throttled, so SASL PLAIN can't be used to
|
||||||
|
// brute-force past the IDENTIFY lockout.
|
||||||
|
Some((account, _)) if self.db.auth_lockout(&account).is_some() => {
|
||||||
|
let mut out = mk("D", vec!["F".to_string()]);
|
||||||
|
out.extend(self.auth_report(false, Some(&account), "SASL PLAIN", &client, Some("rate limited")));
|
||||||
|
out
|
||||||
|
}
|
||||||
Some((account, verifier)) => vec![NetAction::DeferAuthenticate {
|
Some((account, verifier)) => vec![NetAction::DeferAuthenticate {
|
||||||
verifier,
|
verifier,
|
||||||
password: passwd,
|
password: passwd,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue