diff --git a/Cargo.toml b/Cargo.toml index fbea1f38..a80104c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ tls-rust = [ encoding = ["dep:encoding", "irc-proto/encoding"] [dependencies] +base64ct = { version = "1.6.0", features = ["std"] } chrono = { version = "0.4.24", default-features = false, features = [ "clock", "std", 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 8c3f08c6..0898c0a8 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 21b25b86..124c87cc 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.