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) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-14 15:12:15 +02:00
co-authored by Claude Opus 4.6
parent 07384116cb
commit b2b470dd69
5 changed files with 69 additions and 9 deletions
+2
View File
@@ -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)
-1
View File
@@ -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
+1
View File
@@ -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 <roman@shl.dev> Mon, 14 Apr 2026 00:00:00 +0000
+23
View File
@@ -231,6 +231,29 @@ impl DnsUpdater {
Ok(())
}
/// Query TLSA records at a given name.
pub async fn query_tlsa(&mut self, name: &Name) -> Result<Vec<TlsaRecord>> {
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(
+43 -8
View File
@@ -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(())
}