From bda032d51d7da96f60250efe5e9fbdb4d90479e7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 07:07:38 -0700 Subject: [PATCH] Shared outbound TLS crate for the relay The TLSA and MTA-STS types, the DANE certificate check, MTA-STS policy parsing and matching, and the DNSSEC lookups with the bogus-answer recheck move out of crates/smtp and crates/common into crates/outbound-tls (RL-30), so inbuxa-relay can deliver with the same code. The server keeps its caches, trace events, policy fetch and test mocks around them; its behavior is unchanged. --- Cargo.lock | 17 + Cargo.toml | 1 + crates/common/Cargo.toml | 1 + crates/common/src/config/smtp/resolver.rs | 229 ++--- crates/common/src/config/smtp/session.rs | 2 +- crates/http/src/api/diagnose.rs | 4 +- crates/outbound-tls/Cargo.toml | 22 + crates/outbound-tls/src/dane.rs | 199 +++++ crates/outbound-tls/src/dnssec.rs | 798 +++++++++++++++++ crates/outbound-tls/src/lib.rs | 115 +++ .../parse.rs => outbound-tls/src/mta_sts.rs} | 93 +- crates/services/src/inbuxa_deliverability.rs | 2 +- crates/smtp/Cargo.toml | 1 + crates/smtp/src/outbound/dane/dnssec.rs | 843 +----------------- crates/smtp/src/outbound/dane/verify.rs | 249 +----- crates/smtp/src/outbound/delivery.rs | 3 +- crates/smtp/src/outbound/mta_sts/lookup.rs | 2 +- crates/smtp/src/outbound/mta_sts/mod.rs | 4 +- crates/smtp/src/outbound/mta_sts/verify.rs | 43 - crates/utils/Cargo.toml | 1 + crates/utils/src/cache.rs | 30 + docs/spec/features/relay.md | 6 + tests/src/smtp/lookup/utils.rs | 7 +- tests/src/smtp/outbound/mta_sts.rs | 5 +- 24 files changed, 1406 insertions(+), 1271 deletions(-) create mode 100644 crates/outbound-tls/Cargo.toml create mode 100644 crates/outbound-tls/src/dane.rs create mode 100644 crates/outbound-tls/src/dnssec.rs create mode 100644 crates/outbound-tls/src/lib.rs rename crates/{smtp/src/outbound/mta_sts/parse.rs => outbound-tls/src/mta_sts.rs} (55%) delete mode 100644 crates/smtp/src/outbound/mta_sts/verify.rs diff --git a/Cargo.lock b/Cargo.lock index fa84a396..17cf3b9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1319,6 +1319,7 @@ dependencies = [ "idna", "imagesize", "inbuxa-features", + "inbuxa-outbound-tls", "infer 0.22.0", "jmap_proto", "jsonwebtoken", @@ -3976,6 +3977,20 @@ dependencies = [ "zip", ] +[[package]] +name = "inbuxa-outbound-tls" +version = "27.1.0-rc.4" +dependencies = [ + "idna", + "mail-auth", + "rustls-pki-types", + "rustls-webpki", + "serde", + "sha2 0.11.1", + "tokio", + "x509-parser", +] + [[package]] name = "inbuxa-relay" version = "27.1.0-rc.4" @@ -8144,6 +8159,7 @@ dependencies = [ "email", "hashify", "inbuxa-features", + "inbuxa-outbound-tls", "mail-auth", "mail-builder 1.0.0", "mail-parser", @@ -9525,6 +9541,7 @@ dependencies = [ "form_urlencoded", "futures", "idna", + "inbuxa-outbound-tls", "mail-auth", "nohash-hasher", "privdrop", diff --git a/Cargo.toml b/Cargo.toml index c731904f..8c221ebf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/migration", "crates/features", "crates/relay-proto", + "crates/outbound-tls", "crates/relay", "tests", ] diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index b7ad28f2..3c31b0fd 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -6,6 +6,7 @@ build = "build.rs" [dependencies] utils = { path = "../utils" } +inbuxa-outbound-tls = { path = "../outbound-tls" } nlp = { path = "../nlp" } store = { path = "../store" } trc = { path = "../trc" } diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index cedf5606..f5fbf439 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use ahash::AHashMap; @@ -22,16 +24,12 @@ use registry::schema::{ prelude::ObjectType, structs::{DnsResolver, MtaSts, SystemSettings}, }; -use serde::{Deserialize, Serialize}; use std::{ - fmt::Display, hash::{DefaultHasher, Hash, Hasher}, net::IpAddr, - str::FromStr, sync::Arc, }; use store::registry::bootstrap::Bootstrap; -use utils::cache::CacheItemWeight; pub struct Resolvers { pub dns: MessageAuthenticator, @@ -44,75 +42,8 @@ pub struct DnssecResolver { pub resolver: TokioResolver, } -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub enum TlsaMatching { - Full, - Sha256, - Sha512, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct TlsaEntry { - pub is_end_entity: bool, - pub is_spki: bool, - pub matching: TlsaMatching, - pub data: Vec, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct Tlsa { - pub entries: Vec, - pub has_end_entities: bool, - pub has_intermediates: bool, -} - -#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Mode { - Enforce, - Testing, - #[default] - None, -} - -#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MxPattern { - Equals(String), - StartsWith(String), -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] -pub struct Policy { - pub id: String, - pub mode: Mode, - pub mx: Box<[MxPattern]>, - pub max_age: u64, -} - -impl CacheItemWeight for Tlsa { - fn weight(&self) -> u64 { - self.entries - .iter() - .map(|entry| (entry.data.len() + std::mem::size_of::()) as u64) - .sum::() - + std::mem::size_of::() as u64 - } -} - -impl CacheItemWeight for Policy { - fn weight(&self) -> u64 { - (std::mem::size_of::() - + self - .mx - .iter() - .map(|mx| match mx { - MxPattern::Equals(t) => t.len(), - MxPattern::StartsWith(t) => t.len(), - }) - .sum::()) as u64 - } -} +// inbuxa: shared with inbuxa-relay (RL-30) +pub use inbuxa_outbound_tls::{Mode, MxPattern, Policy, Tlsa, TlsaEntry, TlsaMatching}; impl Resolvers { pub async fn parse(bp: &mut Bootstrap) -> Self { @@ -257,77 +188,64 @@ async fn ensure_dnssec(config: &ResolverConfig, resolver: &TokioResolver) -> boo }) } -impl Policy { - pub async fn try_parse(bp: &mut Bootstrap) -> Option { - let mta = bp.setting_infallible::().await; +/// This server's own MTA-STS policy, from its settings. +pub async fn mta_sts_policy(bp: &mut Bootstrap) -> Option { + let mta = bp.setting_infallible::().await; - if matches!(mta.mode, PolicyEnforcement::Disable) { - return None; - } - - let mut mx_hosts = mta.mx_hosts.into_inner(); - - if mx_hosts.is_empty() { - let settings = bp.setting_infallible::().await; - let default_host = settings.default_hostname.as_str(); - mx_hosts = settings - .mail_exchangers - .iter() - .map(|mx| mx.hostname.as_deref().unwrap_or(default_host).to_string()) - .collect(); - } - - if !mx_hosts.is_empty() { - mx_hosts.sort_unstable(); - mx_hosts.dedup(); - - let mut policy = Policy { - id: Default::default(), - mode: match mta.mode { - PolicyEnforcement::Enforce => Mode::Enforce, - PolicyEnforcement::Testing => Mode::Testing, - PolicyEnforcement::Disable => Mode::None, - }, - mx: mx_hosts - .into_iter() - .map(|mx| { - if let Some(mx) = mx.strip_prefix("*.") { - MxPattern::StartsWith(mx.to_string()) - } else { - MxPattern::Equals(mx) - } - }) - .collect(), - max_age: mta.max_age.into_inner().as_secs(), - }; - - policy.id = policy.hash().to_string(); - - Some(policy) - } else { - None - } + if matches!(mta.mode, PolicyEnforcement::Disable) { + return None; } - 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() + let mut mx_hosts = mta.mx_hosts.into_inner(); + + if mx_hosts.is_empty() { + let settings = bp.setting_infallible::().await; + let default_host = settings.default_hostname.as_str(); + mx_hosts = settings + .mail_exchangers + .iter() + .map(|mx| mx.hostname.as_deref().unwrap_or(default_host).to_string()) + .collect(); + } + + if !mx_hosts.is_empty() { + mx_hosts.sort_unstable(); + mx_hosts.dedup(); + + let mut policy = Policy { + id: Default::default(), + mode: match mta.mode { + PolicyEnforcement::Enforce => Mode::Enforce, + PolicyEnforcement::Testing => Mode::Testing, + PolicyEnforcement::Disable => Mode::None, + }, + mx: mx_hosts + .into_iter() + .map(|mx| { + if let Some(mx) = mx.strip_prefix("*.") { + MxPattern::StartsWith(mx.to_string()) + } else { + MxPattern::Equals(mx) + } + }) + .collect(), + max_age: mta.max_age.into_inner().as_secs(), + }; + + policy.id = policy_hash(&policy).to_string(); + + Some(policy) + } else { + None } } -impl FromStr for Mode { - type Err = String; - fn from_str(value: &str) -> Result { - match value { - "enforce" => Ok(Self::Enforce), - "testing" | "test" => Ok(Self::Testing), - "none" => Ok(Self::None), - _ => Err(format!("Invalid mode value {value:?}")), - } - } +fn policy_hash(policy: &Policy) -> u64 { + let mut s = DefaultHasher::new(); + policy.mode.hash(&mut s); + policy.max_age.hash(&mut s); + policy.mx.hash(&mut s); + s.finish() } impl Default for Resolvers { @@ -360,41 +278,6 @@ 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 => f.write_str("none")?, - } - 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: ")?; - mx.fmt(f)?; - f.write_str("\r\n")?; - } - - Ok(()) - } -} - -impl Display for MxPattern { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MxPattern::Equals(mx) => f.write_str(mx), - MxPattern::StartsWith(mx) => { - f.write_str("*.")?; - f.write_str(mx) - } - } - } -} - 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 6890e251..a1b6bcb7 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -374,7 +374,7 @@ impl SessionConfig { &ext.ctx_mt_priority(), ), }, - mta_sts_policy: Policy::try_parse(bp).await, + mta_sts_policy: super::resolver::mta_sts_policy(bp).await, milters: bp .list_infallible::() .await diff --git a/crates/http/src/api/diagnose.rs b/crates/http/src/api/diagnose.rs index 55fa81e7..d0a1b598 100644 --- a/crates/http/src/api/diagnose.rs +++ b/crates/http/src/api/diagnose.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::{ @@ -22,7 +24,7 @@ use smtp::outbound::{ }, error::ClientError, lookup::{DnsLookup, SourceIp, ToNextHop}, - mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy}, + mta_sts::lookup::MtaStsLookup, }; use std::{ net::{IpAddr, SocketAddr}, diff --git a/crates/outbound-tls/Cargo.toml b/crates/outbound-tls/Cargo.toml new file mode 100644 index 00000000..376dbe15 --- /dev/null +++ b/crates/outbound-tls/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "inbuxa-outbound-tls" +description = "Outbound SMTP TLS policy: DANE (RFC 7672) with DNSSEC lookups, and MTA-STS (RFC 8461) policies. Shared by the server and inbuxa-relay (docs/spec/features/relay.md, RL-30)" +license = "AGPL-3.0-only OR LicenseRef-SEL" +version.workspace = true +edition = "2024" + +# Small on purpose: inbuxa-relay links it, so no inbuxa server crate. +[dependencies] +mail-auth = { version = "0.13", default-features = false, features = ["aws-lc-rs"] } +rustls-pki-types = { version = "1", features = ["std"] } +rustls-webpki = { version = "0.103", default-features = false, features = ["std", "aws-lc-rs"] } +x509-parser = { version = "0.18" } +sha2 = "0.11" +serde = { version = "1.0", features = ["derive"] } +idna = "1.1" + +[dev-dependencies] +tokio = { version = "1.53", features = ["rt-multi-thread", "macros"] } + +[lints] +workspace = true diff --git a/crates/outbound-tls/src/dane.rs b/crates/outbound-tls/src/dane.rs new file mode 100644 index 00000000..358b996f --- /dev/null +++ b/crates/outbound-tls/src/dane.rs @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +//! DANE (RFC 7672): a server's certificate chain against its TLSA records. + +use crate::{Tlsa, TlsaEntry, TlsaMatching}; +use rustls_pki_types::{CertificateDer, Der, ServerName, TrustAnchor, UnixTime}; +use sha2::{Digest, Sha256, Sha512}; +use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert}; +use x509_parser::asn1_rs::Any; +use x509_parser::prelude::{FromDer, X509Certificate}; + +/// Which kind of TLSA record matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaneMatch { + EndEntity, + TrustAnchor, +} + +impl DaneMatch { + pub fn as_str(&self) -> &'static str { + match self { + DaneMatch::EndEntity => "end-entity", + DaneMatch::TrustAnchor => "trust-anchor", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaneFailure { + /// The host sent no certificates. + NoCertificates, + /// One of them didn't parse; the parser's reason. + CertificateParse(String), + /// None matched a TLSA record. + NoMatch, +} + +impl DaneFailure { + pub fn description(&self) -> &'static str { + match self { + DaneFailure::NoCertificates => "No certificates were provided by host", + DaneFailure::CertificateParse(_) => "Failed to parse X.509 certificate", + DaneFailure::NoMatch => "No matching certificates found in TLSA records", + } + } +} + +/// Checks the chain a host presented against its TLSA records: a DANE-EE +/// record matching the end entity, or a DANE-TA record anchoring a chain +/// that is valid for one of `reference_ids`. +pub fn verify( + tlsa: &Tlsa, + reference_ids: &[&str], + certificates: Option<&[CertificateDer<'_>]>, +) -> Result { + let certificates = match certificates { + Some(certificates) if !certificates.is_empty() => certificates, + _ => return Err(DaneFailure::NoCertificates), + }; + + let mut parsed = Vec::with_capacity(certificates.len()); + for der_certificate in certificates { + match X509Certificate::from_der(der_certificate.as_ref()) { + Ok((_, cert)) => parsed.push(cert), + Err(err) => return Err(DaneFailure::CertificateParse(err.to_string())), + } + } + + if verify_end_entity(tlsa, certificates, &parsed) { + Ok(DaneMatch::EndEntity) + } else if verify_trust_anchor(tlsa, reference_ids, certificates, &parsed) { + Ok(DaneMatch::TrustAnchor) + } else { + Err(DaneFailure::NoMatch) + } +} + +fn verify_end_entity( + tlsa: &Tlsa, + certificates: &[CertificateDer<'_>], + parsed: &[X509Certificate<'_>], +) -> bool { + tlsa.has_end_entities + && tlsa + .entries + .iter() + .filter(|record| record.is_end_entity) + .any(|record| record_matches(record, &parsed[0], certificates[0].as_ref())) +} + +fn verify_trust_anchor( + tlsa: &Tlsa, + reference_ids: &[&str], + certificates: &[CertificateDer<'_>], + parsed: &[X509Certificate<'_>], +) -> bool { + if !tlsa.has_intermediates { + return false; + } + + let end_entity = match EndEntityCert::try_from(&certificates[0]) { + Ok(end_entity) => end_entity, + Err(_) => return false, + }; + + let mut anchors: Vec> = Vec::new(); + + for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) { + match (record.is_spki, record.matching) { + (false, TlsaMatching::Full) => { + let der = CertificateDer::from(record.data.clone()); + if let Ok(anchor) = anchor_from_trusted_cert(&der) { + anchors.push(anchor.to_owned()); + } + } + (true, TlsaMatching::Full) => { + if let Some(depth) = (1..certificates.len()) + .find(|&depth| parsed[depth].public_key().raw == record.data.as_slice()) + { + if let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) { + anchors.push(anchor.to_owned()); + } + } else if let Some(spki) = der_value(&record.data) { + for depth in 1..certificates.len() { + if is_chain_top(parsed, depth) + && let Some(subject) = der_value(parsed[depth].issuer().as_raw()) + { + anchors.push(TrustAnchor { + subject: Der::from(subject.to_vec()), + subject_public_key_info: Der::from(spki.to_vec()), + name_constraints: None, + }); + } + } + } + } + _ => { + for depth in 1..certificates.len() { + if record_matches(record, &parsed[depth], certificates[depth].as_ref()) + && let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) + { + anchors.push(anchor.to_owned()); + } + } + } + } + } + + !anchors.is_empty() + && end_entity + .verify_for_usage( + ALL_VERIFICATION_ALGS, + &anchors, + &certificates[1..], + UnixTime::now(), + KeyUsage::server_auth(), + None, + None, + ) + .is_ok() + && reference_ids.iter().any(|reference| { + ServerName::try_from(*reference) + .map(|name| end_entity.verify_is_valid_for_subject_name(&name).is_ok()) + .unwrap_or(false) + }) +} + +fn is_chain_top(parsed: &[X509Certificate<'_>], depth: usize) -> bool { + let issuer = parsed[depth].issuer().as_raw(); + !parsed + .iter() + .enumerate() + .any(|(other, cert)| other != depth && cert.subject().as_raw() == issuer) +} + +fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) -> bool { + let selected: &[u8] = if record.is_spki { + cert.public_key().raw + } else { + raw + }; + + match record.matching { + TlsaMatching::Full => selected == record.data.as_slice(), + TlsaMatching::Sha256 => Sha256::digest(selected).as_slice() == record.data.as_slice(), + TlsaMatching::Sha512 => Sha512::digest(selected).as_slice() == record.data.as_slice(), + } +} + +#[inline(always)] +fn der_value(der: &[u8]) -> Option<&[u8]> { + Any::from_der(der).ok().map(|(_, any)| any.data) +} diff --git a/crates/outbound-tls/src/dnssec.rs b/crates/outbound-tls/src/dnssec.rs new file mode 100644 index 00000000..9516d588 --- /dev/null +++ b/crates/outbound-tls/src/dnssec.rs @@ -0,0 +1,798 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +//! The DNSSEC lookups DANE needs (RFC 7672 §2.1): MX, A and AAAA with how +//! secure each answer is, and TLSA records only from a signed zone. Each +//! answer comes with until when it may be cached; the caller caches. + +use crate::{Tlsa, TlsaEntry, TlsaMatching}; +pub use mail_auth::DnssecStatus; +use mail_auth::{ + MX, RecordSet, + hickory_resolver::{ + TokioResolver, + lookup::Lookup, + net::{DnsError, NetError}, + proto::{ + dnssec::Proof, + op::ResponseCode, + rr::{ + Name, RData, Record, RecordType, + rdata::tlsa::{CertUsage, Matching, Selector}, + }, + }, + }, +}; +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::{Duration, Instant}, +}; + +/// A validating resolver, and a plain one for names proved unsigned. +pub struct Resolvers<'a> { + pub dnssec: &'a TokioResolver, + pub plain: &'a TokioResolver, +} + +pub enum TlsaResult { + Secure(Arc), + Bogus, + Missing, +} + +/// An answer, and until when it may be cached: `None` when it mustn't be. +pub type Cacheable = (T, Option); + +/// MX records for `name`, a fully qualified name. +pub async fn mx( + resolvers: &Resolvers<'_>, + name: &str, +) -> mail_auth::Result>> { + let (mx_lookup, forced_insecure) = match validated_lookup( + resolvers.dnssec, + resolvers.plain, + Name::from_str_relaxed::<&str>(name)?, + RecordType::MX, + ) + .await + { + Ok(validated) => (validated.lookup, validated.insecure), + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) + && denial.response_code == ResponseCode::NoError + { + let records = RecordSet { + rrset: Arc::new([]), + dnssec_status: denial.dnssec_status, + }; + return Ok((records, denial.valid_until)); + } + return Err(err.into()); + } + }; + let mx_records = mx_lookup.answers(); + let mut dnssec_status: Option = None; + let mut records: Vec<(u16, Vec>)> = Vec::with_capacity(mx_records.len()); + for mx_record in mx_records { + if let RData::MX(mx) = &mx_record.data { + dnssec_status = Some(match dnssec_status { + Some(status) => least_secure(status, proof_to_dnssec_status(mx_record.proof)), + None => proof_to_dnssec_status(mx_record.proof), + }); + + let preference = mx.preference; + let exchange = mx.exchange.to_lowercase().to_ascii().into_boxed_str(); + + if let Some(record) = records.iter_mut().find(|r| r.0 == preference) { + record.1.push(exchange); + } else { + records.push((preference, vec![exchange])); + } + } + } + + records.sort_unstable_by_key(|a| a.0); + let rrset: Arc<[MX]> = records + .into_iter() + .map(|(preference, exchanges)| MX { + preference, + exchanges: exchanges.into_boxed_slice(), + }) + .collect::>(); + let records = RecordSet { + rrset, + dnssec_status: if forced_insecure { + DnssecStatus::Insecure + } else { + dnssec_status.unwrap_or(DnssecStatus::Indeterminate) + }, + }; + + Ok((records, Some(mx_lookup.valid_until()))) +} + +/// TLSA records for `name` (`_25._tcp..`). Only a secure answer may +/// be cached. +pub async fn tlsa( + resolvers: &Resolvers<'_>, + name: &str, +) -> mail_auth::Result> { + // Through `validated_lookup`, like the MX and address lookups: a TLSA + // name that is a signed CNAME to a name with no TLSA record (seen at + // `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is + // otherwise called bogus, and the message waits on it until it + // expires. + let tlsa_lookup = match validated_lookup( + resolvers.dnssec, + resolvers.plain, + Name::from_str_relaxed(name)?, + RecordType::TLSA, + ) + .await + { + // A TLSA record proved to sit in an unsigned zone is no DANE + // policy at all. + Ok(validated) if validated.insecure => return Ok((TlsaResult::Missing, None)), + Ok(validated) => validated.lookup, + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) { + return Ok(( + if denial.dnssec_status == DnssecStatus::Bogus { + TlsaResult::Bogus + } else { + TlsaResult::Missing + }, + None, + )); + } + return Err(err.into()); + } + }; + + let mut entries = Vec::new(); + let mut has_end_entities = false; + let mut has_intermediates = false; + let mut dnssec_status: Option = None; + + for record in tlsa_lookup.answers() { + if let RData::TLSA(tlsa) = &record.data { + dnssec_status = Some(match dnssec_status { + Some(status) => least_secure(status, proof_to_dnssec_status(record.proof)), + None => proof_to_dnssec_status(record.proof), + }); + + if !record.proof.is_secure() { + continue; + } + + let is_end_entity = match tlsa.cert_usage { + CertUsage::DaneEe => true, + CertUsage::DaneTa => false, + _ => continue, + }; + let matching = match tlsa.matching { + Matching::Raw => TlsaMatching::Full, + Matching::Sha256 => TlsaMatching::Sha256, + Matching::Sha512 => TlsaMatching::Sha512, + _ => continue, + }; + let is_spki = match tlsa.selector { + Selector::Spki => true, + Selector::Full => false, + _ => continue, + }; + if is_end_entity { + has_end_entities = true; + } else { + has_intermediates = true; + } + entries.push(TlsaEntry { + is_end_entity, + is_spki, + matching, + data: tlsa.cert_data.clone(), + }); + } + } + + Ok(match dnssec_status { + Some(DnssecStatus::Bogus) => (TlsaResult::Bogus, None), + Some(DnssecStatus::Secure) => ( + TlsaResult::Secure(Arc::new(Tlsa { + entries, + has_end_entities, + has_intermediates, + })), + Some(tlsa_lookup.valid_until()), + ), + _ => (TlsaResult::Missing, None), + }) +} + +/// A records for `name`, a fully qualified name. +pub async fn ipv4( + resolvers: &Resolvers<'_>, + name: &str, +) -> mail_auth::Result>> { + addresses(resolvers, name, RecordType::A, |data| match data { + RData::A(addr) => Some(addr.0), + _ => None, + }) + .await +} + +/// AAAA records for `name`, a fully qualified name. +pub async fn ipv6( + resolvers: &Resolvers<'_>, + name: &str, +) -> mail_auth::Result>> { + addresses(resolvers, name, RecordType::AAAA, |data| match data { + RData::AAAA(addr) => Some(addr.0), + _ => None, + }) + .await +} + +async fn addresses( + resolvers: &Resolvers<'_>, + key: &str, + record_type: RecordType, + address: impl Fn(&RData) -> Option, +) -> mail_auth::Result>> { + let name = Name::from_str_relaxed::<&str>(key)?; + let (lookup, forced_insecure) = match validated_lookup( + resolvers.dnssec, + resolvers.plain, + name.clone(), + record_type, + ) + .await + { + Ok(validated) => (validated.lookup, validated.insecure), + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) + && denial.response_code == ResponseCode::NoError + { + let records = RecordSet { + rrset: Arc::new([]), + dnssec_status: denial.dnssec_status, + }; + return Ok((records, denial.valid_until)); + } + return Err(err.into()); + } + }; + + let answers = lookup.answers(); + let records = RecordSet { + rrset: answers + .iter() + .filter_map(|record| address(&record.data)) + .collect::>(), + dnssec_status: if forced_insecure { + DnssecStatus::Insecure + } else { + tlsa_base_status(&name, answers, record_type) + }, + }; + + Ok((records, Some(lookup.valid_until()))) +} + +// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then +// retries those hosts until the message expires. Two cases seen in production +// (the hickory vendored since upstream v0.16.26 gets both right in the +// `--ignored` tests below; this stays as the fallback): +// +// - A zone delegated beneath an unsigned zone, such as `l.google.com` under +// `google.com`. To prove the delegation insecure, hickory wants an SOA +// record in the DS reply, and public resolvers often send none. +// - A signed CNAME to a signed name without the record type queried. Hickory +// checks the denial of existence against the name first asked for, not the +// target's, and rejects it. TLSA lookups hit this too: a TLSA name that is +// a CNAME to the zone apex, with no TLSA there, held mail to it for a week. +// +// When hickory says bogus, check the answer again with lookups it gets right. +// A signed CNAME is followed and the lookup repeated at its target. Otherwise +// the name's zone and its parents are looked up, nearest first. If one +// validates as unsigned, nothing below it can be signed, so the plain resolver +// answers and the result is insecure. If one validates as signed first, the +// verdict stands. + +const MAX_BOGUS_ALIASES: usize = 8; + +struct ValidatedLookup { + lookup: Lookup, + insecure: bool, +} + +enum BogusRecheck { + Alias(Name), + Insecure, + Bogus, +} + +async fn validated_lookup( + dnssec: &TokioResolver, + plain: &TokioResolver, + name: Name, + record_type: RecordType, +) -> Result { + let mut query = name; + let mut aliases = 0; + + loop { + let err = match dnssec.lookup(query.clone(), record_type).await { + Ok(lookup) => { + return Ok(ValidatedLookup { + lookup, + insecure: false, + }); + } + Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err, + Err(err) => return Err(err), + }; + + match recheck_bogus(dnssec, &query).await { + BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => { + aliases += 1; + query = target; + } + BogusRecheck::Insecure => { + return plain + .lookup(query, record_type) + .await + .map(|lookup| ValidatedLookup { + lookup, + insecure: true, + }); + } + BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err), + } + } +} + +async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck { + if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await + && let Some(target) = secure_alias(name, lookup.answers()) + { + return BogusRecheck::Alias(target); + } + + let mut zone = name.clone(); + while !zone.is_root() { + if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await { + match apex_status(&zone, lookup.answers()) { + Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure, + Some(DnssecStatus::Secure) => return BogusRecheck::Bogus, + _ => {} + } + } + zone = zone.base_name(); + } + + BogusRecheck::Bogus +} + +fn secure_alias(query: &Name, answers: &[Record]) -> Option { + answers.iter().find_map(|record| match &record.data { + RData::CNAME(target) if &record.name == query && record.proof.is_secure() => { + Some(target.0.clone()) + } + _ => None, + }) +} + +fn apex_status(zone: &Name, answers: &[Record]) -> Option { + answers + .iter() + .filter(|record| record.record_type() == RecordType::SOA && &record.name == zone) + .map(|record| proof_to_dnssec_status(record.proof)) + .reduce(least_secure) +} + +struct NegativeAnswer { + response_code: ResponseCode, + dnssec_status: DnssecStatus, + valid_until: Option, +} + +impl NegativeAnswer { + fn from_error(err: &NetError) -> Option { + let NetError::Dns(dns_error) = err else { + return None; + }; + + match dns_error { + DnsError::NoRecordsFound(no_records) => Some(NegativeAnswer { + response_code: no_records.response_code, + dnssec_status: no_records + .authorities + .as_deref() + .map(denial_dnssec_status) + .unwrap_or(DnssecStatus::Indeterminate), + valid_until: no_records + .negative_ttl + .map(|ttl| Instant::now() + Duration::from_secs(ttl as u64)), + }), + DnsError::Nsec { + response, proof, .. + } => Some(NegativeAnswer { + response_code: response.response_code, + dnssec_status: proof_to_dnssec_status(*proof), + valid_until: None, + }), + _ => None, + } + } +} + +fn denial_dnssec_status(authorities: &[Record]) -> DnssecStatus { + authorities + .iter() + .filter(|record| matches!(record.record_type(), RecordType::NSEC | RecordType::NSEC3)) + .map(|record| proof_to_dnssec_status(record.proof)) + .reduce(least_secure) + .unwrap_or(DnssecStatus::Indeterminate) +} + +fn proof_to_dnssec_status(proof: Proof) -> DnssecStatus { + match proof { + Proof::Secure => DnssecStatus::Secure, + Proof::Insecure => DnssecStatus::Insecure, + Proof::Bogus => DnssecStatus::Bogus, + Proof::Indeterminate => DnssecStatus::Indeterminate, + } +} + +fn tlsa_base_status(query: &Name, answers: &[Record], address_type: RecordType) -> DnssecStatus { + let mut addresses: Option = None; + let mut alias: Option = None; + + for record in answers { + let status = proof_to_dnssec_status(record.proof); + if record.record_type() == address_type { + addresses = Some(match addresses { + Some(current) => least_secure(current, status), + None => status, + }); + } else if record.record_type() == RecordType::CNAME && &record.name == query { + alias = Some(match alias { + Some(current) => least_secure(current, status), + None => status, + }); + } + } + + match (addresses, alias) { + (Some(DnssecStatus::Insecure), Some(DnssecStatus::Secure)) => DnssecStatus::Secure, + (Some(status), _) => status, + (None, _) => DnssecStatus::Indeterminate, + } +} + +pub fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus { + fn rank(status: DnssecStatus) -> u8 { + match status { + DnssecStatus::Bogus => 0, + DnssecStatus::Indeterminate => 1, + DnssecStatus::Insecure => 2, + DnssecStatus::Secure => 3, + } + } + + if rank(a) <= rank(b) { a } else { b } +} + +#[cfg(test)] +mod tests { + use super::*; + use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA}; + use std::net::Ipv4Addr; + + fn name(value: &str) -> Name { + Name::from_ascii(value).unwrap() + } + + fn address(owner: &str, proof: Proof) -> Record { + let mut record = + Record::from_rdata(name(owner), 3600, RData::A(A(Ipv4Addr::new(192, 0, 2, 1)))); + record.proof = proof; + record + } + + fn alias(owner: &str, target: &str, proof: Proof) -> Record { + let mut record = Record::from_rdata(name(owner), 3600, RData::CNAME(CNAME(name(target)))); + record.proof = proof; + record + } + + #[test] + fn tlsa_base_status_follows_address_records() { + let query = name("mx.example.org."); + + for (proof, expected) in [ + (Proof::Secure, DnssecStatus::Secure), + (Proof::Insecure, DnssecStatus::Insecure), + (Proof::Bogus, DnssecStatus::Bogus), + (Proof::Indeterminate, DnssecStatus::Indeterminate), + ] { + assert_eq!( + tlsa_base_status(&query, &[address("mx.example.org.", proof)], RecordType::A), + expected, + "proof {proof}" + ); + } + } + + #[test] + fn tlsa_base_status_is_indeterminate_without_addresses() { + assert_eq!( + tlsa_base_status(&name("mx.example.org."), &[], RecordType::A), + DnssecStatus::Indeterminate + ); + } + + #[test] + fn tlsa_base_status_takes_least_secure_address() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + address("mx.example.org.", Proof::Secure), + address("mx.example.org.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } + + #[test] + fn tlsa_base_status_keeps_secure_alias_to_insecure_zone() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.example.org.", "mx.provider.net.", Proof::Secure), + address("mx.provider.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Secure + ); + } + + #[test] + fn tlsa_base_status_skips_insecure_alias() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.example.org.", "mx.provider.net.", Proof::Insecure), + address("mx.provider.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } + + #[test] + fn tlsa_base_status_ignores_alias_below_query_name() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.provider.net.", "mx.other.net.", Proof::Secure), + address("mx.other.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } + + fn soa(owner: &str, proof: Proof) -> Record { + let mut record = Record::from_rdata( + name(owner), + 3600, + RData::SOA(SOA::new( + name("ns1.example.org."), + name("hostmaster.example.org."), + 1, + 900, + 900, + 1800, + 60, + )), + ); + record.proof = proof; + record + } + + #[test] + fn secure_alias_follows_signed_cname() { + assert_eq!( + secure_alias( + &name("mail.example.org."), + &[alias("mail.example.org.", "mx.example.net.", Proof::Secure)] + ), + Some(name("mx.example.net.")) + ); + } + + #[test] + fn secure_alias_ignores_unsigned_or_other_cname() { + let query = name("mail.example.org."); + + assert_eq!( + secure_alias( + &query, + &[alias( + "mail.example.org.", + "mx.example.net.", + Proof::Insecure + )] + ), + None + ); + assert_eq!( + secure_alias( + &query, + &[alias( + "other.example.org.", + "mx.example.net.", + Proof::Secure + )] + ), + None + ); + } + + #[test] + fn apex_status_reads_the_zone_soa() { + let zone = name("example.com."); + + for (proof, expected) in [ + (Proof::Secure, Some(DnssecStatus::Secure)), + (Proof::Insecure, Some(DnssecStatus::Insecure)), + (Proof::Bogus, Some(DnssecStatus::Bogus)), + ] { + assert_eq!( + apex_status(&zone, &[soa("example.com.", proof)]), + expected, + "proof {proof}" + ); + } + } + + #[test] + fn apex_status_ignores_other_records() { + assert_eq!( + apex_status( + &name("example.com."), + &[ + soa("sub.example.com.", Proof::Insecure), + address("example.com.", Proof::Insecure), + ] + ), + None + ); + } + + // Needs the network: a signed MX pointing into a zone delegated beneath an + // unsigned one. Run with `--ignored` to check a hickory upgrade. hickory + // 0.26.3 called it bogus and the recheck proved it insecure; the vendored + // hickory proves it itself. Either way the answer has to come out insecure. + // The TLSA test below prints whether hickory alone gets its case right. + #[tokio::test] + #[ignore] + async fn validated_lookup_proves_delegation_below_unsigned_zone() { + use mail_auth::hickory_resolver::{ + config::{CLOUDFLARE, ResolverConfig, ResolverOpts}, + net::runtime::TokioRuntimeProvider, + }; + + let build = |validate: bool| { + // Same options as the server's DNSSEC resolver; hickory fails + // validation with concurrent requests. + let mut opts = ResolverOpts::default(); + opts.validate = validate; + opts.num_concurrent_reqs = 1; + opts.cache_size = 0; + TokioResolver::builder_with_config( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + TokioRuntimeProvider::default(), + ) + .with_options(opts) + .build() + .unwrap() + }; + let (dnssec, plain) = (build(true), build(false)); + + let query = name("aspmx.l.google.com."); + let validated = validated_lookup(&dnssec, &plain, query.clone(), RecordType::A) + .await + .unwrap(); + assert!(!validated.lookup.answers().is_empty()); + let status = if validated.insecure { + DnssecStatus::Insecure + } else { + tlsa_base_status(&query, validated.lookup.answers(), RecordType::A) + }; + assert_eq!(status, DnssecStatus::Insecure); + } + + // Needs the network: a TLSA name that is a signed CNAME to the zone apex, + // which has no TLSA record. Cloudflare's resolver answers with a compact + // denial at the name itself; Hetzner's (and others) follow the CNAME, and + // hickory then calls the answer bogus. Point the lookup at a resolver that + // follows it with INBUXA_TEST_DNS_TCP= (TCP), for instance over + // an SSH tunnel to 185.12.64.2:53 from a Hetzner host. + #[tokio::test] + #[ignore] + async fn validated_lookup_follows_signed_cname_for_tlsa() { + use mail_auth::hickory_resolver::{ + config::{ + CLOUDFLARE, ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts, + }, + net::runtime::TokioRuntimeProvider, + }; + + let config = match std::env::var("INBUXA_TEST_DNS_TCP") { + Ok(addr) => { + let addr: std::net::SocketAddr = addr.parse().unwrap(); + let mut ns = NameServerConfig::new(addr.ip(), true, vec![ConnectionConfig::tcp()]); + if let Some(c) = ns.connections.first_mut() { + c.port = addr.port(); + } + ResolverConfig::from_parts(None, vec![], vec![ns]) + } + Err(_) => ResolverConfig::udp_and_tcp(&CLOUDFLARE), + }; + let build = |validate: bool| { + let mut opts = ResolverOpts::default(); + opts.validate = validate; + opts.num_concurrent_reqs = 1; + opts.cache_size = 0; + TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default()) + .with_options(opts) + .build() + .unwrap() + }; + let (dnssec, plain) = (build(true), build(false)); + let query = name("_25._tcp.mail.usefulinsight.com."); + + let direct = dnssec.lookup(query.clone(), RecordType::TLSA).await; + eprintln!( + "hickory alone: {:?}", + direct.as_ref().err().map(|e| e.to_string()) + ); + + let err = match validated_lookup(&dnssec, &plain, query, RecordType::TLSA).await { + Ok(validated) => panic!( + "expected no TLSA record, got {:?}", + validated.lookup.answers() + ), + Err(err) => err, + }; + let denial = NegativeAnswer::from_error(&err).expect("a denial of existence"); + assert_eq!(denial.response_code, ResponseCode::NoError); + assert_ne!(denial.dnssec_status, DnssecStatus::Bogus); + } +} diff --git a/crates/outbound-tls/src/lib.rs b/crates/outbound-tls/src/lib.rs new file mode 100644 index 00000000..0fe25a93 --- /dev/null +++ b/crates/outbound-tls/src/lib.rs @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +//! Outbound SMTP TLS policy, shared by the server's queue and inbuxa-relay +//! (RL-30): DANE (RFC 7672) and the DNSSEC lookups it needs, and MTA-STS +//! (RFC 8461) policies. Moved here from `crates/smtp` and +//! `crates/common`; caching, tracing and the policy fetch stay with each +//! caller. + +pub mod dane; +pub mod dnssec; +pub mod mta_sts; + +pub use mail_auth; + +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum TlsaMatching { + Full, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct TlsaEntry { + pub is_end_entity: bool, + pub is_spki: bool, + pub matching: TlsaMatching, + pub data: Vec, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tlsa { + pub entries: Vec, + pub has_end_entities: bool, + pub has_intermediates: bool, +} + +#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + Enforce, + Testing, + #[default] + None, +} + +#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MxPattern { + Equals(String), + StartsWith(String), +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct Policy { + pub id: String, + pub mode: Mode, + pub mx: Box<[MxPattern]>, + pub max_age: u64, +} + +impl FromStr for Mode { + type Err = String; + fn from_str(value: &str) -> Result { + match value { + "enforce" => Ok(Self::Enforce), + "testing" | "test" => Ok(Self::Testing), + "none" => Ok(Self::None), + _ => Err(format!("Invalid mode value {value:?}")), + } + } +} + +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 => f.write_str("none")?, + } + 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: ")?; + mx.fmt(f)?; + f.write_str("\r\n")?; + } + + Ok(()) + } +} + +impl Display for MxPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MxPattern::Equals(mx) => f.write_str(mx), + MxPattern::StartsWith(mx) => { + f.write_str("*.")?; + f.write_str(mx) + } + } + } +} diff --git a/crates/smtp/src/outbound/mta_sts/parse.rs b/crates/outbound-tls/src/mta_sts.rs similarity index 55% rename from crates/smtp/src/outbound/mta_sts/parse.rs rename to crates/outbound-tls/src/mta_sts.rs index 50306863..75615520 100644 --- a/crates/smtp/src/outbound/mta_sts/parse.rs +++ b/crates/outbound-tls/src/mta_sts.rs @@ -2,26 +2,30 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use common::config::smtp::resolver::{Mode, MxPattern, Policy}; -use utils::DomainPart; +//! MTA-STS (RFC 8461) policies: parsing the fetched file, and matching MX +//! hosts against it. Fetching and caching stay with each caller. +use crate::{Mode, MxPattern, Policy}; + +/// A domain as an A-label, lowercased, for matching MX hosts. fn to_a_label(domain: &str) -> String { - domain - .to_ascii_domain() - .map(|domain| domain.to_lowercase()) - .unwrap_or_else(|| domain.to_lowercase()) + if domain.is_ascii() { + domain.to_ascii_lowercase() + } else { + idna::domain_to_ascii(domain) + .map(|domain| domain.to_lowercase()) + .unwrap_or_else(|_| domain.to_lowercase()) + } } -pub trait ParsePolicy { - fn parse(data: &str, id: String) -> Result - where - Self: Sized; -} - -impl ParsePolicy for Policy { - fn parse(mut data: &str, id: String) -> Result { +impl Policy { + /// Parses a policy file (RFC 8461 §3.2), as fetched; `id` is the + /// policy id from the TXT record. + pub fn parse(mut data: &str, id: String) -> Result { let mut mode = Mode::None; let mut max_age: u64 = 86400; let mut mx = Vec::new(); @@ -35,8 +39,8 @@ impl ParsePolicy for Policy { data = ""; next_data.trim() }; - hashify::fnc_map!(key.trim().as_bytes(), - b"mx" => { + match key.trim() { + "mx" => { if let Some(suffix) = value.strip_prefix("*.") { if !suffix.is_empty() { mx.push(MxPattern::StartsWith(to_a_label(suffix))); @@ -44,27 +48,25 @@ impl ParsePolicy for Policy { } else if !value.is_empty() { mx.push(MxPattern::Equals(to_a_label(value))); } - }, - b"max_age" => { + } + "max_age" => { if let Ok(value) = value.parse() { max_age = value; } - }, - b"mode" => { + } + "mode" => { mode = match value { "enforce" => Mode::Enforce, "testing" => Mode::Testing, "none" => Mode::None, _ => return Err(format!("Unsupported mode {value:?}.")), }; - }, - b"version" => { - if !value.eq_ignore_ascii_case("STSv1") { - return Err(format!("Unsupported version {value:?}.")); - } - }, + } + "version" if !value.eq_ignore_ascii_case("STSv1") => { + return Err(format!("Unsupported version {value:?}.")); + } _ => {} - ); + } } else { break; } @@ -83,11 +85,41 @@ impl ParsePolicy for Policy { } } +impl Policy { + /// Whether `mx_host` may be delivered to under this policy. + pub fn verify(&self, mx_host: &str) -> bool { + if self.mode != Mode::None { + for mx_pattern in &self.mx { + match mx_pattern { + MxPattern::Equals(host) => { + if host == mx_host { + return true; + } + } + MxPattern::StartsWith(domain) => { + if let Some((_, suffix)) = mx_host.split_once('.') + && suffix == domain + { + return true; + } + } + } + } + + false + } else { + true + } + } + + pub fn enforce(&self) -> bool { + self.mode == Mode::Enforce + } +} + #[cfg(test)] mod test { - use super::ParsePolicy; - use crate::outbound::mta_sts::verify::VerifyPolicy; - use common::config::smtp::resolver::Policy; + use crate::Policy; #[test] fn mx_patterns_are_a_labels() { @@ -106,5 +138,6 @@ mod test { assert!(policy.verify("mx.xn--eebajf.xn--9dbq2a")); assert!(policy.verify("mail.xn--eebajf.xn--9dbq2a")); assert!(!policy.verify("mx.example.org")); + assert!(policy.enforce()); } } diff --git a/crates/services/src/inbuxa_deliverability.rs b/crates/services/src/inbuxa_deliverability.rs index 5405aba6..0fddc6de 100644 --- a/crates/services/src/inbuxa_deliverability.rs +++ b/crates/services/src/inbuxa_deliverability.rs @@ -29,7 +29,7 @@ use mail_auth::{ spf::verify::SpfParameters, }; use registry::schema::{prelude::ObjectType, structs::Domain}; -use smtp::outbound::mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy}; +use smtp::outbound::mta_sts::lookup::MtaStsLookup; use std::{ collections::BTreeSet, future::Future, diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index bc62bb47..728df283 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -15,6 +15,7 @@ types = { path = "../types" } utils = { path = "../utils" } directory = { path = "../directory" } common = { path = "../common" } +inbuxa-outbound-tls = { path = "../outbound-tls" } email = { path = "../email" } registry = { path = "../registry" } inbuxa-features = { path = "../features" } diff --git a/crates/smtp/src/outbound/dane/dnssec.rs b/crates/smtp/src/outbound/dane/dnssec.rs index e2420e61..48c2b1d6 100644 --- a/crates/smtp/src/outbound/dane/dnssec.rs +++ b/crates/smtp/src/outbound/dane/dnssec.rs @@ -6,33 +6,16 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use common::{ - Server, - config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}, -}; -pub use mail_auth::DnssecStatus; -use mail_auth::{ - MX, RecordSet, - common::resolver::ToFqdn, - hickory_resolver::{ - TokioResolver, - lookup::Lookup, - net::{DnsError, NetError}, - proto::{ - dnssec::Proof, - op::ResponseCode, - rr::{ - Name, RData, Record, RecordType, - rdata::tlsa::{CertUsage, Matching, Selector}, - }, - }, - }, -}; +// inbuxa: the lookups themselves are in inbuxa-outbound-tls, shared with +// inbuxa-relay (RL-30); this adds the server's caches and test mocks. + +use common::Server; +use inbuxa_outbound_tls::dnssec::{self, Resolvers}; +pub use inbuxa_outbound_tls::dnssec::{DnssecStatus, TlsaResult, least_secure}; +use mail_auth::{MX, RecordSet, common::resolver::ToFqdn}; use std::{ future::Future, net::{Ipv4Addr, Ipv6Addr}, - sync::Arc, - time::{Duration, Instant}, }; pub trait TlsaLookup: Sync + Send { @@ -57,10 +40,11 @@ pub trait TlsaLookup: Sync + Send { ) -> impl Future>> + Send; } -pub enum TlsaResult { - Secure(Arc), - Bogus, - Missing, +fn resolvers(server: &Server) -> Resolvers<'_> { + Resolvers { + dnssec: &server.core.smtp.resolvers.dnssec.resolver, + plain: server.core.smtp.resolvers.dns.resolver(), + } } impl TlsaLookup for Server { @@ -87,78 +71,14 @@ impl TlsaLookup for Server { return mail_auth::common::resolver::mock_resolve(key.as_ref()); } - let (mx_lookup, forced_insecure) = match validated_lookup( - &self.core.smtp.resolvers.dnssec.resolver, - self.core.smtp.resolvers.dns.resolver(), - Name::from_str_relaxed::<&str>(key.as_ref())?, - RecordType::MX, - ) - .await - { - Ok(validated) => (validated.lookup, validated.insecure), - Err(err) => { - if let Some(denial) = NegativeAnswer::from_error(&err) - && denial.response_code == ResponseCode::NoError - { - let records = RecordSet { - rrset: Arc::new([]), - dnssec_status: denial.dnssec_status, - }; - if let Some(valid_until) = denial.valid_until { - self.inner.cache.dns_mx.insert_with_expiry( - key, - records.clone(), - valid_until, - ); - } - return Ok(records); - } - return Err(err.into()); - } - }; - let mx_records = mx_lookup.answers(); - let mut dnssec_status: Option = None; - let mut records: Vec<(u16, Vec>)> = Vec::with_capacity(mx_records.len()); - for mx_record in mx_records { - if let RData::MX(mx) = &mx_record.data { - dnssec_status = Some(match dnssec_status { - Some(status) => least_secure(status, proof_to_dnssec_status(mx_record.proof)), - None => proof_to_dnssec_status(mx_record.proof), - }); - - let preference = mx.preference; - let exchange = mx.exchange.to_lowercase().to_ascii().into_boxed_str(); - - if let Some(record) = records.iter_mut().find(|r| r.0 == preference) { - record.1.push(exchange); - } else { - records.push((preference, vec![exchange])); - } - } + let (records, valid_until) = dnssec::mx(&resolvers(self), key.as_ref()).await?; + if let Some(valid_until) = valid_until { + self.inner + .cache + .dns_mx + .insert_with_expiry(key, records.clone(), valid_until); } - records.sort_unstable_by_key(|a| a.0); - let rrset: Arc<[MX]> = records - .into_iter() - .map(|(preference, exchanges)| MX { - preference, - exchanges: exchanges.into_boxed_slice(), - }) - .collect::>(); - let records = RecordSet { - rrset, - dnssec_status: if forced_insecure { - DnssecStatus::Insecure - } else { - dnssec_status.unwrap_or(DnssecStatus::Indeterminate) - }, - }; - - self.inner - .cache - .dns_mx - .insert_with_expiry(key, records.clone(), mx_lookup.valid_until()); - Ok(records) } @@ -176,100 +96,15 @@ impl TlsaLookup for Server { return mail_auth::common::resolver::mock_resolve(key.as_ref()); } - // Through `validated_lookup`, like the MX and address lookups: a TLSA - // name that is a signed CNAME to a name with no TLSA record (seen at - // `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is - // otherwise called bogus, and the message waits on it until it - // expires. - let tlsa_lookup = match validated_lookup( - &self.core.smtp.resolvers.dnssec.resolver, - self.core.smtp.resolvers.dns.resolver(), - Name::from_str_relaxed(key.as_ref())?, - RecordType::TLSA, - ) - .await - { - // A TLSA record proved to sit in an unsigned zone is no DANE - // policy at all. - Ok(validated) if validated.insecure => return Ok(TlsaResult::Missing), - Ok(validated) => validated.lookup, - Err(err) => { - if let Some(denial) = NegativeAnswer::from_error(&err) { - return Ok(if denial.dnssec_status == DnssecStatus::Bogus { - TlsaResult::Bogus - } else { - TlsaResult::Missing - }); - } - return Err(err.into()); - } - }; - - let mut entries = Vec::new(); - let mut has_end_entities = false; - let mut has_intermediates = false; - let mut dnssec_status: Option = None; - - for record in tlsa_lookup.answers() { - if let RData::TLSA(tlsa) = &record.data { - dnssec_status = Some(match dnssec_status { - Some(status) => least_secure(status, proof_to_dnssec_status(record.proof)), - None => proof_to_dnssec_status(record.proof), - }); - - if !record.proof.is_secure() { - continue; - } - - let is_end_entity = match tlsa.cert_usage { - CertUsage::DaneEe => true, - CertUsage::DaneTa => false, - _ => continue, - }; - let matching = match tlsa.matching { - Matching::Raw => TlsaMatching::Full, - Matching::Sha256 => TlsaMatching::Sha256, - Matching::Sha512 => TlsaMatching::Sha512, - _ => continue, - }; - let is_spki = match tlsa.selector { - Selector::Spki => true, - Selector::Full => false, - _ => continue, - }; - if is_end_entity { - has_end_entities = true; - } else { - has_intermediates = true; - } - entries.push(TlsaEntry { - is_end_entity, - is_spki, - matching, - data: tlsa.cert_data.clone(), - }); - } + let (result, valid_until) = dnssec::tlsa(&resolvers(self), key.as_ref()).await?; + if let (TlsaResult::Secure(tlsa), Some(valid_until)) = (&result, valid_until) { + self.inner + .cache + .dns_tlsa + .insert_with_expiry(key, tlsa.clone(), valid_until); } - match dnssec_status { - Some(DnssecStatus::Bogus) => Ok(TlsaResult::Bogus), - Some(DnssecStatus::Secure) => { - let tlsa = Arc::new(Tlsa { - entries, - has_end_entities, - has_intermediates, - }); - - self.inner.cache.dns_tlsa.insert_with_expiry( - key, - tlsa.clone(), - tlsa_lookup.valid_until(), - ); - - Ok(TlsaResult::Secure(tlsa)) - } - _ => Ok(TlsaResult::Missing), - } + Ok(result) } async fn ipv4_lookup_dnssec( @@ -298,57 +133,13 @@ impl TlsaLookup for Server { return mail_auth::common::resolver::mock_resolve(key.as_ref()); } - let name = Name::from_str_relaxed::<&str>(key.as_ref())?; - let (lookup, forced_insecure) = match validated_lookup( - &self.core.smtp.resolvers.dnssec.resolver, - self.core.smtp.resolvers.dns.resolver(), - name.clone(), - RecordType::A, - ) - .await - { - Ok(validated) => (validated.lookup, validated.insecure), - Err(err) => { - if let Some(denial) = NegativeAnswer::from_error(&err) - && denial.response_code == ResponseCode::NoError - { - let records = RecordSet { - rrset: Arc::new([]), - dnssec_status: denial.dnssec_status, - }; - if let Some(valid_until) = denial.valid_until { - self.inner.cache.dns_ipv4.insert_with_expiry( - key, - records.clone(), - valid_until, - ); - } - return Ok(records); - } - return Err(err.into()); - } - }; - - let answers = lookup.answers(); - let records = RecordSet { - rrset: answers - .iter() - .filter_map(|record| match &record.data { - RData::A(addr) => Some(addr.0), - _ => None, - }) - .collect::>(), - dnssec_status: if forced_insecure { - DnssecStatus::Insecure - } else { - tlsa_base_status(&name, answers, RecordType::A) - }, - }; - - self.inner - .cache - .dns_ipv4 - .insert_with_expiry(key, records.clone(), lookup.valid_until()); + let (records, valid_until) = dnssec::ipv4(&resolvers(self), key.as_ref()).await?; + if let Some(valid_until) = valid_until { + self.inner + .cache + .dns_ipv4 + .insert_with_expiry(key, records.clone(), valid_until); + } Ok(records) } @@ -379,570 +170,14 @@ impl TlsaLookup for Server { return mail_auth::common::resolver::mock_resolve(key.as_ref()); } - let name = Name::from_str_relaxed::<&str>(key.as_ref())?; - let (lookup, forced_insecure) = match validated_lookup( - &self.core.smtp.resolvers.dnssec.resolver, - self.core.smtp.resolvers.dns.resolver(), - name.clone(), - RecordType::AAAA, - ) - .await - { - Ok(validated) => (validated.lookup, validated.insecure), - Err(err) => { - if let Some(denial) = NegativeAnswer::from_error(&err) - && denial.response_code == ResponseCode::NoError - { - let records = RecordSet { - rrset: Arc::new([]), - dnssec_status: denial.dnssec_status, - }; - if let Some(valid_until) = denial.valid_until { - self.inner.cache.dns_ipv6.insert_with_expiry( - key, - records.clone(), - valid_until, - ); - } - return Ok(records); - } - return Err(err.into()); - } - }; - - let answers = lookup.answers(); - let records = RecordSet { - rrset: answers - .iter() - .filter_map(|record| match &record.data { - RData::AAAA(addr) => Some(addr.0), - _ => None, - }) - .collect::>(), - dnssec_status: if forced_insecure { - DnssecStatus::Insecure - } else { - tlsa_base_status(&name, answers, RecordType::AAAA) - }, - }; - - self.inner - .cache - .dns_ipv6 - .insert_with_expiry(key, records.clone(), lookup.valid_until()); + let (records, valid_until) = dnssec::ipv6(&resolvers(self), key.as_ref()).await?; + if let Some(valid_until) = valid_until { + self.inner + .cache + .dns_ipv6 + .insert_with_expiry(key, records.clone(), valid_until); + } Ok(records) } } - -// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then -// retries those hosts until the message expires. Two cases seen in production -// (the hickory vendored since upstream v0.16.26 gets both right in the -// `--ignored` tests below; this stays as the fallback): -// -// - A zone delegated beneath an unsigned zone, such as `l.google.com` under -// `google.com`. To prove the delegation insecure, hickory wants an SOA -// record in the DS reply, and public resolvers often send none. -// - A signed CNAME to a signed name without the record type queried. Hickory -// checks the denial of existence against the name first asked for, not the -// target's, and rejects it. TLSA lookups hit this too: a TLSA name that is -// a CNAME to the zone apex, with no TLSA there, held mail to it for a week. -// -// When hickory says bogus, check the answer again with lookups it gets right. -// A signed CNAME is followed and the lookup repeated at its target. Otherwise -// the name's zone and its parents are looked up, nearest first. If one -// validates as unsigned, nothing below it can be signed, so the plain resolver -// answers and the result is insecure. If one validates as signed first, the -// verdict stands. - -const MAX_BOGUS_ALIASES: usize = 8; - -struct ValidatedLookup { - lookup: Lookup, - insecure: bool, -} - -enum BogusRecheck { - Alias(Name), - Insecure, - Bogus, -} - -async fn validated_lookup( - dnssec: &TokioResolver, - plain: &TokioResolver, - name: Name, - record_type: RecordType, -) -> Result { - let mut query = name; - let mut aliases = 0; - - loop { - let err = match dnssec.lookup(query.clone(), record_type).await { - Ok(lookup) => { - return Ok(ValidatedLookup { - lookup, - insecure: false, - }); - } - Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err, - Err(err) => return Err(err), - }; - - match recheck_bogus(dnssec, &query).await { - BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => { - aliases += 1; - query = target; - } - BogusRecheck::Insecure => { - return plain - .lookup(query, record_type) - .await - .map(|lookup| ValidatedLookup { - lookup, - insecure: true, - }); - } - BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err), - } - } -} - -async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck { - if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await - && let Some(target) = secure_alias(name, lookup.answers()) - { - return BogusRecheck::Alias(target); - } - - let mut zone = name.clone(); - while !zone.is_root() { - if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await { - match apex_status(&zone, lookup.answers()) { - Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure, - Some(DnssecStatus::Secure) => return BogusRecheck::Bogus, - _ => {} - } - } - zone = zone.base_name(); - } - - BogusRecheck::Bogus -} - -fn secure_alias(query: &Name, answers: &[Record]) -> Option { - answers.iter().find_map(|record| match &record.data { - RData::CNAME(target) if &record.name == query && record.proof.is_secure() => { - Some(target.0.clone()) - } - _ => None, - }) -} - -fn apex_status(zone: &Name, answers: &[Record]) -> Option { - answers - .iter() - .filter(|record| record.record_type() == RecordType::SOA && &record.name == zone) - .map(|record| proof_to_dnssec_status(record.proof)) - .reduce(least_secure) -} - -struct NegativeAnswer { - response_code: ResponseCode, - dnssec_status: DnssecStatus, - valid_until: Option, -} - -impl NegativeAnswer { - fn from_error(err: &NetError) -> Option { - let NetError::Dns(dns_error) = err else { - return None; - }; - - match dns_error { - DnsError::NoRecordsFound(no_records) => Some(NegativeAnswer { - response_code: no_records.response_code, - dnssec_status: no_records - .authorities - .as_deref() - .map(denial_dnssec_status) - .unwrap_or(DnssecStatus::Indeterminate), - valid_until: no_records - .negative_ttl - .map(|ttl| Instant::now() + Duration::from_secs(ttl as u64)), - }), - DnsError::Nsec { - response, proof, .. - } => Some(NegativeAnswer { - response_code: response.response_code, - dnssec_status: proof_to_dnssec_status(*proof), - valid_until: None, - }), - _ => None, - } - } -} - -fn denial_dnssec_status(authorities: &[Record]) -> DnssecStatus { - authorities - .iter() - .filter(|record| matches!(record.record_type(), RecordType::NSEC | RecordType::NSEC3)) - .map(|record| proof_to_dnssec_status(record.proof)) - .reduce(least_secure) - .unwrap_or(DnssecStatus::Indeterminate) -} - -fn proof_to_dnssec_status(proof: Proof) -> DnssecStatus { - match proof { - Proof::Secure => DnssecStatus::Secure, - Proof::Insecure => DnssecStatus::Insecure, - Proof::Bogus => DnssecStatus::Bogus, - Proof::Indeterminate => DnssecStatus::Indeterminate, - } -} - -fn tlsa_base_status(query: &Name, answers: &[Record], address_type: RecordType) -> DnssecStatus { - let mut addresses: Option = None; - let mut alias: Option = None; - - for record in answers { - let status = proof_to_dnssec_status(record.proof); - if record.record_type() == address_type { - addresses = Some(match addresses { - Some(current) => least_secure(current, status), - None => status, - }); - } else if record.record_type() == RecordType::CNAME && &record.name == query { - alias = Some(match alias { - Some(current) => least_secure(current, status), - None => status, - }); - } - } - - match (addresses, alias) { - (Some(DnssecStatus::Insecure), Some(DnssecStatus::Secure)) => DnssecStatus::Secure, - (Some(status), _) => status, - (None, _) => DnssecStatus::Indeterminate, - } -} - -pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus { - fn rank(status: DnssecStatus) -> u8 { - match status { - DnssecStatus::Bogus => 0, - DnssecStatus::Indeterminate => 1, - DnssecStatus::Insecure => 2, - DnssecStatus::Secure => 3, - } - } - - if rank(a) <= rank(b) { a } else { b } -} - -#[cfg(test)] -mod tests { - use super::*; - use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA}; - use std::net::Ipv4Addr; - - fn name(value: &str) -> Name { - Name::from_ascii(value).unwrap() - } - - fn address(owner: &str, proof: Proof) -> Record { - let mut record = - Record::from_rdata(name(owner), 3600, RData::A(A(Ipv4Addr::new(192, 0, 2, 1)))); - record.proof = proof; - record - } - - fn alias(owner: &str, target: &str, proof: Proof) -> Record { - let mut record = Record::from_rdata(name(owner), 3600, RData::CNAME(CNAME(name(target)))); - record.proof = proof; - record - } - - #[test] - fn tlsa_base_status_follows_address_records() { - let query = name("mx.example.org."); - - for (proof, expected) in [ - (Proof::Secure, DnssecStatus::Secure), - (Proof::Insecure, DnssecStatus::Insecure), - (Proof::Bogus, DnssecStatus::Bogus), - (Proof::Indeterminate, DnssecStatus::Indeterminate), - ] { - assert_eq!( - tlsa_base_status(&query, &[address("mx.example.org.", proof)], RecordType::A), - expected, - "proof {proof}" - ); - } - } - - #[test] - fn tlsa_base_status_is_indeterminate_without_addresses() { - assert_eq!( - tlsa_base_status(&name("mx.example.org."), &[], RecordType::A), - DnssecStatus::Indeterminate - ); - } - - #[test] - fn tlsa_base_status_takes_least_secure_address() { - let query = name("mx.example.org."); - - assert_eq!( - tlsa_base_status( - &query, - &[ - address("mx.example.org.", Proof::Secure), - address("mx.example.org.", Proof::Insecure), - ], - RecordType::A - ), - DnssecStatus::Insecure - ); - } - - #[test] - fn tlsa_base_status_keeps_secure_alias_to_insecure_zone() { - let query = name("mx.example.org."); - - assert_eq!( - tlsa_base_status( - &query, - &[ - alias("mx.example.org.", "mx.provider.net.", Proof::Secure), - address("mx.provider.net.", Proof::Insecure), - ], - RecordType::A - ), - DnssecStatus::Secure - ); - } - - #[test] - fn tlsa_base_status_skips_insecure_alias() { - let query = name("mx.example.org."); - - assert_eq!( - tlsa_base_status( - &query, - &[ - alias("mx.example.org.", "mx.provider.net.", Proof::Insecure), - address("mx.provider.net.", Proof::Insecure), - ], - RecordType::A - ), - DnssecStatus::Insecure - ); - } - - #[test] - fn tlsa_base_status_ignores_alias_below_query_name() { - let query = name("mx.example.org."); - - assert_eq!( - tlsa_base_status( - &query, - &[ - alias("mx.provider.net.", "mx.other.net.", Proof::Secure), - address("mx.other.net.", Proof::Insecure), - ], - RecordType::A - ), - DnssecStatus::Insecure - ); - } - - fn soa(owner: &str, proof: Proof) -> Record { - let mut record = Record::from_rdata( - name(owner), - 3600, - RData::SOA(SOA::new( - name("ns1.example.org."), - name("hostmaster.example.org."), - 1, - 900, - 900, - 1800, - 60, - )), - ); - record.proof = proof; - record - } - - #[test] - fn secure_alias_follows_signed_cname() { - assert_eq!( - secure_alias( - &name("mail.example.org."), - &[alias("mail.example.org.", "mx.example.net.", Proof::Secure)] - ), - Some(name("mx.example.net.")) - ); - } - - #[test] - fn secure_alias_ignores_unsigned_or_other_cname() { - let query = name("mail.example.org."); - - assert_eq!( - secure_alias( - &query, - &[alias( - "mail.example.org.", - "mx.example.net.", - Proof::Insecure - )] - ), - None - ); - assert_eq!( - secure_alias( - &query, - &[alias( - "other.example.org.", - "mx.example.net.", - Proof::Secure - )] - ), - None - ); - } - - #[test] - fn apex_status_reads_the_zone_soa() { - let zone = name("example.com."); - - for (proof, expected) in [ - (Proof::Secure, Some(DnssecStatus::Secure)), - (Proof::Insecure, Some(DnssecStatus::Insecure)), - (Proof::Bogus, Some(DnssecStatus::Bogus)), - ] { - assert_eq!( - apex_status(&zone, &[soa("example.com.", proof)]), - expected, - "proof {proof}" - ); - } - } - - #[test] - fn apex_status_ignores_other_records() { - assert_eq!( - apex_status( - &name("example.com."), - &[ - soa("sub.example.com.", Proof::Insecure), - address("example.com.", Proof::Insecure), - ] - ), - None - ); - } - - // Needs the network: a signed MX pointing into a zone delegated beneath an - // unsigned one. Run with `--ignored` to check a hickory upgrade. hickory - // 0.26.3 called it bogus and the recheck proved it insecure; the vendored - // hickory proves it itself. Either way the answer has to come out insecure. - // The TLSA test below prints whether hickory alone gets its case right. - #[tokio::test] - #[ignore] - async fn validated_lookup_proves_delegation_below_unsigned_zone() { - use mail_auth::hickory_resolver::{ - config::{CLOUDFLARE, ResolverConfig, ResolverOpts}, - net::runtime::TokioRuntimeProvider, - }; - - let build = |validate: bool| { - // Same options as the server's DNSSEC resolver; hickory fails - // validation with concurrent requests. - let mut opts = ResolverOpts::default(); - opts.validate = validate; - opts.num_concurrent_reqs = 1; - opts.cache_size = 0; - TokioResolver::builder_with_config( - ResolverConfig::udp_and_tcp(&CLOUDFLARE), - TokioRuntimeProvider::default(), - ) - .with_options(opts) - .build() - .unwrap() - }; - let (dnssec, plain) = (build(true), build(false)); - - let query = name("aspmx.l.google.com."); - let validated = validated_lookup(&dnssec, &plain, query.clone(), RecordType::A) - .await - .unwrap(); - assert!(!validated.lookup.answers().is_empty()); - let status = if validated.insecure { - DnssecStatus::Insecure - } else { - tlsa_base_status(&query, validated.lookup.answers(), RecordType::A) - }; - assert_eq!(status, DnssecStatus::Insecure); - } - - // Needs the network: a TLSA name that is a signed CNAME to the zone apex, - // which has no TLSA record. Cloudflare's resolver answers with a compact - // denial at the name itself; Hetzner's (and others) follow the CNAME, and - // hickory then calls the answer bogus. Point the lookup at a resolver that - // follows it with INBUXA_TEST_DNS_TCP= (TCP), for instance over - // an SSH tunnel to 185.12.64.2:53 from a Hetzner host. - #[tokio::test] - #[ignore] - async fn validated_lookup_follows_signed_cname_for_tlsa() { - use mail_auth::hickory_resolver::{ - config::{ - CLOUDFLARE, ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts, - }, - net::runtime::TokioRuntimeProvider, - }; - - let config = match std::env::var("INBUXA_TEST_DNS_TCP") { - Ok(addr) => { - let addr: std::net::SocketAddr = addr.parse().unwrap(); - let mut ns = NameServerConfig::new(addr.ip(), true, vec![ConnectionConfig::tcp()]); - if let Some(c) = ns.connections.first_mut() { - c.port = addr.port(); - } - ResolverConfig::from_parts(None, vec![], vec![ns]) - } - Err(_) => ResolverConfig::udp_and_tcp(&CLOUDFLARE), - }; - let build = |validate: bool| { - let mut opts = ResolverOpts::default(); - opts.validate = validate; - opts.num_concurrent_reqs = 1; - opts.cache_size = 0; - TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default()) - .with_options(opts) - .build() - .unwrap() - }; - let (dnssec, plain) = (build(true), build(false)); - let query = name("_25._tcp.mail.usefulinsight.com."); - - let direct = dnssec.lookup(query.clone(), RecordType::TLSA).await; - eprintln!( - "hickory alone: {:?}", - direct.as_ref().err().map(|e| e.to_string()) - ); - - let err = match validated_lookup(&dnssec, &plain, query, RecordType::TLSA).await { - Ok(validated) => panic!( - "expected no TLSA record, got {:?}", - validated.lookup.answers() - ), - Err(err) => err, - }; - let denial = NegativeAnswer::from_error(&err).expect("a denial of existence"); - assert_eq!(denial.response_code, ResponseCode::NoError); - assert_ne!(denial.dnssec_status, DnssecStatus::Bogus); - } -} diff --git a/crates/smtp/src/outbound/dane/verify.rs b/crates/smtp/src/outbound/dane/verify.rs index b464874f..5c9515e9 100644 --- a/crates/smtp/src/outbound/dane/verify.rs +++ b/crates/smtp/src/outbound/dane/verify.rs @@ -2,16 +2,18 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ +// inbuxa: the check itself is in inbuxa-outbound-tls, shared with +// inbuxa-relay (RL-30); this adds the queue's errors and trace events. + use crate::queue::{Error, ErrorDetails, HostResponse, Status}; -use common::config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}; -use rustls_pki_types::{CertificateDer, Der, ServerName, TrustAnchor, UnixTime}; -use sha2::{Digest, Sha256, Sha512}; +use common::config::smtp::resolver::Tlsa; +use inbuxa_outbound_tls::dane::{self, DaneFailure}; +use rustls_pki_types::CertificateDer; use trc::DaneEvent; -use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert}; -use x509_parser::asn1_rs::Any; -use x509_parser::prelude::{FromDer, X509Certificate}; pub trait TlsaVerify { fn verify( @@ -31,212 +33,47 @@ impl TlsaVerify for Tlsa { reference_ids: &[&str], certificates: Option<&[CertificateDer<'_>]>, ) -> Result<(), Status>, ErrorDetails>> { - let certificates = match certificates { - Some(certificates) if !certificates.is_empty() => certificates, - _ => { - trc::event!( - Dane(DaneEvent::NoCertificatesFound), - SpanId = session_id, - Hostname = hostname.to_string(), - ); - - return Err(Status::TemporaryFailure(ErrorDetails { - entity: hostname.into(), - details: Error::DaneError("No certificates were provided by host".into()), - })); - } - }; - - let mut parsed = Vec::with_capacity(certificates.len()); - for der_certificate in certificates { - match X509Certificate::from_der(der_certificate.as_ref()) { - Ok((_, cert)) => parsed.push(cert), - Err(err) => { - trc::event!( - Dane(DaneEvent::CertificateParseError), - SpanId = session_id, - Hostname = hostname.to_string(), - Reason = err.to_string(), - ); - - return Err(Status::TemporaryFailure(ErrorDetails { - entity: hostname.into(), - details: Error::DaneError("Failed to parse X.509 certificate".into()), - })); - } - } - } - - if verify_end_entity(self, session_id, hostname, certificates, &parsed) - || verify_trust_anchor( - self, - session_id, - hostname, - reference_ids, - certificates, - &parsed, - ) - { - trc::event!( - Dane(DaneEvent::AuthenticationSuccess), - SpanId = session_id, - Hostname = hostname.to_string(), - ); - - Ok(()) - } else { - trc::event!( - Dane(DaneEvent::AuthenticationFailure), - SpanId = session_id, - Hostname = hostname.to_string(), - ); - - Err(Status::TemporaryFailure(ErrorDetails { - entity: hostname.into(), - details: Error::DaneError("No matching certificates found in TLSA records".into()), - })) - } - } -} - -fn verify_end_entity( - tlsa: &Tlsa, - session_id: u64, - hostname: &str, - certificates: &[CertificateDer<'_>], - parsed: &[X509Certificate<'_>], -) -> bool { - if tlsa.has_end_entities { - for record in tlsa.entries.iter().filter(|record| record.is_end_entity) { - if record_matches(record, &parsed[0], certificates[0].as_ref()) { + match dane::verify(self, reference_ids, certificates) { + Ok(matched) => { trc::event!( Dane(DaneEvent::TlsaRecordMatch), SpanId = session_id, Hostname = hostname.to_string(), - Type = "end-entity", + Type = matched.as_str(), ); - return true; + trc::event!( + Dane(DaneEvent::AuthenticationSuccess), + SpanId = session_id, + Hostname = hostname.to_string(), + ); + + Ok(()) + } + Err(failure) => { + match &failure { + DaneFailure::NoCertificates => trc::event!( + Dane(DaneEvent::NoCertificatesFound), + SpanId = session_id, + Hostname = hostname.to_string(), + ), + DaneFailure::CertificateParse(reason) => trc::event!( + Dane(DaneEvent::CertificateParseError), + SpanId = session_id, + Hostname = hostname.to_string(), + Reason = reason.clone(), + ), + DaneFailure::NoMatch => trc::event!( + Dane(DaneEvent::AuthenticationFailure), + SpanId = session_id, + Hostname = hostname.to_string(), + ), + } + + Err(Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::DaneError(failure.description().into()), + })) } } } - - false -} - -fn verify_trust_anchor( - tlsa: &Tlsa, - session_id: u64, - hostname: &str, - reference_ids: &[&str], - certificates: &[CertificateDer<'_>], - parsed: &[X509Certificate<'_>], -) -> bool { - if !tlsa.has_intermediates { - return false; - } - - let end_entity = match EndEntityCert::try_from(&certificates[0]) { - Ok(end_entity) => end_entity, - Err(_) => return false, - }; - - let mut anchors: Vec> = Vec::new(); - - for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) { - match (record.is_spki, record.matching) { - (false, TlsaMatching::Full) => { - let der = CertificateDer::from(record.data.clone()); - if let Ok(anchor) = anchor_from_trusted_cert(&der) { - anchors.push(anchor.to_owned()); - } - } - (true, TlsaMatching::Full) => { - if let Some(depth) = (1..certificates.len()) - .find(|&depth| parsed[depth].public_key().raw == record.data.as_slice()) - { - if let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) { - anchors.push(anchor.to_owned()); - } - } else if let Some(spki) = der_value(&record.data) { - for depth in 1..certificates.len() { - if is_chain_top(parsed, depth) - && let Some(subject) = der_value(parsed[depth].issuer().as_raw()) - { - anchors.push(TrustAnchor { - subject: Der::from(subject.to_vec()), - subject_public_key_info: Der::from(spki.to_vec()), - name_constraints: None, - }); - } - } - } - } - _ => { - for depth in 1..certificates.len() { - if record_matches(record, &parsed[depth], certificates[depth].as_ref()) - && let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) - { - anchors.push(anchor.to_owned()); - } - } - } - } - } - - if anchors.is_empty() - || end_entity - .verify_for_usage( - ALL_VERIFICATION_ALGS, - &anchors, - &certificates[1..], - UnixTime::now(), - KeyUsage::server_auth(), - None, - None, - ) - .is_err() - || !reference_ids.iter().any(|reference| { - ServerName::try_from(*reference) - .map(|name| end_entity.verify_is_valid_for_subject_name(&name).is_ok()) - .unwrap_or(false) - }) - { - false - } else { - trc::event!( - Dane(DaneEvent::TlsaRecordMatch), - SpanId = session_id, - Hostname = hostname.to_string(), - Type = "trust-anchor", - ); - - true - } -} - -fn is_chain_top(parsed: &[X509Certificate<'_>], depth: usize) -> bool { - let issuer = parsed[depth].issuer().as_raw(); - !parsed - .iter() - .enumerate() - .any(|(other, cert)| other != depth && cert.subject().as_raw() == issuer) -} - -fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) -> bool { - let selected: &[u8] = if record.is_spki { - cert.public_key().raw - } else { - raw - }; - - match record.matching { - TlsaMatching::Full => selected == record.data.as_slice(), - TlsaMatching::Sha256 => Sha256::digest(selected).as_slice() == record.data.as_slice(), - TlsaMatching::Sha512 => Sha512::digest(selected).as_slice() == record.data.as_slice(), - } -} - -#[inline(always)] -fn der_value(der: &[u8]) -> Option<&[u8]> { - Any::from_der(der).ok().map(|(_, any)| any.data) } diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index d2b6b7ea..d70e3ceb 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{NextHop, lookup::ToNextHop, mta_sts, session::SessionParams}; @@ -13,7 +15,6 @@ use crate::outbound::dane::dnssec::{DnssecStatus, TlsaLookup, TlsaResult}; use crate::outbound::error::ClientError; use crate::outbound::lookup::{DnsLookup, SourceIp}; use crate::outbound::mta_sts::lookup::MtaStsLookup; -use crate::outbound::mta_sts::verify::VerifyPolicy; use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify}; use crate::queue::dsn::{DsnStatus, SendDsn}; use crate::queue::spool::{DSN_RETRY, SmtpSpool}; diff --git a/crates/smtp/src/outbound/mta_sts/lookup.rs b/crates/smtp/src/outbound/mta_sts/lookup.rs index e9f3fd15..8c97bad4 100644 --- a/crates/smtp/src/outbound/mta_sts/lookup.rs +++ b/crates/smtp/src/outbound/mta_sts/lookup.rs @@ -14,7 +14,7 @@ pub static STS_TEST_POLICY: parking_lot::Mutex> = parking_lot::Mutex::ne use common::{Server, config::smtp::resolver::Policy}; use mail_auth::{mta_sts::MtaSts, report::tlsrpt::ResultType}; -use super::{Error, parse::ParsePolicy}; +use super::Error; #[cfg(not(feature = "test_mode"))] use utils::HttpLimitResponse; diff --git a/crates/smtp/src/outbound/mta_sts/mod.rs b/crates/smtp/src/outbound/mta_sts/mod.rs index 68a6af63..1988e172 100644 --- a/crates/smtp/src/outbound/mta_sts/mod.rs +++ b/crates/smtp/src/outbound/mta_sts/mod.rs @@ -2,11 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod lookup; -pub mod parse; -pub mod verify; #[derive(Debug)] pub enum Error { diff --git a/crates/smtp/src/outbound/mta_sts/verify.rs b/crates/smtp/src/outbound/mta_sts/verify.rs deleted file mode 100644 index 757679b7..00000000 --- a/crates/smtp/src/outbound/mta_sts/verify.rs +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use common::config::smtp::resolver::{Mode, MxPattern, Policy}; - -pub trait VerifyPolicy { - fn verify(&self, mx_host: &str) -> bool; - fn enforce(&self) -> bool; -} - -impl VerifyPolicy for Policy { - fn verify(&self, mx_host: &str) -> bool { - if self.mode != Mode::None { - for mx_pattern in &self.mx { - match mx_pattern { - MxPattern::Equals(host) => { - if host == mx_host { - return true; - } - } - MxPattern::StartsWith(domain) => { - if let Some((_, suffix)) = mx_host.split_once('.') - && suffix == domain - { - return true; - } - } - } - } - - false - } else { - true - } - } - - fn enforce(&self) -> bool { - self.mode == Mode::Enforce - } -} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 9449f512..9e783c55 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -12,6 +12,7 @@ tokio = { version = "1.53", features = ["net", "macros", "signal"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } serde = { version = "1.0", features = ["derive"]} mail-auth = { version = "0.13" } +inbuxa-outbound-tls = { path = "../outbound-tls" } ahash = { version = "0.8", features = ["serde"] } chrono = "0.4" aws-lc-rs = { version = "1" } diff --git a/crates/utils/src/cache.rs b/crates/utils/src/cache.rs index d4a7ccaf..8b72e9d3 100644 --- a/crates/utils/src/cache.rs +++ b/crates/utils/src/cache.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use arcstr::ArcStr; @@ -259,6 +261,34 @@ impl CacheItemWeight for Arc { } } +// inbuxa: the outbound TLS types moved to their own crate (RL-30), so +// their weights live here, with the trait. +impl CacheItemWeight for inbuxa_outbound_tls::Tlsa { + fn weight(&self) -> u64 { + self.entries + .iter() + .map(|entry| { + (entry.data.len() + std::mem::size_of::()) as u64 + }) + .sum::() + + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for inbuxa_outbound_tls::Policy { + fn weight(&self) -> u64 { + (std::mem::size_of::() + + self + .mx + .iter() + .map(|mx| match mx { + inbuxa_outbound_tls::MxPattern::Equals(t) => t.len(), + inbuxa_outbound_tls::MxPattern::StartsWith(t) => t.len(), + }) + .sum::()) as u64 + } +} + impl CacheItemWeight for u64 { fn weight(&self) -> u64 { std::mem::size_of::() as u64 diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index a6021f51..9f68346f 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -761,6 +761,12 @@ In progress. Done so far: link to a joined relay for an hour raises `relay.link-down` (a warning), once, and `relay.link-up` when it is back (RL-T23). Both reach the log, webhooks and OpenTelemetry. +- `crates/outbound-tls` (RL-30): the TLSA and MTA-STS types, the DANE + certificate check, MTA-STS policy parsing and matching, and the DNSSEC + lookups (with the bogus-answer recheck), moved out of `crates/smtp` and + `crates/common`. The server keeps its caches, trace events, policy fetch + and test mocks around them, so its behavior is unchanged. The relay will + use the same code to deliver. - Not done yet: `inbuxa:RelayMessage`, which comes with inbuxa-admin's Relays page (step 5), once `Outbound` exists to show beside `Inbound`. diff --git a/tests/src/smtp/lookup/utils.rs b/tests/src/smtp/lookup/utils.rs index 51a1967a..916b5de4 100644 --- a/tests/src/smtp/lookup/utils.rs +++ b/tests/src/smtp/lookup/utils.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServerBuilder; @@ -24,10 +26,7 @@ use registry::{ types::{ipaddr::IpAddr, list::List}, }; use smtp::{ - outbound::{ - lookup::{SourceIp, ToNextHop}, - mta_sts::parse::ParsePolicy, - }, + outbound::lookup::{SourceIp, ToNextHop}, queue::{ Error, ErrorDetails, FROM_AUTHENTICATED, Message, QueueEnvelope, Recipient, Schedule, Status, diff --git a/tests/src/smtp/outbound/mta_sts.rs b/tests/src/smtp/outbound/mta_sts.rs index a62da033..38baeaf9 100644 --- a/tests/src/smtp/outbound/mta_sts.rs +++ b/tests/src/smtp/outbound/mta_sts.rs @@ -28,10 +28,7 @@ use registry::{ }, types::{map::Map, socketaddr::SocketAddr}, }; -use smtp::outbound::mta_sts::{ - lookup::{MtaStsLookup, STS_TEST_POLICY}, - parse::ParsePolicy, -}; +use smtp::outbound::mta_sts::lookup::{MtaStsLookup, STS_TEST_POLICY}; use std::{ str::FromStr, sync::Arc, -- 2.54.0