Repository: osa1/tiny (language: rust)

Issue:
SASL External
Can you add support for the SASL external (rfc 4422)?

Refs:
https://tools.ietf.org/html/rfc4422
https://ircv3.net/specs/extensions/sasl-3.1


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51d1afbc..b673aa2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
 
@@ -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.
diff --git a/Cargo.lock b/Cargo.lock
index f9d2d34d..e296bd35 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -538,6 +538,7 @@ dependencies = [
  "log",
  "native-tls",
  "rustls-native-certs",
+ "rustls-pemfile 0.3.0",
  "tokio",
  "tokio-native-tls",
  "tokio-rustls",
@@ -662,9 +663,9 @@ dependencies = [
 
 [[package]]
 name = "native-tls"
-version = "0.2.8"
+version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d"
+checksum = "09bf6f32a3afefd0b587ee42ed19acd945c6d1f3b5424040f50b2f24ab16be77"
 dependencies = [
  "lazy_static",
  "libc",
@@ -1050,7 +1051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5ca9ebdfa27d3fc180e42879037b5338ab1c040c06affd00d8338598e7800943"
 dependencies = [
  "openssl-probe",
- "rustls-pemfile",
+ "rustls-pemfile 0.2.1",
  "schannel",
  "security-framework",
 ]
@@ -1064,6 +1065,15 @@ dependencies = [
  "base64",
 ]
 
+[[package]]
+name = "rustls-pemfile"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360"
+dependencies = [
+ "base64",
+]
+
 [[package]]
 name = "ryu"
 version = "1.0.9"
diff --git a/crates/libtiny_client/Cargo.toml b/crates/libtiny_client/Cargo.toml
index 4b750d4b..68c6fb45 100644
--- a/crates/libtiny_client/Cargo.toml
+++ b/crates/libtiny_client/Cargo.toml
@@ -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"
@@ -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 }
diff --git a/crates/libtiny_client/src/lib.rs b/crates/libtiny_client/src/lib.rs
index 62b21638..8d7611c7 100644
--- a/crates/libtiny_client/src/lib.rs
+++ b/crates/libtiny_client/src/lib.rs
@@ -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.
@@ -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,
         )
@@ -623,6 +638,7 @@ 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>> {
@@ -630,7 +646,7 @@ async fn try_connect<S: StreamExt<Item = Cmd> + Unpin>(
         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
             };
diff --git a/crates/libtiny_client/src/state.rs b/crates/libtiny_client/src/state.rs
index daf4d373..72e24dc8 100644
--- a/crates/libtiny_client/src/state.rs
+++ b/crates/libtiny_client/src/state.rs
@@ -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;
@@ -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" => {
@@ -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();
                     }
                 }
             }
diff --git a/crates/libtiny_client/src/stream.rs b/crates/libtiny_client/src/stream.rs
index 7ff9e51e..844a1bf1 100644
--- a/crates/libtiny_client/src/stream.rs
+++ b/crates/libtiny_client/src/stream.rs
@@ -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.
@@ -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()))
     }
 }
diff --git a/crates/tiny/src/config.rs b/crates/tiny/src/config.rs
index 70e99857..3e64f1b7 100644
--- a/crates/tiny/src/config.rs
+++ b/crates/tiny/src/config.rs
@@ -1,3 +1,4 @@
+use libtiny_client::SASLAuth as ClientSASLAuth;
 use serde::{Deserialize, Deserializer};
 use std::fs;
 use std::fs::File;
@@ -6,9 +7,32 @@ 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(untagged, 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 (PKCS8 format).
+        /// A fingerprint of the certificate should be registered with NickServ
+        pem: PathBuf,
+    },
+}
+
+impl TryFrom<SASLAuth<String>> for ClientSASLAuth {
+    type Error = String;
+
+    fn try_from(sasl: SASLAuth<String>) -> Result<Self, Self::Error> {
+        Ok(match sasl {
+            SASLAuth::Plain { username, password } => ClientSASLAuth::Plain { username, password },
+            SASLAuth::External { pem } => ClientSASLAuth::External {
+                pem: std::fs::read(pem).map_err(|e| format!("Could not read PEM file: {}", e))?,
+            },
+        })
+    }
 }
 
 #[derive(Clone, Deserialize)]
@@ -256,11 +280,18 @@ impl Config<PassOrCmd> {
                 }
             }
 
-            if let Some(ref sasl_auth) = server.sasl_auth {
-                if sasl_auth.password.is_empty_cmd() {
+            if let Some(SASLAuth::Plain { password, .. }) = &server.sasl_auth {
+                if password.is_empty_cmd() {
                     errors.push(format!("Empty SASL password command for '{}'", server.addr));
                 }
             }
+
+            if let Some(SASLAuth::External { .. }) = &server.sasl_auth {
+                if !server.tls {
+                    errors.push(format!("TLS is not enabled for '{}', but SASL EXTERNAL authentication requires TLS. \
+                                        Please enable TLS for this server in the config file.", server.addr));
+                }
+            }
         }
 
         errors
@@ -305,20 +336,21 @@ impl Config<PassOrCmd> {
 
             let sasl_auth = match sasl_auth {
                 None => None,
-                Some(SASLAuth {
+                Some(SASLAuth::Plain {
                     username,
                     password: PassOrCmd::Pass(pass),
-                }) => Some(SASLAuth {
+                }) => Some(SASLAuth::Plain {
                     username,
                     password: pass,
                 }),
-                Some(SASLAuth {
+                Some(SASLAuth::Plain {
                     username,
                     password: PassOrCmd::Cmd(cmd),
                 }) => {
                     let password = run_command("SASL password", &addr, &cmd)?;
-                    Some(SASLAuth { username, password })
+                    Some(SASLAuth::Plain { username, password })
                 }
+                Some(SASLAuth::External { pem }) => Some(SASLAuth::External { pem }),
             };
 
             servers_.push(Server {
diff --git a/crates/tiny/src/main.rs b/crates/tiny/src/main.rs
index c9567c43..be8b3542 100644
--- a/crates/tiny/src/main.rs
+++ b/crates/tiny/src/main.rs
@@ -11,7 +11,7 @@ mod utils;
 #[cfg(test)]
 mod tests;
 
-use libtiny_client::{Client, ServerInfo};
+use libtiny_client::{Client, SASLAuth, ServerInfo};
 use libtiny_common::{ChanNameRef, MsgTarget};
 use libtiny_logger::{Logger, LoggerInitError};
 use libtiny_tui::TUI;
@@ -147,10 +147,21 @@ fn run(
         for server in servers.iter().cloned() {
             tui.new_server_tab(&server.addr, server.alias);
 
+            let tls = server.tls;
+            let sasl_auth = server.sasl_auth.and_then(|sasl| -> Option<SASLAuth> {
+                match sasl.try_into() {
+                    Ok(sasl) => Some(sasl),
+                    Err(e) => {
+                        tui.add_client_err_msg(&e, &MsgTarget::Server { serv: &server.addr });
+                        None
+                    }
+                }
+            });
+
             let server_info = ServerInfo {
                 addr: server.addr,
                 port: server.port,
-                tls: server.tls,
+                tls,
                 pass: server.pass,
                 realname: server.realname,
                 nicks: server.nicks,
@@ -160,10 +171,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);


Test patch: