Files
chatmail-core/src/smtp/connect.rs
link2xt 82924952fb feat: allow TLS connections with invalid certificate if the key is unchanged
This change weakens TLS checks.
Every time we make a successful TLS connection,
we remember public key hash from the certificate
in relation to the hostname.
If later we connect to the same hostname and the public key does not change,
we skip checking certificate chain.
This way we will still connect successfully
even if certificate expires or becomes invalid for another reason,
but keeps the key.

We always check that certificate corresponds to the hostname.
We also do this for certificates starting with _
where we allow self-signed certificates,
so self-signed certificates with mismatching domains are not allowed.
Previously we did not check this for domains starting with _.
2026-04-17 18:07:20 +00:00

400 lines
13 KiB
Rust

//! SMTP connection establishment.
use std::net::SocketAddr;
use anyhow::{Context as _, Result, bail};
use async_smtp::{SmtpClient, SmtpTransport};
use tokio::io::{AsyncBufRead, AsyncWrite, BufStream};
use crate::context::Context;
use crate::log::warn;
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::net::tls::{SpkiHashStore, TlsSessionStore, wrap_tls};
use crate::net::{
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
};
use crate::oauth2::get_oauth2_access_token;
use crate::sql::Sql;
use crate::tools::time;
use crate::transport::ConnectionCandidate;
use crate::transport::ConnectionSecurity;
/// Converts port number to ALPN.
fn alpn(port: u16) -> &'static str {
if port == 465 {
// Do not request ALPN on standard port.
""
} else {
"smtp"
}
}
// Constructs a new SMTP transport
// over a stream with already skipped SMTP greeting.
async fn new_smtp_transport<S: AsyncBufRead + AsyncWrite + Unpin>(
stream: S,
) -> Result<SmtpTransport<S>> {
// We always read the greeting manually to unify
// the cases of STARTTLS where the greeting is
// sent outside the encrypted channel and implicit TLS
// where the greeting is sent after establishing TLS channel.
let client = SmtpClient::new().smtp_utf8(true).without_greeting();
let transport = SmtpTransport::new(client, stream)
.await
.context("Failed to send EHLO command")?;
Ok(transport)
}
#[expect(clippy::too_many_arguments)]
pub(crate) async fn connect_and_auth(
context: &Context,
proxy_config: &Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
oauth2: bool,
addr: &str,
user: &str,
password: &str,
) -> Result<SmtpTransport<Box<dyn SessionBufStream>>> {
let session_stream = connect_stream(context, proxy_config.clone(), strict_tls, candidate)
.await
.context("SMTP failed to connect")?;
let mut transport = new_smtp_transport(session_stream).await?;
// Authenticate.
let (creds, mechanism) = if oauth2 {
// oauth2
let access_token = get_oauth2_access_token(context, addr, password, false)
.await
.context("SMTP failed to get OAUTH2 access token")?;
if access_token.is_none() {
bail!("SMTP OAuth 2 error {addr}");
}
(
async_smtp::authentication::Credentials::new(
user.to_string(),
access_token.unwrap_or_default(),
),
vec![async_smtp::authentication::Mechanism::Xoauth2],
)
} else {
// plain
(
async_smtp::authentication::Credentials::new(user.to_string(), password.to_string()),
vec![
async_smtp::authentication::Mechanism::Plain,
async_smtp::authentication::Mechanism::Login,
],
)
};
transport
.try_login(&creds, &mechanism)
.await
.context("SMTP failed to login")?;
Ok(transport)
}
async fn connection_attempt(
context: Context,
host: String,
security: ConnectionSecurity,
resolved_addr: SocketAddr,
strict_tls: bool,
) -> Result<Box<dyn SessionBufStream>> {
let context = &context;
let host = &host;
info!(
context,
"Attempting SMTP connection to {host} ({resolved_addr})."
);
let res = match security {
ConnectionSecurity::Tls => {
connect_secure(
resolved_addr,
host,
strict_tls,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
}
ConnectionSecurity::Starttls => {
connect_starttls(
resolved_addr,
host,
strict_tls,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
}
ConnectionSecurity::Plain => connect_insecure(resolved_addr).await,
};
match res {
Ok(stream) => {
let ip_addr = resolved_addr.ip().to_string();
let port = resolved_addr.port();
let save_cache = match security {
ConnectionSecurity::Tls | ConnectionSecurity::Starttls => strict_tls,
ConnectionSecurity::Plain => false,
};
if save_cache {
update_connect_timestamp(context, host, &ip_addr).await?;
}
update_connection_history(context, "smtp", host, port, &ip_addr, time()).await?;
Ok(stream)
}
Err(err) => {
warn!(
context,
"SMTP failed to connect to {host} ({resolved_addr}): {err:#}."
);
Err(err)
}
}
}
/// Returns TLS, STARTTLS or plaintext connection
/// using SOCKS5 or direct connection depending on the given configuration.
///
/// Connection is returned after skipping the welcome message
/// and is ready for sending commands. Because SMTP STARTTLS
/// does not send welcome message over TLS connection
/// after establishing it, welcome message is always ignored
/// to unify the result regardless of whether TLS or STARTTLS is used.
async fn connect_stream(
context: &Context,
proxy_config: Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
) -> Result<Box<dyn SessionBufStream>> {
let host = &candidate.host;
let port = candidate.port;
let security = candidate.security;
if let Some(proxy_config) = proxy_config {
let stream = match security {
ConnectionSecurity::Tls => {
connect_secure_proxy(context, host, port, strict_tls, proxy_config.clone()).await?
}
ConnectionSecurity::Starttls => {
connect_starttls_proxy(context, host, port, strict_tls, proxy_config.clone())
.await?
}
ConnectionSecurity::Plain => {
connect_insecure_proxy(context, host, port, proxy_config.clone()).await?
}
};
update_connection_history(context, "smtp", host, port, host, time()).await?;
Ok(stream)
} else {
let load_cache = match security {
ConnectionSecurity::Tls | ConnectionSecurity::Starttls => strict_tls,
ConnectionSecurity::Plain => false,
};
let connection_futures = lookup_host_with_cache(context, host, port, "smtp", load_cache)
.await?
.into_iter()
.map(|resolved_addr| {
let context = context.clone();
let host = host.to_string();
connection_attempt(context, host, security, resolved_addr, strict_tls)
});
run_connection_attempts(connection_futures).await
}
}
/// Reads and ignores SMTP greeting.
///
/// This function is used to unify
/// TLS, STARTTLS and plaintext connection setup
/// by skipping the greeting in case of TLS
/// and STARTTLS connection setup.
async fn skip_smtp_greeting<R: tokio::io::AsyncBufReadExt + Unpin>(stream: &mut R) -> Result<()> {
let mut line = String::with_capacity(512);
loop {
line.clear();
let read = stream
.read_line(&mut line)
.await
.context("Failed to read from stream while waiting for SMTP greeting")?;
if read == 0 {
bail!("Unexpected EOF while reading SMTP greeting");
}
if line.starts_with("220-") {
continue;
} else if line.starts_with("220 ") {
return Ok(());
} else {
bail!("Unexpected greeting: {line:?}");
}
}
}
async fn connect_secure_proxy(
context: &Context,
hostname: &str,
port: u16,
strict_tls: bool,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let use_sni = true;
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
let tls_stream = wrap_tls(
strict_tls,
hostname,
port,
use_sni,
alpn(port),
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let mut buffered_stream = BufStream::new(tls_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_starttls_proxy(
context: &Context,
hostname: &str,
port: u16,
strict_tls: bool,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let use_sni = false;
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
// Run STARTTLS command and convert the client back into a stream.
let mut buffered_stream = BufStream::new(proxy_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let transport = new_smtp_transport(buffered_stream).await?;
let tcp_stream = transport.starttls().await?.into_inner();
let tls_stream = wrap_tls(
strict_tls,
hostname,
port,
use_sni,
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufStream::new(tls_stream);
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_insecure_proxy(
context: &Context,
hostname: &str,
port: u16,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let proxy_stream = proxy_config.connect(context, hostname, port, false).await?;
let mut buffered_stream = BufStream::new(proxy_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_secure(
addr: SocketAddr,
hostname: &str,
strict_tls: bool,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<Box<dyn SessionBufStream>> {
let tls_stream = connect_tls_inner(
addr,
hostname,
strict_tls,
alpn(addr.port()),
tls_session_store,
spki_hash_store,
sql,
)
.await?;
let mut buffered_stream = BufStream::new(tls_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_starttls(
addr: SocketAddr,
host: &str,
strict_tls: bool,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<Box<dyn SessionBufStream>> {
let use_sni = false;
let tcp_stream = connect_tcp_inner(addr).await?;
// Run STARTTLS command and convert the client back into a stream.
let mut buffered_stream = BufStream::new(tcp_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let transport = new_smtp_transport(buffered_stream).await?;
let tcp_stream = transport.starttls().await?.into_inner();
let tls_stream = wrap_tls(
strict_tls,
host,
addr.port(),
use_sni,
"",
tcp_stream,
tls_session_store,
spki_hash_store,
sql,
)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufStream::new(tls_stream);
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_insecure(addr: SocketAddr) -> Result<Box<dyn SessionBufStream>> {
let tcp_stream = connect_tcp_inner(addr).await?;
let mut buffered_stream = BufStream::new(tcp_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
#[cfg(test)]
mod tests {
use tokio::io::BufReader;
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_skip_smtp_greeting() -> Result<()> {
let greeting = b"220-server261.web-hosting.com ESMTP Exim 4.96.2 #2 Sat, 24 Aug 2024 12:25:53 -0400 \r\n\
220-We do not authorize the use of this system to transport unsolicited,\r\n\
220 and/or bulk e-mail.\r\n";
let mut buffered_stream = BufReader::new(&greeting[..]);
skip_smtp_greeting(&mut buffered_stream).await
}
}