DANE: Improper TLSA record validation (#2328 - credits to @vdukhovni)

This commit is contained in:
Maurus Decimus
2026-06-21 15:23:53 +02:00
parent d33c4afbc1
commit 94bea19f01
5 changed files with 708 additions and 141 deletions

View File

@@ -42,7 +42,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
- `Thread/changes`: emit a container delete when a thread becomes empty.
- `VacationResponse/set`: incorrect singleton handling.
- IMAP: Discard oversized non-synchronizing literals (#2768).
- DANE: Improper `TLSA` record validation (#2328).
- DANE: Improper `TLSA` record validation (#2328 - credits to @vdukhovni).
- OIDC: Add default domain name to groups that are not email addresses.
- RocksDB: Enable blob garbage collection to reclaim disk space from deleted blobs.
- Sieve: `include` statements ignore capitalisation of sub-script names (#1643)

1
Cargo.lock generated
View File

@@ -7550,6 +7550,7 @@ dependencies = [
"rustls 0.23.40",
"rustls-pemfile",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"serde",
"serde_json",
"sha1 0.11.0",

View File

@@ -29,7 +29,8 @@ sieve-rs = { version = "0.7", features = ["rkyv"] }
ahash = { version = "0.8" }
rustls = { version = "0.23.5", default-features = false, features = ["std", "aws_lc_rs", "tls12"] }
rustls-pemfile = "2.0"
rustls-pki-types = { version = "1" }
rustls-pki-types = { version = "1", features = ["std"] }
rustls-webpki = { version = "0.103", default-features = false, features = ["std", "aws-lc-rs"] }
tokio = { version = "1.47", features = ["full"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
hyper = { version = "1.0.1", features = ["server", "http1", "http2"] }

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::queue::{Error, ErrorDetails, HostResponse, Status};
use common::config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching};
use rustls_pki_types::CertificateDer;
use rustls_pki_types::{CertificateDer, Der, ServerName, TrustAnchor, UnixTime};
use sha2::{Digest, Sha256, Sha512};
use trc::DaneEvent;
use x509_parser::prelude::{FromDer, GeneralName, SubjectPublicKeyInfo, X509Certificate};
use crate::queue::{Error, ErrorDetails, HostResponse, Status};
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(
@@ -22,11 +23,6 @@ pub trait TlsaVerify {
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>>;
}
struct ChainCert<'a> {
raw: &'a [u8],
cert: X509Certificate<'a>,
}
impl TlsaVerify for Tlsa {
fn verify(
&self,
@@ -51,13 +47,10 @@ impl TlsaVerify for Tlsa {
}
};
let mut chain = Vec::with_capacity(certificates.len());
let mut parsed = Vec::with_capacity(certificates.len());
for der_certificate in certificates {
match X509Certificate::from_der(der_certificate.as_ref()) {
Ok((_, cert)) => chain.push(ChainCert {
raw: der_certificate.as_ref(),
cert,
}),
Ok((_, cert)) => parsed.push(cert),
Err(err) => {
trc::event!(
Dane(DaneEvent::CertificateParseError),
@@ -74,8 +67,15 @@ impl TlsaVerify for Tlsa {
}
}
if verify_end_entity(self, session_id, hostname, &chain)
|| verify_trust_anchor(self, session_id, hostname, reference_ids, &chain)
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),
@@ -103,23 +103,23 @@ fn verify_end_entity(
tlsa: &Tlsa,
session_id: u64,
hostname: &str,
chain: &[ChainCert<'_>],
certificates: &[CertificateDer<'_>],
parsed: &[X509Certificate<'_>],
) -> bool {
if !tlsa.has_end_entities {
return false;
}
let leaf = &chain[0];
for record in tlsa.entries.iter().filter(|record| record.is_end_entity) {
if record_matches(record, &leaf.cert, leaf.raw) {
trc::event!(
Dane(DaneEvent::TlsaRecordMatch),
SpanId = session_id,
Hostname = hostname.to_string(),
Type = "end-entity",
);
return true;
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()) {
trc::event!(
Dane(DaneEvent::TlsaRecordMatch),
SpanId = session_id,
Hostname = hostname.to_string(),
Type = "end-entity",
);
return true;
}
}
}
false
}
@@ -128,86 +128,98 @@ fn verify_trust_anchor(
session_id: u64,
hostname: &str,
reference_ids: &[&str],
chain: &[ChainCert<'_>],
certificates: &[CertificateDer<'_>],
parsed: &[X509Certificate<'_>],
) -> bool {
if !tlsa.has_intermediates {
return false;
}
let path = build_verified_chain(chain);
let leaf = &chain[path[0]];
let end_entity = match EndEntityCert::try_from(&certificates[0]) {
Ok(end_entity) => end_entity,
Err(_) => return false,
};
for depth in 0..path.len() {
let anchor = &chain[path[depth]];
let mut anchors: Vec<TrustAnchor<'static>> = Vec::new();
for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) {
if record_matches(record, &anchor.cert, anchor.raw)
&& dates_valid(chain, &path[..depth])
&& name_matches(&leaf.cert, reference_ids)
{
trc::event!(
Dane(DaneEvent::TlsaRecordMatch),
SpanId = session_id,
Hostname = hostname.to_string(),
Type = "trust-anchor",
);
return true;
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());
}
}
}
for record in tlsa.entries.iter().filter(|record| {
!record.is_end_entity && record.is_spki && record.matching == TlsaMatching::Full
}) {
if let Ok((_, spki)) = SubjectPublicKeyInfo::from_der(&record.data)
&& anchor.cert.verify_signature(Some(&spki)).is_ok()
&& dates_valid(chain, &path[..=depth])
&& name_matches(&leaf.cert, reference_ids)
{
trc::event!(
Dane(DaneEvent::TlsaRecordMatch),
SpanId = session_id,
Hostname = hostname.to_string(),
Type = "trust-anchor-bare-key",
);
return true;
(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());
}
}
}
}
}
false
}
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",
);
fn build_verified_chain(chain: &[ChainCert<'_>]) -> Vec<usize> {
let mut path = vec![0];
let mut used = vec![false; chain.len()];
used[0] = true;
loop {
let current = &chain[*path.last().unwrap()].cert;
if current.verify_signature(None).is_ok() {
break;
}
let issuer = (0..chain.len()).find(|&idx| {
!used[idx]
&& current
.verify_signature(Some(chain[idx].cert.public_key()))
.is_ok()
});
match issuer {
Some(idx) => {
used[idx] = true;
path.push(idx);
}
None => break,
}
true
}
path
}
fn dates_valid(chain: &[ChainCert<'_>], path: &[usize]) -> bool {
path.iter()
.all(|&idx| chain[idx].cert.validity().is_valid())
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 {
@@ -224,45 +236,7 @@ fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) ->
}
}
fn name_matches(cert: &X509Certificate<'_>, reference_ids: &[&str]) -> bool {
if let Ok(Some(san)) = cert.subject_alternative_name() {
let mut has_dns_id = false;
for name in &san.value.general_names {
if let GeneralName::DNSName(dns_id) = name {
has_dns_id = true;
if reference_ids
.iter()
.any(|reference| dns_id_matches(dns_id, reference))
{
return true;
}
}
}
if has_dns_id {
return false;
}
}
cert.subject()
.iter_common_name()
.filter_map(|cn| cn.as_str().ok())
.any(|cn| {
reference_ids
.iter()
.any(|reference| dns_id_matches(cn, reference))
})
}
fn dns_id_matches(presented: &str, reference: &str) -> bool {
let presented = presented.trim_end_matches('.');
let reference = reference.trim_end_matches('.');
if let Some(suffix) = presented.strip_prefix("*.") {
match reference.split_once('.') {
Some((label, rest)) => !label.is_empty() && rest.eq_ignore_ascii_case(suffix),
None => false,
}
} else {
presented.eq_ignore_ascii_case(reference)
}
#[inline(always)]
fn der_value(der: &[u8]) -> Option<&[u8]> {
Any::from_der(der).ok().map(|(_, any)| any.data)
}

View File

@@ -22,8 +22,8 @@ use mail_auth::{
report::tlsrpt::ResultType,
};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose,
PublicKeyData, date_time_ymd,
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
KeyUsagePurpose, PublicKeyData, date_time_ymd,
};
use registry::schema::{
enums::MtaRequiredOrOptional,
@@ -48,7 +48,7 @@ use store::write::now;
#[tokio::test]
#[serial_test::serial]
async fn dane_verify() {
let mut local = TestServerBuilder::new("smtp_dane_local")
let mut local = TestServerBuilder::new("smtp_dane_verify_local")
.await
.with_http_listener(19018)
.await
@@ -57,7 +57,7 @@ async fn dane_verify() {
.capture_reporting()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_dane_remote")
let mut remote = TestServerBuilder::new("smtp_dane_verify_remote")
.await
.with_dummy_tls_cert(["*.foobar.org"])
.await
@@ -373,6 +373,7 @@ async fn dane_downgrade_on_tlsa_servfail() {
}
#[tokio::test]
#[serial_test::serial]
async fn dane_test() {
let test = TestServerBuilder::new("smtp_dane_remote")
.await
@@ -699,6 +700,20 @@ fn sub_ca(common_name: &str, parent: &TestCa) -> TestCa {
}
}
fn sub_ca_path_len(common_name: &str, parent: &TestCa, path_len: u8) -> TestCa {
let key = KeyPair::generate().unwrap();
let mut params = ca_params(common_name);
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(path_len));
let cert = params.signed_by(&key, &parent.issuer).unwrap();
let der = cert.der().clone();
let spki = key.subject_public_key_info();
TestCa {
issuer: Issuer::new(params, key),
der,
spki,
}
}
fn leaf_cert(san: &str, parent: &TestCa) -> (CertificateDer<'static>, Vec<u8>) {
let key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(vec![san.to_string()]).unwrap();
@@ -741,3 +756,579 @@ fn tlsa(entries: Vec<TlsaEntry>) -> Tlsa {
entries,
}
}
#[test]
fn dane_ta_does_not_match_leaf() {
let root = root_ca("Depth Root");
let intermediate = sub_ca("Depth Intermediate", &root);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate);
let chain = vec![leaf_der.clone(), intermediate.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_full_sha256(&leaf_der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
let ee = tlsa(vec![ee_full_sha256(&leaf_der)]);
assert!(
ee.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
}
#[test]
fn dane_ta_bare_key_in_chain() {
let root = root_ca("In Chain Root");
let intermediate = sub_ca("In Chain Intermediate", &root);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate);
let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_spki_full(&intermediate.spki)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
}
#[test]
fn dane_ta_bare_key_anchors_deep_chain() {
let root = root_ca("Deep Bare Key Root");
let intermediate1 = sub_ca("Deep Bare Key Intermediate 1", &root);
let intermediate2 = sub_ca("Deep Bare Key Intermediate 2", &intermediate1);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate2);
let chain = vec![
leaf_der,
intermediate2.der.clone(),
intermediate1.der.clone(),
];
let record = tlsa(vec![ta_spki_full(&root.spki)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
let unrelated = root_ca("Deep Bare Key Unrelated");
let wrong = tlsa(vec![ta_spki_full(&unrelated.spki)]);
assert!(
wrong
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
}
#[test]
fn dane_ta_rejects_path_len_violation() {
let root = root_ca("Path Len Root");
let constrained = sub_ca_path_len("Path Len Constrained", &root, 0);
let extra = sub_ca("Path Len Extra", &constrained);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &extra);
let chain = vec![
leaf_der,
extra.der.clone(),
constrained.der.clone(),
root.der.clone(),
];
let record = tlsa(vec![ta_full_sha256(&root.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
let allowed = root_ca("Path Len Allowed Root");
let constrained = sub_ca_path_len("Path Len Allowed Constrained", &allowed, 0);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &constrained);
let chain = vec![leaf_der, constrained.der.clone(), allowed.der.clone()];
let record = tlsa(vec![ta_full_sha256(&allowed.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
}
#[test]
fn dane_ta_validates_unordered_padded_chain() {
let root = root_ca("Unordered Root");
let intermediate = sub_ca("Unordered Intermediate", &root);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate);
let noise = root_ca("Unordered Noise");
let chain = vec![
leaf_der,
root.der.clone(),
noise.der.clone(),
intermediate.der.clone(),
];
let record = tlsa(vec![ta_full_sha256(&intermediate.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
}
#[test]
fn dane_ta_rejects_non_ca_issuer() {
let root = root_ca("Non CA Root");
let forged = sub_ca_non_ca("Non CA Intermediate", &root);
let (leaf_der, _) = leaf_cert("mx.foobar.org", &forged);
let chain = vec![leaf_der, forged.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_full_sha256(&root.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
}
#[test]
fn dane_ta_rejects_ee_without_server_auth_eku() {
let root = root_ca("EKU Root");
let intermediate = sub_ca("EKU Intermediate", &root);
let (leaf_der, _) = leaf_cert_eku(
"mx.foobar.org",
&intermediate,
vec![ExtendedKeyUsagePurpose::ClientAuth],
);
let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_full_sha256(&intermediate.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
}
#[test]
fn dane_ta_accepts_ee_with_server_auth_eku() {
let root = root_ca("EKU OK Root");
let intermediate = sub_ca("EKU OK Intermediate", &root);
let (leaf_der, _) = leaf_cert_eku(
"mx.foobar.org",
&intermediate,
vec![ExtendedKeyUsagePurpose::ServerAuth],
);
let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_full_sha256(&intermediate.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_ok()
);
}
#[test]
fn dane_ta_ignores_common_name() {
let root = root_ca("CN Root");
let intermediate = sub_ca("CN Intermediate", &root);
let leaf_der = leaf_cert_cn_only("mx.foobar.org", &intermediate);
let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()];
let record = tlsa(vec![ta_full_sha256(&intermediate.der)]);
assert!(
record
.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain))
.is_err()
);
}
fn sub_ca_non_ca(common_name: &str, parent: &TestCa) -> TestCa {
let key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(Vec::<String>::new()).unwrap();
params.is_ca = IsCa::ExplicitNoCa;
params
.distinguished_name
.push(DnType::CommonName, common_name);
let cert = params.signed_by(&key, &parent.issuer).unwrap();
let der = cert.der().clone();
let spki = key.subject_public_key_info();
TestCa {
issuer: Issuer::new(params, key),
der,
spki,
}
}
fn leaf_cert_eku(
san: &str,
parent: &TestCa,
ekus: Vec<ExtendedKeyUsagePurpose>,
) -> (CertificateDer<'static>, Vec<u8>) {
let key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(vec![san.to_string()]).unwrap();
params.distinguished_name.push(DnType::CommonName, san);
params.extended_key_usages = ekus;
let cert = params.signed_by(&key, &parent.issuer).unwrap();
(cert.der().clone(), key.subject_public_key_info())
}
fn leaf_cert_cn_only(common_name: &str, parent: &TestCa) -> CertificateDer<'static> {
let key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(Vec::<String>::new()).unwrap();
params
.distinguished_name
.push(DnType::CommonName, common_name);
let cert = params.signed_by(&key, &parent.issuer).unwrap();
cert.der().clone()
}
fn ee_full_sha256(der: &CertificateDer<'_>) -> TlsaEntry {
TlsaEntry {
is_end_entity: true,
is_spki: false,
matching: TlsaMatching::Sha256,
data: Sha256::digest(der.as_ref()).to_vec(),
}
}
#[tokio::test]
#[ignore = "live network test: requires outbound TCP port 25 and a DNSSEC-validating resolver path"]
async fn dane_live_smtp_hosts() {
use mail_auth::hickory_resolver::{
TokioResolver,
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
net::runtime::TokioRuntimeProvider,
proto::rr::{
Name, RData,
rdata::tlsa::{CertUsage, Matching, Selector},
},
};
use smtp::outbound::client::{SmtpClient, StartTlsResult};
use std::net::{IpAddr, SocketAddr};
use tokio::io::AsyncWriteExt;
use tokio_rustls::TlsConnector;
use utils::tls::build_tls_connector;
enum DaneProbe {
Verified {
mx: String,
usages: Vec<String>,
chain_len: usize,
native_ee: bool,
native_ta: bool,
webpki_forced: Option<bool>,
},
Skipped(String),
Failed {
mx: String,
reason: String,
},
}
async fn probe(resolver: &TokioResolver, connector: &TlsConnector, domain: &str) -> DaneProbe {
let mx_lookup = match resolver.mx_lookup(format!("{domain}.")).await {
Ok(lookup) => lookup,
Err(err) => return DaneProbe::Skipped(format!("MX lookup failed: {err}")),
};
let mut mx_hosts: Vec<(u16, String)> = mx_lookup
.answers()
.iter()
.filter_map(|record| match &record.data {
RData::MX(mx) => Some((
mx.preference,
mx.exchange.to_string().trim_end_matches('.').to_string(),
)),
_ => None,
})
.collect();
mx_hosts.sort_by_key(|(preference, _)| *preference);
if mx_hosts.is_empty() {
mx_hosts.push((0, domain.to_string()));
}
for (_, mx) in &mx_hosts {
let tlsa_name = match Name::from_str_relaxed(format!("_25._tcp.{mx}.")) {
Ok(name) => name,
Err(err) => {
return DaneProbe::Skipped(format!("invalid TLSA name for {mx}: {err}"));
}
};
let tlsa_lookup = match resolver.tlsa_lookup(tlsa_name).await {
Ok(lookup) => lookup,
Err(_) => continue,
};
let mut entries = Vec::new();
let mut usages = Vec::new();
let mut has_end_entities = false;
let mut has_intermediates = false;
for record in tlsa_lookup.answers() {
if let RData::TLSA(tlsa) = &record.data {
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;
}
let usage = format!(
"{} {} {}",
if is_end_entity { 3 } else { 2 },
if is_spki { 1 } else { 0 },
match matching {
TlsaMatching::Full => 0,
TlsaMatching::Sha256 => 1,
TlsaMatching::Sha512 => 2,
}
);
if !usages.contains(&usage) {
usages.push(usage);
}
entries.push(TlsaEntry {
is_end_entity,
is_spki,
matching,
data: tlsa.cert_data.clone(),
});
}
}
if entries.is_empty() {
continue;
}
let tlsa = Tlsa {
entries,
has_end_entities,
has_intermediates,
};
let mut ips: Vec<IpAddr> = match resolver.lookup_ip(format!("{mx}.")).await {
Ok(ips) => ips.iter().collect(),
Err(err) => {
return DaneProbe::Skipped(format!("address lookup failed for {mx}: {err}"));
}
};
if ips.is_empty() {
return DaneProbe::Skipped(format!("no A/AAAA records for {mx}"));
}
ips.sort_by_key(|ip| ip.is_ipv6());
let mut connected = None;
let mut last_error = String::new();
for ip in ips {
match SmtpClient::connect(SocketAddr::new(ip, 25), Duration::from_secs(20), 0).await
{
Ok(client) => {
connected = Some(client);
break;
}
Err(err) => {
last_error = format!("connect to [{ip}]:25 ({mx}) failed: {err:?}");
}
}
}
let mut client = match connected {
Some(client) => client,
None => return DaneProbe::Skipped(last_error),
};
if let Err(err) = client.read_greeting(mx).await {
return DaneProbe::Skipped(format!("greeting from {mx} failed: {err:?}"));
}
if client
.stream
.write_all(b"EHLO dane-live-test.invalid\r\n")
.await
.is_err()
{
return DaneProbe::Skipped(format!("EHLO write to {mx} failed"));
}
let _ = client.stream.flush().await;
let capabilities = match client.read_ehlo().await {
Ok(capabilities) => capabilities,
Err(err) => return DaneProbe::Skipped(format!("EHLO to {mx} failed: {err:?}")),
};
let tls_client = match client.try_start_tls(connector, mx, &capabilities).await {
StartTlsResult::Success { smtp_client } => smtp_client,
StartTlsResult::Unavailable { .. } => {
return DaneProbe::Skipped(format!("{mx} does not offer STARTTLS"));
}
StartTlsResult::Error { error } => {
return DaneProbe::Skipped(format!("STARTTLS with {mx} failed: {error:?}"));
}
};
let certificates = match tls_client.tls_connection().peer_certificates() {
Some(certificates) => certificates.to_vec(),
None => {
return DaneProbe::Failed {
mx: mx.clone(),
reason: "server presented no certificates after TLS handshake".into(),
};
}
};
let reference_ids = [mx.as_str(), domain];
if let Err(status) = tlsa.verify(0, mx, &reference_ids, Some(&certificates)) {
return DaneProbe::Failed {
mx: mx.clone(),
reason: format!("TLSA verification rejected a live DANE host: {status:?}"),
};
}
let verify_subset = |keep_end_entity: bool| {
let entries: Vec<TlsaEntry> = tlsa
.entries
.iter()
.filter(|entry| entry.is_end_entity == keep_end_entity)
.cloned()
.collect();
if entries.is_empty() {
return false;
}
Tlsa {
has_end_entities: keep_end_entity,
has_intermediates: !keep_end_entity,
entries,
}
.verify(0, mx, &reference_ids, Some(&certificates))
.is_ok()
};
let native_ee = verify_subset(true);
let native_ta = verify_subset(false);
// Force the rustls-webpki trust-chain path
let webpki_forced = (certificates.len() >= 2).then(|| {
let anchor = certificates.last().unwrap();
Tlsa {
entries: vec![TlsaEntry {
is_end_entity: false,
is_spki: false,
matching: TlsaMatching::Sha256,
data: Sha256::digest(anchor.as_ref()).to_vec(),
}],
has_end_entities: false,
has_intermediates: true,
}
.verify(0, mx, &reference_ids, Some(&certificates))
.is_ok()
});
return DaneProbe::Verified {
mx: mx.clone(),
usages,
chain_len: certificates.len(),
native_ee,
native_ta,
webpki_forced,
};
}
DaneProbe::Skipped("no MX host published usable secure TLSA records".into())
}
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut opts = ResolverOpts::default();
opts.validate = true;
opts.cache_size = 0;
let resolver = TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
)
.with_options(opts)
.build()
.expect("failed to build DNSSEC-validating resolver");
let connector = build_tls_connector(true).expect("failed to build TLS connector");
let domains = [
"dukhovni.org",
"nlnetlabs.nl",
"debian.org",
"freebsd.org",
"posteo.de",
"mailbox.org",
];
let mut verified = 0usize;
let mut webpki_chain_validated = 0usize;
let mut hard_failures = Vec::new();
for domain in domains {
match probe(&resolver, &connector, domain).await {
DaneProbe::Verified {
mx,
usages,
chain_len,
native_ee,
native_ta,
webpki_forced,
} => {
verified += 1;
let native_path = match (native_ee, native_ta) {
(true, true) => "EE+TA",
(true, false) => "EE (no webpki)",
(false, true) => "TA (webpki)",
(false, false) => "?",
};
let forced = match webpki_forced {
Some(true) => {
webpki_chain_validated += 1;
"PASS"
}
Some(false) => "FAIL",
None => "n/a (single-cert chain)",
};
if native_ta {
webpki_chain_validated += 1;
}
println!(
"[ OK ] {domain}: MX {mx} | TLSA [{}] | chain {chain_len} certs | native path: {native_path} | forced webpki-TA vs real chain: {forced}",
usages.join(", ")
);
if webpki_forced == Some(false) {
hard_failures.push(format!(
"{domain} (MX {mx}): rustls-webpki rejected the server's own presented chain"
));
}
}
DaneProbe::Skipped(reason) => {
println!("[SKIP] {domain}: {reason}");
}
DaneProbe::Failed { mx, reason } => {
println!("[FAIL] {domain} (MX {mx}): {reason}");
hard_failures.push(format!("{domain} (MX {mx}): {reason}"));
}
}
}
assert!(
hard_failures.is_empty(),
"DANE verification rejected hosts that published valid secure TLSA records: {hard_failures:#?}"
);
assert!(
verified > 0,
"no DANE-enabled host could be reached and verified; check outbound port 25 and DNSSEC connectivity"
);
assert!(
webpki_chain_validated > 0,
"no host exercised the rustls-webpki trust-chain path; the live test only covered DANE-EE direct matches"
);
}