Skip to content

SASL: wait for the sasl ACK, #180's failure causes, and a TLA+ model of the handshake - #215

Merged
rdrake merged 11 commits into
evilnet:developfrom
rdrake:claude/project-thread-4hmvgi
Nov 8, 2026
Merged

rdrake merged 11 commits into
evilnet:developfrom
rdrake:claude/project-thread-4hmvgi

Conversation

@rdrake

@rdrake rdrake commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #210 (the only commit here not on develop); review it after that lands. Carries #180: its two commits, by their author, ported to t() and merged with the ACK wait below. Once #210 is in, this supersedes #180.

What changes

  • SASL waits for the sasl ACK before AUTHENTICATE. sasl-3.1 says the client MUST enable sasl before using AUTHENTICATE. The opener used to go out in the same flush as the CAP REQ, and when that REQ was NAKed the server dropped it. The one-at-a-time retry never sent it again, so the login waited out 12 s and reconnected into the same NAK. sasl-3.2 has a server NAK sasl when its authentication layer is gone, so a conforming server reaches this. The cost is one round trip and no extra command, since registration lines are not charged fake lag.
  • A deadline covers that wait (CapNegotiator.onHold arms the SASL timer). A REQ that is never answered fails as a slow server; a sasl already NAKed or DELed keeps its refusal.
  • SASL: the server's own timeout, unreachable services and a trusted login's bare 904 retry instead of ending the network #180's failure causes (server / unavailable / unexplained / refused, plus trust for logins that have worked) replace the branch's first attempt at the same fix. That attempt reached the same server-side rule independently: a 906 we did not ask for, or a 904 saying timed out or unavailable.
  • test/formal/handshake/: a TLA+ model of CAP 302, SASL PLAIN and the registration gate, covering Seance and nefarious2 across every combination of caps offered and refused, services up, down or split, password, required SASL, trust, a client that ends CAP early, and slow peers.
    • replay.ts (in pnpm test:mocha) replays each of the model's 774 client views into the real IrcClient. It checks the lines Seance sends, and also how Seance leaves: refused (onSaslRejected), retrying (transport.abandon) or neither. Dropping the server-side cause fails 54 of them; dropping onHold fails 136.
    • live_server.py plays the model's client against a running ircd: 1728 of 1728 behaviours matched ircv3.2-upgrade.
    • The README lists what the model found and what stays open. Open: Seance's timer can beat slow services, and a misconfigured server that always sends 906 is retried every minute.

Checks

pnpm lint, pnpm typecheck and the production build pass, and pnpm test:mocha passes 5056 tests. TLC MC.cfg explores 88,570 distinct states with no error. Reviewed four times against the sources of nefarious2, X3, Ergo, Solanum, Atheme, Anope, InspIRCd and UnrealIRCd.

The intermediate commits include the superseded first fix; squash on merge.

rdrake and others added 11 commits November 7, 2026 22:17
nefarious2 gives up on slow or silent services after FEAT_SASL_TIMEOUT
(10 s) with "904 :SASL authentication failed: request timed out", before
our own 12 s timer. That 904 was taken for a wrong password: with SASL
required the client quit and called onSaslRejected, so a network whose
services lagged once stopped connecting by itself.

SaslAuth now marks a 904 transient when its text says the server timed
out, or when it arrives before the credentials went out (it cannot be a
refusal of them). saslProgress treats a transient failure like its own
timeout: QUIT, abandon the socket, and let the usual reconnect follow.
test/formal/handshake models Seance's CAP 302 / SASL PLAIN handshake
against nefarious2's registration gate and SASL relay, across every
combination of caps offered and refused, services up or down, password,
required SASL, a client that ends CAP early, and timely or slow peers.

replay.ts (run by mocha) replays every model behaviour into the real
IrcClient and fails on any line Seance sends that the model would not;
live_server.py plays the model's client against a running ircd. On
ircv3.2-upgrade all 990 server behaviours matched.

