Skip to content

feat: SASL authentication and IRCv3 capability negotiation - #138

Merged
chrj merged 4 commits into
mainfrom
chrj/sasl-authentication
Aug 17, 2026
Merged

feat: SASL authentication and IRCv3 capability negotiation#138
chrj merged 4 commits into
mainfrom
chrj/sasl-authentication

Conversation

@chrj

@chrj chrj commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What this changes

The registration handshake was a bare NICK/USER pair, so a bot could not identify itself to services. Most networks need this: +r channels refuse an unauthenticated bot, and the account cloak never applies.

Registration now sends CAP LS 302 before NICK/USER, which makes the server hold registration open, and closes the exchange with CAP END. SASL runs inside that window.

Server::tls("irc.libera.chat:6697").with_sasl_plain("mybot", &password)
Server::tls("…").with_client_cert_pem(pem).with_sasl_external()   // CertFP
Server::plain("…").with_password("…")                              // PASS
Server::plain("…").with_capabilities(["server-time"])

The builders are on Server and on TlsServer.

Why the credentials live on Server

The generated new() connects immediately. By the time a State exists for a with_* builder to configure, the handshake is over. Server is the only value the caller supplies early enough, and it already travels into Blueprint, so a reconnect re-authenticates for free.

A refused SASL exchange fails the connection

If the network offers no sasl capability, refuses the mechanism, or rejects the credentials, connect returns an error. The bot does not continue unauthenticated. Arriving without your identity is the fault SASL prevents, so it is loud rather than silent.

A bot with no credentials sends the same handshake as before, and no CAP line at all.

Two failures this had to avoid

A dropped nick collision. The server answers NICK as soon as it reads it, which is in the middle of the capability exchange. The handshake buffers every line that does not belong to it and hands them back; the read loop drains those before it touches the socket. Without this the fallback nick would have stopped working.

A downgrade after a hot reload. A reloaded process rebuilt its Server from the inherited environment, which holds only the address. The inherited socket is already registered, so the bot kept working, but a later reconnect re-ran the handshake with no credentials. The generated new() now keeps the Server the caller passed. Nothing secret goes through the exec environment.

Notes

  • No new dependency. base64 is about 25 lines in auth.rs, tested against the RFC 4648 vectors. The test-util feature is added to the existing tokio dev-dependency so the timeout test runs on a paused clock.
  • PASS and AUTHENTICATE log a redacted form, and Debug redacts every credential, so a password cannot reach the protocol trace. Tests pin this.
  • The capability exchange is bounded by a 30 second timeout and by a line count, so a server that streams instead of answering cannot stall the bot or grow the buffer without limit.
  • All additive. No signature changed.

Testing

18 tests in ircbot/tests/sasl.rs drive the real State::connect against a scripted server on loopback and assert the bytes on the wire: the RFC 4616 payload for PLAIN, the empty response for EXTERNAL, multi-line CAP LS reassembly, a PING during the exchange, a nick collision reaching the read loop, and the message of every failure mode. 9 unit tests cover base64, redaction, capability selection, and CAP line parsing.

cargo test --workspace, cargo clippy --workspace --all-targets --features tls -- -D warnings, cargo fmt --all --check, and cargo doc with -D warnings all pass.

chrj added 4 commits August 16, 2026 06:15
The handshake was a bare NICK/USER pair, so a bot could not identify
itself to services. Modern networks need this: +r channels refuse an
unauthenticated bot, and the account cloak never applies.

Registration now sends CAP LS 302 before NICK/USER, which makes the
server hold registration open, and closes with CAP END. SASL PLAIN and
SASL EXTERNAL run inside that window. A PASS server password is sent
too, before NICK.

Credentials go on Server, not behind a State builder. The generated
new() connects immediately, so by the time a State exists to configure,
the handshake is over.

A refused SASL exchange fails the connection. If the network offers no
sasl capability, refuses the mechanism, or rejects the credentials,
connect returns an error. The bot does not continue unauthenticated.

Lines that arrive during the exchange but do not belong to it are handed
back to the read loop in arrival order. ERR_NICKNAMEINUSE is the one
that matters: the server sends it as soon as it reads NICK, which is in
the middle of the exchange, and dropping it would break the fallback
nick.

base64 is implemented here rather than added as a dependency. SASL needs
a few dozen bytes encoded once per connection.

PASS and AUTHENTICATE log a redacted form, and Debug redacts every
credential, so a password cannot reach the protocol trace.
A reloaded process rebuilt its Server from the inherited environment,
which holds only the address. The inherited socket is already
registered, so the bot kept working. A later reconnect did not: it
re-ran the handshake without credentials and arrived unauthenticated.

The generated new() now keeps the Server the caller passed, which
carries both the transport and the credentials. Nothing secret goes
through the exec environment.
Each test drives State::connect against a scripted server on loopback
and asserts the bytes on the wire: the RFC 4616 payload for SASL PLAIN,
the empty response for EXTERNAL, multi-line CAP LS reassembly, a PING
during the exchange, a nick collision reaching the read loop, and the
message of every failure mode.

The tokio test-util feature runs the registration timeout on a paused
clock, so that test costs no real time.
Add an Authentication section to the README, and show both SASL
mechanisms in the TLS example.
@chrj
chrj marked this pull request as ready for review August 17, 2026 10:10
@chrj
chrj merged commit 9a7bb6d into main Aug 17, 2026
9 checks passed
@chrj
chrj deleted the chrj/sasl-authentication branch August 17, 2026 10:11
@chrj-release-app chrj-release-app Bot mentioned this pull request Aug 16, 2026
chrj pushed a commit that referenced this pull request Aug 17, 2026
## 🤖 New release

* `ircbot-macros`: 0.4.2 -> 0.4.3
* `ircbot`: 0.4.2 -> 0.4.3 (✓ API compatible changes)

<details><summary><i><b>Changelog</b></i></summary><p>

## `ircbot-macros`

<blockquote>

##
[0.4.3](ircbot-macros-v0.4.2...ircbot-macros-v0.4.3)
- 2026-08-17

### Added

- SASL authentication and IRCv3 capability negotiation
([#138](#138))

### Other

- warn on missing documentation
([#131](#131))
</blockquote>

## `ircbot`

<blockquote>

## [0.4.3](v0.4.2...v0.4.3) -
2026-08-17

### Added

- SASL authentication and IRCv3 capability negotiation
([#138](#138))

### Other

- *(deps)* bump testcontainers from 0.27.3 to 0.28.0
([#135](#135))
- warn on missing documentation
([#131](#131))
</blockquote>


</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).

Co-authored-by: chrj-release-app[bot] <272076618+chrj-release-app[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant