use core::fmt;
use alloc::{string::String, vec, vec::Vec};
use imap_codec::{
fragmentizer::Fragmentizer,
imap_types::{
core::{IString, NString},
error::ValidationError,
response::Capability,
},
};
use io_sasl::{
login::SaslLoginCreds,
mechanism::{Sasl, SaslMechanism},
rfc4505::anonymous::SaslAnonymousCreds,
rfc4616::plain::SaslPlainCreds,
rfc7628::oauthbearer::SaslOauthbearerCreds,
xoauth2::SaslXoauth2Creds,
};
use log::debug;
use secrecy::ExposeSecret;
use thiserror::Error;
#[cfg(feature = "url")]
use url::Url;
#[cfg(feature = "scram")]
use crate::rfc7677::auth_scram_sha_256::*;
use crate::{
coroutine::*,
imap_try,
rfc3501::{capability::*, greeting::*, login::*, starttls::*},
rfc7628::auth_oauthbearer::*,
sasl::{auth_anonymous::*, auth_login::*, auth_plain::*, auth_xoauth2::*},
};
#[derive(Debug, Error)]
pub enum ImapSessionOpenError {
#[error("STARTTLS requested on an already-encrypted transport: TLS is active")]
StartTlsOverTls,
#[error("IMAP STARTTLS response carried trailing bytes: refusing the TLS upgrade")]
StartTlsInjection,
#[error("{} SASL mechanism is not supported by this crate", .0.as_str())]
UnsupportedMechanism(SaslMechanism),
#[cfg(feature = "url")]
#[error("IMAP URL `{0}` has no host")]
UrlMissingHost(String),
#[cfg(feature = "url")]
#[error("IMAP URL `{0}` has unsupported scheme `{1}` (expected `imap`, `imaps` or `unix`)")]
UrlUnsupportedScheme(String, String),
#[error("Invalid IMAP LOGIN credentials")]
InvalidLoginCredentials(#[from] ValidationError),
#[error(transparent)]
StartTls(#[from] ImapStartTlsError),
#[error(transparent)]
Greeting(#[from] ImapGreetingGetError),
#[error(transparent)]
Capability(#[from] ImapCapabilityGetError),
#[error(transparent)]
Login(#[from] ImapLoginError),
#[error(transparent)]
AuthAnonymous(#[from] ImapAuthAnonymousError),
#[error(transparent)]
AuthLogin(#[from] ImapAuthLoginError),
#[error(transparent)]
AuthPlain(#[from] ImapAuthPlainError),
#[error(transparent)]
AuthOauthbearer(#[from] ImapAuthOauthbearerError),
#[error(transparent)]
AuthXoauth2(#[from] ImapAuthXoauth2Error),
#[cfg(feature = "scram")]
#[error(transparent)]
AuthScramSha256(#[from] ImapAuthScramSha256Error),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ImapSessionTransport {
Tcp {
host: String,
port: u16,
},
Tls {
host: String,
port: u16,
},
Unix(String),
}
#[cfg(feature = "url")]
impl ImapSessionTransport {
pub fn from_url(url: &Url) -> Result<Self, ImapSessionOpenError> {
let scheme = url.scheme();
if scheme.eq_ignore_ascii_case("unix") {
return Ok(Self::Unix(String::from(url.path())));
}
let Some(host) = url.host_str() else {
let url = String::from(url.as_str());
return Err(ImapSessionOpenError::UrlMissingHost(url));
};
let host = String::from(host);
let port = url.port().unwrap_or_else(|| default_port(scheme));
if scheme.eq_ignore_ascii_case("imap") {
Ok(Self::Tcp { host, port })
} else if scheme.eq_ignore_ascii_case("imaps") {
Ok(Self::Tls { host, port })
} else {
let scheme = String::from(scheme);
let url = String::from(url.as_str());
Err(ImapSessionOpenError::UrlUnsupportedScheme(url, scheme))
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ImapSessionOpenOptions {
pub starttls: bool,
pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
pub sasl_ir: Option<bool>,
}
#[derive(Clone, Debug)]
pub struct ImapSessionOpenData {
pub capability: Vec<Capability<'static>>,
pub pre_authenticated: bool,
}
#[derive(Debug)]
pub enum ImapSessionOpenYield {
WantsTcpConnect {
host: String,
port: u16,
},
WantsTlsConnect {
host: String,
port: u16,
},
WantsUnixConnect(String),
WantsTlsUpgrade,
WantsRead,
WantsWrite(Vec<u8>),
}
impl From<ImapYield> for ImapSessionOpenYield {
fn from(y: ImapYield) -> Self {
match y {
ImapYield::WantsRead => Self::WantsRead,
ImapYield::WantsWrite(bytes) => Self::WantsWrite(bytes),
}
}
}
pub struct ImapSessionOpen {
state: State,
transport: ImapSessionTransport,
sasl: Option<Sasl>,
capability: Vec<Capability<'static>>,
pre_authenticated: bool,
opts: ImapSessionOpenOptions,
}
impl ImapSessionOpen {
pub fn new(
transport: ImapSessionTransport,
sasl: Option<impl Into<Sasl>>,
opts: ImapSessionOpenOptions,
) -> Self {
Self {
state: State::Connect,
transport,
sasl: sasl.map(Into::into),
capability: Vec::new(),
pre_authenticated: false,
opts,
}
}
fn wants_auth(&mut self) -> Result<Option<State>, ImapSessionOpenError> {
if self.pre_authenticated {
return Ok(None);
}
let Some(sasl) = self.sasl.take() else {
return Ok(None);
};
let initial_request = self
.opts
.sasl_ir
.unwrap_or_else(|| self.capability.contains(&Capability::SaslIr));
let auto_id = self.opts.auto_id.take();
let ensure_capabilities = true;
let auth = match sasl {
Sasl::Anonymous(SaslAnonymousCreds { message }) => {
let opts = ImapAuthAnonymousOptions {
initial_request,
ensure_capabilities,
auto_id,
};
Auth::Anonymous(ImapAuthAnonymous::new(message, opts))
}
Sasl::Login(SaslLoginCreds { username, password }) => {
let opts = ImapLoginOptions {
ensure_capabilities,
auto_id,
};
Auth::Login(ImapLogin::new(username, password.expose_secret(), opts)?)
}
Sasl::Plain(SaslPlainCreds {
authzid,
authcid,
passwd,
}) => {
let opts = ImapAuthPlainOptions {
initial_request,
ensure_capabilities,
auto_id,
};
Auth::Plain(ImapAuthPlain::new(
authzid,
authcid,
passwd.expose_secret(),
opts,
))
}
Sasl::Oauthbearer(SaslOauthbearerCreds {
username,
host,
port,
token,
}) => {
let opts = ImapAuthOauthbearerOptions {
initial_request,
ensure_capabilities,
auto_id,
};
Auth::Oauthbearer(ImapAuthOauthbearer::new(
username,
host,
port,
token.expose_secret(),
opts,
))
}
Sasl::Xoauth2(SaslXoauth2Creds { username, token }) => {
let opts = ImapAuthXoauth2Options {
initial_request,
ensure_capabilities,
auto_id,
};
Auth::Xoauth2(ImapAuthXoauth2::new(username, token.expose_secret(), opts))
}
#[cfg(feature = "scram")]
Sasl::ScramSha256(creds) => {
let opts = ImapAuthScramSha256Options {
initial_request,
ensure_capabilities,
auto_id,
};
Auth::ScramSha256(ImapAuthScramSha256::new(creds, opts))
}
sasl => {
let mechanism = sasl.mechanism();
return Err(ImapSessionOpenError::UnsupportedMechanism(mechanism));
}
};
Ok(Some(State::Auth(auth)))
}
fn complete(
&mut self,
) -> ImapCoroutineState<ImapSessionOpenYield, <Self as ImapCoroutine>::Return> {
let data = ImapSessionOpenData {
capability: core::mem::take(&mut self.capability),
pre_authenticated: self.pre_authenticated,
};
ImapCoroutineState::Complete(Ok(data))
}
}
impl ImapCoroutine for ImapSessionOpen {
type Yield = ImapSessionOpenYield;
type Return = Result<ImapSessionOpenData, ImapSessionOpenError>;
fn resume(
&mut self,
fragmentizer: &mut Fragmentizer,
arg: Option<&[u8]>,
) -> ImapCoroutineState<Self::Yield, Self::Return> {
loop {
match &mut self.state {
State::Connect => {
let is_tls = matches!(self.transport, ImapSessionTransport::Tls { .. });
if self.opts.starttls && is_tls {
let err = ImapSessionOpenError::StartTlsOverTls;
return ImapCoroutineState::Complete(Err(err));
}
let yielded = match &self.transport {
ImapSessionTransport::Tcp { host, port } => {
ImapSessionOpenYield::WantsTcpConnect {
host: host.clone(),
port: *port,
}
}
ImapSessionTransport::Tls { host, port } => {
ImapSessionOpenYield::WantsTlsConnect {
host: host.clone(),
port: *port,
}
}
ImapSessionTransport::Unix(path) => {
ImapSessionOpenYield::WantsUnixConnect(path.clone())
}
};
self.state = State::Connected;
debug!("{}", self.state);
return ImapCoroutineState::Yielded(yielded);
}
State::Connected => {
self.state = if self.opts.starttls {
State::StartTls(ImapStartTls::new())
} else {
State::Greeting(ImapGreetingGet::new(ImapGreetingGetOptions {
ensure_capabilities: true,
}))
};
debug!("{}", self.state);
}
State::StartTls(starttls) => {
let leftover = imap_try!(starttls, fragmentizer, arg);
if !leftover.is_empty() {
let err = ImapSessionOpenError::StartTlsInjection;
return ImapCoroutineState::Complete(Err(err));
}
self.state = State::Upgraded;
debug!("{}", self.state);
return ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade);
}
State::Upgraded => {
self.state = State::Capability(ImapCapabilityGet::new());
debug!("{}", self.state);
}
State::Capability(capability) => {
self.capability = imap_try!(capability, fragmentizer, arg);
match self.wants_auth() {
Err(err) => return ImapCoroutineState::Complete(Err(err)),
Ok(None) => return self.complete(),
Ok(Some(next)) => {
self.state = next;
debug!("{}", self.state);
}
}
}
State::Greeting(greeting) => {
let greeting = imap_try!(greeting, fragmentizer, arg);
self.capability = greeting.capability;
self.pre_authenticated = greeting.pre_authenticated;
match self.wants_auth() {
Err(err) => return ImapCoroutineState::Complete(Err(err)),
Ok(None) => return self.complete(),
Ok(Some(next)) => {
self.state = next;
debug!("{}", self.state);
}
}
}
State::Auth(auth) => {
self.capability = match auth {
Auth::Anonymous(auth) => imap_try!(auth, fragmentizer, arg),
Auth::Login(auth) => imap_try!(auth, fragmentizer, arg),
Auth::Plain(auth) => imap_try!(auth, fragmentizer, arg),
Auth::Oauthbearer(auth) => imap_try!(auth, fragmentizer, arg),
Auth::Xoauth2(auth) => imap_try!(auth, fragmentizer, arg),
#[cfg(feature = "scram")]
Auth::ScramSha256(auth) => imap_try!(auth, fragmentizer, arg),
};
return self.complete();
}
}
}
}
}
pub fn default_alpn() -> Vec<String> {
vec![String::from("imap")]
}
pub fn default_port(scheme: &str) -> u16 {
if scheme.eq_ignore_ascii_case("imaps") {
993
} else {
143
}
}
#[allow(clippy::large_enum_variant)]
enum State {
Connect,
Connected,
StartTls(ImapStartTls),
Upgraded,
Capability(ImapCapabilityGet),
Greeting(ImapGreetingGet),
Auth(Auth),
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Connect => f.write_str("open connection"),
Self::Connected => f.write_str("connection opened"),
Self::StartTls(_) => f.write_str("send starttls"),
Self::Upgraded => f.write_str("connection upgraded to tls"),
Self::Capability(_) => f.write_str("fetch capabilities"),
Self::Greeting(_) => f.write_str("read greeting"),
Self::Auth(auth) => write!(f, "authenticate with {auth}"),
}
}
}
enum Auth {
Anonymous(ImapAuthAnonymous),
Login(ImapLogin),
Plain(ImapAuthPlain),
Oauthbearer(ImapAuthOauthbearer),
Xoauth2(ImapAuthXoauth2),
#[cfg(feature = "scram")]
ScramSha256(ImapAuthScramSha256),
}
impl fmt::Display for Auth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Anonymous(_) => f.write_str("anonymous"),
Self::Login(_) => f.write_str("login"),
Self::Plain(_) => f.write_str("plain"),
Self::Oauthbearer(_) => f.write_str("oauthbearer"),
Self::Xoauth2(_) => f.write_str("xoauth2"),
#[cfg(feature = "scram")]
Self::ScramSha256(_) => f.write_str("scram-sha-256"),
}
}
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;
use crate::session::*;
#[test]
fn tcp_transport_yields_tcp_connect_then_greeting() {
let transport = ImapSessionTransport::Tcp {
host: "localhost".to_string(),
port: 143,
};
let mut session = ImapSessionOpen::new(transport, None::<Sasl>, Default::default());
let mut frag = Fragmentizer::new(50 * 1024 * 1024);
match session.resume(&mut frag, None) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { host, port }) => {
assert_eq!(host, "localhost");
assert_eq!(port, 143);
}
state => panic!("expected WantsTcpConnect, got {state:?}"),
}
match session.resume(&mut frag, None) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
state => panic!("expected WantsRead, got {state:?}"),
}
let greeting = b"* OK [CAPABILITY IMAP4REV1] server ready\r\n";
match session.resume(&mut frag, Some(greeting)) {
ImapCoroutineState::Complete(Ok(data)) => {
assert!(!data.pre_authenticated);
assert_eq!(data.capability, vec![Capability::Imap4Rev1]);
}
state => panic!("expected Complete(Ok), got {state:?}"),
}
}
#[test]
fn preauth_greeting_skips_the_sasl_step() {
let transport = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
let sasl = SaslPlainCreds {
authzid: None,
authcid: "alice".to_string(),
passwd: "secret".to_string().into(),
};
let mut session = ImapSessionOpen::new(transport, Some(sasl), Default::default());
let mut frag = Fragmentizer::new(50 * 1024 * 1024);
match session.resume(&mut frag, None) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsUnixConnect(path)) => {
assert_eq!(path, "/run/sirup.sock");
}
state => panic!("expected WantsUnixConnect, got {state:?}"),
}
match session.resume(&mut frag, None) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
state => panic!("expected WantsRead, got {state:?}"),
}
let greeting = b"* PREAUTH [CAPABILITY IMAP4REV1] already authenticated\r\n";
match session.resume(&mut frag, Some(greeting)) {
ImapCoroutineState::Complete(Ok(data)) => assert!(data.pre_authenticated),
state => panic!("expected Complete(Ok), got {state:?}"),
}
}
#[test]
fn starttls_over_tls_fails_before_opening_a_socket() {
let transport = ImapSessionTransport::Tls {
host: "localhost".to_string(),
port: 993,
};
let opts = ImapSessionOpenOptions {
starttls: true,
..Default::default()
};
let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
let mut frag = Fragmentizer::new(50 * 1024 * 1024);
match session.resume(&mut frag, None) {
ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsOverTls)) => {}
state => panic!("expected StartTlsOverTls, got {state:?}"),
}
}
#[test]
fn starttls_reaches_the_upgrade_then_refetches_capabilities() {
let transport = ImapSessionTransport::Tcp {
host: "localhost".to_string(),
port: 143,
};
let opts = ImapSessionOpenOptions {
starttls: true,
..Default::default()
};
let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
let mut frag = Fragmentizer::new(50 * 1024 * 1024);
assert!(matches!(
session.resume(&mut frag, None),
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { .. })
));
assert!(matches!(
session.resume(&mut frag, None),
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
));
let greeting = b"* OK server ready\r\n";
let command = match session.resume(&mut frag, Some(greeting)) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
state => panic!("expected WantsWrite, got {state:?}"),
};
let command = String::from_utf8(command).expect("utf8 command");
assert!(command.contains("STARTTLS"));
let tag = command
.split_whitespace()
.next()
.expect("first whitespace-separated token")
.to_string();
assert!(matches!(
session.resume(&mut frag, None),
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
));
let reply = alloc::format!("{tag} OK begin TLS negotiation\r\n");
assert!(matches!(
session.resume(&mut frag, Some(reply.as_bytes())),
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade)
));
let command = match session.resume(&mut frag, None) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
state => panic!("expected WantsWrite, got {state:?}"),
};
let command = String::from_utf8(command).expect("utf8 command");
assert!(command.contains("CAPABILITY"));
}
#[test]
fn starttls_trailing_bytes_refuse_the_upgrade() {
let transport = ImapSessionTransport::Tcp {
host: "localhost".to_string(),
port: 143,
};
let opts = ImapSessionOpenOptions {
starttls: true,
..Default::default()
};
let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
let mut frag = Fragmentizer::new(50 * 1024 * 1024);
session.resume(&mut frag, None);
session.resume(&mut frag, None);
let command = match session.resume(&mut frag, Some(b"* OK server ready\r\n")) {
ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
state => panic!("expected WantsWrite, got {state:?}"),
};
let command = String::from_utf8(command).expect("utf8 command");
let tag = command
.split_whitespace()
.next()
.expect("first whitespace-separated token")
.to_string();
session.resume(&mut frag, None);
let reply = alloc::format!("{tag} OK begin TLS negotiation\r\na1 NOOP\r\n");
match session.resume(&mut frag, Some(reply.as_bytes())) {
ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsInjection)) => {}
state => panic!("expected StartTlsInjection, got {state:?}"),
}
}
#[cfg(feature = "url")]
#[test]
fn urls_map_onto_transports() {
let url = Url::parse("imap://example.org").unwrap();
let expected = ImapSessionTransport::Tcp {
host: "example.org".to_string(),
port: 143,
};
assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
let url = Url::parse("imaps://example.org:1993").unwrap();
let expected = ImapSessionTransport::Tls {
host: "example.org".to_string(),
port: 1993,
};
assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
let url = Url::parse("unix:///run/sirup.sock").unwrap();
let expected = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
let url = Url::parse("http://example.org").unwrap();
let err = ImapSessionTransport::from_url(&url).unwrap_err();
assert!(matches!(
err,
ImapSessionOpenError::UrlUnsupportedScheme(_, _)
));
}
}