DANE: Improper TLSA record validation (fixes #2328)

This commit is contained in:
Maurus Decimus
2026-06-19 17:14:26 +02:00
parent 88412f9540
commit ad47f03f48
14 changed files with 1025 additions and 277 deletions

View File

@@ -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<HostResponse<Box<str>>, 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<HostResponse<Box<str>>, 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<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,
}
}
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)
}
}