From baef85e55b85a2aab5feb9c072cd689e50ebefae Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 7 May 2024 15:20:54 +0200 Subject: [PATCH] MTA-STS Policy management --- crates/common/src/config/smtp/resolver.rs | 138 +++++++++++++++++++++- crates/common/src/config/smtp/session.rs | 5 +- crates/common/src/manager/config.rs | 67 +++++++++++ crates/jmap/src/api/http.rs | 11 ++ crates/jmap/src/api/management/domain.rs | 126 +++++++++++++++----- 5 files changed, 311 insertions(+), 36 deletions(-) diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index 86314825..c7f6ad5d 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -1,6 +1,9 @@ use std::{ + fmt::Display, + hash::{DefaultHasher, Hash, Hasher}, net::{IpAddr, Ipv4Addr, SocketAddr}, sync::Arc, + time::Duration, }; use mail_auth::{ @@ -13,7 +16,12 @@ use mail_auth::{ Resolver, }; use parking_lot::Mutex; -use utils::{config::Config, suffixlist::PublicSuffix}; +use utils::{ + config::{utils::ParseValue, Config}, + suffixlist::PublicSuffix, +}; + +use crate::Core; pub struct Resolvers { pub dns: Resolver, @@ -47,20 +55,21 @@ pub struct Tlsa { pub has_intermediates: bool, } -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy)] pub enum Mode { Enforce, Testing, + #[default] None, } -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)] pub enum MxPattern { Equals(String), StartsWith(String), } -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, PartialEq, Eq, Hash, Clone)] pub struct Policy { pub id: String, pub mode: Mode, @@ -227,6 +236,97 @@ impl Resolvers { } } +impl Policy { + pub fn try_parse(config: &mut Config) -> Option { + let mode = config + .property_or_default::>("session.mta-sts.mode", "testing") + .unwrap_or_default()?; + let max_age = config + .property_or_default::("session.mta-sts.max-age", "7d") + .unwrap_or_else(|| Duration::from_secs(604800)) + .as_secs(); + let mut mx = Vec::new(); + + for (_, item) in config.values("session.mta-sts.mx") { + if let Some(item) = item.strip_prefix("*.") { + mx.push(MxPattern::StartsWith(item.to_string())); + } else { + mx.push(MxPattern::Equals(item.to_string())); + } + } + + let mut policy = Self { + id: Default::default(), + mode, + mx, + max_age, + }; + + if !policy.mx.is_empty() { + policy.mx.sort_unstable(); + policy.id = policy.hash().to_string(); + } + + policy.into() + } + + pub fn try_build(mut self, names: I) -> Option + where + I: IntoIterator, + T: AsRef, + { + if self.mx.is_empty() { + for name in names { + let name = name.as_ref(); + if let Some(domain) = name.strip_prefix('.') { + self.mx.push(MxPattern::StartsWith(domain.to_string())); + } else if name != "*" && !name.is_empty() { + self.mx.push(MxPattern::Equals(name.to_string())); + } + } + + if !self.mx.is_empty() { + self.mx.sort_unstable(); + self.id = self.hash().to_string(); + Some(self) + } else { + None + } + } else { + Some(self) + } + } + + fn hash(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.mode.hash(&mut s); + self.max_age.hash(&mut s); + self.mx.hash(&mut s); + s.finish() + } +} + +impl Core { + pub fn build_mta_sts_policy(&self) -> Option { + self.smtp + .session + .mta_sts_policy + .clone() + .and_then(|policy| policy.try_build(self.tls.certificates.load().keys())) + } +} + +impl ParseValue for Mode { + fn parse_value(value: &str) -> utils::config::Result { + match value { + "enforce" => Ok(Self::Enforce), + "testing" | "test" => Ok(Self::Testing), + "none" => Ok(Self::None), + _ => Err(format!("Invalid mode value {value:?}")), + } + } +} + impl Default for Resolvers { fn default() -> Self { let (config, opts) = match read_system_conf() { @@ -253,6 +353,36 @@ impl Default for Resolvers { } } +impl Display for Policy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("version: STSv1\r\n")?; + f.write_str("mode: ")?; + match self.mode { + Mode::Enforce => f.write_str("enforce")?, + Mode::Testing => f.write_str("testing")?, + Mode::None => unreachable!(), + } + f.write_str("\r\nmax_age: ")?; + self.max_age.fmt(f)?; + f.write_str("\r\n")?; + + for mx in &self.mx { + f.write_str("mx: ")?; + let mx = match mx { + MxPattern::StartsWith(mx) => { + f.write_str("*.")?; + mx + } + MxPattern::Equals(mx) => mx, + }; + f.write_str(mx)?; + f.write_str("\r\n")?; + } + + Ok(()) + } +} + impl Clone for Resolvers { fn clone(&self) -> Self { Self { diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index d9649d99..b5a687d4 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -11,7 +11,7 @@ use crate::{ expr::{if_block::IfBlock, tokenizer::TokenMap, *}, }; -use self::throttle::parse_throttle; +use self::{resolver::Policy, throttle::parse_throttle}; use super::*; @@ -29,6 +29,7 @@ pub struct SessionConfig { pub rcpt: Rcpt, pub data: Data, pub extensions: Extensions, + pub mta_sts_policy: Option, } #[derive(Default, Debug, Clone)] @@ -188,6 +189,7 @@ impl SessionConfig { .filter_map(|id| parse_pipe(config, &id, &has_rcpt_vars)) .collect(); session.throttle = SessionThrottle::parse(config); + session.mta_sts_policy = Policy::try_parse(config); for (value, key, token_map) in [ (&mut session.duration, "session.duration", &has_conn_vars), @@ -713,6 +715,7 @@ impl Default for SessionConfig { "false", ), }, + mta_sts_policy: None, } } } diff --git a/crates/common/src/manager/config.rs b/crates/common/src/manager/config.rs index d0c45b0a..75dc17cb 100644 --- a/crates/common/src/manager/config.rs +++ b/crates/common/src/manager/config.rs @@ -27,6 +27,7 @@ use std::{ sync::Arc, }; +use ahash::AHashMap; use arc_swap::ArcSwap; use store::{ write::{BatchBuilder, ValueClass}, @@ -124,6 +125,32 @@ impl ConfigManager { Ok(results) } + pub async fn group( + &self, + prefix: &str, + suffix: &str, + ) -> store::Result>> { + let mut grouped = AHashMap::new(); + + let mut list = self.list(prefix, true).await?; + for (key, _) in &list { + if let Some(key) = key.strip_suffix(suffix) { + grouped.insert(key.to_string(), AHashMap::new()); + } + } + + for (name, entries) in &mut grouped { + let prefix = format!("{name}."); + for (key, value) in &mut list { + if let Some(key) = key.strip_prefix(&prefix) { + entries.insert(key.to_string(), std::mem::take(value)); + } + } + } + + Ok(grouped) + } + async fn db_list( &self, prefix: &str, @@ -385,6 +412,46 @@ impl ConfigManager { Err("External configuration file does not contain a version key".to_string()) } } + + pub async fn get_services(&self) -> store::Result> { + let mut result = Vec::new(); + + for listener in self + .group("server.listener.", ".protocol") + .await + .unwrap_or_default() + .into_values() + { + let is_tls = listener + .get("tls.implicit") + .map_or(false, |tls| tls == "true"); + let protocol = listener + .get("protocol") + .map(|s| s.as_str()) + .unwrap_or_default(); + let port = listener + .get("bind") + .or_else(|| { + listener.iter().find_map(|(key, value)| { + if key.starts_with("bind.") { + Some(value) + } else { + None + } + }) + }) + .and_then(|s| s.rsplit_once(':').and_then(|(_, p)| p.parse::().ok())) + .unwrap_or_default(); + + if port > 0 { + result.push((protocol.to_string(), port, is_tls)); + } + } + + result.sort_unstable(); + + Ok(result) + } } impl Patterns { diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 5346fad4..63a88f49 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -254,6 +254,17 @@ impl JMAP { }; } } + ("mta-sts.txt", &Method::GET) => { + if let Some(policy) = self.core.build_mta_sts_policy() { + return Resource { + content_type: "text/plain", + contents: policy.to_string().into_bytes(), + } + .into_http_response(); + } else { + return RequestError::not_found().into_http_response(); + } + } (_, &Method::OPTIONS) => { return ().into_http_response(); } diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs index 233c8221..cee4b5ae 100644 --- a/crates/jmap/src/api/management/domain.rs +++ b/crates/jmap/src/api/management/domain.rs @@ -224,6 +224,72 @@ impl JMAP { content: "v=spf1 mx ra=postmaster -all".to_string(), }); + records.push(DnsRecord { + typ: "CNAME".to_string(), + name: format!("autoconfig.{domain_name}."), + content: format!("{server_name}."), + }); + + let mut has_https = false; + for (protocol, port, is_tls) in self + .core + .storage + .config + .get_services() + .await + .unwrap_or_default() + { + match (protocol.as_str(), port) { + ("smtp", port @ 26..=u16::MAX) => { + records.push(DnsRecord { + typ: "SRV".to_string(), + name: format!( + "_submission{}._tcp.{domain_name}.", + if is_tls { "s" } else { "" } + ), + content: format!("0 1 {port} {server_name}."), + }); + } + ("imap" | "pop3", port @ 1..=u16::MAX) => { + records.push(DnsRecord { + typ: "SRV".to_string(), + name: format!( + "_{protocol}{}._tcp.{domain_name}.", + if is_tls { "s" } else { "" } + ), + content: format!("0 1 {port} {server_name}."), + }); + } + ("http", port @ 1..=u16::MAX) => { + if is_tls { + has_https = true; + records.push(DnsRecord { + typ: "SRV".to_string(), + name: format!("_autodiscover._tcp.{domain_name}."), + content: format!("0 1 {port} {server_name}."), + }); + } + } + _ => (), + } + } + + // Add MTA-STS record + if has_https { + if let Some(policy) = self.core.build_mta_sts_policy() { + records.push(DnsRecord { + typ: "CNAME".to_string(), + name: format!("mta-sts.{domain_name}."), + content: format!("{server_name}."), + }); + records.push(DnsRecord { + typ: "TXT".to_string(), + name: format!("_mta-sts.{domain_name}."), + content: format!("v=STSv1; id={}", policy.id), + }); + } + } + // Add DMARC records records.push(DnsRecord { typ: "TXT".to_string(), @@ -236,39 +302,37 @@ impl JMAP { if !name.ends_with(domain_name) { continue; } - let cert = if let Some(cert) = key.cert.first().map(|cert| cert.as_ref()) { - cert - } else { - tracing::debug!("No certificate found for domain: {}", domain_name); - continue; - }; - let parsed_cert = match parse_x509_certificate(cert) { - Ok((_, parsed_cert)) => parsed_cert, - Err(err) => { - tracing::debug!("Failed to parse certificate: {}", err); - continue; - } - }; - let name = if !name.starts_with('.') { - format!("_25._tcp.{name}.") - } else { - format!("_25._tcp.mail.{name}.") - }; + for (cert_num, cert) in key.cert.iter().enumerate() { + let parsed_cert = match parse_x509_certificate(cert) { + Ok((_, parsed_cert)) => parsed_cert, + Err(err) => { + tracing::debug!("Failed to parse certificate: {}", err); + continue; + } + }; - for (s, cert) in [cert, parsed_cert.subject_pki.raw].into_iter().enumerate() { - for (m, hash) in [ - format!("{:x}", sha2::Sha256::digest(cert)), - format!("{:x}", sha2::Sha512::digest(cert)), - ] - .into_iter() - .enumerate() - { - records.push(DnsRecord { - typ: "TLSA".to_string(), - name: name.clone(), - content: format!("3 {} {} {}", s, m + 1, hash), - }); + let name = if !name.starts_with('.') { + format!("_25._tcp.{name}.") + } else { + format!("_25._tcp.mail.{name}.") + }; + let cu = if cert_num == 0 { 3 } else { 2 }; + + for (s, cert) in [cert, parsed_cert.subject_pki.raw].into_iter().enumerate() { + for (m, hash) in [ + format!("{:x}", sha2::Sha256::digest(cert)), + format!("{:x}", sha2::Sha512::digest(cert)), + ] + .into_iter() + .enumerate() + { + records.push(DnsRecord { + typ: "TLSA".to_string(), + name: name.clone(), + content: format!("{} {} {} {}", cu, s, m + 1, hash), + }); + } } } }