From b2b470dd696a68d38d7ebb4be78a3ae3cd66881e Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Tue, 14 Apr 2026 15:12:15 +0200 Subject: [PATCH] Implement TLSA record verification in dane-check dane-check now queries the authoritative DNS server for published TLSA records and compares them against expected values computed from the current certificate. Queries are TSIG-signed using the same key as the DNS client config (for view selection support). Reports OK, MISSING, or MISMATCH for each name and exits with code 1 if any check fails. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 ++ README.md | 1 - debian/changelog | 1 + src/dns/update.rs | 23 +++++++++++++++++++++ src/main.rs | 51 +++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9c1ac..cf78384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Validate that every domain has a solver configured (via `solver`, `solvers`, or `default_solver`) - Warn about unused DNS clients, solvers, and hooks in config +- `dane-check` now queries published TLSA records from DNS and verifies them against expected values. Exits with code 1 if any records are missing or mismatched. + ### Changes ## 0.5.0 (2026-04-13) diff --git a/README.md b/README.md index 2d0dcea..95f3f37 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,6 @@ For encrypted credentials, add `LoadCredentialEncrypted=` directives to service - **TSIG HMAC-SHA-512**: BIND 9.18+ truncates HMAC-SHA-512 MACs by default, and the underlying DNS library (hickory-dns) does not support verifying truncated HMACs. Use `hmac-sha256` instead. - RSA key types not yet supported (ECDSA only). -- `dane-check` shows expected TLSA records but does not query DNS to verify. ## License diff --git a/debian/changelog b/debian/changelog index a0732d8..5cc5d5a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -4,6 +4,7 @@ certforge (0.6.0-1) unstable; urgency=low * Multiple listen addresses for HTTP-01 and TLS-ALPN-01 solvers * Fix inline hooks ignoring --dry-run flag * Improve config validation + * dane-check verifies published TLSA records against expected values -- Roman Kivalin Mon, 14 Apr 2026 00:00:00 +0000 diff --git a/src/dns/update.rs b/src/dns/update.rs index 54850db..0d1abfb 100644 --- a/src/dns/update.rs +++ b/src/dns/update.rs @@ -231,6 +231,29 @@ impl DnsUpdater { Ok(()) } + /// Query TLSA records at a given name. + pub async fn query_tlsa(&mut self, name: &Name) -> Result> { + let response = self + .client + .query(name.clone(), hickory_proto::rr::DNSClass::IN, RecordType::TLSA) + .await + .map_err(|e| dns_err("TLSA query failed", e))?; + + let mut records = Vec::new(); + for record in response.answers() { + if let RData::TLSA(tlsa) = record.data() { + records.push(TlsaRecord { + usage: tlsa.cert_usage().into(), + selector: tlsa.selector().into(), + matching_type: tlsa.matching().into(), + association_data: tlsa.cert_data().to_vec(), + }); + } + } + + Ok(records) + } + /// Delete a specific TLSA record. #[allow(dead_code)] pub async fn delete_tlsa_record( diff --git a/src/main.rs b/src/main.rs index 4e3b51b..ccb5e91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,6 +188,8 @@ async fn dane_check( config: &config::Config, name_filter: Option<&str>, ) -> anyhow::Result<()> { + let mut all_ok = true; + for cert_config in &config.certificates { if let Some(filter) = name_filter && cert_config.name != filter { @@ -203,16 +205,45 @@ async fn dane_check( let cert_result = certs::CertInfo::load(&cert_config.cert_path); for dane_config in &cert_config.dane { - match &cert_result { - Ok(cert_info) => { - let records = dane::compute_tlsa_records(cert_info, dane_config)?; - let record = &records[0]; - for name in &dane_config.names { - println!(" {name}: expected TLSA {}", record.to_rdata_string()); - } - } + let expected = match &cert_result { + Ok(cert_info) => dane::compute_tlsa_records(cert_info, dane_config)?, Err(_) => { println!(" Certificate not found, cannot compute expected TLSA records"); + continue; + } + }; + let expected_record = &expected[0]; + + let dns_config = config.dns_client(&dane_config.dns)?; + let signer = dns::tsig::load_tsig_signer(dns_config).await?; + let mut querier = dns::update::DnsUpdater::connect(dns_config, signer).await?; + + for tlsa_name in &dane_config.names { + let name = hickory_proto::rr::Name::from_ascii(tlsa_name) + .map_err(|e| anyhow::anyhow!("invalid TLSA name '{tlsa_name}': {e}"))?; + + match querier.query_tlsa(&name).await { + Ok(published) if published.is_empty() => { + println!(" {tlsa_name}: MISSING (no TLSA records published)"); + println!(" expected: {}", expected_record.to_rdata_string()); + all_ok = false; + } + Ok(published) => { + if published.contains(expected_record) { + println!(" {tlsa_name}: OK"); + } else { + println!(" {tlsa_name}: MISMATCH"); + println!(" expected: {}", expected_record.to_rdata_string()); + for rec in &published { + println!(" found: {}", rec.to_rdata_string()); + } + all_ok = false; + } + } + Err(e) => { + println!(" {tlsa_name}: ERROR ({e})"); + all_ok = false; + } } } } @@ -220,6 +251,10 @@ async fn dane_check( println!(); } + if !all_ok { + std::process::exit(1); + } + Ok(()) }