The model found the server-timeout misreading fixed in the previous
commit (NoFalseRejection). Two gaps stay open and are written up in the
README, with the properties they break in Findings.cfg: a NAKed REQ that
carried sasl loses the pipelined opener, and our timer can beat services.
A server that lists other mechanisms (908) will not take PLAIN on the
next connection either, so that 904 must not be retried as transient.
Review against Solanum, InspIRCd, UnrealIRCd, Atheme, Anope, Ergo and X3
sources: a 904 before the credentials went out is not an outage. Atheme
and Anope refuse EXTERNAL without a client certificate that way, and
Ergo's USER password can land there, so retrying looped for good. Only a
904 whose text names an outage (timed out, service unavailable) is
transient now, plus a 906 we did not ask for: nefarious2 sends it when
the services link splits mid-exchange, Solanum without services and
UnrealIRCd on its own timeout. The 908 special case goes with the rule
it guarded.

The model gains the split (ServerAgentLost) and dumps how Seance left
(outcome). replay.ts now asserts it against onSaslRejected and
transport.abandon: the same QUIT line hid the difference, so reverting
the fix passed before; it now fails 12 of 279 behaviours. gen.sh fails
when TLC does, and an empty fixture fails the replay.
sasl-3.1 says the client MUST enable sasl before using AUTHENTICATE.
Pipelining the opener behind the CAP REQ broke that, and when the REQ
was NAKed the server dropped the opener with it: the one-at-a-time
retry never sent it again, so the login waited out 12 s and reconnected
into the same NAK. sasl-3.2 has a server NAK sasl when its
authentication layer is gone, so a conforming server reaches this.

pipelineEnd now leaves the opener to maybeEnd while sasl is
outstanding. It costs one round trip and no extra command (registration
lines are not charged fake lag). A NAK of sasl alone now reports "the
server refused the sasl capability" with nothing opened.

"unavailable" joins the outage texts: nefarious2's Keycloak path says
"introspection unavailable" when it cannot reach Keycloak.

