Repository: osa1/tiny (language: rust)

Issue:
SASL authenication (request for comment)
I have got SASL partially working, but before making a pull request there is one problem and I would ask for comment.

During the conservation of SASL (https://ircv3.net/specs/extensions/sasl-3.1.html), the client has to wait for server reply `:jaguar.test CAP jilles ACK :sasl`.  Not waiting for it sometimes work, but sometimes doesn't, so it is better to check it.  Is it reasonable to add a variant in `enum ConnStatus` for `waiting server ACK`?  I mean we will set this status and go back to `mainloop` without completing authenication, and look for server reply later.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 903368de..47f49c06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,8 @@
 - tiny can now show desktop notifications for incoming messages. See README for
   notification options. Defaults: show notifications for mentions in channels
   and all private messages.
+- Support SASL `PLAIN` authenication.  Add a section of `sasl_auth` in
+  `.tinyrc.yml` to use.
 
 # 2017/11/12: 0.3.0
 
diff --git a/Cargo.toml b/Cargo.toml
index 6baa7099..8c15b321 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ take_mut = "0.2.0"
 term_input = "0.1.3"
 termbox_simple = "0.2.0"
 time = "0.1"
+base64 = "0.6.0"
 
 [dev-dependencies]
 quickcheck = "0.3"
diff --git a/src/cmd.rs b/src/cmd.rs
index 1e7765d4..958c225c 100644
--- a/src/cmd.rs
+++ b/src/cmd.rs
@@ -331,6 +331,7 @@ fn connect_<'a, 'b>(serv_addr: &str, pass: Option<&str>, poll: &'b Poll, tiny: &
             nicks: tiny.defaults.nicks.clone(),
             auto_cmds: tiny.defaults.auto_cmds.clone(),
             join: tiny.defaults.join.clone(),
+            sasl_auth: None,
         },
         poll,
     );
diff --git a/src/config.rs b/src/config.rs
index 86fe869c..2d254737 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -10,6 +10,12 @@ use std::io::Write;
 use std::path::PathBuf;
 use std::path::Path;
 
+#[derive(Clone, Deserialize)]
+pub struct SASLAuth {
+    pub sasl_username: String,
+    pub sasl_password: String,
+}
+
 #[derive(Clone, Deserialize)]
 pub struct Server {
     /// Address of the server
@@ -43,6 +49,9 @@ pub struct Server {
     /// Channels to automatically join. Any `/join` commands in `auto_cmds` will be moved here.
     #[serde(default)]
     pub join: Vec<String>,
+
+    /// Authenication method
+    pub sasl_auth: Option<SASLAuth>,
 }
 
 /// Similar to `Server`, but used when connecting via the `/connect` command.
diff --git a/src/conn.rs b/src/conn.rs
index b26d010d..2766eb27 100644
--- a/src/conn.rs
+++ b/src/conn.rs
@@ -1,3 +1,5 @@
+extern crate base64;
+
 use mio::Poll;
 use mio::Token;
 use std::io::Write;
@@ -51,6 +53,8 @@ pub struct Conn<'poll> {
 
     /// Incoming message buffer
     in_buf: Vec<u8>,
+
+    sasl_auth: Option<config::SASLAuth>,
 }
 
 pub type ConnErr = StreamErr;
@@ -146,13 +150,17 @@ impl<'poll> Conn<'poll> {
         let mut stream =
             Stream::new(poll, &server.addr, server.port, server.tls).map_err(StreamErr::from)?;
 
-        introduce(
-            &mut stream,
-            server.pass.as_ref().map(String::as_str),
-            &server.hostname,
-            &server.realname,
-            &server.nicks[0],
-        );
+        if server.sasl_auth.is_some() {
+            wire::cap_ls(&mut stream).unwrap();
+        } else {
+            introduce(
+                &mut stream,
+                server.pass.as_ref().map(String::as_str),
+                &server.hostname,
+                &server.realname,
+                &server.nicks[0],
+            );
+        }
 
         Ok(Conn {
             serv_addr: server.addr,
@@ -173,6 +181,7 @@ impl<'poll> Conn<'poll> {
                 stream: stream,
             },
             in_buf: vec![],
+            sasl_auth: server.sasl_auth
         })
     }
 
