diff --git a/api/src/lib.rs b/api/src/lib.rs index ca1482f..227bc92 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -243,8 +243,10 @@ pub enum AuthThen { // NickServ IDENTIFY: `uid` logs in, `agent` (NickServ uid) sends the notices, // `name` is what the user typed (for lockout/note_auth), `account` is canonical. Identify { uid: String, agent: String, name: String, account: String }, - // SASL PLAIN: finish the SASL exchange for `client`, sourced from `agent`. - Sasl { agent: String, client: String, account: String }, + // SASL: finish the exchange for `client`, sourced from `agent`. `password` is + // whether this was a password verify (feeds the brute-force throttle) vs a + // one-time keycard redemption (which must not touch the password lockout). + Sasl { agent: String, client: String, account: String, password: bool }, } /// The ircd link layer. The engine only ever sees [`NetEvent`] / [`NetAction`]; diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index ef10405..d73a8ac 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -165,7 +165,9 @@ impl GameServ { gt, meid, from.account.is_some(), - target_acc.clone().unwrap_or_else(|| target.to_string()), + // Key the target by the same identity ident() uses (account, else UID), + // or a guest target's acc_b (their nick) never matches their later meid. + target_acc.clone().unwrap_or_else(|| tuid.clone()), target_acc.is_some(), from.nick.to_string(), target.to_string(), diff --git a/src/engine/register.rs b/src/engine/register.rs index 7024d1b..36a0d10 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -340,10 +340,13 @@ impl Engine { actions.extend(feed); actions } - 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); + AuthThen::Sasl { agent, client, account, password } => { + // Feed the same brute-force throttle IDENTIFY uses (success clears it, + // failure grows the backoff) — but ONLY for a password verify; a failed + // one-time keycard redemption must not lock out the account's password. + if password { + self.db.note_auth(&account, ok); + } if ok { self.sasl_login("SASL PLAIN", &agent, &client, account) } else { diff --git a/src/engine/sasl.rs b/src/engine/sasl.rs index a007068..dc77e8a 100644 --- a/src/engine/sasl.rs +++ b/src/engine/sasl.rs @@ -74,7 +74,7 @@ impl Engine { vec![NetAction::DeferKeycard { token: passwd, account: account.clone(), - then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account }, + then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account, password: false }, }] } Some((authcid, passwd)) => match self.scram_verifier(&authcid) { @@ -88,7 +88,7 @@ impl Engine { Some((account, verifier)) => vec![NetAction::DeferAuthenticate { verifier, password: passwd, - then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account }, + then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account, password: true }, }], None => { let mut out = mk("D", vec!["F".to_string()]); @@ -134,6 +134,11 @@ impl Engine { let (account, Some(verifier)) = (account.to_string(), scram::parse_verifier(verifier)) else { return fail(); }; + // Refuse while throttled, so SCRAM (Orbit's default mech) can't be used + // to brute-force past the IDENTIFY/PLAIN lockout. + if self.db.auth_lockout(&account).is_some() { + return fail(); + } let (server_first, nonce) = scram::server_first(&cf.cnonce, &verifier); let out = challenge(server_first.clone()); self.stash_sasl(client.to_string(), SaslSession::Scram { @@ -153,11 +158,17 @@ impl Engine { let Some(msg) = decode(chunk) else { return fail() }; match scram::verify_final(hash, &verifier, &client_first_bare, &server_first, &gs2_header, &nonce, &msg) { Some(server_final) => { + // Feed the same brute-force throttle as IDENTIFY/PLAIN: clear it + // on success, grow the backoff on a wrong password. + self.db.note_auth(&account, true); let out = challenge(server_final); self.stash_sasl(client.to_string(), SaslSession::Scram { hash, step: ScramStep::Ack { account } }); out } - None => fail(), + None => { + self.db.note_auth(&account, false); + fail() + } } } // Client acknowledged our server-final ("+"); apply the login.