Skip to content

Commit

Permalink
Added support for SASL EXTERNAL
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 osa1#196
  • Loading branch information
trevarj committed Dec 18, 2022
1 parent 29ef024 commit 38a994c
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 46 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

28 changes: 23 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
#[derive(Debug, Clone)]
pub struct SASLAuth {
pub username: String,
pub password: String,
pub enum SASLAuth {
Plain { username: String, password: String },
External(SASLExternal),
}

#[derive(Debug, Clone)]
pub struct SASLExternal {
/// DER-encoded X.509 certificate used for TLS Client Authorization
pub cert: Vec<u8>,
/// DER-encoded Private Key that was used to generate `cert`
pub key: Vec<u8>,
}

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

let sasl_ext = server_info.sasl_auth.as_ref().and_then(|s| {
if let SASLAuth::External(s) = s {
Some(s)
} else {
None
}
});

let stream = match try_connect(
addrs,
&serv_name,
server_info.tls,
sasl_ext,
&mut rcv_cmd,
&mut snd_ev,
)
Expand Down Expand Up @@ -623,14 +640,15 @@ async fn try_connect<S: StreamExt<Item = Cmd> + Unpin>(
addrs: Vec<SocketAddr>,
serv_name: &str,
use_tls: bool,
sasl: Option<&SASLExternal>,
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).await
} else {
Stream::new_tcp(addr).await
};
Expand Down
31 changes: 21 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 @@ -650,15 +658,18 @@ impl StateInner {
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();
match auth {
SASLAuth::Plain { username, password } => {
let msg = format!("{}\x00{}\x00{}", username, username, password);
snd_irc_msg
.try_send(wire::authenticate(&base64::encode(&msg)))
.unwrap();
}
SASLAuth::External { .. } => {
snd_irc_msg.try_send(wire::authenticate("+")).unwrap()
}
}
}
}
}
Expand Down
84 changes: 64 additions & 20 deletions crates/libtiny_client/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,53 @@ use tokio_native_tls::TlsStream;
#[cfg(feature = "tls-rustls")]
use tokio_rustls::client::TlsStream;

use crate::SASLExternal;

#[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(sasl: Option<&SASLExternal>) -> tokio_native_tls::TlsConnector {
let mut builder = native_tls::TlsConnector::builder();
if let Some(SASLExternal { cert, key }) = sasl {
todo!("Waiting for https://github.com/sfackler/rust-native-tls/pull/209")
}
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<&SASLExternal>) -> tokio_rustls::TlsConnector {
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(SASLExternal { cert, key }) = sasl {
builder
.with_single_cert(
vec![Certificate(cert.to_owned())],
PrivateKey(key.to_owned()),
)
.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 +97,38 @@ 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<&SASLExternal>,
) -> 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<&SASLExternal>,
) -> Result<Stream, StreamError> {
use std::convert::TryFrom;
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
1 change: 1 addition & 0 deletions crates/tiny/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ libtiny_logger = { path = "../libtiny_logger" }
libtiny_tui = { path = "../libtiny_tui", default-features = false }
libtiny_wire = { path = "../libtiny_wire" }
log = "0.4"
rustls-pemfile = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8"
shell-words = "1.1.0"
Expand Down
10 changes: 8 additions & 2 deletions crates/tiny/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ servers:
# Three authentication methods: pass, sasl, and nickserv_ident
# These are optional and you probably only need one of these, delete
# others.
# For SASL EXTERNAL certificate and fingerprint generation, see server documentation.
# You will need to register the cert's fingerprint with NickServ
# ex. https://www.oftc.net/NickServ/CertFP/

# Server or nick password
# pass: 'hunter2'

# SASL authentication
# sasl:
# username: 'tiny_user'
# password: 'hunter2'
# plain:
# username: 'tiny_user'
# password: 'hunter2'
# external:
# pem: "/home/.config/tiny/oftc.pem"

# Identify nick by sending a message to NickServ:
# (useful when `pass` or `sasl` fields above are not used)
Expand Down
16 changes: 13 additions & 3 deletions crates/tiny/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Clone, Deserialize, Debug, PartialEq, Eq)]
pub(crate) struct SASLAuth<P> {
pub(crate) username: String,
pub(crate) password: P,
#[serde(rename_all = "snake_case")]
pub(crate) enum SASLAuth<P> {
Plain {
/// Registered username
username: String,
/// Password
password: P,
},
External {
/// Path to PEM file with private key and certificate (PKCS12 format).
/// A fingerprint of the certificate should be registered with NickServ
pem: PathBuf,
},
}

#[derive(Clone, Deserialize)]
Expand Down
65 changes: 59 additions & 6 deletions crates/tiny/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ mod utils;
#[cfg(test)]
mod tests;

use libtiny_client::{Client, ServerInfo};
use libtiny_client::{Client, SASLAuth, SASLExternal, ServerInfo};
use libtiny_common::{ChanNameRef, MsgTarget};
use libtiny_logger::{Logger, LoggerInitError};
use libtiny_tui::TUI;
use ui::UI;

use std::fs::File;
use std::io::{BufReader, Seek, SeekFrom};
use std::path::PathBuf;
use std::process::exit;

Expand Down Expand Up @@ -147,10 +149,26 @@ fn run(
for server in servers.iter().cloned() {
tui.new_server_tab(&server.addr, server.alias);

let tls = server.tls;
let sasl_auth = if let Some(sasl_auth) = &server.sasl_auth {
match sasl_from_config(tls, sasl_auth) {
Ok(sasl) => Some(sasl),
Err(e) => {
tui.add_client_err_msg(
&format!("SASL error for server [{}]: {}", server.addr, e),
&MsgTarget::Server { serv: "mentions" },
);
None
}
}
} else {
None
};

let server_info = ServerInfo {
addr: server.addr,
port: server.port,
tls: server.tls,
tls,
pass: server.pass,
realname: server.realname,
nicks: server.nicks,
Expand All @@ -160,10 +178,7 @@ fn run(
.map(|c| ChanNameRef::new(c).to_owned())
.collect(),
nickserv_ident: server.nickserv_ident,
sasl_auth: server.sasl_auth.map(|auth| libtiny_client::SASLAuth {
username: auth.username,
password: auth.password,
}),
sasl_auth,
};

let (client, rcv_conn_ev) = Client::new(server_info);
Expand All @@ -183,3 +198,41 @@ fn run(

runtime.block_on(local);
}

// Helper to parse SASL config into a libtiny_client SASL struct
fn sasl_from_config(tls: bool, sasl_config: &config::SASLAuth) -> Result<SASLAuth, String> {
match sasl_config {
config::SASLAuth::Plain { username, password } => Ok(SASLAuth::Plain {
username: username.to_string(),
password: password.to_string(),
}),
config::SASLAuth::External { pem } => {
// TLS must be on for EXTERNAL
if !tls {
Err("TLS not enabled".to_string())
} else {
// load in a cert and private key for TLS client auth
match File::open(pem) {
Ok(file) => {
let mut buf = BufReader::new(file);
// extract certificate
let cert = rustls_pemfile::certs(&mut buf)
.map_err(|e| format!("Could not parse pkcs8 PEM: {}", e))?
.pop()
.ok_or("Cert PEM must have one cert")?;

// extract private key
buf.seek(SeekFrom::Start(0)).unwrap();
let key = rustls_pemfile::pkcs8_private_keys(&mut buf)
.map_err(|e| format!("Could not parse pkcs8 PEM: {}", e))?
.pop()
.ok_or("Cert PEM must have one private key")?;

Ok(SASLAuth::External(SASLExternal { cert, key }))
}
Err(e) => Err(format!("Could not open PEM file: {}", e)),
}
}
}
}
}

0 comments on commit 38a994c

Please sign in to comment.