From ad47f03f4821f7d14549e9ce56e6436ec5bf2fb2 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:14:26 +0200 Subject: [PATCH] DANE: Improper `TLSA` record validation (fixes #2328) --- Cargo.lock | 31 +- crates/common/src/config/inner.rs | 4 + crates/common/src/config/smtp/resolver.rs | 9 +- crates/common/src/lib.rs | 1 + crates/jmap/src/submission/set.rs | 4 +- crates/smtp/Cargo.toml | 2 +- crates/smtp/src/outbound/dane/dnssec.rs | 31 +- crates/smtp/src/outbound/dane/verify.rs | 283 +++++++++++----- crates/smtp/src/outbound/delivery.rs | 323 ++++++++++-------- crates/smtp/src/outbound/lookup.rs | 204 ++++++++++- crates/smtp/src/reporting/tls.rs | 8 +- tests/Cargo.toml | 3 + tests/src/smtp/outbound/dane.rs | 391 +++++++++++++++++++++- tests/src/utils/dns.rs | 8 + 14 files changed, 1025 insertions(+), 277 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9365143f..54b95579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2087,9 +2087,9 @@ dependencies = [ [[package]] name = "dns-update" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61121fe40a074b4b6f5bad089259c492a1d786c6346362a2a4b8961f484f36c5" +checksum = "ad8114773ce0ea3c3443cfb63025aa02fcddddf5f2b3d090e822e10dbf0572c2" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -2097,7 +2097,7 @@ dependencies = [ "hex", "hickory-net", "hickory-proto", - "quick-xml 0.39.4", + "quick-xml 0.40.1", "reqwest 0.13.4", "rustls 0.23.40", "serde", @@ -4345,9 +4345,9 @@ dependencies = [ [[package]] name = "mail-auth" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99f0db3f1292f72149bd50fcca0354d243defda0d26532ef7281ca716c77592" +checksum = "62a9d0c96cc84030705fec4bc4789fa918c89caec0d95ce7ee96b6f922d4a664" dependencies = [ "aws-lc-rs", "flate2", @@ -4355,7 +4355,7 @@ dependencies = [ "hickory-resolver", "mail-builder", "mail-parser", - "quick-xml 0.39.4", + "quick-xml 0.40.1", "rand 0.8.6", "rkyv", "rsa", @@ -5862,16 +5862,6 @@ dependencies = [ "serde", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.40.1" @@ -5879,6 +5869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" dependencies = [ "memchr", + "serde", ] [[package]] @@ -6255,9 +6246,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" +checksum = "bae41a63fd0b8a5372f82b21e810e09a316f5dd7efd96bf08e678fb240fc1918" dependencies = [ "arcstr", "async-lock", @@ -8060,6 +8051,7 @@ dependencies = [ "pop3", "quick-xml 0.40.1", "rayon", + "rcgen", "registry", "reqwest 0.13.4", "rkyv", @@ -8071,12 +8063,14 @@ dependencies = [ "serde_json", "serial_test", "services", + "sha2 0.11.0", "sieve-rs", "smtp", "smtp-proto", "spam-filter", "store", "testcontainers", + "time", "tokio", "tokio-rustls", "trc", @@ -9656,6 +9650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", + "aws-lc-rs", "data-encoding", "der-parser", "lazy_static", diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index f967ef63..cb2e7b20 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -178,6 +178,10 @@ impl Caches { ((std::mem::size_of::() + 255) * 2) as u64, ), dns_tlsa: CacheWithTtl::new(cache.dns_tlsa, (std::mem::size_of::() + 255) as u64), + dns_dnssec: CacheWithTtl::new( + cache.dns_tlsa, + (std::mem::size_of::() + 255) as u64, + ), dns_mta_sts: CacheWithTtl::new( cache.dns_mta_sts, (std::mem::size_of::() + 255) as u64, diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index ca388c20..45d03d0d 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -41,11 +41,18 @@ 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_sha256: bool, pub is_spki: bool, + pub matching: TlsaMatching, pub data: Vec, } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9e880859..a4205619 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -201,6 +201,7 @@ pub struct Caches { pub dns_ipv4: CacheWithTtl, Arc<[Ipv4Addr]>>, pub dns_ipv6: CacheWithTtl, Arc<[Ipv6Addr]>>, pub dns_tlsa: CacheWithTtl, Arc>, + pub dns_dnssec: CacheWithTtl, bool>, pub dns_mta_sts: CacheWithTtl, Arc>, pub dns_rbl: CacheWithTtl, Option>>, diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index 99f099f7..e2038500 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -141,9 +141,7 @@ impl EmailSubmissionSet for Server { let id = match id { MaybeInvalid::Value(id) => id, invalid => { - response - .not_updated - .append(invalid, SetError::not_found()); + response.not_updated.append(invalid, SetError::not_found()); continue 'update; } }; diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 25594b30..627ce497 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -45,7 +45,7 @@ regex = "1.7.0" blake3 = "1.3" lru-cache = "0.1.2" rand = "0.9.0" -x509-parser = "0.18" +x509-parser = { version = "0.18", features = ["verify-aws"] } reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" diff --git a/crates/smtp/src/outbound/dane/dnssec.rs b/crates/smtp/src/outbound/dane/dnssec.rs index 07cfd911..d1ce4e12 100644 --- a/crates/smtp/src/outbound/dane/dnssec.rs +++ b/crates/smtp/src/outbound/dane/dnssec.rs @@ -6,7 +6,7 @@ use common::{ Server, - config::smtp::resolver::{Tlsa, TlsaEntry}, + config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}, }; use mail_auth::{ common::resolver::ToFqdn, @@ -61,6 +61,17 @@ impl TlsaLookup for Server { 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 { @@ -68,16 +79,8 @@ impl TlsaLookup for Server { } entries.push(TlsaEntry { is_end_entity, - is_sha256: match tlsa.matching { - Matching::Sha256 => true, - Matching::Sha512 => false, - _ => continue, - }, - is_spki: match tlsa.selector { - Selector::Spki => true, - Selector::Full => false, - _ => continue, - }, + is_spki, + matching, data: tlsa.cert_data.clone(), }); } else { @@ -105,3 +108,9 @@ impl TlsaLookup for Server { } } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DnssecStatus { + Secure, + Insecure, +} diff --git a/crates/smtp/src/outbound/dane/verify.rs b/crates/smtp/src/outbound/dane/verify.rs index 0921ad9e..ff525ab3 100644 --- a/crates/smtp/src/outbound/dane/verify.rs +++ b/crates/smtp/src/outbound/dane/verify.rs @@ -4,12 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::config::smtp::resolver::Tlsa; +use common::config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}; use rustls_pki_types::CertificateDer; -use sha1::Digest; -use sha2::{Sha256, Sha512}; +use sha2::{Digest, Sha256, Sha512}; use trc::DaneEvent; -use x509_parser::prelude::{FromDer, X509Certificate}; +use x509_parser::prelude::{FromDer, GeneralName, SubjectPublicKeyInfo, X509Certificate}; use crate::queue::{Error, ErrorDetails, HostResponse, Status}; @@ -18,38 +17,47 @@ pub trait TlsaVerify { &self, session_id: u64, hostname: &str, + reference_ids: &[&str], certificates: Option<&[CertificateDer<'_>]>, ) -> Result<(), Status>, ErrorDetails>>; } +struct ChainCert<'a> { + raw: &'a [u8], + cert: X509Certificate<'a>, +} + impl TlsaVerify for Tlsa { fn verify( &self, session_id: u64, hostname: &str, + reference_ids: &[&str], certificates: Option<&[CertificateDer<'_>]>, ) -> Result<(), Status>, ErrorDetails>> { - let certificates = if let Some(certificates) = certificates { - certificates - } else { - trc::event!( - Dane(DaneEvent::NoCertificatesFound), - SpanId = session_id, - Hostname = hostname.to_string(), - ); + 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()), - })); + return Err(Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::DaneError("No certificates were provided by host".into()), + })); + } }; - let mut matched_end_entity = false; - let mut matched_intermediate = false; - 'outer: for (pos, der_certificate) in certificates.iter().enumerate() { - // Parse certificate - let certificate = match X509Certificate::from_der(der_certificate.as_ref()) { - Ok((_, certificate)) => certificate, + let mut chain = 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, + }), Err(err) => { trc::event!( Dane(DaneEvent::CertificateParseError), @@ -63,70 +71,11 @@ impl TlsaVerify for Tlsa { details: Error::DaneError("Failed to parse X.509 certificate".into()), })); } - }; - - // Match against TLSA records - let is_end_entity = pos == 0; - let mut sha256 = [None, None]; - let mut sha512 = [None, None]; - for record in self.entries.iter() { - if record.is_end_entity == is_end_entity { - let hash: &[u8] = if record.is_sha256 { - &sha256[usize::from(record.is_spki)].get_or_insert_with(|| { - let mut hasher = Sha256::new(); - hasher.update(if record.is_spki { - certificate.public_key().raw - } else { - der_certificate.as_ref() - }); - hasher.finalize() - })[..] - } else { - &sha512[usize::from(record.is_spki)].get_or_insert_with(|| { - let mut hasher = Sha512::new(); - hasher.update(if record.is_spki { - certificate.public_key().raw - } else { - der_certificate.as_ref() - }); - hasher.finalize() - })[..] - }; - - if hash == record.data { - trc::event!( - Dane(DaneEvent::TlsaRecordMatch), - SpanId = session_id, - Hostname = hostname.to_string(), - Type = if is_end_entity { - "end-entity" - } else { - "intermediate" - }, - Details = format!("{:x?}", hash), - ); - - if is_end_entity { - matched_end_entity = true; - if !self.has_intermediates { - break 'outer; - } - } else { - matched_intermediate = true; - break 'outer; - } - } - } } } - // DANE is valid if: - // - EE matched even if no TA matched - // - Both EE and TA matched - // - EE is not present and TA matched - if (self.has_end_entities && matched_end_entity) - || ((self.has_end_entities == matched_end_entity) - && (self.has_intermediates == matched_intermediate)) + if verify_end_entity(self, session_id, hostname, &chain) + || verify_trust_anchor(self, session_id, hostname, reference_ids, &chain) { trc::event!( Dane(DaneEvent::AuthenticationSuccess), @@ -149,3 +98,171 @@ impl TlsaVerify for Tlsa { } } } + +fn verify_end_entity( + tlsa: &Tlsa, + session_id: u64, + hostname: &str, + chain: &[ChainCert<'_>], +) -> 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; + } + } + false +} + +fn verify_trust_anchor( + tlsa: &Tlsa, + session_id: u64, + hostname: &str, + reference_ids: &[&str], + chain: &[ChainCert<'_>], +) -> bool { + if !tlsa.has_intermediates { + return false; + } + + let path = build_verified_chain(chain); + let leaf = &chain[path[0]]; + + for depth in 0..path.len() { + let anchor = &chain[path[depth]]; + + 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 && 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; + } + } + } + + false +} + +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, + } + } + + path +} + +fn dates_valid(chain: &[ChainCert<'_>], path: &[usize]) -> bool { + path.iter() + .all(|&idx| chain[idx].cert.validity().is_valid()) +} + +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(), + } +} + +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) + } +} diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index 3807db82..0e678d37 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -9,7 +9,7 @@ use crate::outbound::DeliveryResult; use crate::outbound::client::{ SmtpClient, from_error_details, from_error_status, from_mail_send_error, }; -use crate::outbound::dane::dnssec::TlsaLookup; +use crate::outbound::dane::dnssec::{DnssecStatus, TlsaLookup}; use crate::outbound::error::ClientError; use crate::outbound::lookup::{DnsLookup, SourceIp}; use crate::outbound::mta_sts::lookup::MtaStsLookup; @@ -630,9 +630,21 @@ impl QueuedMessage { } } + // Update TLS strategy + tls_strategy = server.get_tls_or_default( + &server + .eval_if::(&queue_config.tls, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + // Obtain source and remote IPs let time = Instant::now(); - let resolve_result = match server.resolve_host(remote_host, &envelope).await { + let resolve_result = match server + .resolve_host(remote_host, &envelope, tls_strategy.try_dane() && is_smtp) + .await + { Ok(result) => { trc::event!( Delivery(DeliveryEvent::IpLookup), @@ -665,142 +677,119 @@ impl QueuedMessage { } }; - // Update TLS strategy - tls_strategy = server.get_tls_or_default( - &server - .eval_if::(&queue_config.tls, &envelope, message.span_id) - .await - .unwrap_or_else(|| "default".to_string()), - message.span_id, - ); - // Lookup DANE policy let dane_policy = if tls_strategy.try_dane() && is_smtp { let time = Instant::now(); let strict = tls_strategy.is_dane_required(); - match server - .tlsa_lookup(format!("_25._tcp.{}.", envelope.mx)) - .await - { - Ok(Some(tlsa)) => { - if tlsa.has_end_entities { - trc::event!( - Dane(DaneEvent::TlsaRecordFetch), - SpanId = message.span_id, - Domain = domain.to_string(), - Hostname = envelope.mx.to_string(), - Details = format!("{tlsa:?}"), - Strict = strict, - Elapsed = time.elapsed(), - ); - tlsa.into() - } else { - trc::event!( - Dane(DaneEvent::TlsaRecordInvalid), - SpanId = message.span_id, - Domain = domain.to_string(), - Hostname = envelope.mx.to_string(), - Details = format!("{tlsa:?}"), - Strict = strict, - Elapsed = time.elapsed(), - ); + if resolve_result.dnssec_status == DnssecStatus::Insecure { + trc::event!( + Dane(DaneEvent::TlsaRecordNotDnssecSigned), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); - // Report invalid TLSA record - if let Some(tls_report) = &tls_report { - server - .schedule_report(TlsEvent { - policy: tlsa.into(), - domain: domain.to_string(), - failure: FailureDetails::new(ResultType::TlsaInvalid) + if strict { + // Report DANE required + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new(ResultType::DaneRequired) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "MX host is not in a DNSSEC signed zone.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError("No TLSA DNSSEC records found".into()), + }); + continue 'next_host; + } + None + } else { + match server + .tlsa_lookup(format!("_25._tcp.{}.", envelope.mx)) + .await + { + Ok(Some(tlsa)) => { + if tlsa.has_end_entities || tlsa.has_intermediates { + trc::event!( + Dane(DaneEvent::TlsaRecordFetch), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = format!("{tlsa:?}"), + Strict = strict, + Elapsed = time.elapsed(), + ); + + tlsa.into() + } else { + trc::event!( + Dane(DaneEvent::TlsaRecordInvalid), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = format!("{tlsa:?}"), + Strict = strict, + Elapsed = time.elapsed(), + ); + + // Report invalid TLSA record + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: tlsa.into(), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::TlsaInvalid, + ) .with_receiving_mx_hostname(envelope.mx) .with_failure_reason_code("Invalid TLSA record.") .into(), - tls_record: tls_report.record.clone(), - interval: tls_report.interval, - span_id: message.span_id, - }) - .await; + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + if strict { + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No valid TLSA records were found".into(), + ), + }); + continue 'next_host; + } + None } + } + Ok(None) => { + trc::event!( + Dane(DaneEvent::TlsaRecordNotDnssecSigned), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); if strict { - last_status = Status::PermanentFailure(ErrorDetails { - entity: envelope.mx.into(), - details: Error::DaneError( - "No valid TLSA records were found".into(), - ), - }); - continue 'next_host; - } - None - } - } - Ok(None) => { - trc::event!( - Dane(DaneEvent::TlsaRecordNotDnssecSigned), - SpanId = message.span_id, - Domain = domain.to_string(), - Hostname = envelope.mx.to_string(), - Strict = strict, - Elapsed = time.elapsed(), - ); - - if strict { - // Report DANE required - if let Some(tls_report) = &tls_report { - server - .schedule_report(TlsEvent { - policy: PolicyType::Tlsa(None), - domain: domain.to_string(), - failure: FailureDetails::new(ResultType::DaneRequired) - .with_receiving_mx_hostname(envelope.mx) - .with_failure_reason_code( - "No TLSA DNSSEC records found.", - ) - .into(), - tls_record: tls_report.record.clone(), - interval: tls_report.interval, - span_id: message.span_id, - }) - .await; - } - - last_status = Status::PermanentFailure(ErrorDetails { - entity: envelope.mx.into(), - details: Error::DaneError( - "No TLSA DNSSEC records found".into(), - ), - }); - continue 'next_host; - } - None - } - Err(err) => { - let not_found = matches!(&err, mail_auth::Error::DnsRecordNotFound(_)); - - if not_found { - trc::event!( - Dane(DaneEvent::TlsaRecordNotFound), - SpanId = message.span_id, - Domain = domain.to_string(), - Hostname = envelope.mx.to_string(), - Strict = strict, - Elapsed = time.elapsed(), - ); - } else { - trc::event!( - Dane(DaneEvent::TlsaRecordFetchError), - SpanId = message.span_id, - Domain = domain.to_string(), - Hostname = envelope.mx.to_string(), - CausedBy = trc::Error::from(err.clone()), - Strict = strict, - Elapsed = time.elapsed(), - ); - } - - if strict { - last_status = if not_found { // Report DANE required if let Some(tls_report) = &tls_report { server @@ -812,7 +801,7 @@ impl QueuedMessage { ) .with_receiving_mx_hostname(envelope.mx) .with_failure_reason_code( - "No TLSA records found for MX.", + "No TLSA DNSSEC records found.", ) .into(), tls_record: tls_report.record.clone(), @@ -822,16 +811,76 @@ impl QueuedMessage { .await; } - Status::PermanentFailure(ErrorDetails { + last_status = Status::PermanentFailure(ErrorDetails { entity: envelope.mx.into(), - details: Error::DaneError("No TLSA records found".into()), - }) - } else { - Status::from_mail_auth_error(envelope.mx, err) - }; - continue 'next_host; + details: Error::DaneError( + "No TLSA DNSSEC records found".into(), + ), + }); + continue 'next_host; + } + None + } + Err(err) => { + let not_found = + matches!(&err, mail_auth::Error::DnsRecordNotFound(_)); + + if not_found { + trc::event!( + Dane(DaneEvent::TlsaRecordNotFound), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + + if strict { + // Report DANE required + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::DaneRequired, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "No TLSA records found for MX.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No TLSA records found".into(), + ), + }); + continue 'next_host; + } + None + } else { + trc::event!( + Dane(DaneEvent::TlsaRecordFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + CausedBy = trc::Error::from(err.clone()), + Strict = strict, + Elapsed = time.elapsed(), + ); + + last_status = Status::from_mail_auth_error(envelope.mx, err); + continue 'next_host; + } } - None } } } else { @@ -948,10 +997,9 @@ impl QueuedMessage { || (message.message.flags & MAIL_REQUIRETLS) != 0 || mta_sts_policy.is_some() || dane_policy.is_some(); - // As per RFC7671 Section 5.1, DANE-EE(3) allows name mismatch let tls_connector = if tls_strategy.allow_invalid_certs || remote_host.allow_invalid_certs() - || dane_policy.as_ref().is_some_and(|t| t.has_end_entities) + || dane_policy.is_some() { &server.inner.data.smtp_connectors.dummy_verify } else { @@ -1040,6 +1088,7 @@ impl QueuedMessage { && let Err(status) = dane_policy.verify( message.span_id, envelope.mx, + &[envelope.mx, domain], smtp_client.tls_connection().peer_certificates(), ) { diff --git a/crates/smtp/src/outbound/lookup.rs b/crates/smtp/src/outbound/lookup.rs index 62e4f739..df7c62e7 100644 --- a/crates/smtp/src/outbound/lookup.rs +++ b/crates/smtp/src/outbound/lookup.rs @@ -5,19 +5,30 @@ */ use super::NextHop; +use crate::outbound::dane::dnssec::DnssecStatus; use crate::queue::{Error, ErrorDetails, HostResponse, Status}; use common::{ Server, config::smtp::queue::{ConnectionStrategy, HostOrIp, IpAndHost, MxConfig}, expr::functions::ResolveVariable, }; -use mail_auth::{IpLookupStrategy, MX}; +use mail_auth::{ + IpLookupStrategy, MX, + common::resolver::ToFqdn, + hickory_resolver::proto::rr::{Name, RData, RecordType}, +}; use rand::{Rng, seq::SliceRandom}; use registry::schema::enums::ExpressionVariable; -use std::{future::Future, net::IpAddr, sync::Arc}; +use std::{ + future::Future, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::Instant, +}; pub struct IpLookupResult { pub remote_ips: Vec, + pub dnssec_status: DnssecStatus, } pub trait DnsLookup: Sync + Send { @@ -28,10 +39,18 @@ pub trait DnsLookup: Sync + Send { max_results: usize, ) -> impl Future>> + Send; + fn dnssec_ip_lookup( + &self, + key: &str, + strategy: IpLookupStrategy, + max_results: usize, + ) -> impl Future, DnssecStatus)>> + Send; + fn resolve_host( &self, remote_host: &NextHop<'_>, envelope: &impl ResolveVariable, + use_dnssec: bool, ) -> impl Future>, ErrorDetails>>> + Send; } @@ -105,21 +124,174 @@ impl DnsLookup for Server { } } + async fn dnssec_ip_lookup( + &self, + key: &str, + strategy: IpLookupStrategy, + max_results: usize, + ) -> mail_auth::Result<(Vec, DnssecStatus)> { + let fqdn = key.to_fqdn(); + if let Some(secure) = self.inner.cache.dns_dnssec.get(fqdn.as_ref()) { + return Ok(( + self.ip_lookup(key, strategy, max_results).await?, + if secure { + DnssecStatus::Secure + } else { + DnssecStatus::Insecure + }, + )); + } + + #[cfg(any(test, feature = "test_mode"))] + if true { + return Ok(( + self.ip_lookup(key, strategy, max_results).await?, + DnssecStatus::Secure, + )); + } + + let (query_v4, query_v6, v4_first) = match strategy { + IpLookupStrategy::Ipv4Only => (true, false, true), + IpLookupStrategy::Ipv6Only => (false, true, false), + IpLookupStrategy::Ipv4thenIpv6 => (true, true, true), + IpLookupStrategy::Ipv6thenIpv4 => (true, true, false), + }; + let resolver = &self.core.smtp.resolvers.dnssec.resolver; + let name = Name::from_str_relaxed(fqdn.as_ref())?; + + let mut ipv4: Vec = Vec::new(); + let mut ipv6: Vec = Vec::new(); + let mut all_secure = true; + let mut v4_valid_until: Option = None; + let mut v6_valid_until: Option = None; + let mut not_found: Option = None; + + let mut record_types = Vec::with_capacity(2); + if query_v4 { + record_types.push(RecordType::A); + } + if query_v6 { + record_types.push(RecordType::AAAA); + } + + for record_type in record_types { + match resolver.lookup(name.clone(), record_type).await { + Ok(lookup) => { + let valid_until = lookup.valid_until(); + let mut found = false; + for record in lookup.answers() { + if !record.proof.is_secure() { + all_secure = false; + } + match &record.data { + RData::A(a) => { + ipv4.push(a.0); + found = true; + } + RData::AAAA(aaaa) => { + ipv6.push(aaaa.0); + found = true; + } + _ => {} + } + } + if found { + if record_type == RecordType::A { + v4_valid_until = Some(valid_until); + } else { + v6_valid_until = Some(valid_until); + } + } + } + Err(err) => { + let err: mail_auth::Error = err.into(); + if matches!(err, mail_auth::Error::DnsRecordNotFound(_)) { + not_found = Some(err); + } else { + return Err(err); + } + } + } + } + + if ipv4.is_empty() && ipv6.is_empty() { + return Err(not_found.unwrap_or(mail_auth::Error::DnsRecordNotFound( + mail_auth::hickory_resolver::proto::op::ResponseCode::NXDomain, + ))); + } + + if let Some(valid_until) = v4_valid_until { + self.inner.cache.dns_ipv4.insert_with_expiry( + fqdn.clone(), + Arc::from(ipv4.as_slice()), + valid_until, + ); + } + if let Some(valid_until) = v6_valid_until { + self.inner.cache.dns_ipv6.insert_with_expiry( + fqdn.clone(), + Arc::from(ipv6.as_slice()), + valid_until, + ); + } + if let Some(valid_until) = v4_valid_until.into_iter().chain(v6_valid_until).min() { + self.inner + .cache + .dns_dnssec + .insert_with_expiry(fqdn, all_secure, valid_until); + } + + let remote_ips: Vec = if v4_first { + ipv4.into_iter() + .map(IpAddr::from) + .chain(ipv6.into_iter().map(IpAddr::from)) + .take(max_results) + .collect() + } else { + ipv6.into_iter() + .map(IpAddr::from) + .chain(ipv4.into_iter().map(IpAddr::from)) + .take(max_results) + .collect() + }; + + Ok(( + remote_ips, + if all_secure { + DnssecStatus::Secure + } else { + DnssecStatus::Insecure + }, + )) + } + #[allow(unused_mut)] async fn resolve_host( &self, remote_host: &NextHop<'_>, envelope: &impl ResolveVariable, + use_dnssec: bool, ) -> Result>, ErrorDetails>> { - let mut remote_ips = match remote_host.fqdn_hostname() { - HostOrIp::Host(hostname) => self - .ip_lookup( - hostname.as_ref(), - remote_host.ip_lookup_strategy(), - remote_host.max_multi_homed(), - ) - .await - .map_err(|err| { + let (mut remote_ips, dnssec_status) = match remote_host.fqdn_hostname() { + HostOrIp::Host(hostname) => { + let lookup = if use_dnssec { + self.dnssec_ip_lookup( + hostname.as_ref(), + remote_host.ip_lookup_strategy(), + remote_host.max_multi_homed(), + ) + .await + } else { + self.ip_lookup( + hostname.as_ref(), + remote_host.ip_lookup_strategy(), + remote_host.max_multi_homed(), + ) + .await + .map(|ips| (ips, DnssecStatus::Insecure)) + }; + + lookup.map_err(|err| { if let mail_auth::Error::DnsRecordNotFound(_) = &err { if matches!( remote_host, @@ -146,8 +318,9 @@ impl DnsLookup for Server { ), }) } - })?, - HostOrIp::Ip(ip) => vec![ip], + })? + } + HostOrIp::Ip(ip) => (vec![ip], DnssecStatus::Insecure), }; if !remote_ips.is_empty() { @@ -162,7 +335,10 @@ impl DnsLookup for Server { } } - Ok(IpLookupResult { remote_ips }) + Ok(IpLookupResult { + remote_ips, + dnssec_status, + }) } else { Err(Status::TemporaryFailure(ErrorDetails { entity: remote_host.hostname().into(), diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs index e283fe30..f4d65e1e 100644 --- a/crates/smtp/src/reporting/tls.rs +++ b/crates/smtp/src/reporting/tls.rs @@ -13,7 +13,7 @@ use common::{ Server, USER_AGENT, config::smtp::{ report::AggregateFrequency, - resolver::{Mode, MxPattern}, + resolver::{Mode, MxPattern, TlsaMatching}, }, ipc::{TlsEvent, ToHash}, }; @@ -363,7 +363,11 @@ impl TlsReporting for Server { "{} {} {} {}", if entry.is_end_entity { 3 } else { 2 }, i32::from(entry.is_spki), - if entry.is_sha256 { 1 } else { 2 }, + match entry.matching { + TlsaMatching::Full => 0, + TlsaMatching::Sha256 => 1, + TlsaMatching::Sha512 => 2, + }, entry.data.iter().fold( String::with_capacity(64), |mut s, b| { diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 1657b12c..28cd06e3 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -83,6 +83,9 @@ quick-xml = "0.40" jmap-tools = { version = "0.1" } dns-update = { version = "0.5", features = ["test_provider"] } x509-parser = "0.18" +rcgen = "0.14" +sha2 = "0.11" +time = "0.3" testcontainers = { version = "0.27", features = ["reusable-containers"] } rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] } diff --git a/tests/src/smtp/outbound/dane.rs b/tests/src/smtp/outbound/dane.rs index b2281a25..2624ba25 100644 --- a/tests/src/smtp/outbound/dane.rs +++ b/tests/src/smtp/outbound/dane.rs @@ -12,7 +12,7 @@ use crate::{ utils::{dns::DnsCache, server::TestServerBuilder}, }; use common::{ - config::smtp::resolver::{Tlsa, TlsaEntry}, + config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}, ipc::PolicyType, }; use mail_auth::{ @@ -21,14 +21,20 @@ use mail_auth::{ mta_sts::{ReportUri, TlsRpt}, report::tlsrpt::ResultType, }; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, + PublicKeyData, date_time_ymd, +}; use registry::schema::{ enums::MtaRequiredOrOptional, prelude::ObjectType, structs::{Expression, MtaTlsStrategy, TlsReportSettings}, }; use rustls_pki_types::CertificateDer; +use sha2::{Digest, Sha256}; use smtp::outbound::dane::{dnssec::TlsaLookup, verify::TlsaVerify}; use smtp::queue::{Error, ErrorDetails, Status}; +use store::write::now; use std::{ collections::BTreeSet, fs::{self, File}, @@ -168,8 +174,8 @@ async fn dane_verify() { let tlsa = Arc::new(Tlsa { entries: vec![TlsaEntry { is_end_entity: true, - is_sha256: true, is_spki: true, + matching: TlsaMatching::Sha256, data: vec![1, 2, 3], }], has_end_entities: true, @@ -211,8 +217,8 @@ async fn dane_verify() { let tlsa = Arc::new(Tlsa { entries: vec![TlsaEntry { is_end_entity: true, - is_sha256: true, is_spki: true, + matching: TlsaMatching::Sha256, data: vec![ 73, 186, 44, 106, 13, 198, 100, 180, 0, 44, 158, 188, 15, 195, 39, 198, 61, 254, 215, 237, 100, 26, 15, 155, 219, 235, 120, 64, 128, 172, 17, 0, @@ -246,6 +252,124 @@ async fn dane_verify() { let report = local.read_report().await.unwrap_tls(); assert_eq!(report.policy, PolicyType::Tlsa(tlsa.into())); assert!(report.failure.is_none()); + + // An insecure (non-DNSSEC) MX zone must not honor TLSA records, + // even when valid records are cached. + local.server.dnssec_add( + "mx.foobar.org", + false, + Instant::now() + Duration::from_secs(10), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (DANE failed to authenticate"); + local.read_event().await.assert_done(); + local.assert_no_events(); + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DaneRequired + ); + remote.assert_no_events(); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_downgrade_on_tlsa_servfail() { + let mut local = TestServerBuilder::new("smtp_dane_downgrade_local") + .await + .with_http_listener(19020) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_dane_downgrade_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19021) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Optional; + tls_strategy.start_tls = MtaRequiredOrOptional::Require; + tls_strategy.allow_invalid_certs = true; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_add_all_headers().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx._dns_error.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx._dns_error.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + remote.assert_no_events(); } #[tokio::test] @@ -306,8 +430,8 @@ async fn dane_test() { } tlsa.entries.push(TlsaEntry { is_end_entity, - is_sha256: true, is_spki: true, + matching: TlsaMatching::Sha256, data: decode_hex(item).unwrap(), }); } @@ -336,7 +460,7 @@ async fn dane_test() { } } - // Successful DANE verification + // Successful DANE verification (end-entity match, RFC 7671 Section 5.1) let tlsa = test .server .tlsa_lookup(format!("_25._tcp.{host}.")) @@ -344,12 +468,15 @@ async fn dane_test() { .unwrap() .unwrap(); - assert_eq!(tlsa.verify(0, &host, Some(&certs)), Ok(())); + assert_eq!( + tlsa.verify(0, &host, &[host.as_str()], Some(&certs)), + Ok(()) + ); // Failed DANE verification certs.remove(0); assert_eq!( - tlsa.verify(0, &host, Some(&certs)), + tlsa.verify(0, &host, &[host.as_str()], Some(&certs)), Err(Status::PermanentFailure(ErrorDetails { entity: host.into(), details: Error::DaneError("No matching certificates found in TLSA records".into()) @@ -364,3 +491,253 @@ pub fn decode_hex(s: &str) -> Result, ParseIntError> { .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) .collect() } + +struct TestCa { + issuer: Issuer<'static, KeyPair>, + der: CertificateDer<'static>, + spki: Vec, +} + +#[test] +fn dane_ee_matches_leaf_ignoring_name() { + let root = root_ca("EE Root"); + let intermediate = sub_ca("EE Intermediate", &root); + let (leaf_der, leaf_spki) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone()]; + + let record = tlsa(vec![ee_spki_sha256(&leaf_spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["name.mismatch.example"], Some(&chain)) + .is_ok() + ); + + let wrong = tlsa(vec![ee_spki_sha256(&root.spki)]); + assert!( + wrong + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ee_ignores_expiration() { + let root = root_ca("Expired EE Root"); + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(vec!["mx.foobar.org".to_string()]).unwrap(); + params.not_before = date_time_ymd(2018, 1, 1); + params.not_after = date_time_ymd(2021, 1, 1); + params + .distinguished_name + .push(DnType::CommonName, "mx.foobar.org"); + let leaf = params.signed_by(&key, &root.issuer).unwrap(); + let leaf_spki = key.subject_public_key_info(); + let chain = vec![leaf.der().clone()]; + + let record = tlsa(vec![ee_spki_sha256(&leaf_spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_anchors_at_private_intermediate() { + let root = root_ca("TA Private Root"); + let intermediate = sub_ca("TA 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_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_matches_when_ee_present_but_unmatched() { + let root = root_ca("Mixed Root"); + let intermediate = sub_ca("Mixed 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![ + ee_spki_sha256(&root.spki), + ta_full_sha256(&intermediate.der), + ]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_requires_name_match() { + let root = root_ca("Name Root"); + let intermediate = sub_ca("Name 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_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["other.example"], Some(&chain)) + .is_err() + ); + assert!( + record + .verify( + 0, + "mx.foobar.org", + &["other.example", "mx.foobar.org"], + Some(&chain) + ) + .is_ok() + ); +} + +#[test] +fn dane_ta_rejects_unrelated_ca_matching_hash() { + let root = root_ca("Forgery Root"); + let intermediate = sub_ca("Forgery Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let unrelated = root_ca("Unrelated CA"); + let chain = vec![leaf_der, intermediate.der.clone(), unrelated.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&unrelated.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_rejects_expired_intermediate() { + let root = root_ca("Validity Root"); + let int_key = KeyPair::generate().unwrap(); + let mut int_params = ca_params("Expired Intermediate"); + int_params.not_before = date_time_ymd(2018, 1, 1); + int_params.not_after = date_time_ymd(2021, 1, 1); + let int_cert = int_params.signed_by(&int_key, &root.issuer).unwrap(); + let intermediate = TestCa { + der: int_cert.der().clone(), + spki: int_key.subject_public_key_info(), + issuer: Issuer::new(int_params, int_key), + }; + 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_full_sha256(&root.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_bare_key_anchors_chain() { + let root = root_ca("Bare Key Root"); + let intermediate = sub_ca("Bare Key Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.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("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() + ); +} + +fn ca_params(common_name: &str) -> CertificateParams { + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params +} + +fn root_ca(common_name: &str) -> TestCa { + let key = KeyPair::generate().unwrap(); + let params = ca_params(common_name); + let cert = params.self_signed(&key).unwrap(); + let der = cert.der().clone(); + let spki = key.subject_public_key_info(); + TestCa { + issuer: Issuer::new(params, key), + der, + spki, + } +} + +fn sub_ca(common_name: &str, parent: &TestCa) -> TestCa { + let key = KeyPair::generate().unwrap(); + let params = ca_params(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(san: &str, parent: &TestCa) -> (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); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + (cert.der().clone(), key.subject_public_key_info()) +} + +fn ee_spki_sha256(spki: &[u8]) -> TlsaEntry { + TlsaEntry { + is_end_entity: true, + is_spki: true, + matching: TlsaMatching::Sha256, + data: Sha256::digest(spki).to_vec(), + } +} + +fn ta_full_sha256(der: &CertificateDer<'_>) -> TlsaEntry { + TlsaEntry { + is_end_entity: false, + is_spki: false, + matching: TlsaMatching::Sha256, + data: Sha256::digest(der.as_ref()).to_vec(), + } +} + +fn ta_spki_full(spki: &[u8]) -> TlsaEntry { + TlsaEntry { + is_end_entity: false, + is_spki: true, + matching: TlsaMatching::Full, + data: spki.to_vec(), + } +} + +fn tlsa(entries: Vec) -> Tlsa { + Tlsa { + has_end_entities: entries.iter().any(|entry| entry.is_end_entity), + has_intermediates: entries.iter().any(|entry| !entry.is_end_entity), + entries, + } +} diff --git a/tests/src/utils/dns.rs b/tests/src/utils/dns.rs index c84d9279..31757622 100644 --- a/tests/src/utils/dns.rs +++ b/tests/src/utils/dns.rs @@ -22,6 +22,7 @@ pub trait DnsCache { fn ptr_add(&self, name: IpAddr, value: Vec, valid_until: std::time::Instant); fn mx_add(&self, name: impl ToFqdn, value: Vec, valid_until: std::time::Instant); fn tlsa_add(&self, name: impl ToFqdn, value: Arc, valid_until: std::time::Instant); + fn dnssec_add(&self, name: impl ToFqdn, secure: bool, valid_until: std::time::Instant); } impl DnsCache for Server { @@ -82,4 +83,11 @@ impl DnsCache for Server { .dns_tlsa .insert_with_expiry(name.to_fqdn(), value, valid_until); } + + fn dnssec_add(&self, name: impl ToFqdn, secure: bool, valid_until: std::time::Instant) { + self.inner + .cache + .dns_dnssec + .insert_with_expiry(name.to_fqdn(), secure, valid_until); + } }