diff --git a/src/authentication.rs b/src/authentication.rs index 4d2c6b7..6404ad1 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -9,6 +9,9 @@ //! Provides authentication mecanisms +use std::fmt::{Display, Formatter}; +use std::fmt::Result as FmtResult; + use serialize::base64::{self, ToBase64, FromBase64}; use serialize::hex::ToHex; use crypto::hmac::Hmac; @@ -17,84 +20,83 @@ use crypto::mac::Mac; use NUL; use error::Error; -use extension::Extension; -/// Represents an authentication mecanism -pub trait Mecanism { - /// Returns the matching `Extension` - fn extension() -> Extension; - /// Returns the initial response support - fn supports_initial_response() -> bool; - /// Returns the response - fn response(username: &str, password: &str, challenge: Option<&str>) -> Result; +/// TODO +#[derive(PartialEq,Eq,Copy,Clone,Hash,Debug)] +pub enum Mecanism { + /// PLAIN authentication mecanism + /// RFC 4616: https://tools.ietf.org/html/rfc4616 + Plain, + /// CRAM-MD5 authentication mecanism + /// RFC 2195: https://tools.ietf.org/html/rfc2195 + CramMd5, } -/// PLAIN authentication mecanism -/// RFC 4616: https://tools.ietf.org/html/rfc4616 -#[derive(Copy,Clone)] -pub struct Plain; - -impl Mecanism for Plain { - fn extension() -> Extension { - Extension::PlainAuthentication +impl Display for Mecanism { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{}", + match *self { + Mecanism::Plain => "PLAIN", + Mecanism::CramMd5 => "CRAM-MD5", + } + ) } +} - fn supports_initial_response() -> bool { - true - } - - fn response(username: &str, password: &str, challenge: Option<&str>) -> Result { - match challenge { - Some(_) => Err(Error::ClientError("This mecanism does not expect a challenge")), - None => Ok(format!("{}{}{}{}", NUL, username, NUL, password).as_bytes().to_base64(base64::STANDARD)), +impl Mecanism { + /// TODO + pub fn supports_initial_response(&self) -> bool { + match *self { + Mecanism::Plain => true, + Mecanism::CramMd5 => false, } } -} -/// CRAM-MD5 authentication mecanism -/// RFC 2195: https://tools.ietf.org/html/rfc2195 -#[derive(Copy,Clone)] -pub struct CramMd5; + /// TODO + pub fn response(&self, username: &str, password: &str, challenge: Option<&str>) -> Result { + match *self { + Mecanism::Plain => { + match challenge { + Some(_) => Err(Error::ClientError("This mecanism does not expect a challenge")), + None => Ok(format!("{}{}{}{}", NUL, username, NUL, password).as_bytes().to_base64(base64::STANDARD)), + } + }, + Mecanism::CramMd5 => { + let encoded_challenge = match challenge { + Some(challenge) => challenge, + None => return Err(Error::ClientError("This mecanism does expect a challenge")), + }; -impl Mecanism for CramMd5 { - fn extension() -> Extension { - Extension::CramMd5Authentication - } + let decoded_challenge = match encoded_challenge.from_base64() { + Ok(challenge) => challenge, + Err(error) => return Err(Error::ChallengeParsingError(error)), + }; - fn supports_initial_response() -> bool { - false - } + let mut hmac = Hmac::new(Md5::new(), password.as_bytes()); + hmac.input(&decoded_challenge); - fn response(username: &str, password: &str, challenge: Option<&str>) -> Result { - let encoded_challenge = match challenge { - Some(challenge) => challenge, - None => return Err(Error::ClientError("This mecanism does expect a challenge")), - }; - - let decoded_challenge = match encoded_challenge.from_base64() { - Ok(challenge) => challenge, - Err(error) => return Err(Error::ChallengeParsingError(error)), - }; - - let mut hmac = Hmac::new(Md5::new(), password.as_bytes()); - hmac.input(&decoded_challenge); - - Ok(format!("{} {}", username, hmac.result().code().to_hex()).as_bytes().to_base64(base64::STANDARD)) + Ok(format!("{} {}", username, hmac.result().code().to_hex()).as_bytes().to_base64(base64::STANDARD)) + }, + } } } #[cfg(test)] mod test { - use super::{Mecanism, Plain, CramMd5}; + use super::Mecanism; #[test] fn test_plain() { - assert_eq!(Plain::response("username", "password", None).unwrap(), "AHVzZXJuYW1lAHBhc3N3b3Jk"); + let mecanism = Mecanism::Plain; + + assert_eq!(mecanism.response("username", "password", None).unwrap(), "AHVzZXJuYW1lAHBhc3N3b3Jk"); } #[test] fn test_cram_md5() { - assert_eq!(CramMd5::response("alice", "wonderland", + let mecanism = Mecanism::CramMd5; + + assert_eq!(mecanism.response("alice", "wonderland", Some("PDE3ODkzLjEzMjA2NzkxMjNAdGVzc2VyYWN0LnN1c2FtLmluPg==")).unwrap(), "YWxpY2UgNjRiMmE0M2MxZjZlZDY4MDZhOTgwOTE0ZTIzZTc1ZjA="); } diff --git a/src/client/authentication.rs b/src/client/authentication.rs new file mode 100644 index 0000000..b3c8a5c --- /dev/null +++ b/src/client/authentication.rs @@ -0,0 +1,54 @@ +// Copyright 2014 Alexis Mousset. See the COPYRIGHT +// file at the top-level directory of this distribution. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Provides authentication functions + +use serialize::base64::{self, ToBase64, FromBase64}; +use serialize::hex::ToHex; +use crypto::hmac::Hmac; +use crypto::md5::Md5; +use crypto::mac::Mac; + +use NUL; +use error::Error; + +/// Returns a PLAIN mecanism response +pub fn plain(username: &str, password: &str) -> String { + format!("{}{}{}{}", NUL, username, NUL, password).as_bytes().to_base64(base64::STANDARD) +} + +/// Returns a CRAM-MD5 mecanism response +pub fn cram_md5(username: &str, password: &str, encoded_challenge: &str) -> Result { + let challenge = match encoded_challenge.from_base64() { + Ok(challenge) => challenge, + Err(error) => return Err(Error::ChallengeParsingError(error)), + }; + + let mut hmac = Hmac::new(Md5::new(), password.as_bytes()); + hmac.input(&challenge); + + Ok(format!("{} {}", username, hmac.result().code().to_hex()).as_bytes().to_base64(base64::STANDARD)) +} + +#[cfg(test)] +mod test { + use super::{plain, cram_md5}; + + #[test] + fn test_plain() { + assert_eq!(plain("username", "password"), "AHVzZXJuYW1lAHBhc3N3b3Jk"); + } + + #[test] + fn test_cram_md5() { + assert_eq!(cram_md5("alice", "wonderland", + "PDE3ODkzLjEzMjA2NzkxMjNAdGVzc2VyYWN0LnN1c2FtLmluPg==").unwrap(), + "YWxpY2UgNjRiMmE0M2MxZjZlZDY4MDZhOTgwOTE0ZTIzZTc1ZjA="); + } +} diff --git a/src/client/connector.rs b/src/client/connector.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/client/mod.rs b/src/client/mod.rs index 405a4bc..302faf2 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -16,7 +16,7 @@ use std::io::{BufRead, Read, Write}; use bufstream::BufStream; use response::ResponseParser; -use authentication::{Mecanism, CramMd5, Plain}; +use authentication::Mecanism; use error::{Error, SmtpResult}; use client::net::{Connector, SmtpStream}; use {CRLF, MESSAGE_ENDING}; @@ -167,21 +167,21 @@ impl Client { self.command("RSET") } - /// Sends an AUTH command with PLAIN mecanism - pub fn auth_plain(&mut self, username: &str, password: &str) -> SmtpResult { - self.command(&format!("AUTH PLAIN {}", try!(Plain::response(username, password, None)))) - } + /// Sends an AUTH command with the given mecanism + pub fn auth(&mut self, mecanism: Mecanism, username: &str, password: &str) -> SmtpResult { - /// Sends an AUTH command with CRAM-MD5 mecanism - pub fn auth_cram_md5(&mut self, username: &str, password: &str) -> SmtpResult { - let encoded_challenge = match try!(self.command("AUTH CRAM-MD5")).first_word() { - Some(challenge) => challenge, - None => return Err(Error::ResponseParsingError("Could not read CRAM challenge")), - }; + if mecanism.supports_initial_response() { + self.command(&format!("AUTH {} {}", mecanism, try!(mecanism.response(username, password, None)))) + } else { + let encoded_challenge = match try!(self.command("AUTH CRAM-MD5")).first_word() { + Some(challenge) => challenge, + None => return Err(Error::ResponseParsingError("Could not read CRAM challenge")), + }; - let cram_response = try!(CramMd5::response(username, password, Some(&encoded_challenge))); + let cram_response = try!(mecanism.response(username, password, Some(&encoded_challenge))); - self.command(&format!("AUTH CRAM-MD5 {}", cram_response)) + self.command(&format!("AUTH CRAM-MD5 {}", cram_response)) + } } /// Sends the message content diff --git a/src/extension.rs b/src/extension.rs index beb7b31..a72eb5b 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -9,14 +9,17 @@ //! ESMTP features -use std::str::FromStr; use std::result::Result; +use std::fmt::{Display, Formatter}; +use std::fmt::Result as FmtResult; +use std::collections::HashSet; use response::Response; -use self::Extension::*; +use error::Error; +use authentication::Mecanism; /// Supported ESMTP keywords -#[derive(PartialEq,Eq,Copy,Clone,Debug)] +#[derive(PartialEq,Eq,Hash,Clone,Debug)] pub enum Extension { /// 8BITMIME keyword /// @@ -30,85 +33,115 @@ pub enum Extension { /// /// RFC 2487: https://tools.ietf.org/html/rfc2487 StartTls, - /// AUTH PLAIN mecanism - /// - /// RFC 4616: https://tools.ietf.org/html/rfc4616 - PlainAuthentication, - /// AUTH CRAM-MD5 mecanism - /// - /// RFC 2195: https://tools.ietf.org/html/rfc2195 - CramMd5Authentication, + /// AUTH mecanism + Authentication(Mecanism), } -impl Extension { - fn from_str(s: &str) -> Result, &'static str> { - let splitted : Vec<&str> = s.split_whitespace().collect(); - match (splitted[0], splitted.len()) { - ("8BITMIME", 1) => Ok(vec![EightBitMime]), - ("SMTPUTF8", 1) => Ok(vec![SmtpUtfEight]), - ("STARTTLS", 1) => Ok(vec![StartTls]), - ("AUTH", _) => { - let mut mecanisms: Vec = vec![]; - for &mecanism in &splitted[1..] { - match mecanism { - "PLAIN" => mecanisms.push(PlainAuthentication), - "CRAM-MD5" => mecanisms.push(CramMd5Authentication), - _ => (), - } - } - Ok(mecanisms) - }, - _ => Err("Unknown extension"), - } +impl Display for Extension { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{}", + match *self { + Extension::EightBitMime => "8BITMIME", + Extension::SmtpUtfEight => "SMTPUTF8", + Extension::StartTls => "STARTTLS", + Extension::Authentication(_) => "AUTH", + } + ) } +} - /// Parses supported ESMTP features - pub fn parse_esmtp_response(response: &Response) -> Vec { - let mut esmtp_features: Vec = Vec::new(); +/// Contains information about an SMTP server +#[derive(Clone,Debug)] +pub struct ServerInfo { + /// Server name + /// + /// The name given in the server banner + pub name: String, + /// ESMTP features supported by the server + /// + /// It contains the features supported by the server and known by the `Extension` module. + pub esmtp_features: HashSet, +} + +impl Display for ServerInfo { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{} with {}", + self.name, + match self.esmtp_features.is_empty() { + true => "no supported features".to_string(), + false => format! ("{:?}", self.esmtp_features), + } + ) + } +} + +impl ServerInfo { + /// Parses a response to create a `ServerInfo` + pub fn from_response(response: &Response) -> Result { + let name = match response.first_word() { + Some(name) => name, + None => return Err(Error::ResponseParsingError("Could not read server name")) + }; + + let mut esmtp_features: HashSet = HashSet::new(); for line in response.message() { - if let Ok(keywords) = Extension::from_str(&line) { - for keyword in keywords { - esmtp_features.push(keyword); - } + + let splitted : Vec<&str> = line.split_whitespace().collect(); + let _ = match (splitted[0], splitted.len()) { + ("8BITMIME", 1) => {esmtp_features.insert(Extension::EightBitMime);}, + ("SMTPUTF8", 1) => {esmtp_features.insert(Extension::SmtpUtfEight);}, + ("STARTTLS", 1) => {esmtp_features.insert(Extension::StartTls);}, + ("AUTH", _) => { + for &mecanism in &splitted[1..] { + match mecanism { + "PLAIN" => {esmtp_features.insert(Extension::Authentication(Mecanism::Plain));}, + "CRAM-MD5" => {esmtp_features.insert(Extension::Authentication(Mecanism::CramMd5));}, + _ => (), + } + } + }, + (_, _) => (), }; } - esmtp_features + Ok(ServerInfo{ + name: name, + esmtp_features: esmtp_features, + }) + } + + /// Checks if the server supports an ESMTP feature + pub fn supports_feature(&self, keyword: &Extension) -> bool { + self.esmtp_features.contains(keyword) + } + + /// Checks if the server supports an ESMTP feature + pub fn supports_auth_mecanism(&self, mecanism: Mecanism) -> bool { + self.esmtp_features.contains(&Extension::Authentication(mecanism)) } } #[cfg(test)] mod test { - use super::Extension; - use response::{Severity, Category, Response, Code}; - + use std::collections::HashSet; + + use super::{ServerInfo, Extension}; + #[test] - fn test_from_str() { - assert_eq!(Extension::from_str("8BITMIME"), Ok(vec![Extension::EightBitMime])); - assert_eq!(Extension::from_str("AUTH PLAIN"), Ok(vec![Extension::PlainAuthentication])); - assert_eq!(Extension::from_str("AUTH PLAIN LOGIN CRAM-MD5"), Ok(vec![Extension::PlainAuthentication, Extension::CramMd5Authentication])); - assert_eq!(Extension::from_str("AUTH CRAM-MD5 PLAIN"), Ok(vec![Extension::CramMd5Authentication, Extension::PlainAuthentication])); - assert_eq!(Extension::from_str("AUTH DIGEST-MD5 PLAIN CRAM-MD5"), Ok(vec![Extension::PlainAuthentication, Extension::CramMd5Authentication])); - } - - #[test] - fn test_parse_esmtp_response() { - assert_eq!(Extension::parse_esmtp_response(&Response::new( - Code::new( - "2".parse::().unwrap(), - "2".parse::().unwrap(), - 1, - ), - vec!["me".to_string(), "8BITMIME".to_string(), "SIZE 42".to_string()] - )), vec![Extension::EightBitMime]); - assert_eq!(Extension::parse_esmtp_response(&Response::new( - Code::new( - "4".parse::().unwrap(), - "3".parse::().unwrap(), - 3, - ), - vec!["me".to_string(), "8BITMIME".to_string(), "AUTH PLAIN CRAM-MD5".to_string()] - )), vec![Extension::EightBitMime, Extension::PlainAuthentication, Extension::CramMd5Authentication]); + fn test_serverinfo_fmt() { + let mut eightbitmime = HashSet::new(); + assert!(eightbitmime.insert(Extension::EightBitMime)); + + let empty = HashSet::new(); + + assert_eq!(format!("{}", ServerInfo{ + name: "name".to_string(), + esmtp_features: eightbitmime.clone() + }), "name with {EightBitMime}".to_string()); + assert_eq!(format!("{}", ServerInfo{ + name: "name".to_string(), + esmtp_features: empty, + }), "name with no supported features".to_string()); } } diff --git a/src/sender/mod.rs b/src/sender/mod.rs index ebbf8e3..67f098f 100644 --- a/src/sender/mod.rs +++ b/src/sender/mod.rs @@ -13,14 +13,12 @@ use std::string::String; use std::net::{SocketAddr, ToSocketAddrs}; use SMTP_PORT; -use extension::Extension; +use extension::{Extension, ServerInfo}; use error::{SmtpResult, Error}; use sendable_email::SendableEmail; -use sender::server_info::ServerInfo; use client::Client; use client::net::SmtpStream; - -mod server_info; +use authentication::Mecanism; /// Contains client configuration pub struct SenderBuilder { @@ -171,37 +169,26 @@ impl Sender { if self.state.connection_reuse_count == 0 { try!(self.client.connect()); - let hello_error = Error::ResponseParsingError("No hostname announced by the server"); - // Log the connection info!("connection established to {}", self.client_info.server_addr); // Extended Hello or Hello if needed - match self.client.ehlo(&self.client_info.hello_name) { - Ok(response) => {self.server_info = Some( - ServerInfo{ - name: try_smtp!(response.first_word().ok_or(hello_error), self), - esmtp_features: Extension::parse_esmtp_response(&response), - }); - }, + let hello_response = match self.client.ehlo(&self.client_info.hello_name) { + Ok(response) => response, Err(error) => match error { Error::PermanentError(ref response) if response.has_code(550) => { match self.client.helo(&self.client_info.hello_name) { - Ok(response) => {self.server_info = Some( - ServerInfo{ - name: try_smtp!(response.first_word().ok_or(hello_error), self), - esmtp_features: vec!(), - }); - }, + Ok(response) => response, Err(error) => try_smtp!(Err(error), self) } - }, _ => { try_smtp!(Err(error), self) }, }, - } + }; + + self.server_info = Some(try_smtp!(ServerInfo::from_response(&hello_response), self)); // Print server information debug!("server {}", self.server_info.as_ref().unwrap()); @@ -212,11 +199,11 @@ impl Sender { let (username, password) = self.client_info.credentials.clone().unwrap(); - if self.server_info.as_ref().unwrap().supports_feature(Extension::CramMd5Authentication) { - let result = self.client.auth_cram_md5(&username, &password); + if self.server_info.as_ref().unwrap().supports_auth_mecanism(Mecanism::CramMd5) { + let result = self.client.auth(Mecanism::CramMd5, &username, &password); try_smtp!(result, self); - } else if self.server_info.as_ref().unwrap().supports_feature(Extension::PlainAuthentication) { - let result = self.client.auth_plain(&username, &password); + } else if self.server_info.as_ref().unwrap().supports_auth_mecanism(Mecanism::Plain) { + let result = self.client.auth(Mecanism::Plain, &username, &password); try_smtp!(result, self); } else { debug!("No supported authentication mecanisms available"); @@ -229,7 +216,7 @@ impl Sender { let message = email.message(); // Mail - let mail_options = match self.server_info.as_ref().unwrap().supports_feature(Extension::EightBitMime) { + let mail_options = match self.server_info.as_ref().unwrap().supports_feature(&Extension::EightBitMime) { true => Some("BODY=8BITMIME"), false => None, }; diff --git a/src/sender/server_info.rs b/src/sender/server_info.rs deleted file mode 100644 index 11c2df3..0000000 --- a/src/sender/server_info.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2014 Alexis Mousset. See the COPYRIGHT -// file at the top-level directory of this distribution. -// -// Licensed under the Apache License, Version 2.0 or the MIT license -// , at your -// option. This file may not be copied, modified, or distributed -// except according to those terms. - -//! Information about a server - -use std::fmt; -use std::fmt::{Display, Formatter}; - -use extension::Extension; - -/// Contains information about an SMTP server -#[derive(Clone,Debug)] -pub struct ServerInfo { - /// Server name - /// - /// The name given in the server banner - pub name: String, - /// ESMTP features supported by the server - /// - /// It contains the features supported by the server and known by the `Extension` module. - pub esmtp_features: Vec, -} - -impl Display for ServerInfo { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "{} with {}", - self.name, - match self.esmtp_features.is_empty() { - true => "no supported features".to_string(), - false => format! ("{:?}", self.esmtp_features), - } - ) - } -} - -impl ServerInfo { - /// Checks if the server supports an ESMTP feature - pub fn supports_feature(&self, keyword: Extension) -> bool { - self.esmtp_features.contains(&keyword) - } -} - -#[cfg(test)] -mod test { - use super::ServerInfo; - use extension::Extension; - - #[test] - fn test_fmt() { - assert_eq!(format!("{}", ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![Extension::EightBitMime] - }), "name with [EightBitMime]".to_string()); - assert_eq!(format!("{}", ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![Extension::EightBitMime] - }), "name with [EightBitMime]".to_string()); - assert_eq!(format!("{}", ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![] - }), "name with no supported features".to_string()); - } - - #[test] - fn test_supports_feature() { - assert!(ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![Extension::EightBitMime] - }.supports_feature(Extension::EightBitMime)); - assert!(ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![Extension::PlainAuthentication, Extension::EightBitMime] - }.supports_feature(Extension::EightBitMime)); - assert_eq!(ServerInfo{ - name: "name".to_string(), - esmtp_features: vec![Extension::EightBitMime] - }.supports_feature(Extension::PlainAuthentication), false); - } -}