Skip to content

Commit e56fbb5

Browse files
committed
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.
1 parent 3b9ab62 commit e56fbb5

File tree

9 files changed

+121
-4
lines changed

9 files changed

+121
-4
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ tls-rust = ["tokio-rustls", "webpki-roots", "rustls-pemfile"]
4343

4444
[dependencies]
4545
chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] }
46+
base64ct = { version = "1.6.0", features = ["std"] }
4647
encoding = "0.2.33"
4748
futures-util = { version = "0.3.30", default-features = false, features = ["alloc", "sink"] }
4849
irc-proto = { version = "1.0.0", path = "irc-proto" }

src/client/data/client_config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"username": "test",
77
"realname": "test",
88
"password": "",
9+
"sasl": "none",
910
"server": "irc.test.net",
1011
"port": 6667,
1112
"encoding": "UTF-8",

src/client/data/client_config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ realname = "test"
55
server = "irc.test.net"
66
port = 6667
77
password = ""
8+
sasl = "none"
89
encoding = "UTF-8"
910
channels = ["#test", "#test2"]
1011
umodes = "+BR"

src/client/data/client_config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ realname: test
77
server: irc.test.net
88
port: 6667
99
password: ""
10+
sasl: none
1011
encoding: UTF-8
1112
channels:
1213
- "#test"

src/client/data/config.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use toml;
1717

1818
#[cfg(feature = "proxy")]
1919
use crate::client::data::proxy::ProxyType;
20+
use crate::client::data::sasl::SASLMode;
2021

2122
use crate::error::Error::InvalidConfig;
2223
#[cfg(feature = "toml_config")]
@@ -97,9 +98,16 @@ pub struct Config {
9798
/// The port to connect on.
9899
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
99100
pub port: Option<u16>,
100-
/// The password to connect to the server.
101+
/// The password to connect to the server. Used with PASS or with SASL authentication. Those
102+
/// two modes are exclusive of each other.
101103
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
102104
pub password: Option<String>,
105+
/// The login to connect to the server (used with SASL plain)
106+
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
107+
pub login: Option<String>,
108+
/// SASL authentication mode. Only SASL PLAIN is is supported
109+
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
110+
pub sasl: Option<SASLMode>,
103111
/// The proxy type to connect to.
104112
#[cfg(feature = "proxy")]
105113
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
@@ -467,6 +475,18 @@ impl Config {
467475
self.password.as_ref().map_or("", String::as_str)
468476
}
469477

478+
/// Gets the server login specified in the configuration.
479+
/// This defaults to an empty string when not specified.
480+
pub fn login(&self) -> &str {
481+
self.login.as_ref().map_or("", String::as_str)
482+
}
483+
484+
/// Gets the SASL mode specified in the configuration.
485+
/// This defaults to None when not specified.
486+
pub fn sasl(&self) -> SASLMode {
487+
self.sasl.as_ref().cloned().unwrap_or(SASLMode::None)
488+
}
489+
470490
/// Gets the type of the proxy specified in the configuration.
471491
/// This defaults to a None ProxyType when not specified.
472492
#[cfg(feature = "proxy")]
@@ -646,6 +666,8 @@ impl Config {
646666

647667
#[cfg(test)]
648668
mod test {
669+
use crate::client::data::sasl::SASLMode;
670+
649671
use super::Config;
650672
use std::collections::HashMap;
651673

@@ -664,6 +686,7 @@ mod test {
664686
username: Some("test".to_string()),
665687
realname: Some("test".to_string()),
666688
password: Some(String::new()),
689+
sasl: Some(SASLMode::None),
667690
umodes: Some("+BR".to_string()),
668691
server: Some("irc.test.net".to_string()),
669692
port: Some(6667),

src/client/data/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ pub use crate::client::data::user::{AccessLevel, User};
88
pub mod config;
99
#[cfg(feature = "proxy")]
1010
pub mod proxy;
11+
pub mod sasl;
1112
pub mod user;

src/client/data/sasl.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//! SASL authentication support
2+
//!
3+
//! ```
4+
//! use irc::client::prelude::Config;
5+
//! use irc::client::data::sasl::SASLMode;
6+
//!
7+
//! # fn main() {
8+
//! let config = Config {
9+
//! nickname: Some("test".to_owned()),
10+
//! server: Some("irc.example.com".to_owned()),
11+
//! login: Some("server_login".to_owned()),
12+
//! password: Some("server_password".to_owned()),
13+
//! sasl: Some(SASLMode::Plain),
14+
//! ..Config::default()
15+
//! };
16+
//! # }
17+
//! ```
18+
#[cfg(feature = "serde")]
19+
use serde::{Deserialize, Serialize};
20+
21+
/// An enum which defines which type of SASL authentication mode should be used.
22+
#[derive(Clone, PartialEq, Debug)]
23+
#[non_exhaustive]
24+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25+
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
26+
pub enum SASLMode {
27+
/// Do not use any SASL auth
28+
None,
29+
/// Authenticate in SASL PLAIN mode
30+
Plain,
31+
}

src/client/mod.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
//! # }
4848
//! ```
4949
50+
use base64ct::{Base64, Encoding};
5051
#[cfg(feature = "ctcp")]
5152
use chrono::prelude::*;
5253
use futures_util::{
@@ -72,6 +73,7 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
7273
use crate::{
7374
client::{
7475
conn::Connection,
76+
data::sasl::SASLMode,
7577
data::{Config, User},
7678
},
7779
error,
@@ -1070,11 +1072,37 @@ impl Client {
10701072
}
10711073

10721074
/// Sends a CAP END, NICK and USER to identify.
1075+
/// Also does the authentication with PASS or SASL if enabled
10731076
pub fn identify(&self) -> error::Result<()> {
10741077
// Send a CAP END to signify that we're IRCv3-compliant (and to end negotiations!).
1078+
if let SASLMode::Plain = self.config().sasl() {
1079+
// Best effort no state-machine (blind) SASL auth
1080+
if self.config().login().contains('\0') {
1081+
return Err(error::Error::LoginContainsNullByte);
1082+
}
1083+
if self.config().password().contains('\0') {
1084+
return Err(error::Error::PasswordContainsNullByte);
1085+
}
1086+
// No need to ask server for its capabilities, we won't parse the output
1087+
//self.send_cap_ls(NegotiationVersion::V301)?;
1088+
self.send_cap_req(&[Capability::Sasl])?;
1089+
self.send_sasl_plain()?;
1090+
let sasl_pass = Base64::encode_string(
1091+
&format!(
1092+
"\x00{}\x00{}",
1093+
self.config().login(),
1094+
self.config().password(),
1095+
)
1096+
.bytes()
1097+
.collect::<Vec<_>>(),
1098+
);
1099+
self.send_sasl(sasl_pass)?;
1100+
}
10751101
self.send(CAP(None, END, None, None))?;
1076-
if self.config().password() != "" {
1077-
self.send(PASS(self.config().password().to_owned()))?;
1102+
if let SASLMode::None = self.config().sasl() {
1103+
if self.config().password() != "" {
1104+
self.send(PASS(self.config().password().to_owned()))?;
1105+
}
10781106
}
10791107
self.send(NICK(self.config().nickname()?.to_owned()))?;
10801108
self.send(USER(
@@ -1097,7 +1125,7 @@ mod test {
10971125
#[cfg(feature = "channel-lists")]
10981126
use crate::client::data::User;
10991127
use crate::{
1100-
client::data::Config,
1128+
client::data::{sasl::SASLMode, Config},
11011129
error::Error,
11021130
proto::{
11031131
command::Command::{Raw, PRIVMSG},
@@ -1658,6 +1686,28 @@ mod test {
16581686
Ok(())
16591687
}
16601688

1689+
#[tokio::test]
1690+
async fn identify_with_sasl() -> Result<()> {
1691+
let sasl_config = Config {
1692+
login: Some("test_login".to_string()),
1693+
password: Some("test_password".to_string()),
1694+
sasl: Some(SASLMode::Plain),
1695+
..test_config()
1696+
};
1697+
let mut client = Client::from_config(sasl_config).await?;
1698+
client.identify()?;
1699+
client.stream()?.collect().await?;
1700+
assert_eq!(
1701+
&get_client_value(client)[..],
1702+
// echo -ne "\x00test_login\x00test_password" | base64
1703+
"CAP REQ sasl\r\nAUTHENTICATE PLAIN\r\n\
1704+
AUTHENTICATE AHRlc3RfbG9naW4AdGVzdF9wYXNzd29yZA==\r\n\
1705+
CAP END\r\nNICK test\r\n\
1706+
USER test 0 * test\r\n"
1707+
);
1708+
Ok(())
1709+
}
1710+
16611711
#[tokio::test]
16621712
async fn identify_with_password() -> Result<()> {
16631713
let mut client = Client::from_config(Config {

src/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ pub enum Error {
123123
/// Stream has already been configured.
124124
#[error("stream has already been configured")]
125125
StreamAlreadyConfigured,
126+
127+
/// Login contains null bytes
128+
#[error("Login contains null '\\0' byte, which makes SASL plain authentication impossible")]
129+
LoginContainsNullByte,
130+
131+
/// Password contains null bytes
132+
#[error("Password contains null '\\0' byte, which makes SASL plain authentication impossible")]
133+
PasswordContainsNullByte,
126134
}
127135

128136
/// Errors that occur with configurations.

0 commit comments

Comments
 (0)