feat(irc): SASL PLAIN login and IRCv3 server-time

Registration now opens with CAP LS 302 (servers without IRCv3 ignore it
and register as before). From the offered capabilities (multi-line LS
lists included) the client requests server-time and, when the profile
enables it, sasl; then AUTHENTICATE PLAIN with the account (defaulting
to the nick) and a stored SASL password (irc-sasl-password), base64 in
400-byte chunks, and CAP END on success or failure — failures are shown
on the Server tab and the connection carries on (NickServ still works).
server-time tags stamp events with the server's clock, so replayed
history shows real times.

Tested against the fake IRC server: multi-line LS, REQ/ACK, the PLAIN
exchange, 903, CAP END, and a tagged message's timestamp.
This commit is contained in:
Andy Dixon
2026-10-12 19:11:16 +01:00
parent 0fc9451583
commit 32890c6415
7 changed files with 159 additions and 6 deletions
+13
View File
@@ -812,6 +812,19 @@ impl ProfileEditor {
}
}
});
section(ui, "SASL", |ui| {
check(ui, "Log In with SASL", &mut c.sasl, "Authenticates during registration (SASL PLAIN), as most networks now expect");
if c.sasl {
text(ui, "SASL Account", &mut c.sasl_account, "Defaults to your nick");
mode(ui, "SASL Password Storage", &mut c.sasl_mode);
if c.sasl_mode == SecretMode::Saved {
secret(ui, "SASL Password", secrets, SecretKind::IrcSaslPassword, "Leave blank to keep the saved password");
}
if !c.use_tls && !c.ssh_tunnel {
widgets::hint(ui, "Without TLS or an SSH tunnel the password crosses the network in the clear.");
}
}
});
section(ui, "NickServ", |ui| {
check(ui, "Identify on Connect", &mut c.nick_serv_identify, "");
if c.nick_serv_identify {
+125 -4
View File
@@ -53,6 +53,10 @@ pub struct Config {
pub nickserv_account: String,
pub nickserv_password: String,
/// SASL PLAIN login during registration (IRCv3); empty account disables it.
pub sasl_account: String,
pub sasl_password: String,
/// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when
/// enabled, is layered on top of it.
pub connector: Option<Connector>,
@@ -78,6 +82,8 @@ impl Default for Config {
nickserv_identify: false,
nickserv_account: String::new(),
nickserv_password: String::new(),
sasl_account: String::new(),
sasl_password: String::new(),
connector: None,
connect_timeout: Duration::ZERO,
on_event: Arc::new(|_| {}),
@@ -139,6 +145,8 @@ struct Channel {
struct State {
nick: String,
/// Capabilities from a multi-line CAP LS, gathered until the last line.
caps_offered: Vec<String>,
connected: bool,
closing: bool,
list_rooms: Vec<RoomInfo>,
@@ -153,6 +161,9 @@ pub struct Client {
cfg: Config,
state: Mutex<State>,
out: Mutex<Option<Sender<Out>>>,
/// The server-time of the line being handled (ms since the epoch; 0 = none), stamped on the
/// events it produces.
line_time: std::sync::atomic::AtomicI64,
}
/// A parsed server line.
@@ -160,6 +171,8 @@ struct Message {
source: String,
command: String,
params: Vec<String>,
/// IRCv3 `@time=` tag, in ms since the epoch.
time: Option<i64>,
}
impl Message {
@@ -187,6 +200,7 @@ impl Client {
cfg,
state: Mutex::new(State {
nick,
caps_offered: Vec::new(),
connected: false,
closing: false,
list_rooms: Vec::new(),
@@ -196,6 +210,7 @@ impl Client {
last_pong: Instant::now(),
}),
out: Mutex::new(None),
line_time: Default::default(),
})
}
@@ -546,6 +561,8 @@ impl Client {
stream.write_all(b"\r\n")?;
Ok(stream.flush()?)
};
// IRCv3 capability negotiation; servers without it ignore the command and register as usual.
write(&mut stream, "CAP LS 302")?;
if !self.cfg.server_pass.is_empty() {
write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?;
}
@@ -589,7 +606,9 @@ impl Client {
let raw: Vec<u8> = buf.drain(..=pos).collect();
let line = String::from_utf8_lossy(&raw);
if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) {
self.line_time.store(msg.time.unwrap_or(0), std::sync::atomic::Ordering::Relaxed);
self.handle_message(&msg);
self.line_time.store(0, std::sync::atomic::Ordering::Relaxed);
}
}
if buf.len() > MAX_LINE_BYTES {
@@ -612,6 +631,27 @@ impl Client {
fn handle_message(&self, m: &Message) {
match m.command.as_str() {
"CAP" => self.handle_cap(m),
"AUTHENTICATE" if m.param(0) == "+" => {
// SASL PLAIN: authzid NUL authcid NUL password, base64, in 400-byte chunks.
use base64::Engine;
let blob = base64::engine::general_purpose::STANDARD.encode(format!("{0}\0{0}\0{1}", self.cfg.sasl_account, self.cfg.sasl_password));
for chunk in blob.as_bytes().chunks(400) {
let _ = self.send("AUTHENTICATE", &[std::str::from_utf8(chunk).unwrap_or("")]);
}
if blob.len() % 400 == 0 {
let _ = self.send("AUTHENTICATE", &["+"]);
}
}
// 900 logged in, 903 success; 902/904–907 failures. Either way registration goes on.
"903" => {
self.emit(Event { kind: "notice".into(), text: format!("SASL: logged in as {}", self.cfg.sasl_account), ..Default::default() });
let _ = self.send("CAP", &["END"]);
}
"902" | "904" | "905" | "906" | "907" => {
self.emit(Event { kind: "error".into(), text: format!("SASL login failed: {}", m.last()), ..Default::default() });
let _ = self.send("CAP", &["END"]);
}
"001" => self.handle_welcome(m),
"433" | "436" | "437" => {
let mut st = self.st();
@@ -736,6 +776,52 @@ impl Client {
self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() });
}
/// CAP LS / ACK / NAK: ask for server-time (and sasl when configured), then authenticate or
/// end negotiation.
fn handle_cap(&self, m: &Message) {
let sub = m.param(1).to_ascii_uppercase();
let caps: Vec<&str> = m.last().split_whitespace().map(|c| c.split('=').next().unwrap_or(c)).collect();
let want_sasl = !self.cfg.sasl_account.is_empty();
match sub.as_str() {
// A multi-line LS list ("CAP * LS * :…") continues; act on the last line.
"LS" if m.params.len() >= 4 && m.param(2) == "*" => {
self.st().caps_offered.extend(caps.iter().map(|c| c.to_string()));
}
"LS" => {
let offered: Vec<String> = {
let mut st = self.st();
st.caps_offered.extend(caps.iter().map(|c| c.to_string()));
std::mem::take(&mut st.caps_offered)
};
let mut req = vec![];
if offered.iter().any(|c| c == "server-time") {
req.push("server-time");
}
if want_sasl && offered.iter().any(|c| c == "sasl") {
req.push("sasl");
} else if want_sasl {
self.emit(Event { kind: "error".into(), text: "SASL: the server doesn't offer it".into(), ..Default::default() });
}
if req.is_empty() {
let _ = self.send("CAP", &["END"]);
} else {
let _ = self.send("CAP", &["REQ", &req.join(" ")]);
}
}
"ACK" => {
if want_sasl && caps.contains(&"sasl") {
let _ = self.send("AUTHENTICATE", &["PLAIN"]);
} else {
let _ = self.send("CAP", &["END"]);
}
}
"NAK" => {
let _ = self.send("CAP", &["END"]);
}
_ => {}
}
}
fn handle_join(&self, m: &Message) {
let room = m.param(0).to_string();
if room.is_empty() {
@@ -931,7 +1017,8 @@ impl Client {
fn emit(&self, mut event: Event) {
if event.timestamp == 0 {
event.timestamp = chrono::Utc::now().timestamp_millis();
let t = self.line_time.load(std::sync::atomic::Ordering::Relaxed);
event.timestamp = if t > 0 { t } else { chrono::Utc::now().timestamp_millis() };
}
if event.target.is_empty() {
event.target = SERVER_TARGET.into();
@@ -1004,8 +1091,11 @@ fn encode(command: &str, params: &[&str]) -> String {
fn parse_line(line: &str) -> Option<Message> {
let mut rest = line.trim_start();
if rest.starts_with('@') {
rest = rest.split_once(' ')?.1.trim_start();
let mut time = None;
if let Some(tagged) = rest.strip_prefix('@') {
let (tags, tail) = tagged.split_once(' ')?;
time = tags.split(';').find_map(|t| t.strip_prefix("time=")).and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok()).map(|d| d.timestamp_millis());
rest = tail.trim_start();
}
let mut source = String::new();
if let Some(prefixed) = rest.strip_prefix(':') {
@@ -1021,7 +1111,7 @@ fn parse_line(line: &str) -> Option<Message> {
let command = words.next()?.to_uppercase();
let mut params: Vec<String> = words.map(String::from).collect();
params.extend(trailing.map(String::from));
Some(Message { source, command, params })
Some(Message { source, command, params, time })
}
/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`.
@@ -1204,6 +1294,36 @@ mod tests {
}
}
#[test]
fn negotiates_server_time_and_sasl() {
let (client, listener, rx, _) = harness(Config { sasl_account: "acct".into(), sasl_password: "s3cret".into(), ..Default::default() });
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
assert_eq!(srv.line(), "CAP LS 302");
assert_eq!(srv.line(), "NICK me");
assert_eq!(srv.line(), "USER me * * me");
// A two-line LS list; only the last line triggers the request.
srv.send(":srv CAP * LS * :multi-prefix server-time");
srv.send(":srv CAP * LS :sasl=PLAIN,EXTERNAL away-notify");
assert_eq!(srv.line(), "CAP REQ :server-time sasl");
srv.send(":srv CAP me ACK :server-time sasl");
assert_eq!(srv.line(), "AUTHENTICATE PLAIN");
srv.send("AUTHENTICATE +");
use base64::Engine;
assert_eq!(srv.line(), format!("AUTHENTICATE {}", base64::engine::general_purpose::STANDARD.encode("acct\0acct\0s3cret")));
srv.send(":srv 903 me :SASL authentication successful");
assert_eq!(srv.line(), "CAP END");
assert!(wait_for(&rx, |e| e.text.starts_with("SASL: logged in")).text.contains("acct"));
srv.send(":srv 001 me :Welcome");
connecting.join().unwrap().unwrap();
// server-time stamps events with the server's clock, not ours.
srv.send("@time=2024-05-01T12:00:00.250Z :bob!u@h PRIVMSG me :old news");
let e = wait_for(&rx, |e| e.kind == "message" || e.kind == "privmsg" || e.text == "old news");
assert_eq!(e.timestamp, 1714564800250);
client.close();
}
#[test]
fn registers_tracks_rooms_and_handles_input() {
let (client, listener, rx, closes) = harness(Config {
@@ -1217,6 +1337,7 @@ mod tests {
let c = client.clone();
let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener);
assert_eq!(srv.line(), "CAP LS 302");
assert_eq!(srv.line(), "PASS pw");
assert_eq!(srv.line(), "NICK me");
assert_eq!(srv.line(), "USER me * * me");
+5
View File
@@ -466,6 +466,11 @@ pub struct IrcConfig {
pub nick_serv_identify: bool,
pub nick_serv_account: String,
pub nick_serv_mode: SecretMode,
/// Log in with SASL PLAIN during registration (needs the network to offer it).
pub sasl: bool,
/// SASL account (defaults to the nick).
pub sasl_account: String,
pub sasl_mode: SecretMode,
#[serde(deserialize_with = "nd")]
pub auto_join_rooms: Vec<String>,
pub ssh_tunnel: bool,
+4 -1
View File
@@ -35,12 +35,13 @@ pub enum SecretKind {
RdpSshPassword,
RdpSshPassphrase,
RdpGatewayPassword,
IrcSaslPassword,
}
impl SecretKind {
/// Every kind, so deleting a profile can purge them all from the keychain. A new variant
/// must be added here as well as to `as_str`.
pub const ALL: [SecretKind; 16] = [
pub const ALL: [SecretKind; 17] = [
Self::Password,
Self::Passphrase,
Self::Totp,
@@ -57,6 +58,7 @@ impl SecretKind {
Self::RdpSshPassword,
Self::RdpSshPassphrase,
Self::RdpGatewayPassword,
Self::IrcSaslPassword,
];
/// The storage name, shared with the Go app.
@@ -78,6 +80,7 @@ impl SecretKind {
Self::RdpSshPassword => "rdp-ssh-password",
Self::RdpSshPassphrase => "rdp-ssh-passphrase",
Self::RdpGatewayPassword => "rdp-gateway-password",
Self::IrcSaslPassword => "irc-sasl-password",
}
}
+9
View File
@@ -143,6 +143,13 @@ impl Manager {
} else {
String::new()
};
let (sasl_account, sasl_password) = if ic.sasl {
let account = if ic.sasl_account.trim().is_empty() { ic.nick.clone() } else { ic.sasl_account.trim().to_string() };
let pw = self.secret_fn(id, &p.id, SecretKind::IrcSaslPassword, ic.sasl_mode, prompt.clone())().map_err(|e| format!("SASL password: {e}"))?;
(account, pw.to_string())
} else {
(String::new(), String::new())
};
let mut rooms = ic.auto_join_rooms.clone();
if !p.id.is_empty() {
let last = self.store.irc_last_rooms(&p.id).map_err(|e| format!("loading remembered IRC rooms: {e}"))?;
@@ -206,6 +213,8 @@ impl Manager {
nickserv_identify: ic.nick_serv_identify,
nickserv_account: ic.nick_serv_account.clone(),
nickserv_password: ns_pass,
sasl_account,
sasl_password,
connector,
connect_timeout: Duration::from_secs(p.connect_timeout.max(1) as u64),
on_event,