Shared outbound TLS crate for the relay #259

Merged
jcoffey-dev merged 2 commits from relay/outbound-tls into main 2026-10-30 15:39:43 +00:00
24 changed files with 1406 additions and 1271 deletions

No files matched your search

Generated
+17
View File
@@ -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",
+1
View File
@@ -33,6 +33,7 @@ members = [
"crates/migration",
"crates/features",
"crates/relay-proto",
"crates/outbound-tls",
"crates/relay",
"tests",
]
+1
View File
@@ -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" }
+56 -173
View File
@@ -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 {
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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},
+22
View File
@@ -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
+199
View File
@@ -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)
}
+798
View File
@@ -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);
}
}
+115
View File
@@ -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());
}
}
+1 -1
View File
@@ -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,
+1
View File
@@ -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" }
+39 -804
View File
@@ -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);
}
}
+43 -206
View File
@@ -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 -1
View File
@@ -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};
+1 -1
View File
@@ -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 -2
View File
@@ -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
}
}
+1
View File
@@ -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" }
+30
View File
@@ -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
+6
View File
@@ -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`.
+3 -4
View File
@@ -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,
+1 -4
View File
@@ -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,