Choose how IRC proves who you are

A mechanism picker in the SASL block, defaulted rather than chosen: the
default is right for nearly everyone, and each alternative needs something
the network has to support first.

The certificate field appears only for EXTERNAL, since every other
mechanism ignores it and a path box beside PLAIN reads as something that
does nothing. The path is never reported back - a frontend has no use for
it, and it names a file on whichever machine the daemon runs on rather
than this one - so the field shows only whether one is set.

SCRAM says plainly that almost no IRC network offers it. Better than
letting somebody pick it, watch the connection fail, and conclude the
client is broken.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 00:18:56 -04:00
co-authored by Claude Opus 5
parent cd72efb3e5
commit f47667928c
3 changed files with 58 additions and 2 deletions
+1 -1
Submodule nobilis updated: 696b08a76e...2f37c476eb
+47 -1
View File
@@ -361,7 +361,13 @@ function IrcSasl({
const [password, setPassword] = useState('')
const [allowPlaintext, setAllowPlaintext] = useState(account.allowPlaintextSasl)
const apply = (patch: { enabled?: boolean; saslUser?: string; allowPlaintextSasl?: boolean }): void =>
const apply = (patch: {
enabled?: boolean
saslUser?: string
allowPlaintextSasl?: boolean
saslMechanism?: string
saslCertPath?: string
}): void =>
call('setAccountSasl', {
accountId: account.id,
enabled: account.saslEnabled,
@@ -418,6 +424,46 @@ function IrcSasl({
}}
/>
{/* How the password is proved, where it is proved at all.
Defaulted rather than chosen, because the default is right for
nearly everyone and the alternatives each need something the
network has to support first. */}
<label className="field">
<span className="small muted">Mechanism</span>
<select
className="text-field"
value={account.saslMechanism}
onChange={(e) => apply({ saslMechanism: e.target.value })}
>
<option value="">Automatic — a certificate if set, otherwise PLAIN</option>
<option value="plain">PLAIN — the password, inside TLS</option>
<option value="scram-sha-256">SCRAM-SHA-256 — proves the password without sending it</option>
<option value="external">EXTERNAL — the client certificate, no password at all</option>
</select>
</label>
{/* Only where the mechanism needs one. EXTERNAL is the certificate;
everything else ignores it, and a path field beside PLAIN would
read as something that does nothing. */}
{(account.saslMechanism === 'external' || account.hasSaslCertificate) && (
<LabeledInput
label="Client certificate"
defaultValue={account.hasSaslCertificate ? '(set)' : ''}
placeholder="/path/to/cert.pem on the daemon's machine"
onCommit={(value) => apply({ saslCertPath: value === '(set)' ? undefined : value })}
/>
)}
{/* SCRAM proves the password to the server without sending it, and
proves the server knew it too - worth saying, because that second
half is the part people do not expect from a login. */}
{account.saslMechanism === 'scram-sha-256' && (
<p className="small muted">
Few IRC networks offer SCRAM — Ergo does, Libera and Rizon do not. If this server
refuses it and says what it does accept, moho falls back to that automatically.
</p>
)}
{/* Only where it is a live question. On a TLS connection the
allowance decides nothing, and offering it there would be a
switch that appears to weaken something and does not. */}
+10
View File
@@ -16,6 +16,16 @@ export interface Account {
saslEnabled: boolean
saslUsername: string
allowPlaintextSasl: boolean
/** Which SASL mechanism this account is pinned to, or '' for the default. */
saslMechanism: string
/**
* Whether a TLS client certificate is configured for SASL EXTERNAL.
*
* The path itself is deliberately not reported: a frontend never needs it,
* and it names a file on whichever machine the daemon runs on rather than
* this one.
*/
hasSaslCertificate: boolean
/**
* Whether the connection is encrypted. Only IRC is ever false - every other
* service here is HTTPS or WSS by construction.