mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-13 16:00:44 +00:00
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:
co-authored by
Claude Opus 4.6
parent
07384116cb
commit
b2b470dd69
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user