Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching},
|
||||
};
|
||||
pub use mail_auth::DnssecStatus;
|
||||
use mail_auth::{
|
||||
MX, RecordSet,
|
||||
common::resolver::ToFqdn,
|
||||
hickory_resolver::{
|
||||
net::{DnsError, NetError},
|
||||
proto::{
|
||||
dnssec::Proof,
|
||||
op::ResponseCode,
|
||||
rr::{
|
||||
Name, RData, Record, RecordType,
|
||||
rdata::tlsa::{CertUsage, Matching, Selector},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::{Ipv4Addr, Ipv6Addr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub trait TlsaLookup: Sync + Send {
|
||||
fn mx_lookup(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<MX>>> + Send;
|
||||
|
||||
fn tlsa_lookup(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<TlsaResult>> + Send;
|
||||
|
||||
fn ipv4_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<Ipv4Addr>>> + Send;
|
||||
|
||||
fn ipv6_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<Ipv6Addr>>> + Send;
|
||||
}
|
||||
|
||||
pub enum TlsaResult {
|
||||
Secure(Arc<Tlsa>),
|
||||
Bogus,
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl TlsaLookup for Server {
|
||||
async fn mx_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result<RecordSet<MX>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.mx_lookup(key, Some(&self.inner.cache.dns_mx))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_mx.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let mx_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
||||
.await
|
||||
{
|
||||
Ok(mx_lookup) => mx_lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_mx.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
let mx_records = mx_lookup.answers();
|
||||
let mut dnssec_status: Option<DnssecStatus> = None;
|
||||
let mut records: Vec<(u16, Vec<Box<str>>)> = Vec::with_capacity(mx_records.len());
|
||||
for mx_record in mx_records {
|
||||
if let RData::MX(mx) = &mx_record.data {
|
||||
dnssec_status = Some(match dnssec_status {
|
||||
Some(status) => least_secure(status, proof_to_dnssec_status(mx_record.proof)),
|
||||
None => proof_to_dnssec_status(mx_record.proof),
|
||||
});
|
||||
|
||||
let preference = mx.preference;
|
||||
let exchange = mx.exchange.to_lowercase().to_ascii().into_boxed_str();
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|r| r.0 == preference) {
|
||||
record.1.push(exchange);
|
||||
} else {
|
||||
records.push((preference, vec![exchange]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records.sort_unstable_by_key(|a| a.0);
|
||||
let rrset: Arc<[MX]> = records
|
||||
.into_iter()
|
||||
.map(|(preference, exchanges)| MX {
|
||||
preference,
|
||||
exchanges: exchanges.into_boxed_slice(),
|
||||
})
|
||||
.collect::<Arc<[MX]>>();
|
||||
let records = RecordSet {
|
||||
rrset,
|
||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_mx
|
||||
.insert_with_expiry(key, records.clone(), mx_lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn tlsa_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result<TlsaResult> {
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_tlsa.get(key.as_ref()) {
|
||||
return Ok(TlsaResult::Secure(value));
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
if key.as_ref().contains("_dnssec_bogus.") {
|
||||
return Ok(TlsaResult::Bogus);
|
||||
}
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let tlsa_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?)
|
||||
.await
|
||||
{
|
||||
Ok(tlsa_lookup) => tlsa_lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err) {
|
||||
return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
|
||||
TlsaResult::Bogus
|
||||
} else {
|
||||
TlsaResult::Missing
|
||||
});
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut has_end_entities = false;
|
||||
let mut has_intermediates = false;
|
||||
let mut dnssec_status: Option<DnssecStatus> = None;
|
||||
|
||||
for record in tlsa_lookup.answers() {
|
||||
if let RData::TLSA(tlsa) = &record.data {
|
||||
dnssec_status = Some(match dnssec_status {
|
||||
Some(status) => least_secure(status, proof_to_dnssec_status(record.proof)),
|
||||
None => proof_to_dnssec_status(record.proof),
|
||||
});
|
||||
|
||||
if !record.proof.is_secure() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_end_entity = match tlsa.cert_usage {
|
||||
CertUsage::DaneEe => true,
|
||||
CertUsage::DaneTa => false,
|
||||
_ => continue,
|
||||
};
|
||||
let matching = match tlsa.matching {
|
||||
Matching::Raw => TlsaMatching::Full,
|
||||
Matching::Sha256 => TlsaMatching::Sha256,
|
||||
Matching::Sha512 => TlsaMatching::Sha512,
|
||||
_ => continue,
|
||||
};
|
||||
let is_spki = match tlsa.selector {
|
||||
Selector::Spki => true,
|
||||
Selector::Full => false,
|
||||
_ => continue,
|
||||
};
|
||||
if is_end_entity {
|
||||
has_end_entities = true;
|
||||
} else {
|
||||
has_intermediates = true;
|
||||
}
|
||||
entries.push(TlsaEntry {
|
||||
is_end_entity,
|
||||
is_spki,
|
||||
matching,
|
||||
data: tlsa.cert_data.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match dnssec_status {
|
||||
Some(DnssecStatus::Bogus) => Ok(TlsaResult::Bogus),
|
||||
Some(DnssecStatus::Secure) => {
|
||||
let tlsa = Arc::new(Tlsa {
|
||||
entries,
|
||||
has_end_entities,
|
||||
has_intermediates,
|
||||
});
|
||||
|
||||
self.inner.cache.dns_tlsa.insert_with_expiry(
|
||||
key,
|
||||
tlsa.clone(),
|
||||
tlsa_lookup.valid_until(),
|
||||
);
|
||||
|
||||
Ok(TlsaResult::Secure(tlsa))
|
||||
}
|
||||
_ => Ok(TlsaResult::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ipv4_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> mail_auth::Result<RecordSet<Ipv4Addr>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv4_lookup(key, Some(&self.inner.cache.dns_ipv4))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_ipv4.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv4_lookup(name.clone())
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_ipv4.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let answers = lookup.answers();
|
||||
let records = RecordSet {
|
||||
rrset: answers
|
||||
.iter()
|
||||
.filter_map(|record| match &record.data {
|
||||
RData::A(addr) => Some(addr.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv4Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_ipv4
|
||||
.insert_with_expiry(key, records.clone(), lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn ipv6_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> mail_auth::Result<RecordSet<Ipv6Addr>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv6_lookup(key, Some(&self.inner.cache.dns_ipv6))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_ipv6.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv6_lookup(name.clone())
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_ipv6.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let answers = lookup.answers();
|
||||
let records = RecordSet {
|
||||
rrset: answers
|
||||
.iter()
|
||||
.filter_map(|record| match &record.data {
|
||||
RData::AAAA(addr) => Some(addr.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv6Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_ipv6
|
||||
.insert_with_expiry(key, records.clone(), lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
struct NegativeAnswer {
|
||||
response_code: ResponseCode,
|
||||
dnssec_status: DnssecStatus,
|
||||
valid_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl NegativeAnswer {
|
||||
fn from_error(err: &NetError) -> Option<Self> {
|
||||
let NetError::Dns(dns_error) = err else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match dns_error {
|
||||
DnsError::NoRecordsFound(no_records) => Some(NegativeAnswer {
|
||||
response_code: no_records.response_code,
|
||||
dnssec_status: no_records
|
||||
.authorities
|
||||
.as_deref()
|
||||
.map(denial_dnssec_status)
|
||||
.unwrap_or(DnssecStatus::Indeterminate),
|
||||
valid_until: no_records
|
||||
.negative_ttl
|
||||
.map(|ttl| Instant::now() + Duration::from_secs(ttl as u64)),
|
||||
}),
|
||||
DnsError::Nsec {
|
||||
response, proof, ..
|
||||
} => Some(NegativeAnswer {
|
||||
response_code: response.response_code,
|
||||
dnssec_status: proof_to_dnssec_status(*proof),
|
||||
valid_until: None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn denial_dnssec_status(authorities: &[Record]) -> DnssecStatus {
|
||||
authorities
|
||||
.iter()
|
||||
.filter(|record| matches!(record.record_type(), RecordType::NSEC | RecordType::NSEC3))
|
||||
.map(|record| proof_to_dnssec_status(record.proof))
|
||||
.reduce(least_secure)
|
||||
.unwrap_or(DnssecStatus::Indeterminate)
|
||||
}
|
||||
|
||||
fn proof_to_dnssec_status(proof: Proof) -> DnssecStatus {
|
||||
match proof {
|
||||
Proof::Secure => DnssecStatus::Secure,
|
||||
Proof::Insecure => DnssecStatus::Insecure,
|
||||
Proof::Bogus => DnssecStatus::Bogus,
|
||||
Proof::Indeterminate => DnssecStatus::Indeterminate,
|
||||
}
|
||||
}
|
||||
|
||||
fn tlsa_base_status(query: &Name, answers: &[Record], address_type: RecordType) -> DnssecStatus {
|
||||
let mut addresses: Option<DnssecStatus> = None;
|
||||
let mut alias: Option<DnssecStatus> = None;
|
||||
|
||||
for record in answers {
|
||||
let status = proof_to_dnssec_status(record.proof);
|
||||
if record.record_type() == address_type {
|
||||
addresses = Some(match addresses {
|
||||
Some(current) => least_secure(current, status),
|
||||
None => status,
|
||||
});
|
||||
} else if record.record_type() == RecordType::CNAME && &record.name == query {
|
||||
alias = Some(match alias {
|
||||
Some(current) => least_secure(current, status),
|
||||
None => status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match (addresses, alias) {
|
||||
(Some(DnssecStatus::Insecure), Some(DnssecStatus::Secure)) => DnssecStatus::Secure,
|
||||
(Some(status), _) => status,
|
||||
(None, _) => DnssecStatus::Indeterminate,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
||||
fn rank(status: DnssecStatus) -> u8 {
|
||||
match status {
|
||||
DnssecStatus::Bogus => 0,
|
||||
DnssecStatus::Indeterminate => 1,
|
||||
DnssecStatus::Insecure => 2,
|
||||
DnssecStatus::Secure => 3,
|
||||
}
|
||||
}
|
||||
|
||||
if rank(a) <= rank(b) { a } else { b }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn name(value: &str) -> Name {
|
||||
Name::from_ascii(value).unwrap()
|
||||
}
|
||||
|
||||
fn address(owner: &str, proof: Proof) -> Record {
|
||||
let mut record =
|
||||
Record::from_rdata(name(owner), 3600, RData::A(A(Ipv4Addr::new(192, 0, 2, 1))));
|
||||
record.proof = proof;
|
||||
record
|
||||
}
|
||||
|
||||
fn alias(owner: &str, target: &str, proof: Proof) -> Record {
|
||||
let mut record = Record::from_rdata(name(owner), 3600, RData::CNAME(CNAME(name(target))));
|
||||
record.proof = proof;
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_follows_address_records() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
for (proof, expected) in [
|
||||
(Proof::Secure, DnssecStatus::Secure),
|
||||
(Proof::Insecure, DnssecStatus::Insecure),
|
||||
(Proof::Bogus, DnssecStatus::Bogus),
|
||||
(Proof::Indeterminate, DnssecStatus::Indeterminate),
|
||||
] {
|
||||
assert_eq!(
|
||||
tlsa_base_status(&query, &[address("mx.example.org.", proof)], RecordType::A),
|
||||
expected,
|
||||
"proof {proof}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_is_indeterminate_without_addresses() {
|
||||
assert_eq!(
|
||||
tlsa_base_status(&name("mx.example.org."), &[], RecordType::A),
|
||||
DnssecStatus::Indeterminate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_takes_least_secure_address() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
address("mx.example.org.", Proof::Secure),
|
||||
address("mx.example.org.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_keeps_secure_alias_to_insecure_zone() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.example.org.", "mx.provider.net.", Proof::Secure),
|
||||
address("mx.provider.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Secure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_skips_insecure_alias() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.example.org.", "mx.provider.net.", Proof::Insecure),
|
||||
address("mx.provider.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_ignores_alias_below_query_name() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.provider.net.", "mx.other.net.", Proof::Secure),
|
||||
address("mx.other.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod dnssec;
|
||||
pub mod verify;
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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, Der, ServerName, TrustAnchor, UnixTime};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
use trc::DaneEvent;
|
||||
use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert};
|
||||
use x509_parser::asn1_rs::Any;
|
||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||
|
||||
pub trait TlsaVerify {
|
||||
fn verify(
|
||||
&self,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
reference_ids: &[&str],
|
||||
certificates: Option<&[CertificateDer<'_>]>,
|
||||
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>>;
|
||||
}
|
||||
|
||||
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 = match certificates {
|
||||
Some(certificates) if !certificates.is_empty() => certificates,
|
||||
_ => {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::NoCertificatesFound),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
return Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("No certificates were provided by host".into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let mut parsed = Vec::with_capacity(certificates.len());
|
||||
for der_certificate in certificates {
|
||||
match X509Certificate::from_der(der_certificate.as_ref()) {
|
||||
Ok((_, cert)) => parsed.push(cert),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::CertificateParseError),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
return Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("Failed to parse X.509 certificate".into()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verify_end_entity(self, session_id, hostname, certificates, &parsed)
|
||||
|| verify_trust_anchor(
|
||||
self,
|
||||
session_id,
|
||||
hostname,
|
||||
reference_ids,
|
||||
certificates,
|
||||
&parsed,
|
||||
)
|
||||
{
|
||||
trc::event!(
|
||||
Dane(DaneEvent::AuthenticationSuccess),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::AuthenticationFailure),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("No matching certificates found in TLSA records".into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_end_entity(
|
||||
tlsa: &Tlsa,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
certificates: &[CertificateDer<'_>],
|
||||
parsed: &[X509Certificate<'_>],
|
||||
) -> bool {
|
||||
if tlsa.has_end_entities {
|
||||
for record in tlsa.entries.iter().filter(|record| record.is_end_entity) {
|
||||
if record_matches(record, &parsed[0], certificates[0].as_ref()) {
|
||||
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],
|
||||
certificates: &[CertificateDer<'_>],
|
||||
parsed: &[X509Certificate<'_>],
|
||||
) -> bool {
|
||||
if !tlsa.has_intermediates {
|
||||
return false;
|
||||
}
|
||||
|
||||
let end_entity = match EndEntityCert::try_from(&certificates[0]) {
|
||||
Ok(end_entity) => end_entity,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut anchors: Vec<TrustAnchor<'static>> = Vec::new();
|
||||
|
||||
for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) {
|
||||
match (record.is_spki, record.matching) {
|
||||
(false, TlsaMatching::Full) => {
|
||||
let der = CertificateDer::from(record.data.clone());
|
||||
if let Ok(anchor) = anchor_from_trusted_cert(&der) {
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
}
|
||||
(true, TlsaMatching::Full) => {
|
||||
if let Some(depth) = (1..certificates.len())
|
||||
.find(|&depth| parsed[depth].public_key().raw == record.data.as_slice())
|
||||
{
|
||||
if let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) {
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
} else if let Some(spki) = der_value(&record.data) {
|
||||
for depth in 1..certificates.len() {
|
||||
if is_chain_top(parsed, depth)
|
||||
&& let Some(subject) = der_value(parsed[depth].issuer().as_raw())
|
||||
{
|
||||
anchors.push(TrustAnchor {
|
||||
subject: Der::from(subject.to_vec()),
|
||||
subject_public_key_info: Der::from(spki.to_vec()),
|
||||
name_constraints: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for depth in 1..certificates.len() {
|
||||
if record_matches(record, &parsed[depth], certificates[depth].as_ref())
|
||||
&& let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth])
|
||||
{
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if anchors.is_empty()
|
||||
|| end_entity
|
||||
.verify_for_usage(
|
||||
ALL_VERIFICATION_ALGS,
|
||||
&anchors,
|
||||
&certificates[1..],
|
||||
UnixTime::now(),
|
||||
KeyUsage::server_auth(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
|| !reference_ids.iter().any(|reference| {
|
||||
ServerName::try_from(*reference)
|
||||
.map(|name| end_entity.verify_is_valid_for_subject_name(&name).is_ok())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
false
|
||||
} else {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::TlsaRecordMatch),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
Type = "trust-anchor",
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chain_top(parsed: &[X509Certificate<'_>], depth: usize) -> bool {
|
||||
let issuer = parsed[depth].issuer().as_raw();
|
||||
!parsed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(other, cert)| other != depth && cert.subject().as_raw() == issuer)
|
||||
}
|
||||
|
||||
fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) -> bool {
|
||||
let selected: &[u8] = if record.is_spki {
|
||||
cert.public_key().raw
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
|
||||
match record.matching {
|
||||
TlsaMatching::Full => selected == record.data.as_slice(),
|
||||
TlsaMatching::Sha256 => Sha256::digest(selected).as_slice() == record.data.as_slice(),
|
||||
TlsaMatching::Sha512 => Sha512::digest(selected).as_slice() == record.data.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn der_value(der: &[u8]) -> Option<&[u8]> {
|
||||
Any::from_der(der).ok().map(|(_, any)| any.data)
|
||||
}
|
||||
Reference in New Issue
Block a user