DANE: Improper TLSA record validation (fixes #2328)
This commit is contained in:
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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("<bill@foobar.org> (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::<MtaTlsStrategy>()
|
||||
.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<Vec<u8>, ParseIntError> {
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct TestCa {
|
||||
issuer: Issuer<'static, KeyPair>,
|
||||
der: CertificateDer<'static>,
|
||||
spki: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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::<String>::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<u8>) {
|
||||
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<TlsaEntry>) -> Tlsa {
|
||||
Tlsa {
|
||||
has_end_entities: entries.iter().any(|entry| entry.is_end_entity),
|
||||
has_intermediates: entries.iter().any(|entry| !entry.is_end_entity),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub trait DnsCache {
|
||||
fn ptr_add(&self, name: IpAddr, value: Vec<String>, valid_until: std::time::Instant);
|
||||
fn mx_add(&self, name: impl ToFqdn, value: Vec<MX>, valid_until: std::time::Instant);
|
||||
fn tlsa_add(&self, name: impl ToFqdn, value: Arc<Tlsa>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user