use hickory_proto::dnssec::Proof;
use hickory_proto::rr::RecordType;
use hickory_resolver::TokioResolver;
use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use crate::error::{Result, ShoheError};
use crate::resolver::{proof_to_trust, TrustState};
const DNSSEC_RESOLVER_FALLBACK: IpAddr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnssecChain {
pub domain: String,
pub steps: Vec<DnssecStep>,
pub overall: TrustState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnssecStep {
pub label: String,
pub step_type: DnssecStepType,
pub status: TrustState,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DnssecStepType {
TrustAnchor,
Ds,
Dnskey,
Answer,
}
impl std::fmt::Display for DnssecStepType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DnssecStepType::TrustAnchor => write!(f, "Trust Anchor"),
DnssecStepType::Ds => write!(f, "DS"),
DnssecStepType::Dnskey => write!(f, "DNSKEY"),
DnssecStepType::Answer => write!(f, "Answer"),
}
}
}
fn zone_step_status(has_records: bool, overall: &TrustState) -> TrustState {
match overall {
TrustState::Secure => TrustState::Secure,
TrustState::Indeterminate => TrustState::Indeterminate,
TrustState::Bogus | TrustState::Insecure => {
if has_records {
TrustState::Secure
} else {
overall.clone()
}
}
}
}
pub async fn build_chain(
domain: &str,
record_type: RecordType,
resolver_ip: Option<IpAddr>,
) -> Result<DnssecChain> {
if domain.trim_end_matches('.').split('.').any(|l| l.is_empty()) {
return Err(ShoheError::Parse(format!(
"Invalid domain: empty label in '{domain}'"
)));
}
let resolver_ip = resolver_ip.unwrap_or(DNSSEC_RESOLVER_FALLBACK);
let overall = get_overall_trust(domain, record_type, resolver_ip).await?;
let ns = NameServerConfig::udp(resolver_ip);
let mut opts = ResolverOpts::default();
opts.attempts = 1;
opts.timeout = std::time::Duration::from_secs(5);
let config = ResolverConfig::from_parts(None, vec![], vec![ns]);
let resolver = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
.with_options(opts)
.build()
.map_err(|e| ShoheError::Transport(format!("Failed to build resolver: {e}")))?;
let zone_labels = build_zone_labels(domain);
let mut steps = Vec::new();
steps.push(DnssecStep {
label: ".".to_string(),
step_type: DnssecStepType::TrustAnchor,
status: TrustState::Secure,
detail: "Root KSK trust anchor (RFC 8509)".to_string(),
});
for zone in &zone_labels {
let has_ds = query_has_records(&resolver, zone, RecordType::DS).await;
let has_dnskey = query_has_records(&resolver, zone, RecordType::DNSKEY).await;
if has_ds {
steps.push(DnssecStep {
label: zone.clone(),
step_type: DnssecStepType::Ds,
status: zone_step_status(true, &overall),
detail: format!("DS record delegates trust to {zone}"),
});
} else {
steps.push(DnssecStep {
label: zone.clone(),
step_type: DnssecStepType::Ds,
status: zone_step_status(false, &overall),
detail: format!("No DS delegation for {zone}"),
});
}
if has_dnskey {
steps.push(DnssecStep {
label: zone.clone(),
step_type: DnssecStepType::Dnskey,
status: zone_step_status(true, &overall),
detail: format!("DNSKEY RRset verified for {zone}"),
});
} else {
steps.push(DnssecStep {
label: zone.clone(),
step_type: DnssecStepType::Dnskey,
status: zone_step_status(false, &overall),
detail: format!("No DNSKEY for {zone}"),
});
}
}
steps.push(DnssecStep {
label: domain.to_string(),
step_type: DnssecStepType::Answer,
status: overall.clone(),
detail: "chain validation complete".to_string(),
});
Ok(DnssecChain {
domain: domain.to_string(),
steps,
overall,
})
}
async fn get_overall_trust(domain: &str, record_type: RecordType, resolver_ip: IpAddr) -> Result<TrustState> {
let ns = NameServerConfig::udp(resolver_ip);
let mut opts = ResolverOpts::default();
opts.validate = true;
opts.attempts = 1;
opts.timeout = std::time::Duration::from_secs(5);
let config = ResolverConfig::from_parts(None, vec![], vec![ns]);
let resolver = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
.with_options(opts)
.build()
.map_err(|e| ShoheError::Transport(format!("Failed to build DNSSEC resolver: {e}")))?;
let lookup = resolver
.lookup(domain, record_type)
.await
.map_err(|e| ShoheError::DnssecValidation(e.to_string()))?;
let overall = lookup
.answers()
.iter()
.map(|r| r.proof)
.reduce(worst_proof)
.map(proof_to_trust)
.unwrap_or(TrustState::Indeterminate);
Ok(overall)
}
fn worst_proof(a: Proof, b: Proof) -> Proof {
let sev = |p: Proof| match p {
Proof::Bogus => 3,
Proof::Indeterminate => 2,
Proof::Insecure => 1,
Proof::Secure => 0,
};
if sev(a) >= sev(b) { a } else { b }
}
async fn query_has_records(resolver: &TokioResolver, name: &str, rtype: RecordType) -> bool {
resolver
.lookup(name, rtype)
.await
.map(|l| !l.answers().is_empty())
.unwrap_or(false)
}
fn build_zone_labels(domain: &str) -> Vec<String> {
let domain = domain.trim_end_matches('.');
let parts: Vec<&str> = domain.split('.').collect();
let mut labels = Vec::new();
for i in (0..parts.len()).rev() {
let label = format!("{}.", parts[i..].join("."));
labels.push(label);
}
labels
}