@@ -192,13 +201,17 @@ impl<'poll> Conn<'poll> {
             Err(err) =>
                 Err(StreamErr::from(err)),
             Ok(mut stream) => {
-                introduce(
-                    &mut stream,
-                    self.pass.as_ref().map(String::as_str),
-                    &self.hostname,
-                    &self.realname,
-                    self.get_nick()
-                );
+                if self.sasl_auth.is_some() {
+                    wire::cap_ls(&mut stream).unwrap();
+                } else {
+                    introduce(
+                        &mut stream,
+                        self.pass.as_ref().map(String::as_str),
+                        &self.hostname,
+                        &self.realname,
+                        self.get_nick(),
+                    );
+                }
                 self.status = ConnStatus::PingPong {
                     ticks_passed: 0,
                     stream: stream,
@@ -242,6 +255,21 @@ impl<'poll> Conn<'poll> {
 }
 
 impl<'poll> Conn<'poll> {
+    fn plain_sasl_authenticate(&mut self) {
+        if let (Some(stream), Some(auth)) =
+                (self.status.get_stream_mut(), self.sasl_auth.as_ref()) {
+            let msg =  format!("{}\x00{}\x00{}",
+                               auth.sasl_username, auth.sasl_username, auth.sasl_password);
+            wire::authenticate(stream, &base64::encode(&msg)).unwrap();
+        }
+    }
+
+    fn end_capability_negotiation(&mut self) {
+        self.status.get_stream_mut().map(|stream| {
+            wire::cap_end(stream).unwrap();
+        });
+    }
+
     pub fn enter_disconnect_state(&mut self) {
         self.status = ConnStatus::Disconnected { ticks_passed: 0 };
     }
@@ -463,6 +491,63 @@ impl<'poll> Conn<'poll> {
     }
 
     fn handle_msg(&mut self, msg: Msg, evs: &mut Vec<ConnEv>, logger: &mut Logger) {
+        if let Msg {
+            cmd: Cmd::CAP { client: _, ref subcommand, ref params },
+            ..
+        } = msg
+        {
+            match subcommand.as_ref() {
+                "ACK" => {
+                    if params.iter().any(|cap| cap.as_str() == "sasl") {
+                        self.status.get_stream_mut().map(|stream| {
+                            wire::authenticate(stream, "PLAIN").unwrap();
+                        });
+                    }
+                }
+                "NAK" => {
+                    self.end_capability_negotiation();
+                }
+                "LS" => {
+                    if params.iter().any(|cap| cap.as_str() == "sasl") {
+                        let stream = match self.status.get_stream_mut() {
+                            Some(stream) => stream,
+                            None => return
+                        };
+                        introduce(
+                            stream,
+                            None,
+                            &self.hostname,
+                            &self.realname,
+                            &self.nicks[0],
+                        );
+                        wire::cap_req(stream, &["sasl"]).unwrap();
+                        // Will wait for CAP ... ACK from server before authenication.
+                    }
+                }
+                _ => {}
+            };
+        }
+
+        if let Msg {
+            cmd: Cmd::AUTHENTICATE { ref param },
+            ..
+        } = msg
+        {
+            if param.as_str() == "+" {
+                // Empty AUTHENTICATE response.  It means server accepted the specified SASL
+                // mechanism (PLAIN)
+                self.plain_sasl_authenticate();
+            }
+        }
+
+        match msg.cmd {
+            // 903: RPL_SASLSUCCESS, 904: ERR_SASLFAIL
+            Cmd::Reply { num: 903, .. } | Cmd::Reply { num: 904, .. } => {
+                self.end_capability_negotiation();
+            }
+            _  => {}
+        }
+
         if let Msg {
             cmd: Cmd::PING { ref server },
             ..
diff --git a/src/lib.rs b/src/lib.rs
index b4dab9ef..40293f10 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -733,6 +733,41 @@ impl<'poll> Tiny<'poll> {
                 );
             }
 
+            Cmd::CAP { client: _, ref subcommand, ref params } => {
+                match subcommand.as_ref() {
+                    "NAK" => {
+                        if params.iter().any(|cap| cap.as_str() == "sasl") {
+                            let msg_target = MsgTarget::Server {
+                                serv_name: conn.get_serv_name(),
+                            };
+                            self.tui.add_err_msg("Server rejected using SASL authenication capability",
+                                                 Timestamp::now(),
+                                                 &msg_target);
+                        }
+                    }
+                    "LS" => {
+                        if !params.iter().any(|cap| cap.as_str() == "sasl") {
+                            let msg_target = MsgTarget::Server {
+                                serv_name: conn.get_serv_name(),
+                            };
+                            self.tui.add_err_msg("Server does not support SASL authenication",
+                                                 Timestamp::now(),
+                                                 &msg_target);
+                        }
+                    }
+                    "ACK" => {}
+                    cmd @ _ => {
+                        self.logger
+                            .get_debug_logs()
+                            .write_line(format_args!("CAP subcommand {} is not handled", cmd));
+                    }
+                };
+            }
+
+            Cmd::AUTHENTICATE { .. } =>
+                // ignore
+                {}
+
             Cmd::Reply { num: n, params } => {
                 if n <= 003 /* RPL_WELCOME, RPL_YOURHOST, RPL_CREATED */
                         || n == 251 /* RPL_LUSERCLIENT */
diff --git a/src/wire.rs b/src/wire.rs
index c8fcc744..6037a6eb 100644
--- a/src/wire.rs
+++ b/src/wire.rs
@@ -53,6 +53,22 @@ pub fn away<W: Write>(sink: &mut W, msg: Option<&str>) -> std::io::Result<()> {
     }
 }
 
+pub fn cap_ls<W: Write>(sink: &mut W) -> std::io::Result<()> {
+    write!(sink, "CAP LS\r\n")
+}
+
+pub fn cap_req<W: Write>(sink: &mut W, cap_identifiers: &[&str]) -> std::io::Result<()> {
+    write!(sink, "CAP REQ :{}\r\n", cap_identifiers.join(" "))
+}
+
+pub fn cap_end<W: Write>(sink: &mut W) -> std::io::Result<()> {
+    write!(sink, "CAP END\r\n")
+}
+
+pub fn authenticate<W: Write>(sink: &mut W, msg: &str) -> std::io::Result<()> {
+    write!(sink, "AUTHENTICATE {}\r\n", msg)
+}
+
 /*
 pub fn quit<W : Write>(mut sink: W, msg : Option<&str>) -> std::io::Result<()> {
     match msg {
@@ -138,6 +154,16 @@ pub enum Cmd {
         topic: String,
     },
 
+    CAP {
+        client: String,
+        subcommand: String,
+        params: Vec<String>,
+    },
+
+    AUTHENTICATE {
+        param: String,
+    },
+
     /// An IRC message other than the ones listed above.
     Other {
         cmd: String,
@@ -284,6 +310,16 @@ impl Msg {
                         chan: params[0].to_owned(),
                         topic: params[1].to_owned(),
                     },
+                MsgType::Cmd("CAP") if params.len() == 3 =>
+                    Cmd::CAP {
+                        client: params[0].to_owned(),
+                        subcommand: params[1].to_owned(),
+                        params: params[2].split(' ').map(|s| s.to_owned()).collect(),
+                    },
+                MsgType::Cmd("AUTHENTICATE") if params.len() == 1 =>
+                    Cmd::AUTHENTICATE {
+                        param: params[0].to_owned(),
+                    },
                 MsgType::Num(n) =>
                     Cmd::Reply {
                         num: n,


Test patch: