Authenticate with SASL instead of messaging NickServ #4

Merged
drawblank merged 1 commit from feat/sasl into main 2026-08-20 10:14:38 +00:00
Owner

The bot identified by PRIVMSGing NickServ after registration. That leaves a window where it is connected but not logged in, and it needs a password for a service the network may not run.

SASL PLAIN authenticates during registration. The irc library already implements the state machine — pass sasl_login alongside the password and it runs CAP LS -> CAP REQ sasl -> AUTHENTICATE PLAIN -> CAP END before the bot is visible on the network.

Config changes

Key
password Removed. load_config now rejects the key rather than ignoring it — a stale config should fail loudly, not connect unauthenticated.
sasl_password The account password. Absent or empty disables SASL.
sasl_account Defaults to the nick; set only when they differ.

Verification

333 tests, up from 309. Six drive the real handshake against a fake socket rather than just checking the wiring:

   -> CAP LS
   -> NICK sloptime
:srv CAP * LS :sasl multi-prefix
   -> CAP REQ sasl
:srv CAP * ACK :sasl
   -> AUTHENTICATE PLAIN
AUTHENTICATE +
   -> AUTHENTICATE AHNsb3Bib3QtYWNjb3VudABodW50ZXIy
      decoded: '\x00slopbot-account\x00hunter2'
:srv 903 sloptime :SASL ok
   -> CAP END

Also covered: a server that does not advertise sasl, and one that rejects the credentials (904).

Six mutations introduced and confirmed caught (sasl_login using the nick instead of the account; sasl_login never set; password not reaching the ServerSpec; the old password key silently ignored; account not defaulting to nick; login_failed reading the wrong slot).

A library bug this works around

irc.client.Event takes (type, source, target, arguments=None), but the SASL code raises Event("login_failed", event.target, ["reason"]) — three positional args, so the reason lands in target and arguments is always empty.

My first on_login_failed read event.arguments and would have logged "no reason given" for every failure. The handshake test caught it; the handler now reads both slots, so it keeps working if the library is ever fixed.

Known constraint: SASL and a server password are mutually exclusive

ServerConnection.connect() uses password for SASL or for PASS, never both — the SASL branch returns before the PASS branch. So if the allow {} block that matches the bot requires a class password, this cannot currently send it and do SASL.

There is a test pinning that branch ordering, so if it ever changes upstream we find out from a failing test rather than in production. Supporting both would mean overriding connect() to send PASS first — happy to add it if the class password is staying.

The bot identified by PRIVMSGing NickServ after registration. That leaves a window where it is connected but not logged in, and it needs a password for a service the network may not run. SASL PLAIN authenticates *during* registration. The `irc` library already implements the state machine — pass `sasl_login` alongside the password and it runs `CAP LS` -> `CAP REQ sasl` -> `AUTHENTICATE PLAIN` -> `CAP END` before the bot is visible on the network. ## Config changes | Key | | |---|---| | `password` | **Removed.** `load_config` now *rejects* the key rather than ignoring it — a stale config should fail loudly, not connect unauthenticated. | | `sasl_password` | The account password. Absent or empty disables SASL. | | `sasl_account` | Defaults to the nick; set only when they differ. | ## Verification 333 tests, up from 309. Six drive the **real handshake** against a fake socket rather than just checking the wiring: ``` -> CAP LS -> NICK sloptime :srv CAP * LS :sasl multi-prefix -> CAP REQ sasl :srv CAP * ACK :sasl -> AUTHENTICATE PLAIN AUTHENTICATE + -> AUTHENTICATE AHNsb3Bib3QtYWNjb3VudABodW50ZXIy decoded: '\x00slopbot-account\x00hunter2' :srv 903 sloptime :SASL ok -> CAP END ``` Also covered: a server that does not advertise `sasl`, and one that rejects the credentials (`904`). Six mutations introduced and confirmed caught (sasl_login using the nick instead of the account; sasl_login never set; password not reaching the ServerSpec; the old `password` key silently ignored; account not defaulting to nick; login_failed reading the wrong slot). ## A library bug this works around `irc.client.Event` takes `(type, source, target, arguments=None)`, but the SASL code raises `Event("login_failed", event.target, ["reason"])` — three positional args, so **the reason lands in `target` and `arguments` is always empty**. My first `on_login_failed` read `event.arguments` and would have logged "no reason given" for every failure. The handshake test caught it; the handler now reads both slots, so it keeps working if the library is ever fixed. ## Known constraint: SASL and a server password are mutually exclusive `ServerConnection.connect()` uses `password` for SASL **or** for `PASS`, never both — the SASL branch returns before the `PASS` branch. So if the `allow {}` block that matches the bot requires a class password, this cannot currently send it *and* do SASL. There is a test pinning that branch ordering, so if it ever changes upstream we find out from a failing test rather than in production. Supporting both would mean overriding `connect()` to send `PASS` first — happy to add it if the class password is staying.
The bot identified by sending NickServ a PRIVMSG after registration, which
means a window where it is connected but not logged in, and a password
sitting in a plaintext config for a service the network may not even run.

SASL PLAIN authenticates during registration instead. The irc library has a
state machine for it: pass sasl_login alongside the password and it does
CAP LS -> CAP REQ sasl -> AUTHENTICATE PLAIN -> CAP END before the bot is
ever visible on the network.

Config
- password        -> removed, and load_config now rejects the key outright
                     rather than ignoring it and connecting unauthenticated.
- sasl_password   -> the account password; absent or empty disables SASL.
- sasl_account    -> defaults to the nick, set it only when they differ.

A rejected login is reported on stderr. The library raises login_failed as
Event(type, source, target) with the reason in the target slot and no
arguments -- Event takes arguments fourth and the call passes three -- so
on_login_failed reads both slots. Caught by a test that drives the real
handshake; the first version read arguments alone and would have logged
"no reason given" every time.

Tests: 333, up from 309. Six cover the protocol exchange itself against a
fake socket, including a server that does not advertise sasl and a server
that rejects the credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slopnode/bot!4
No description provided.