Skip to content

Commit

Permalink
add sasl plain authentication support
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
anisse committed Jul 21, 2024
1 parent 3b9ab62 commit e56fbb5
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 4 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions src/client/data/client_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"username": "test",
"realname": "test",
"password": "",
"sasl": "none",
"server": "irc.test.net",
"port": 6667,
"encoding": "UTF-8",
Expand Down
1 change: 1 addition & 0 deletions src/client/data/client_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ realname = "test"
server = "irc.test.net"
port = 6667
password = ""
sasl = "none"
encoding = "UTF-8"
channels = ["#test", "#test2"]
umodes = "+BR"
Expand Down
1 change: 1 addition & 0 deletions src/client/data/client_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ realname: test
server: irc.test.net
port: 6667
password: ""
sasl: none
encoding: UTF-8
channels:
- "#test"
Expand Down
25 changes: 24 additions & 1 deletion src/client/data/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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<u16>,
/// 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<String>,
/// 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<String>,
/// SASL authentication mode. Only SASL PLAIN is is supported
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub sasl: Option<SASLMode>,
/// The proxy type to connect to.
#[cfg(feature = "proxy")]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -646,6 +666,8 @@ impl Config {

#[cfg(test)]
mod test {
use crate::client::data::sasl::SASLMode;

use super::Config;
use std::collections::HashMap;

Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/client/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
31 changes: 31 additions & 0 deletions src/client/data/sasl.rs
Original file line number Diff line number Diff line change
@@ -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,
}
56 changes: 53 additions & 3 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
//! # }
//! ```
use base64ct::{Base64, Encoding};
#[cfg(feature = "ctcp")]
use chrono::prelude::*;
use futures_util::{
Expand All @@ -72,6 +73,7 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::{
client::{
conn::Connection,
data::sasl::SASLMode,
data::{Config, User},
},
error,
Expand Down Expand Up @@ -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::<Vec<_>>(),
);
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(
Expand All @@ -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},
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit e56fbb5

Please sign in to comment.