Shared outbound TLS crate for the relay #259
No files matched your search
Generated
+17
@@ -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",
|
||||
|
||||
@@ -33,6 +33,7 @@ members = [
|
||||
"crates/migration",
|
||||
"crates/features",
|
||||
"crates/relay-proto",
|
||||
"crates/outbound-tls",
|
||||
"crates/relay",
|
||||
"tests",
|
||||
]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tlsa {
|
||||
pub entries: Vec<TlsaEntry>,
|
||||
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::<TlsaEntry>()) as u64)
|
||||
.sum::<u64>()
|
||||
+ std::mem::size_of::<Tlsa>() as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheItemWeight for Policy {
|
||||
fn weight(&self) -> u64 {
|
||||
(std::mem::size_of::<Policy>()
|
||||
+ self
|
||||
.mx
|
||||
.iter()
|
||||
.map(|mx| match mx {
|
||||
MxPattern::Equals(t) => t.len(),
|
||||
MxPattern::StartsWith(t) => t.len(),
|
||||
})
|
||||
.sum::<usize>()) 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<Self> {
|
||||
let mta = bp.setting_infallible::<MtaSts>().await;
|
||||
/// This server's own MTA-STS policy, from its settings.
|
||||
pub async fn mta_sts_policy(bp: &mut Bootstrap) -> Option<Policy> {
|
||||
let mta = bp.setting_infallible::<MtaSts>().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::<SystemSettings>().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::<SystemSettings>().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<Self, Self::Err> {
|
||||
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 {
|
||||
|
||||
@@ -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::<MtaMilter>()
|
||||
.await
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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},
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<DaneMatch, DaneFailure> {
|
||||
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<TrustAnchor<'static>> = 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)
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<Tlsa>),
|
||||
Bogus,
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// An answer, and until when it may be cached: `None` when it mustn't be.
|
||||
pub type Cacheable<T> = (T, Option<Instant>);
|
||||
|
||||
/// MX records for `name`, a fully qualified name.
|
||||
pub async fn mx(
|
||||
resolvers: &Resolvers<'_>,
|
||||
name: &str,
|
||||
) -> mail_auth::Result<Cacheable<RecordSet<MX>>> {
|
||||
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<DnssecStatus> = None;
|
||||
let mut records: Vec<(u16, Vec<Box<str>>)> = 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::<Arc<[MX]>>();
|
||||
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.<host>.`). Only a secure answer may
|
||||
/// be cached.
|
||||
pub async fn tlsa(
|
||||
resolvers: &Resolvers<'_>,
|
||||
name: &str,
|
||||
) -> mail_auth::Result<Cacheable<TlsaResult>> {
|
||||
// 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<DnssecStatus> = 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<Cacheable<RecordSet<Ipv4Addr>>> {
|
||||
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<Cacheable<RecordSet<Ipv6Addr>>> {
|
||||
addresses(resolvers, name, RecordType::AAAA, |data| match data {
|
||||
RData::AAAA(addr) => Some(addr.0),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn addresses<T>(
|
||||
resolvers: &Resolvers<'_>,
|
||||
key: &str,
|
||||
record_type: RecordType,
|
||||
address: impl Fn(&RData) -> Option<T>,
|
||||
) -> mail_auth::Result<Cacheable<RecordSet<T>>> {
|
||||
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::<Arc<[T]>>(),
|
||||
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<ValidatedLookup, NetError> {
|
||||
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<Name> {
|
||||
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<DnssecStatus> {
|
||||
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<Instant>,
|
||||
}
|
||||
|
||||
impl NegativeAnswer {
|
||||
fn from_error(err: &NetError) -> Option<Self> {
|
||||
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<DnssecStatus> = None;
|
||||
let mut alias: Option<DnssecStatus> = 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=<ip:port> (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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tlsa {
|
||||
pub entries: Vec<TlsaEntry>,
|
||||
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<Self, Self::Err> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,30 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<Self, String>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
impl ParsePolicy for Policy {
|
||||
fn parse(mut data: &str, id: String) -> Result<Policy, String> {
|
||||
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<Policy, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<Output = mail_auth::Result<RecordSet<Ipv6Addr>>> + Send;
|
||||
}
|
||||
|
||||
pub enum TlsaResult {
|
||||
Secure(Arc<Tlsa>),
|
||||
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<DnssecStatus> = None;
|
||||
let mut records: Vec<(u16, Vec<Box<str>>)> = 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::<Arc<[MX]>>();
|
||||
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<DnssecStatus> = 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::<Arc<[Ipv4Addr]>>(),
|
||||
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::<Arc<[Ipv6Addr]>>(),
|
||||
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<ValidatedLookup, NetError> {
|
||||
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<Name> {
|
||||
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<DnssecStatus> {
|
||||
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<Instant>,
|
||||
}
|
||||
|
||||
impl NegativeAnswer {
|
||||
fn from_error(err: &NetError) -> Option<Self> {
|
||||
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<DnssecStatus> = None;
|
||||
let mut alias: Option<DnssecStatus> = 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=<ip:port> (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);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,18 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<HostResponse<Box<str>>, 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<TrustAnchor<'static>> = 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)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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};
|
||||
|
||||
@@ -14,7 +14,7 @@ pub static STS_TEST_POLICY: parking_lot::Mutex<Vec<u8>> = 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;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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 {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<T: CacheItemWeight> CacheItemWeight for Arc<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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::<inbuxa_outbound_tls::TlsaEntry>()) as u64
|
||||
})
|
||||
.sum::<u64>()
|
||||
+ std::mem::size_of::<inbuxa_outbound_tls::Tlsa>() as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheItemWeight for inbuxa_outbound_tls::Policy {
|
||||
fn weight(&self) -> u64 {
|
||||
(std::mem::size_of::<inbuxa_outbound_tls::Policy>()
|
||||
+ self
|
||||
.mx
|
||||
.iter()
|
||||
.map(|mx| match mx {
|
||||
inbuxa_outbound_tls::MxPattern::Equals(t) => t.len(),
|
||||
inbuxa_outbound_tls::MxPattern::StartsWith(t) => t.len(),
|
||||
})
|
||||
.sum::<usize>()) as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheItemWeight for u64 {
|
||||
fn weight(&self) -> u64 {
|
||||
std::mem::size_of::<u64>() as u64
|
||||
|
||||
@@ -774,6 +774,12 @@ In progress. Done so far:
|
||||
`/changes`, with push, in `spool/out/` (RL-19, RL-T10). The relay doesn't
|
||||
deliver it yet, and the downstream doesn't hand anything over yet; both
|
||||
are the rest of step 4.
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user