From e56fbb50b88634b3952d2030638c00ab46c3d139 Mon Sep 17 00:00:00 2001 From: Anisse Astier Date: Wed, 1 May 2024 22:46:08 +0200 Subject: [PATCH] add sasl plain authentication support Add a new config option "sasl", which can be set to "none" or "plain". In none, behavior does not change and will be the same as before, using the password config option with PASS if present. When plain is selected, the new "login" option will be coupled with the existing "password" option to connect to the server with SASL PLAIN mode. The implementation currently does blind SASL login. It does not check if the server supports sasl or sasl plain, and does not verify login errors, etc. It has been successfully tested with irc.libera.chat. --- Cargo.toml | 1 + src/client/data/client_config.json | 1 + src/client/data/client_config.toml | 1 + src/client/data/client_config.yaml | 1 + src/client/data/config.rs | 25 ++++++++++++- src/client/data/mod.rs | 1 + src/client/data/sasl.rs | 31 +++++++++++++++++ src/client/mod.rs | 56 ++++++++++++++++++++++++++++-- src/error.rs | 8 +++++ 9 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/client/data/sasl.rs diff --git a/Cargo.toml b/Cargo.toml index 6d20f66d..7309033a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ tls-rust = ["tokio-rustls", "webpki-roots", "rustls-pemfile"] [dependencies] chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] } +base64ct = { version = "1.6.0", features = ["std"] } encoding = "0.2.33" futures-util = { version = "0.3.30", default-features = false, features = ["alloc", "sink"] } irc-proto = { version = "1.0.0", path = "irc-proto" } diff --git a/src/client/data/client_config.json b/src/client/data/client_config.json index 150b3231..7446374d 100644 --- a/src/client/data/client_config.json +++ b/src/client/data/client_config.json @@ -6,6 +6,7 @@ "username": "test", "realname": "test", "password": "", + "sasl": "none", "server": "irc.test.net", "port": 6667, "encoding": "UTF-8", diff --git a/src/client/data/client_config.toml b/src/client/data/client_config.toml index a99da736..1c515e30 100644 --- a/src/client/data/client_config.toml +++ b/src/client/data/client_config.toml @@ -5,6 +5,7 @@ realname = "test" server = "irc.test.net" port = 6667 password = "" +sasl = "none" encoding = "UTF-8" channels = ["#test", "#test2"] umodes = "+BR" diff --git a/src/client/data/client_config.yaml b/src/client/data/client_config.yaml index d252db12..4d726c3a 100644 --- a/src/client/data/client_config.yaml +++ b/src/client/data/client_config.yaml @@ -7,6 +7,7 @@ realname: test server: irc.test.net port: 6667 password: "" +sasl: none encoding: UTF-8 channels: - "#test" diff --git a/src/client/data/config.rs b/src/client/data/config.rs index 9f65b2bc..eb6804aa 100644 --- a/src/client/data/config.rs +++ b/src/client/data/config.rs @@ -17,6 +17,7 @@ use toml; #[cfg(feature = "proxy")] use crate::client::data::proxy::ProxyType; +use crate::client::data::sasl::SASLMode; use crate::error::Error::InvalidConfig; #[cfg(feature = "toml_config")] @@ -97,9 +98,16 @@ pub struct Config { /// The port to connect on. #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub port: Option, - /// The password to connect to the server. + /// The password to connect to the server. Used with PASS or with SASL authentication. Those + /// two modes are exclusive of each other. #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub password: Option, + /// The login to connect to the server (used with SASL plain) + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub login: Option, + /// SASL authentication mode. Only SASL PLAIN is is supported + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub sasl: Option, /// The proxy type to connect to. #[cfg(feature = "proxy")] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] @@ -467,6 +475,18 @@ impl Config { self.password.as_ref().map_or("", String::as_str) } + /// Gets the server login specified in the configuration. + /// This defaults to an empty string when not specified. + pub fn login(&self) -> &str { + self.login.as_ref().map_or("", String::as_str) + } + + /// Gets the SASL mode specified in the configuration. + /// This defaults to None when not specified. + pub fn sasl(&self) -> SASLMode { + self.sasl.as_ref().cloned().unwrap_or(SASLMode::None) + } + /// Gets the type of the proxy specified in the configuration. /// This defaults to a None ProxyType when not specified. #[cfg(feature = "proxy")] @@ -646,6 +666,8 @@ impl Config { #[cfg(test)] mod test { + use crate::client::data::sasl::SASLMode; + use super::Config; use std::collections::HashMap; @@ -664,6 +686,7 @@ mod test { username: Some("test".to_string()), realname: Some("test".to_string()), password: Some(String::new()), + sasl: Some(SASLMode::None), umodes: Some("+BR".to_string()), server: Some("irc.test.net".to_string()), port: Some(6667), diff --git a/src/client/data/mod.rs b/src/client/data/mod.rs index 5306394c..e21eee49 100644 --- a/src/client/data/mod.rs +++ b/src/client/data/mod.rs @@ -8,4 +8,5 @@ pub use crate::client::data::user::{AccessLevel, User}; pub mod config; #[cfg(feature = "proxy")] pub mod proxy; +pub mod sasl; pub mod user; diff --git a/src/client/data/sasl.rs b/src/client/data/sasl.rs new file mode 100644 index 00000000..278e010f --- /dev/null +++ b/src/client/data/sasl.rs @@ -0,0 +1,31 @@ +//! SASL authentication support +//! +//! ``` +//! use irc::client::prelude::Config; +//! use irc::client::data::sasl::SASLMode; +//! +//! # fn main() { +//! let config = Config { +//! nickname: Some("test".to_owned()), +//! server: Some("irc.example.com".to_owned()), +//! login: Some("server_login".to_owned()), +//! password: Some("server_password".to_owned()), +//! sasl: Some(SASLMode::Plain), +//! ..Config::default() +//! }; +//! # } +//! ``` +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// An enum which defines which type of SASL authentication mode should be used. +#[derive(Clone, PartialEq, Debug)] +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +pub enum SASLMode { + /// Do not use any SASL auth + None, + /// Authenticate in SASL PLAIN mode + Plain, +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 0fffaee7..0da93c5c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -47,6 +47,7 @@ //! # } //! ``` +use base64ct::{Base64, Encoding}; #[cfg(feature = "ctcp")] use chrono::prelude::*; use futures_util::{ @@ -72,6 +73,7 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use crate::{ client::{ conn::Connection, + data::sasl::SASLMode, data::{Config, User}, }, error, @@ -1070,11 +1072,37 @@ impl Client { } /// Sends a CAP END, NICK and USER to identify. + /// Also does the authentication with PASS or SASL if enabled pub fn identify(&self) -> error::Result<()> { // Send a CAP END to signify that we're IRCv3-compliant (and to end negotiations!). + if let SASLMode::Plain = self.config().sasl() { + // Best effort no state-machine (blind) SASL auth + if self.config().login().contains('\0') { + return Err(error::Error::LoginContainsNullByte); + } + if self.config().password().contains('\0') { + return Err(error::Error::PasswordContainsNullByte); + } + // No need to ask server for its capabilities, we won't parse the output + //self.send_cap_ls(NegotiationVersion::V301)?; + self.send_cap_req(&[Capability::Sasl])?; + self.send_sasl_plain()?; + let sasl_pass = Base64::encode_string( + &format!( + "\x00{}\x00{}", + self.config().login(), + self.config().password(), + ) + .bytes() + .collect::>(), + ); + self.send_sasl(sasl_pass)?; + } self.send(CAP(None, END, None, None))?; - if self.config().password() != "" { - self.send(PASS(self.config().password().to_owned()))?; + if let SASLMode::None = self.config().sasl() { + if self.config().password() != "" { + self.send(PASS(self.config().password().to_owned()))?; + } } self.send(NICK(self.config().nickname()?.to_owned()))?; self.send(USER( @@ -1097,7 +1125,7 @@ mod test { #[cfg(feature = "channel-lists")] use crate::client::data::User; use crate::{ - client::data::Config, + client::data::{sasl::SASLMode, Config}, error::Error, proto::{ command::Command::{Raw, PRIVMSG}, @@ -1658,6 +1686,28 @@ mod test { Ok(()) } + #[tokio::test] + async fn identify_with_sasl() -> Result<()> { + let sasl_config = Config { + login: Some("test_login".to_string()), + password: Some("test_password".to_string()), + sasl: Some(SASLMode::Plain), + ..test_config() + }; + let mut client = Client::from_config(sasl_config).await?; + client.identify()?; + client.stream()?.collect().await?; + assert_eq!( + &get_client_value(client)[..], + // echo -ne "\x00test_login\x00test_password" | base64 + "CAP REQ sasl\r\nAUTHENTICATE PLAIN\r\n\ + AUTHENTICATE AHRlc3RfbG9naW4AdGVzdF9wYXNzd29yZA==\r\n\ + CAP END\r\nNICK test\r\n\ + USER test 0 * test\r\n" + ); + Ok(()) + } + #[tokio::test] async fn identify_with_password() -> Result<()> { let mut client = Client::from_config(Config { diff --git a/src/error.rs b/src/error.rs index 391340df..952c7907 100644 --- a/src/error.rs +++ b/src/error.rs @@ -123,6 +123,14 @@ pub enum Error { /// Stream has already been configured. #[error("stream has already been configured")] StreamAlreadyConfigured, + + /// Login contains null bytes + #[error("Login contains null '\\0' byte, which makes SASL plain authentication impossible")] + LoginContainsNullByte, + + /// Password contains null bytes + #[error("Password contains null '\\0' byte, which makes SASL plain authentication impossible")] + PasswordContainsNullByte, } /// Errors that occur with configurations.