In the model, LoggedInWhenPossible and WelcomedWhenPossible now hold in
every world, NAKs included, so the no-NAK restrictions are gone. Replay
matches 319 client views; the live ircd matches 864 of 864 behaviours.
A REQ after CAP END and before 001 holds registration until another END
(capability-negotiation; nefarious2's cap_req does it too). The property
holds only because nefarious2 sends CAP NEW to registered clients alone.
Waiting for the ACK before AUTHENTICATE left no timer running until the
REQ was answered, so a server that never answered it held a required
login open for good. The SASL timer now starts when CAP END is held
back for sasl (CapNegotiator.onHold). If it runs out with nothing
opened, it fails as a timeout: QUIT and reconnect with SASL required,
CAP END without. A late ACK then opens nothing.

The model's timer covers the wait too; dropping onHold fails 68 of 387
replayed behaviours. The README lists the outage rule's limits: a server
misconfigured into 906 is retried every minute, and outages that send
the bare 904 still read as a wrong password.
…gin's bare 904 retry instead of ending the network

A phone brought back after a long time away often did not reconnect at
all, and the cause was on the login path.  nefarious2 gives up on slow
services or a slow Keycloak at 10 s (SASL_TIMEOUT, m_sasl.c abort_sasl),
before the client's own 12 s timer, and says so with `904 :SASL
authentication failed: request timed out`; the client took every 904 as
a wrong password: QUIT, the network down for good and autoconnect
switched off, so a later cold start never dialled either.  Services not
linked (`904 :… service unavailable`, and `sasl` left out of CAP LS by
sasl_server_available()) or split mid-exchange (906) ended the same way.
A phone coming back after hours meets exactly that slow path.

saslFailed takes a SaslFailureCause:

- `server`: our timeout, the server's own, "unavailable", or a 906 the
  server sent mid-exchange (sasl.ts isServerSideFailure).  QUIT,
  transport.abandon(), the usual schedule -- the path our own timeout
  already took.
- `unavailable`: no usable sasl cap, or a NAK.  Retried like `server`
  on a network where this account has logged in before
  (irc/saslTrust.ts: thelounge.state.saslOk, uuid -> account, never in
  the settings backup; forgotten when the credentials are edited, when
  a refusal is final and when the network is removed), final anywhere
  else.
- `unexplained`: a bare 904, which nefarious2 sends for a wrong password
  and for a Keycloak it cannot reach alike.  A login that has worked
  here gets SASL_TRUSTED_RETRIES (3) more connections in a row, reset by
  a success; otherwise final.
- `refused`: missing credentials, 902, 905, 907.  Final, as before; a
  final failure still stays down, forgets the login and turns
  autoconnect off.

Tests: test/irc/client-sasl.ts "the server could not check the login",
test/irc/sasl.ts "isServerSideFailure".  Docs: irc-layer.md's SASL
paragraph, seamless-reconnect.md round nine (which also records the
reconnect-timing parts of the first cut that evilnet#170, evilnet#172 and evilnet#155
superseded).

Ported onto this branch: strings go through t(), and since the opener
now waits for the sasl ACK, a NAKed sasl opens nothing. The retry rule
replaces the branch's own SaslResult.transient, which reached the same
server-side set (906, "timed out", "unavailable").
…, no false "not connected" lines, trust forgotten without a client

A Keycloak outage could still end the network. Keycloak down is a bare
904 at every attempt, and nefarious2 withdraws `sasl` and offers it
again meanwhile (its health timer reports success from a cached admin
token without reaching Keycloak), so the trusted retries ran out inside
the outage and the final refusal forgot the login and turned autoconnect
off: the cold start this branch is about never dialled. A trusted login
that runs out of retries now stays down for this connection only, keeps
the login and the autoconnect, says so (SASL_TRUSTED_GAVE_UP_HINT), and
the next connect, the user's or a page load's, gets the retries again.

A retried failure abandoned the socket from inside the negotiator's
`beforeEnd`; the `CAP END` flush handlers/cap.ts then sent went to the
dropped socket, and every line of it was reported as sent while
disconnected. `client.socketOpen` gates the flush, `finalLines` and
`registrationAwayLine` (which otherwise set state for lines that never
went out); `startSasl`'s missing-cap branch clears `saslOk`.

Trust was forgotten only through a live client. Now the bus edit
handler forgets it when the credentials differ from the saved entry's
(a blank password field keeps the remembered one), `rememberNetwork`
when the connect form's differ (`typedCredentialsDiffer`: a password the
entry does not remember starts untrusted at every sign-in, where the
user is at the keyboard to read a refusal), `saved-networks.ts remove`
beside its other forgets (the Networks page's Delete), and a restore
clears the key (CLEARED_ON_RESTORE).

Minor: `applyPendingOptions` forgets credentials edited while the old
login ran and succeeded; once the user has disconnected a failure ends
with its report, so no `closeExplained` is left to swallow the next real
drop's; the comment on SASL_TRUSTED_RETRIES says minutes, not seconds.

Tests: test/irc/client-sasl.ts (expectRetried pins the two error lines;
the trusted login that runs out keeps the login and retries at the next
connect; an edit during the exchange; a disconnect during a retryable
904), test/irc/saslTrust.ts (new), test/irc/multi-network.ts (an edit
with no live client), test/irc/saved-networks.ts (removal),
test/helpers/settingsBackup.ts (restore).

Ported onto this branch: the gave-up hint is connect.saslTrustedGaveUpHint
through t(), beside saslRequiredHint().
The environment gains `trusted` (the login has worked on this network,
saslTrust.ts). A trusted login retries a NAK or a missing sasl (cause
unavailable) and its first bare 904 (unexplained); an untrusted one
leaves as before. New invariant TrustedKeepsNetwork: a trusted login
never turns autoconnect off.

replay.ts trusts the login for those behaviours and gives each one a
fresh trust store (a success trusts it). 774 client views match;
breaking the unavailable rule fails 13 of them. The live ircd matches
1728 of 1728 behaviours.
Review of the evilnet#180 port. When sasl was already NAKed or DELed and some
other cap's reply never came, the ACK deadline failed as a slow server
(cause server), so an untrusted required login reconnected for good
instead of being refused. The deadline now fails as unavailable when
sasl itself is settled, through the same saslCapLost the beforeEnd
hook uses, which also stops the timer once there is nothing to wait
for. disconnect() stops it too: with the close event late, an optional
login reported a timeout and sent CAP END to a closed socket.

The model's timer makes the same distinction (its server answers sasl
last, so it never reaches the case; the client test does). The
TrustedKeepsNetwork comment says it covers a first connection only.
@rdrake
rdrake force-pushed the claude/project-thread-4hmvgi branch from 85f6bb1 to e706485 Compare November 8, 2026 03:20
@rdrake
rdrake marked this pull request as ready for review November 8, 2026 03:25
@rdrake
rdrake merged commit ba8a333 into evilnet:develop Nov 8, 2026
8 checks passed

This branch was previously deployed

1 inactive deployment
github-pages — e706485c Deployed Nov 8, 2026 by rdrake via deploy #757
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.

2 participants