diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4d51e0..99b36497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Cargo.lock b/Cargo.lock index 98538ea8..94285291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 627ce497..c3e9d948 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -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"] } diff --git a/crates/smtp/src/outbound/dane/verify.rs b/crates/smtp/src/outbound/dane/verify.rs index ff525ab3..6b145222 100644 --- a/crates/smtp/src/outbound/dane/verify.rs +++ b/crates/smtp/src/outbound/dane/verify.rs @@ -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>, 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> = 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 { - 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) } diff --git a/tests/src/smtp/outbound/dane.rs b/tests/src/smtp/outbound/dane.rs index 943183cc..953fbaf4 100644 --- a/tests/src/smtp/outbound/dane.rs +++ b/tests/src/smtp/outbound/dane.rs @@ -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) { let key = KeyPair::generate().unwrap(); let mut params = CertificateParams::new(vec![san.to_string()]).unwrap(); @@ -741,3 +756,579 @@ fn tlsa(entries: Vec) -> 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::::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, +) -> (CertificateDer<'static>, Vec) { + 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::::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, + chain_len: usize, + native_ee: bool, + native_ta: bool, + webpki_forced: Option, + }, + 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 = 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 = 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" + ); +}