diff --git a/docs/overview.md b/docs/overview.md index 8a948e0..97fe7b5 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -168,6 +168,7 @@ Use Nexus Terminal when you need to: ### IRC capabilities +- IRCv3 capability negotiation with SASL PLAIN login and server-time timestamps. - Nick, real name, TLS and an optional server password. - NickServ identification on connect. - Auto-join rooms. diff --git a/docs/user-manual.md b/docs/user-manual.md index 73b0f55..fde11db 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -180,8 +180,9 @@ The viewer works like the VNC one: click the desktop to give it keyboard focus, ### IRC - **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password. +- **SASL**: log in during registration with SASL PLAIN (account defaults to your nick), which most networks now expect; use TLS or an SSH tunnel so the password isn't sent in the clear. If the network doesn't offer SASL or rejects the login, the Server tab says so and the connection carries on. - **NickServ**: identify on connect, with an optional account name and password. -- **Rooms**: rooms to join automatically, one per line or comma-separated. +- **Rooms**: rooms to join automatically, one per line or comma-separated. Networks that support IRCv3 server-time stamp messages with the time they were sent, so replayed history shows its real times. - **SSH Tunnel** and **Reconnection** settings. The IRC view has a **Server** tab and a tab per room or conversation, with a dot on tabs that have unread messages. Type messages or `/commands` in the composer (`/join`, `/part`, `/msg`, `/me`, `/notice`, `/nick`, `/topic`, `/list`, `/names`, `/whois`, `/who`, `/raw`, `/quit`, `/help`). Press **Tab** to complete a nick, or click a nick to mention it. The side panel lists joined rooms, a room browser with a filter (click a room to join it), and the users in the current room. diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index 7683884..b604d2d 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -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 { diff --git a/rust/core/src/clients/irc.rs b/rust/core/src/clients/irc.rs index 455e041..acd97d9 100644 --- a/rust/core/src/clients/irc.rs +++ b/rust/core/src/clients/irc.rs @@ -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, @@ -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, connected: bool, closing: bool, list_rooms: Vec, @@ -153,6 +161,9 @@ pub struct Client { cfg: Config, state: Mutex, out: Mutex>>, + /// 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, + /// IRCv3 `@time=` tag, in ms since the epoch. + time: Option, } 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 = 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 = { + 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 { 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 { let command = words.next()?.to_uppercase(); let mut params: Vec = 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"); diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index f344777..2d0924b 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -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, pub ssh_tunnel: bool, diff --git a/rust/core/src/secrets.rs b/rust/core/src/secrets.rs index 40d4fc5..6d899d5 100644 --- a/rust/core/src/secrets.rs +++ b/rust/core/src/secrets.rs @@ -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", } } diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index 718880a..deb7a1a 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -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,