From 36d0c0504e7fc552ee1437bb6ce25cec70d68bf2 Mon Sep 17 00:00:00 2001 From: Nadja Reitzenstein Date: Mon, 6 Feb 2023 12:08:41 +0100 Subject: [PATCH 1/2] Implementing capability caching --- src/client.rs | 96 ++++++++++++++++++++++++++++++++++++++- src/types/capabilities.rs | 22 +++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/client.rs b/src/client.rs index 7969497..7526848 100644 --- a/src/client.rs +++ b/src/client.rs @@ -138,6 +138,11 @@ fn validate_sequence_set( #[derive(Debug)] pub struct Session { conn: Connection, + + // Capabilities are almost guaranteed to chance if encryption state or authentication state + // changes, so caching them in `Connection` is inappropiate. + capability_cache: Option, + pub(crate) unsolicited_responses_tx: mpsc::Sender, /// Server responses that are not related to the current command. See also the note on @@ -153,6 +158,10 @@ pub struct Session { #[derive(Debug)] pub struct Client { conn: Connection, + + // Capabilities are almost guaranteed to chance if encryption state or authentication state + // changes, so caching them in `Connection` is inappropiate. + capability_cache: Option, } /// The underlying primitives type. Both `Client`(unauthenticated) and `Session`(after succesful @@ -333,6 +342,7 @@ impl Client { debug: false, greeting_read: false, }, + capability_cache: None, } } @@ -345,6 +355,47 @@ impl Client { Ok(res) } + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a + /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as + /// one of the listed capabilities. See [`Capabilities`] for further details. + /// + /// This method will always bypass the local capabilities cache and send a `CAPABILITY` command + /// to the server. The [`Self::capabilities()`] method can be used when returning a cached + /// response is acceptable. + pub fn capabilities_refresh(&mut self) -> Result<&Capabilities> { + let (mut tx, _rx) = mpsc::channel(); + let caps = self.run_command_and_read_response("CAPABILITY") + .and_then(|lines| Capabilities::parse(lines, &mut tx))?; + self.capability_cache = Some(caps); + + self.capability_cache.as_ref() + // This path will not be hit; if the cache is not populated the above calls will either + // populate it or return with an early error. + .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) + } + + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a + /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as + /// one of the listed capabilities. See [`Capabilities`] for further details. + /// + /// This function will not query the server if a set of capabilities was cached, but request + /// and cache capabilities from the server otherwise. The [`Self::capabilities_refresh`] method + /// can be used to refresh the cache by forcing a `CAPABILITY` command to be send. + pub fn capabilities_ref(&mut self) -> Result<&Capabilities> { + let (mut tx, _rx) = mpsc::channel(); + if self.capability_cache.is_none() { + let caps = self.run_command_and_read_response("CAPABILITY") + .and_then(|lines| Capabilities::parse(lines, &mut tx))?; + + self.capability_cache = Some(caps); + } + + self.capability_cache.as_ref() + // This path will not be hit; if the cache is not populated the above `if` will either + // populate it or return with an early error. + .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) + } + /// Log in to the IMAP server. Upon success a [`Session`](struct.Session.html) instance is /// returned; on error the original `Client` instance is returned in addition to the error. /// This is because `login` takes ownership of `self`, so in order to try again (e.g. after @@ -502,6 +553,7 @@ impl Session { let (tx, rx) = mpsc::channel(); Session { conn, + capability_cache: None, unsolicited_responses: rx, unsolicited_responses_tx: tx, } @@ -771,12 +823,52 @@ impl Session { self.run_command_and_check_ok(&format!("UNSUBSCRIBE {}", quote!(mailbox.as_ref()))) } + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a + /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as + /// one of the listed capabilities. See [`Capabilities`] for further details. + /// + /// This method will always bypass the local capabilities cache and send a `CAPABILITY` command + /// to the server. The [`Self::capabilities()`] method can be used when returning a cached + /// response is acceptable. + pub fn capabilities_refresh(&mut self) -> Result<&Capabilities> { + let caps = self.run_command_and_read_response("CAPABILITY") + .and_then(|lines| Capabilities::parse(lines, &mut self.unsolicited_responses_tx))?; + self.capability_cache = Some(caps); + + self.capability_cache.as_ref() + // This path will not be hit; if the cache is not populated the above calls will either + // populate it or return with an early error. + .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) + } + + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a + /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as + /// one of the listed capabilities. See [`Capabilities`] for further details. + /// + /// This function will not query the server if a set of capabilities was cached, but request + /// and cache capabilities from the server otherwise. The [`Self::capabilities_refresh`] method + /// can be used to refresh the cache by forcing a `CAPABILITY` command to be send. + pub fn capabilities_ref(&mut self) -> Result<&Capabilities> { + if self.capability_cache.is_none() { + let caps = self.run_command_and_read_response("CAPABILITY") + .and_then(|lines| Capabilities::parse(lines, &mut self.unsolicited_responses_tx))?; + + self.capability_cache = Some(caps); + } + + self.capability_cache.as_ref() + // This path will not be hit; if the cache is not populated the above `if` will either + // populate it or return with an early error. + .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) + } + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as /// one of the listed capabilities. See [`Capabilities`] for further details. pub fn capabilities(&mut self) -> Result { - self.run_command_and_read_response("CAPABILITY") - .and_then(|lines| Capabilities::parse(lines, &mut self.unsolicited_responses_tx)) + // TODO: This emulated the same behaviour as before, with each call issuing a command. + // It may be sensible to allow hitting the cache here. + self.capabilities_refresh().map(|caps| caps.clone()) } /// The [`EXPUNGE` command](https://tools.ietf.org/html/rfc3501#section-6.4.3) permanently diff --git a/src/types/capabilities.rs b/src/types/capabilities.rs index 966ce15..a11d9c6 100644 --- a/src/types/capabilities.rs +++ b/src/types/capabilities.rs @@ -5,6 +5,7 @@ use imap_proto::{Capability, Response}; use ouroboros::self_referencing; use std::collections::hash_set::Iter; use std::collections::HashSet; +use std::fmt; use std::sync::mpsc; const IMAP4REV1_CAPABILITY: &str = "IMAP4rev1"; @@ -43,6 +44,17 @@ pub struct Capabilities { pub(crate) capabilities: HashSet>, } +impl Clone for Capabilities { + fn clone(&self) -> Self { + // Give _rx a name so it's not immediately dropped. Otherwise any unsolicited responses + // that would be send there will return a SendError instead of the parsed response simply + // being dropped later. + let (mut tx, _rx) = mpsc::channel(); + Self::parse(self.borrow_data().clone(), &mut tx) + .expect("failed to parse capabilities from data which was already successfully parse before") + } +} + impl Capabilities { /// Parse the given input into one or more [`Capabilitity`] responses. pub(crate) fn parse( @@ -98,3 +110,13 @@ impl Capabilities { self.borrow_capabilities().is_empty() } } + +impl fmt::Debug for Capabilities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut dbg = f.debug_tuple("Capabilities"); + for x in self.borrow_capabilities() { + dbg.field(x); + } + dbg.finish() + } +} \ No newline at end of file From 09d3eadffc13ea2e52f7d059e3ae2ee6a52496f2 Mon Sep 17 00:00:00 2001 From: Nadja Reitzenstein Date: Mon, 6 Feb 2023 12:08:41 +0100 Subject: [PATCH 2/2] Add rsasl for SASL authentication options --- Cargo.lock | 133 ++++++++++++++++++++++++++++++-- Cargo.toml | 11 +++ examples/rustls_sasl.rs | 85 +++++++++++++++++++++ examples/rustls_sasl_gss.rs | 106 ++++++++++++++++++++++++++ src/client.rs | 148 +++++++++++++++++++++++++++++++++--- src/error.rs | 33 ++++++++ 6 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 examples/rustls_sasl.rs create mode 100644 examples/rustls_sasl_gss.rs diff --git a/Cargo.lock b/Cargo.lock index 2516b0b..83f8fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +[[package]] +name = "acid_io" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e08a77c8b11dbd46fd3ba4f0aa0bf7ed078793201540779b0841a297acdad2c" +dependencies = [ + "byteorder", + "libc", + "memchr", + "windows", +] + [[package]] name = "addr2line" version = "0.17.0" @@ -109,6 +121,12 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "cc" version = "1.0.73" @@ -391,6 +409,7 @@ dependencies = [ "openssl", "ouroboros", "regex", + "rsasl", "rustls-connector", "structopt", ] @@ -413,6 +432,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "itoa" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" + [[package]] name = "js-sys" version = "0.3.60" @@ -750,6 +775,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "rsasl" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a808bca6cf3ebfff88d03432b52b0a4e010a96b1a1cd8565a81ce07ff749ed6" +dependencies = [ + "acid_io", + "base64 0.13.0", + "serde_json", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -801,6 +838,12 @@ dependencies = [ "base64 0.13.0", ] +[[package]] +name = "ryu" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" + [[package]] name = "schannel" version = "0.1.20" @@ -844,6 +887,23 @@ dependencies = [ "libc", ] +[[package]] +name = "serde" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" + +[[package]] +name = "serde_json" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "slab" version = "0.4.7" @@ -945,6 +1005,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1119,43 +1199,86 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac7fef12f4b59cd0a29339406cc9203ab44e440ddff6b3f5a41455349fa9cf3" +dependencies = [ + "windows_aarch64_msvc 0.29.0", + "windows_i686_gnu 0.29.0", + "windows_i686_msvc 0.29.0", + "windows_x86_64_gnu 0.29.0", + "windows_x86_64_msvc 0.29.0", +] + [[package]] name = "windows-sys" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", ] +[[package]] +name = "windows_aarch64_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d027175d00b01e0cbeb97d6ab6ebe03b12330a35786cbaca5252b1c4bf5d9b" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +[[package]] +name = "windows_i686_gnu" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8793f59f7b8e8b01eda1a652b2697d87b93097198ae85f823b969ca5b89bba58" + [[package]] name = "windows_i686_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +[[package]] +name = "windows_i686_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8602f6c418b67024be2996c512f5f995de3ba417f4c75af68401ab8756796ae4" + [[package]] name = "windows_i686_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +[[package]] +name = "windows_x86_64_gnu" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d615f419543e0bd7d2b3323af0d86ff19cbc4f816e6453f36a2c2ce889c354" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d95421d9ed3672c280884da53201a5c46b7b2765ca6faf34b0d71cf34a3561" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" diff --git a/Cargo.toml b/Cargo.toml index dac3c08..78c5afa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ bufstream = "0.1.3" imap-proto = "0.16.1" nom = { version = "7.1.0", default-features = false } base64 = "0.21" +rsasl = { version = "2.0.0", default-features = false, features = ["std", "provider_base64"], optional = true } chrono = { version = "0.4", default-features = false, features = ["std"]} lazy_static = "1.4" ouroboros = "0.15.0" @@ -42,6 +43,8 @@ failure = "0.1.8" mime = "0.3.4" openssl = "0.10.35" +rsasl = { version = "2.0.0", default-features = false, features = ["config_builder"] } + [[example]] name = "basic" required-features = ["default"] @@ -69,3 +72,11 @@ required-features = ["default"] [[test]] name = "imap_integration" required-features = ["default"] + +[[example]] +name = "rustls_sasl" +required-features = ["rustls-tls", "rsasl", "rsasl/registry_static"] + +[[example]] +name = "rustls_sasl_gss" +required-features = ["rustls-tls", "rsasl", "rsasl/registry_static", "rsasl/gssapi"] diff --git a/examples/rustls_sasl.rs b/examples/rustls_sasl.rs new file mode 100644 index 0000000..4cc08b9 --- /dev/null +++ b/examples/rustls_sasl.rs @@ -0,0 +1,85 @@ +extern crate imap; + +use std::{env, error::Error}; +use rsasl::callback::{Context, Request, SessionData}; +use rsasl::config::SASLConfig; +use rsasl::prelude::{Mechname, SessionError}; +use rsasl::property::{AuthId, AuthzId, Hostname, Password}; + +fn main() -> Result<(), Box> { + // Read config from environment or .env file + let host = env::var("HOST").expect("missing envvar host"); + let user = env::var("MAILUSER").ok(); + let password = env::var("PASSWORD").ok(); + let port = 993; + + if let Some(email) = fetch_inbox_top(host, user, password, port)? { + println!("{}", &email); + } + + Ok(()) +} + +struct MyCb { + authid: Option, + authzid: Option, + passwd: Option, + host: String, +} +impl rsasl::callback::SessionCallback for MyCb { + fn callback(&self, _session_data: &SessionData, _context: &Context, request: &mut Request) -> Result<(), SessionError> { + if let Some(authid) = self.authid.as_deref() { request.satisfy::(authid)?; } + if let Some(authzid) = self.authzid.as_deref() { request.satisfy::(authzid)?; } + if let Some(passwd) = self.passwd.as_deref() { request.satisfy::(passwd.as_bytes())?; } + if let Some(authid) = self.authid.as_ref() { request.satisfy::(authid)?; } + request.satisfy::(&self.host)?; + Ok(()) + } +} + +fn fetch_inbox_top( + host: String, + user: Option, + password: Option, + port: u16, +) -> Result, Box> { + let client = imap::ClientBuilder::new(&host, port).rustls()?; + + let cb = MyCb { + authid: user, + authzid: None, + passwd: password, + host, + }; + let saslconfig = SASLConfig::builder().with_defaults().with_callback(cb)?; + + println!("SASL configuration options — enable features like 'rsasl/plain' or 'rsasl/sha2' to add available mechanisms:"); + println!("{saslconfig:?}"); + + // the client we have here is unauthenticated. + // to do anything useful with the e-mails, we need to log in + let mut imap_session = client.sasl_auth(saslconfig).map_err(|e| e.0)?; + + // we want to fetch the first email in the INBOX mailbox + imap_session.select("INBOX")?; + + // fetch message number 1 in this mailbox, along with its RFC822 field. + // RFC 822 dictates the format of the body of e-mails + let messages = imap_session.fetch("1", "RFC822")?; + let message = if let Some(m) = messages.iter().next() { + m + } else { + return Ok(None); + }; + + // extract the message's body + let body = message.body().expect("message did not have a body!"); + let body = std::str::from_utf8(body) + .expect("message was not valid utf-8") + .to_string(); + + // be nice to the server and log out + imap_session.logout()?; + + Ok(Some(body)) +} diff --git a/examples/rustls_sasl_gss.rs b/examples/rustls_sasl_gss.rs new file mode 100644 index 0000000..e9f7dcf --- /dev/null +++ b/examples/rustls_sasl_gss.rs @@ -0,0 +1,106 @@ +extern crate imap; + +use std::{env, error::Error, io}; +use std::cmp::Ordering; +use rsasl::callback::{Context, Request, SessionData}; +use rsasl::config::SASLConfig; +use rsasl::prelude::{Mechanism, Mechname, SessionError}; +use rsasl::property::{AuthId, AuthzId, Hostname, Password}; + +fn main() -> Result<(), Box> { + // Read config from environment or .env file + let host = env::var("HOST").expect("missing envvar host"); + let user = env::var("MAILUSER").ok(); + let password = env::var("PASSWORD").ok(); + let port = 993; + + if let Some(email) = fetch_inbox_top(host, user, password, port)? { + println!("{}", &email); + } + + Ok(()) +} + +struct MyCb { + authid: Option, + authzid: Option, + passwd: Option, + host: String, +} +impl rsasl::callback::SessionCallback for MyCb { + fn callback(&self, _session_data: &SessionData, _context: &Context, request: &mut Request) -> Result<(), SessionError> { + request + .satisfy::(&self.host)? + .satisfy::("imap")?; + + if let Some(authid) = self.authid.as_deref() { request.satisfy::(authid)?; } + if let Some(authzid) = self.authzid.as_deref() { request.satisfy::(authzid)?; } + if let Some(passwd) = self.passwd.as_deref() { request.satisfy::(passwd.as_bytes())?; } + if let Some(authid) = self.authid.as_ref() { request.satisfy::(authid)?; } + Ok(()) + } + fn prefer<'a>(&self, a: Option<&'a Mechanism>, b: &'a Mechanism) -> Ordering { + if let Some(a) = a { + let mut buffer = String::new(); + let stdin = io::stdin(); + for _ in 0..3 { + println!("Mechanism preference option: Enter '<' to prefer {a}, or '>' to prefer {b}."); + let _ = stdin.read_line(&mut buffer); + match buffer.trim() { + "<" => { return Ordering::Greater; } + ">" => { return Ordering::Less; } + x => { println!("Invalid input '{x}', enter one of '<' or '>'") }, + } + buffer.clear(); + } + } + Ordering::Less + } +} + +fn fetch_inbox_top( + host: String, + user: Option, + password: Option, + port: u16, +) -> Result, Box> { + let client = imap::ClientBuilder::new(&host, port).rustls()?; + + let cb = MyCb { + authid: user, + authzid: None, + passwd: password, + host, + }; + let saslconfig = SASLConfig::builder().with_defaults().with_callback(cb)?; + + println!("SASL configuration options — enable features like 'rsasl/sha2' to add available mechanisms:"); + println!("{saslconfig:#?}\n"); + + // the client we have here is unauthenticated. + // to do anything useful with the e-mails, we need to log in + let mut imap_session = client.sasl_auth(saslconfig).map_err(|e| e.0)?; + + // we want to fetch the first email in the INBOX mailbox + imap_session.select("INBOX")?; + + // fetch message number 1 in this mailbox, along with its RFC822 field. + // RFC 822 dictates the format of the body of e-mails + let messages = imap_session.fetch("1", "RFC822")?; + let message = if let Some(m) = messages.iter().next() { + m + } else { + return Ok(None); + }; + + // extract the message's body + let body = message.body().expect("message did not have a body!"); + let body = std::str::from_utf8(body) + .expect("message was not valid utf-8") + .to_string(); + + // be nice to the server and log out + imap_session.logout()?; + + Ok(Some(body)) +} diff --git a/src/client.rs b/src/client.rs index 7526848..6c4283a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,3 @@ -use base64::{engine::general_purpose, Engine as _}; use bufstream::BufStream; use chrono::{DateTime, FixedOffset}; use imap_proto::Response; @@ -7,6 +6,7 @@ use std::io::{Read, Write}; use std::ops::{Deref, DerefMut}; use std::str; use std::sync::mpsc; +use base64::Engine; use super::authenticator::Authenticator; use super::error::{Bad, Bye, Error, No, ParseError, Result, ValidateError}; @@ -396,6 +396,24 @@ impl Client { .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) } + /// The [`CAPABILITY` command](https://tools.ietf.org/html/rfc3501#section-6.1.1) requests a + /// listing of capabilities that the server supports. The server will include "IMAP4rev1" as + /// one of the listed capabilities. See [`Capabilities`] for further details. + pub fn capabilities(&mut self) -> Result<&Capabilities> { + if self.capability_cache.is_none() { + // TODO: Figure out how to handle unsolicited messages here. + let (mut tx, _) = mpsc::channel(); + let caps = self.run_command_and_read_response("CAPABILITY") + .and_then(|lines| Capabilities::parse(lines, &mut tx))?; + self.capability_cache = Some(caps); + } + + self.capability_cache.as_ref() + // This path will not be hit; if the cache is not populated the above `if` will either + // populate it or return with an early error. + .ok_or_else(|| panic!("CAPABILITY call did not populate capability cache!")) + } + /// Log in to the IMAP server. Upon success a [`Session`](struct.Session.html) instance is /// returned; on error the original `Client` instance is returned in addition to the error. /// This is because `login` takes ownership of `self`, so in order to try again (e.g. after @@ -411,7 +429,7 @@ impl Client { /// match client.login("user", "pass") { /// Ok(s) => { /// // you are successfully authenticated! - /// }, + /// } /// Err((e, orig_client)) => { /// eprintln!("error logging in: {}", e); /// // prompt user and try again with orig_client here @@ -469,7 +487,7 @@ impl Client { /// match client.authenticate("XOAUTH2", &auth) { /// Ok(session) => { /// // you are successfully authenticated! - /// }, + /// } /// Err((e, orig_client)) => { /// eprintln!("error authenticating: {}", e); /// // prompt user and try again with orig_client here @@ -523,18 +541,16 @@ impl Client { let data = ok_or_unauth_client_err!(parse_authenticate_response(line_str), self); ok_or_unauth_client_err!( - general_purpose::STANDARD_NO_PAD - .decode(data) - .map_err(|e| Error::Parse(ParseError::Authentication( - data.to_string(), - Some(e) - ))), + base64::prelude::BASE64_STANDARD.decode(data).map_err(|e| Error::Parse(ParseError::Authentication( + data.to_string(), + Some(e) + ))), self ) }; let raw_response = &authenticator.process(&challenge); - let auth_response = general_purpose::STANDARD_NO_PAD.encode(raw_response); + let auth_response = base64::prelude::BASE64_STANDARD.encode(raw_response); ok_or_unauth_client_err!( self.write_line(auth_response.into_bytes().as_slice()), self @@ -547,6 +563,118 @@ impl Client { } } +#[cfg(feature = "rsasl")] +mod rsasl { + use super::*; + use std::sync::Arc; + use ::rsasl::prelude::{Mechname, SASLClient, SASLConfig, Session as SASLSession, State as SASLState}; + use imap_proto::Capability; + + impl Client { + /// Authenticate with the server using the given custom SASLConfig to handle the server's + /// challenge. + /// + pub fn sasl_auth( + mut self, + config: Arc, + ) -> ::std::result::Result, (Error, Client)> { + let caps = match self.capabilities() { + Ok(caps) => caps, + Err(error) => { return Err((error, self)) }, + }; + let mechs = caps + .iter() + .filter_map(|capability| match capability { + // TODO: When rust-imap starts using a tracing/logging crate failures to parse + // should be logged. + Capability::Auth(name) => Mechname::parse(name.as_bytes()).ok(), + _ => None, + }); + + let client = SASLClient::new(config); + let session = ok_or_unauth_client_err!( + // TODO: Allow rsasl to select the mechanism + client.start_suggested_iter(mechs).map_err(Error::AuthenticationSetup), + self + ); + // TODO: check for `SASL-IR` capability and send initial data if it's supported. + ok_or_unauth_client_err!( + self.run_command(&format!("AUTHENTICATE {}", session.get_mechname().as_str())), + self + ); + self.do_sasl_handshake(session) + } + + /// This func does the SASL handshake process once the authenticate command is made. + fn do_sasl_handshake( + mut self, + mut authenticator: SASLSession, + ) -> ::std::result::Result, (Error, Client)> { + let mut line = Vec::new(); + let mut output = Vec::new(); + let mut state = SASLState::Running; + + while { + while { + // Drop all read data + line.clear(); + // explicit match blocks necessary to convert error to tuple and not bind self too + // early (see also comment on `login`) + ok_or_unauth_client_err!(self.readline(&mut line), self); + + // ignore server comments — keep going until a line not starting with * is found + line.starts_with(b"* ") + } {} + + line.starts_with(b"+ ") || &line == b"+\r\n" + } { + let challenge = if &line == b"+\r\n" { + None + } else { + let line_str = ok_or_unauth_client_err!( + match str::from_utf8(line.as_slice()) { + Ok(line_str) => Ok(line_str), + Err(e) => Err(Error::Parse(ParseError::DataNotUtf8(line.clone(), e))), + }, + self + ); + let data = ok_or_unauth_client_err!(parse_authenticate_response(line_str), self); + if data.is_empty() { + None + } else { + Some(data.as_bytes()) + } + }; + + // Remove all data written in a previous round + output.clear(); + state = ok_or_unauth_client_err!( + authenticator.step64(challenge, &mut output).map_err(Error::Authentication), + self + ); + + ok_or_unauth_client_err!( + self.write_line(output.as_slice()), + self + ); + } + + // It's important to call the SASL mechanism one last time when the server indicates + // finished authentication but the SASL session doesn't; otherwise a server could subvert + // mutual authentication. + if state.is_running() { + ok_or_unauth_client_err!( + authenticator.step64(None, &mut output).map_err(Error::Authentication), + self + ); + } + + ok_or_unauth_client_err!(self.read_response_onto(&mut line), self); + return Ok(Session::new(self.conn)); + } + } +} + impl Session { // not public, just to avoid duplicating the channel creation code fn new(conn: Connection) -> Self { diff --git a/src/error.rs b/src/error.rs index 477b926..c987015 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,8 @@ use imap_proto::{types::ResponseCode, Response}; use native_tls::Error as TlsError; #[cfg(feature = "native-tls")] use native_tls::HandshakeError as TlsHandshakeError; +#[cfg(feature = "rsasl")] +use rsasl::prelude::{SASLError, SessionError as SASLSessionError}; #[cfg(feature = "rustls-tls")] use rustls_connector::HandshakeError as RustlsHandshakeError; @@ -92,6 +94,12 @@ pub enum Error { ConnectionLost, /// Error parsing a server response. Parse(ParseError), + #[cfg(feature = "rsasl")] + /// Error occurred when tyring to set up authentication + AuthenticationSetup(SASLError), + /// Error occurred during authentication + #[cfg(feature = "rsasl")] + Authentication(SASLSessionError), /// Command inputs were not valid [IMAP /// strings](https://tools.ietf.org/html/rfc3501#section-4.3). Validate(ValidateError), @@ -118,6 +126,19 @@ impl From for Error { } } +#[cfg(feature = "rsasl")] +impl From for Error { + fn from(err: SASLError) -> Self { + Error::AuthenticationSetup(err) + } +} +#[cfg(feature = "rsasl")] +impl From for Error { + fn from(err: SASLSessionError) -> Self { + Error::Authentication(err) + } +} + impl From> for Error { fn from(err: BufError) -> Error { Error::Io(err.into()) @@ -170,6 +191,10 @@ impl fmt::Display for Error { Error::Append => f.write_str("Could not append mail to mailbox"), Error::Unexpected(ref r) => write!(f, "Unexpected Response: {:?}", r), Error::MissingStatusResponse => write!(f, "Missing STATUS Response"), + #[cfg(feature = "rsasl")] + Error::AuthenticationSetup(ref e) => fmt::Display::fmt(e, f), + #[cfg(feature = "rsasl")] + Error::Authentication(ref e) => fmt::Display::fmt(e, f), } } } @@ -194,6 +219,10 @@ impl StdError for Error { Error::Append => "Could not append mail to mailbox", Error::Unexpected(_) => "Unexpected Response", Error::MissingStatusResponse => "Missing STATUS Response", + #[cfg(feature = "rsasl")] + Error::AuthenticationSetup(_) => "Failed to setup authentication", + #[cfg(feature = "rsasl")] + Error::Authentication(_) => "Authentication Failed", } } @@ -207,6 +236,10 @@ impl StdError for Error { #[cfg(feature = "native-tls")] Error::TlsHandshake(ref e) => Some(e), Error::Parse(ParseError::DataNotUtf8(_, ref e)) => Some(e), + #[cfg(feature = "rsasl")] + Error::AuthenticationSetup(ref e) => Some(e), + #[cfg(feature = "rsasl")] + Error::Authentication(ref e) => Some(e), _ => None, } }