Skip to content

Commit

Permalink
Added support for SASL EXTERNAL (#363)
Browse files Browse the repository at this point in the history
A user can now generate a x509 certificate, register it with a server, and
provide the PEM file to tiny for use over TLS.

Closes #196

Overview:

1. Bumped rustls crates

2. Added configuration for SASL EXTERNAL, where a user specifies a path to a
   PEM file with a certificate inside (generated with instructions from server).

3. Use user provided certificate in TLS connector config for client
   authorization (crates/libtiny_client/src/stream.rs)
  • Loading branch information
trevarj authored Dec 31, 2022
1 parent 29ef024 commit 2544793
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 56 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Unreleased

Thanks to @ALEX11BR for contributing to this release.
Thanks to @ALEX11BR and @trevarj for contributing to this release.

- Fixed handling of CR, LF, and tab characters in IRC format parser. IRC RFCs
don't allow standalone CR and LF characters, but some servers still send
Expand All @@ -13,6 +13,10 @@ Thanks to @ALEX11BR for contributing to this release.
temporary file would not be read properly when `$EDITOR` is closed.
- Passwords can now be read from external commands (e.g. a password manager).
See README for details. (#246, #315)
- Added support for SASL EXTERNAL authentication. See the
[wiki page][sasl-wiki] for more details. (#196, #363)

[sasl-wiki]: https://github.com/osa1/tiny/wiki/SASL-EXTERNAL

# 2021/11/07: 0.10.0

Expand Down Expand Up @@ -228,7 +232,7 @@ release.
# 2019/10/05: 0.5.0

Starting with this release tiny is no longer distributed on crates.io. Please
get it from the git repo at https://github.com/osa1/tiny.
get it from the git repo at <https://github.com/osa1/tiny>.

- With the exception of TUI most of tiny is rewritten for this release. See #138
for the details. The TLDR is that the code should now be easier to hack on.
Expand Down
16 changes: 13 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/libtiny_client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ edition = "2021"
[features]
default = ["tls-rustls"]
tls-native = ["native-tls", "tokio-native-tls"]
tls-rustls = ["rustls-native-certs", "tokio-rustls"]
tls-rustls = ["rustls-native-certs", "tokio-rustls", "rustls-pemfile"]

[dependencies]
base64 = "0.13"
Expand All @@ -19,6 +19,7 @@ libtiny_wire = { path = "../libtiny_wire" }
log = "0.4"
native-tls = { version = "0.2", optional = true }
rustls-native-certs = { version = "0.6", optional = true }
rustls-pemfile = { version = "0.3", optional = true }
tokio = { version = "1.17", default-features = false, features = ["net", "rt", "io-util", "macros"] }
tokio-native-tls = { version = "0.3", optional = true }
tokio-rustls = { version = "0.23", optional = true }
Expand Down
26 changes: 21 additions & 5 deletions crates/libtiny_client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ pub struct ServerInfo {
pub sasl_auth: Option<SASLAuth>,
}

/// SASL authentication credentials
/// SASL authentication mechanisms
/// - <https://ircv3.net/docs/sasl-mechs>
/// - <https://www.alphachat.net/sasl.xhtml>
#[derive(Debug, Clone)]
pub struct SASLAuth {
pub username: String,
pub password: String,
pub enum SASLAuth {
Plain {
username: String,
password: String,
},
External {
/// PEM-encoded X509 private cert and private key file
pem: Vec<u8>,
},
}

/// IRC client events. Returned by `Client` to the users via a channel.
Expand Down Expand Up @@ -387,10 +395,17 @@ async fn main_loop(
// Establish TCP connection to the server
//

let sasl_pem = if let Some(SASLAuth::External { pem }) = &server_info.sasl_auth {
Some(pem)
} else {
None
};

let stream = match try_connect(
addrs,
&serv_name,
server_info.tls,
sasl_pem,
&mut rcv_cmd,
&mut snd_ev,
)
Expand Down Expand Up @@ -623,14 +638,15 @@ async fn try_connect<S: StreamExt<Item = Cmd> + Unpin>(
addrs: Vec<SocketAddr>,
serv_name: &str,
use_tls: bool,
sasl_pem: Option<&Vec<u8>>,
rcv_cmd: &mut S,
snd_ev: &mut mpsc::Sender<Event>,
) -> TaskResult<Option<Stream>> {
let connect_task = async move {
for addr in addrs {
snd_ev.send(Event::Connecting(addr)).await.unwrap();
let mb_stream = if use_tls {
Stream::new_tls(addr, serv_name).await
Stream::new_tls(addr, serv_name, sasl_pem).await
} else {
Stream::new_tcp(addr).await
};
Expand Down
30 changes: 20 additions & 10 deletions crates/libtiny_client/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![allow(clippy::zero_prefixed_literal)]

use crate::utils;
use crate::{utils, SASLAuth};
use crate::{Cmd, Event, ServerInfo};
use libtiny_common::{ChanName, ChanNameRef};
use libtiny_wire as wire;
Expand Down Expand Up @@ -630,7 +630,15 @@ impl StateInner {
match subcommand.as_ref() {
"ACK" => {
if params.iter().any(|cap| cap.as_str() == "sasl") {
snd_irc_msg.try_send(wire::authenticate("PLAIN")).unwrap();
if let Some(sasl) = &self.server_info.sasl_auth {
let msg = match sasl {
SASLAuth::Plain { .. } => "PLAIN",
SASLAuth::External { .. } => "EXTERNAL",
};
snd_irc_msg.try_send(wire::authenticate(msg)).unwrap();
} else {
warn!("SASL AUTH not set but got SASL ACK");
}
}
}
"NAK" => {
Expand All @@ -647,18 +655,20 @@ impl StateInner {
}
}

// https://ircv3.net/specs/extensions/sasl-3.1.html
AUTHENTICATE { ref param } => {
if param.as_str() == "+" {
// Empty AUTHENTICATE response; server accepted the specified SASL mechanism
// (PLAIN)
if let Some(ref auth) = self.server_info.sasl_auth {
let msg = format!(
"{}\x00{}\x00{}",
auth.username, auth.username, auth.password
);
snd_irc_msg
.try_send(wire::authenticate(&base64::encode(msg)))
.unwrap();
let msg = match auth {
SASLAuth::Plain { username, password } => {
let msg = format!("{}\x00{}\x00{}", username, username, password);
base64::encode(&msg)
}
// Reply with an empty response (Empty responses are sent as "AUTHENTICATE +")
SASLAuth::External { .. } => "+".to_string(),
};
snd_irc_msg.try_send(wire::authenticate(&msg)).unwrap();
}
}
}
Expand Down
96 changes: 76 additions & 20 deletions crates/libtiny_client/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,64 @@ use tokio_rustls::client::TlsStream;

#[cfg(feature = "tls-native")]
lazy_static! {
static ref TLS_CONNECTOR: tokio_native_tls::TlsConnector =
tokio_native_tls::TlsConnector::from(native_tls::TlsConnector::builder().build().unwrap());
static ref TLS_CONNECTOR: tokio_native_tls::TlsConnector = tls_connector(None);
}

#[cfg(feature = "tls-native")]
fn tls_connector(pem: Option<&Vec<u8>>) -> tokio_native_tls::TlsConnector {
use native_tls::Identity;

let mut builder = native_tls::TlsConnector::builder();
if let Some(pem) = pem {
let identity = Identity::from_pkcs8(pem, pem).expect("X509 Cert and private key");
builder.identity(identity);
}
tokio_native_tls::TlsConnector::from(builder.build().unwrap())
}

#[cfg(feature = "tls-rustls")]
lazy_static! {
static ref TLS_CONNECTOR: tokio_rustls::TlsConnector = {
use tokio_rustls::rustls;
let mut roots = rustls::RootCertStore::empty();
for cert in rustls_native_certs::load_native_certs().unwrap() {
roots.add(&rustls::Certificate(cert.0)).unwrap();
}
let config = rustls::ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots)
.with_no_client_auth();
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
static ref TLS_CONNECTOR: tokio_rustls::TlsConnector = tls_connector(None);
}

#[cfg(feature = "tls-rustls")]
fn tls_connector(sasl: Option<&Vec<u8>>) -> tokio_rustls::TlsConnector {
use std::io::{Cursor, Seek, SeekFrom};
use tokio_rustls::rustls::{Certificate, ClientConfig, PrivateKey, RootCertStore};

let mut roots = RootCertStore::empty();
for cert in rustls_native_certs::load_native_certs().expect("could not load platform certs") {
roots.add(&Certificate(cert.0)).unwrap();
}

let builder = ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots);

let config = if let Some(pem) = sasl {
let mut buf = Cursor::new(pem);
// extract certificate
let cert = rustls_pemfile::certs(&mut buf)
.expect("Could not parse PKCS8 PEM")
.pop()
.expect("Cert PEM must have at least one cert");

// extract private key
buf.seek(SeekFrom::Start(0)).unwrap();
let key = rustls_pemfile::pkcs8_private_keys(&mut buf)
.expect("Could not parse PKCS8 PEM")
.pop()
.expect("Cert PEM must have at least one private key");

builder
.with_single_cert(vec![Certificate(cert)], PrivateKey(key))
.expect("Client auth cert")
} else {
builder.with_no_client_auth()
};
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
}

#[derive(Debug)]
// We box the fields to reduce type size. Without boxing the type size is 64 with native-tls and
// 1288 with native-tls. With boxing it's 16 in both. More importantly, there's a large size
// difference between the variants when using rustls, see #189.
Expand Down Expand Up @@ -73,18 +110,37 @@ impl Stream {
}

#[cfg(feature = "tls-native")]
pub(crate) async fn new_tls(addr: SocketAddr, host_name: &str) -> Result<Stream, StreamError> {
pub(crate) async fn new_tls(
addr: SocketAddr,
host_name: &str,
sasl: Option<&Vec<u8>>,
) -> Result<Stream, StreamError> {
let tcp_stream = TcpStream::connect(addr).await?;
let tls_stream = TLS_CONNECTOR.connect(host_name, tcp_stream).await?;
// If SASL EXTERNAL is enabled create a new TLS connector with client auth cert
let tls_stream = if sasl.is_some() {
tls_connector(sasl).connect(host_name, tcp_stream).await?
} else {
TLS_CONNECTOR.connect(host_name, tcp_stream).await?
};
Ok(Stream::TlsStream(tls_stream.into()))
}

#[cfg(feature = "tls-rustls")]
pub(crate) async fn new_tls(addr: SocketAddr, host_name: &str) -> Result<Stream, StreamError> {
pub(crate) async fn new_tls(
addr: SocketAddr,
host_name: &str,
sasl: Option<&Vec<u8>>,
) -> Result<Stream, StreamError> {
use tokio_rustls::rustls::ServerName;

let tcp_stream = TcpStream::connect(addr).await?;
let name = tokio_rustls::rustls::ServerName::try_from(host_name)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let tls_stream = TLS_CONNECTOR.connect(name, tcp_stream).await?;
let name = ServerName::try_from(host_name).unwrap();
// If SASL EXTERNAL is enabled create a new TLS connector with client auth cert
let tls_stream = if sasl.is_some() {
tls_connector(sasl).connect(name, tcp_stream).await?
} else {
TLS_CONNECTOR.connect(name, tcp_stream).await?
};
Ok(Stream::TlsStream(tls_stream.into()))
}
}
Expand Down
Loading

0 comments on commit 2544793

Please sign in to comment.