diff --git a/src/engine/register.rs b/src/engine/register.rs index 6659f7f..1ab918f 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -58,6 +58,11 @@ impl Engine { pub fn authority_register(&mut self, name: &str, creds: Option, email: Option) -> AuthorityStatus { let Some(creds) = creds else { return AuthorityStatus::Internal }; + // Re-check the freeze pre_check saw: defcon may have frozen registrations + // during the ~1s off-lock derivation between the two calls. + if self.db.registrations_frozen() || self.db.readonly() { + return AuthorityStatus::Invalid; + } // The IRC REGISTER rejects a FORBIDden email; a website write must too. if let Some(addr) = &email { if self.db.is_forbidden(echo_api::ForbidKind::Email, addr).is_some() { @@ -89,6 +94,28 @@ impl Engine { self.db.scram_lookup(name, "SCRAM-SHA-256").map(|(a, v)| (a.to_string(), v.to_string())) } + /// gRPC login pre-check: apply the same guards the IRC IDENTIFY/SASL paths do + /// before the ~1s verify. Refuses a suspended or throttled account — the + /// website only sees a plain failure, so it can't tell these from a bad + /// password — and otherwise returns the canonical name + verifier to check + /// off the engine lock. Feed the result back through [`Self::authority_note_auth`]. + pub fn authority_auth_begin(&self, name: &str) -> Option<(String, String)> { + if let Some(acc) = self.db.resolve_account(name) { + if self.db.is_suspended(acc) { + return None; + } + } + if self.db.auth_lockout(name).is_some() { + return None; + } + self.scram_verifier(name) + } + + /// Record a gRPC login attempt in the brute-force backoff, exactly like IDENTIFY. + pub fn authority_note_auth(&mut self, name: &str, ok: bool) { + self.db.note_auth(name, ok); + } + pub fn authority_set_password(&mut self, account: &str, creds: Option) -> AuthorityStatus { let Some(creds) = creds else { return AuthorityStatus::Internal }; // set_credentials's only Err is Internal, which here always means "no such account". diff --git a/src/grpc.rs b/src/grpc.rs index f97812a..b12f09a 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -302,7 +302,7 @@ impl Accounts for AccountsService { // Fetch the verifier under the lock (cheap), then run the ~1s PBKDF2 on a // blocking thread — never hold the engine lock across it, or every login // freezes the whole daemon (IRC link, gossip, all RPCs). - let Some((account, verifier)) = self.engine.lock().await.scram_verifier(&msg.name) else { + let Some((account, verifier)) = self.engine.lock().await.authority_auth_begin(&msg.name) else { return Ok(Response::new(AuthenticateReply { status: PbStatus::Invalid as i32, account: String::new() })); }; let password = msg.password; @@ -311,6 +311,9 @@ impl Accounts for AccountsService { }) .await .unwrap_or(false); + // Feed the brute-force backoff just like IDENTIFY, so the website login + // can't be used as an unthrottled password oracle against echo. + self.engine.lock().await.authority_note_auth(&msg.name, ok); if ok { Ok(Response::new(AuthenticateReply { status: PbStatus::Ok as i32, account })) } else { @@ -621,6 +624,24 @@ mod tests { assert_eq!(bad.status, PbStatus::Invalid as i32); } + // The website login feeds the same brute-force backoff as IRC IDENTIFY, so it + // can't be used as an unthrottled password oracle: after enough wrong guesses + // even the correct password is refused while the lockout holds. + #[tokio::test] + async fn authenticate_throttles_repeated_failures() { + let (engine, _tx) = engine_with("acct-throttle"); + let svc = accounts_svc(engine); + svc.register(authed(RegisterRequest { name: "carol".into(), password: "hunter2".into(), email: String::new() }, "t")).await.unwrap(); + + for _ in 0..10 { + let out = svc.authenticate(authed(AuthenticateRequest { name: "carol".into(), password: "nope".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(out.status, PbStatus::Invalid as i32); + } + // Correct password, but the account is now locked out. + let locked = svc.authenticate(authed(AuthenticateRequest { name: "carol".into(), password: "hunter2".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(locked.status, PbStatus::Invalid as i32, "throttled even with the right password"); + } + #[tokio::test] async fn provision_creates_account_from_verifiers() { let (engine, _tx) = engine_with("acct-provision");