use std::collections::HashMap;
use std::time::Duration;
use hickory_resolver::config::ResolverConfig;
use hickory_resolver::lookup::Lookup;
use hickory_resolver::net::NetError;
use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
use hickory_resolver::proto::op::{Message, ResponseCode};
use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as HickoryRecordType};
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use super::resolver::{build_plain_resolver, fqdn, google_or_pinned, lookup_with_tcp_fallback};
use super::transport::Transport;
use super::DEFAULT_DNS_TIMEOUT;
use crate::error::{Result, SeerError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnssecReport {
pub domain: String,
pub enabled: bool,
pub has_ds_records: bool,
pub has_dnskey_records: bool,
pub ds_records: Vec<DsInfo>,
pub dnskey_records: Vec<DnskeyInfo>,
pub issues: Vec<String>,
pub status: String,
pub chain_valid: bool,
#[serde(default = "default_authentication_tier")]
pub authentication_tier: AuthenticationTier,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthenticationTier {
Unsigned,
DigestOnly,
}
fn default_authentication_tier() -> AuthenticationTier {
AuthenticationTier::DigestOnly
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DsInfo {
pub key_tag: u16,
pub algorithm: u8,
pub digest_type: u8,
pub digest: String,
pub algorithm_name: String,
pub digest_type_name: String,
pub matched_key: bool,
pub digest_verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnskeyInfo {
pub flags: u16,
pub protocol: u8,
pub algorithm: u8,
pub key_tag: u16,
pub is_ksk: bool,
pub is_zsk: bool,
pub algorithm_name: String,
}
const MAX_ZONE_WALK: usize = 8;
const ZONE_WALK_BUDGET_TIMEOUTS: u32 = 4;
const CHECK_BUDGET_TIMEOUTS: u32 = ZONE_WALK_BUDGET_TIMEOUTS + 2;
#[derive(Debug, PartialEq, Eq)]
enum ZoneApex {
Found(String),
NxDomain,
Unknown(Option<String>),
}
pub struct DnssecChecker {
resolver: TokioResolver,
config: ResolverConfig,
transport: Transport,
timeout: Duration,
}
impl Default for DnssecChecker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
struct ReadFailure {
detail: String,
timed_out: bool,
}
impl From<&NetError> for ReadFailure {
fn from(e: &NetError) -> Self {
Self {
detail: e.to_string(),
timed_out: matches!(e, NetError::Timeout),
}
}
}
impl std::fmt::Display for ReadFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.detail)
}
}
impl DnssecChecker {
pub fn new() -> Self {
Self::with_config(google_or_pinned(None), DEFAULT_DNS_TIMEOUT)
}
fn with_config(config: ResolverConfig, timeout: Duration) -> Self {
Self {
resolver: build_plain_resolver(config.clone(), timeout),
config,
transport: Transport::new(timeout),
timeout,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.dns_timeout())
}
pub fn with_timeout(self, timeout: Duration) -> Self {
Self::with_config(self.config, timeout)
}
#[cfg(test)]
fn with_upstream(self, ip: std::net::IpAddr, port: u16) -> Self {
Self::with_config(google_or_pinned(Some((ip, port))), self.timeout)
}
async fn lookup(
&self,
zone: &str,
wire_type: HickoryRecordType,
) -> std::result::Result<Lookup, NetError> {
lookup_with_tcp_fallback(
&self.resolver,
&self.config,
self.timeout,
&fqdn(zone),
wire_type,
)
.await
}
async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, ReadFailure> {
match self.lookup(zone, HickoryRecordType::DS).await {
Ok(lookup) => Ok(lookup
.answers()
.iter()
.filter_map(|record| match &record.data {
RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
_ => None,
})
.collect()),
Err(e) if e.is_no_records_found() => Ok(Vec::new()),
Err(e) => Err(ReadFailure::from(&e)),
}
}
async fn lookup_dnskeys(
&self,
zone: &str,
) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, ReadFailure> {
match self.lookup(zone, HickoryRecordType::DNSKEY).await {
Ok(lookup) => Ok(lookup
.answers()
.iter()
.filter_map(|record| match &record.data {
RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
Some((key.clone(), key.calculate_key_tag().ok()))
}
_ => None,
})
.collect()),
Err(e) if e.is_no_records_found() => Ok(Vec::new()),
Err(e) => Err(ReadFailure::from(&e)),
}
}
async fn find_zone_apex(&self, name: &str) -> ZoneApex {
let mut candidate = name;
for step in 0..MAX_ZONE_WALK {
let qname = match Name::from_ascii(fqdn(candidate)) {
Ok(qname) => qname,
Err(e) => return ZoneApex::Unknown(Some(format!("invalid name {candidate}: {e}"))),
};
let request = self.transport.request(qname, HickoryRecordType::SOA, true);
let response = self
.transport
.query(
self.config.name_servers(),
&request,
self.transport.deadline(),
)
.await;
let failure = match response {
Ok(message) => match soa_step(candidate, &message, step == 0) {
SoaStep::Apex(apex) => return ZoneApex::Found(apex),
SoaStep::NxDomain => return ZoneApex::NxDomain,
SoaStep::Up => None,
SoaStep::Failed(code) => Some(code.to_string()),
},
Err(no_response) => Some(no_response.to_string()),
};
if let Some(reason) = failure {
return match self.lookup_ds(candidate).await {
Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
_ => ZoneApex::Unknown(Some(format!(
"SOA query for {candidate} failed: {reason}"
))),
};
}
match candidate.split_once('.') {
Some((_, parent)) if parent.contains('.') => candidate = parent,
_ => break,
}
}
ZoneApex::Unknown(None)
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
let domain = crate::validation::normalize_host(domain)?;
debug!(domain = %domain, "Checking DNSSEC");
let started = tokio::time::Instant::now();
let walk_budget = self.timeout.saturating_mul(ZONE_WALK_BUDGET_TIMEOUTS);
let budget = self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS);
let apex = tokio::time::timeout_at(started + walk_budget, self.find_zone_apex(&domain))
.await
.map_err(|_| {
SeerError::Timeout(format!(
"DNSSEC check for {domain} gave up after {walk_budget:?} without finding \
the zone that holds it"
))
})?;
let mut issues = Vec::new();
let zone = match apex {
ZoneApex::Found(zone) => {
if zone != domain {
issues.push(format!(
"{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
enclosing zone {zone}"
));
}
zone
}
ZoneApex::NxDomain => {
issues.push(format!("{domain} does not exist (NXDOMAIN)"));
domain.clone()
}
ZoneApex::Unknown(reason) => {
let fallback = crate::validation::normalize_domain(&domain)?;
if let Some(reason) = reason {
issues.push(format!(
"could not determine the zone enclosing {domain} ({reason}); \
evaluated {fallback}"
));
}
fallback
}
};
let deadline = started + budget;
let out_of_time = || ReadFailure {
detail: format!("timed out \u{2014} the check's {budget:?} budget ran out"),
timed_out: true,
};
let (ds_result, dnskey_result) = tokio::join!(
tokio::time::timeout_at(deadline, self.lookup_ds(&zone)),
tokio::time::timeout_at(deadline, self.lookup_dnskeys(&zone)),
);
let ds_result = ds_result.unwrap_or_else(|_| Err(out_of_time()));
let dnskey_result = dnskey_result.unwrap_or_else(|_| Err(out_of_time()));
if let (Err(ds), Err(dnskey)) = (&ds_result, &dnskey_result) {
let detail =
format!("DNSSEC check for {zone} read neither DS ({ds}) nor DNSKEY ({dnskey})");
return Err(if ds.timed_out && dnskey.timed_out {
SeerError::Timeout(detail)
} else {
SeerError::DnsError(detail)
});
}
let ds_records = ds_result.unwrap_or_else(|e| {
issues.push(format!("DS query failed: {e}"));
Vec::new()
});
let dnskey_query_failed = dnskey_result.is_err();
let dnskeys = dnskey_result.unwrap_or_else(|e| {
issues.push(format!("DNSKEY query failed: {e}"));
Vec::new()
});
let has_ds = !ds_records.is_empty();
let has_dnskey = !dnskeys.is_empty();
let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
for (key, tag) in &dnskeys {
if let Some(tag) = tag {
dnskey_map
.entry((*tag, u8::from(key.public_key().algorithm())))
.or_default()
.push(key);
}
}
let dnskey_info: Vec<DnskeyInfo> = dnskeys
.iter()
.map(|(key, tag)| {
let flags = key.flags();
let algorithm = u8::from(key.public_key().algorithm());
let is_sep = flags & 0x0001 != 0;
let is_zone = flags & 0x0100 != 0;
DnskeyInfo {
flags,
protocol: 3,
algorithm,
key_tag: tag.unwrap_or(0),
is_ksk: is_sep && is_zone,
is_zsk: is_zone && !is_sep,
algorithm_name: algorithm_name(algorithm),
}
})
.collect();
let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
});
let ds_info: Vec<DsInfo> = ds_records
.iter()
.map(|ds| {
let key_tag = ds.key_tag();
let algorithm = u8::from(ds.algorithm());
let digest_type = u8::from(ds.digest_type());
let mut matched_key = false;
let mut digest_verified = false;
if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
matched_key = true;
match Self::to_hickory_digest_type(digest_type) {
Some(hickory_dt) => {
digest_verified = candidates.iter().any(|candidate| {
candidate
.to_digest(&zone_name, hickory_dt)
.is_ok_and(|computed| computed.as_ref() == ds.digest())
});
if !digest_verified {
issues.push(format!(
"DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
key_tag
));
}
}
None => {
issues.push(format!(
"DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
key_tag, digest_type
));
}
}
} else if has_dnskey {
issues.push(format!(
"DS record (key_tag={}) has no matching DNSKEY",
key_tag
));
}
DsInfo {
key_tag,
algorithm,
digest_type,
digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
algorithm_name: algorithm_name(algorithm),
digest_type_name: digest_type_name(digest_type),
matched_key,
digest_verified,
}
})
.collect();
let ds_key_tags: std::collections::HashSet<u16> =
ds_info.iter().map(|ds| ds.key_tag).collect();
for key in &dnskey_info {
if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
issues.push(format!(
"DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
key.key_tag
));
}
}
issues.extend(deprecation_issues(&ds_info, &dnskey_info));
let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
if has_ds && status == "partial" {
issues.push(
"DS records exist at the parent, but every one uses an algorithm or digest \
type validating resolvers do not support \u{2014} the zone is treated as unsigned"
.to_string(),
);
} else if has_ds && !has_dnskey {
issues.push(if dnskey_query_failed {
"DS records exist at the parent but the DNSKEY query failed \u{2014} a \
validating upstream returns SERVFAIL when the chain is broken, so validating \
resolvers are likely failing this zone"
.to_string()
} else {
"DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
validating resolvers will fail (SERVFAIL) this zone"
.to_string()
});
}
if !has_ds && has_dnskey {
issues.push(
"DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
.to_string(),
);
}
let enabled = has_ds || has_dnskey;
let authentication_tier = if enabled {
AuthenticationTier::DigestOnly
} else {
AuthenticationTier::Unsigned
};
Ok(DnssecReport {
domain,
enabled,
has_ds_records: has_ds,
has_dnskey_records: has_dnskey,
ds_records: ds_info,
dnskey_records: dnskey_info,
issues,
status: status.to_string(),
chain_valid,
authentication_tier,
})
}
fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
let dt = DigestType::from(digest_type);
if dt.is_supported() {
Some(dt)
} else {
None
}
}
}
fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
let has_ds = !ds_info.is_empty();
let chain_valid = has_dnskey
&& ds_info
.iter()
.any(|ds| ds.matched_key && ds.digest_verified);
let status = match (has_ds, has_dnskey) {
(true, true) if chain_valid => "signed",
(true, _) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
(true, _) => "partial",
(false, true) => "partial",
(false, false) => "unsigned",
};
(chain_valid, status)
}
fn ds_is_validatable(ds: &DsInfo) -> bool {
matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
&& matches!(ds.digest_type, 1 | 2 | 4)
}
fn is_deprecated_algorithm(algorithm: u8) -> bool {
matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
}
fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
let mut issues = Vec::new();
for ds in ds_info {
if is_deprecated_algorithm(ds.algorithm) {
issues.push(format!(
"DS record uses deprecated algorithm {} ({})",
ds.algorithm, ds.algorithm_name
));
}
if ds.digest_type == 1 {
issues.push(
"DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
.to_string(),
);
}
}
for key in dnskey_info {
if is_deprecated_algorithm(key.algorithm) {
issues.push(format!(
"DNSKEY record uses deprecated algorithm {} ({})",
key.algorithm, key.algorithm_name
));
}
}
issues
}
fn normalize_owner(name: &Name) -> String {
name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
}
fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
!owner.is_empty()
&& (owner == name
|| name
.strip_suffix(owner)
.is_some_and(|prefix| prefix.ends_with('.')))
}
#[derive(Debug, PartialEq, Eq)]
enum SoaStep {
Apex(String),
NxDomain,
Up,
Failed(ResponseCode),
}
fn soa_step(candidate: &str, message: &Message, first: bool) -> SoaStep {
let owns_cname = message
.answers
.iter()
.any(|r| matches!(r.data, RData::CNAME(_)) && normalize_owner(&r.name) == candidate);
if owns_cname {
return SoaStep::Up;
}
match message.metadata.response_code {
ResponseCode::NoError => {}
ResponseCode::NXDomain if first => return SoaStep::NxDomain,
ResponseCode::NXDomain => {}
code => return SoaStep::Failed(code),
}
match soa_apex(
candidate,
message.answers.iter().chain(&message.authorities),
) {
Some(apex) => SoaStep::Apex(apex),
None => SoaStep::Up,
}
}
fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
records
.filter(|record| matches!(record.data, RData::SOA(_)))
.map(|record| normalize_owner(&record.name))
.find(|owner| is_self_or_ancestor(owner, name))
}
fn algorithm_name(algo: u8) -> String {
match algo {
1 => "RSA/MD5 (deprecated)".to_string(),
3 => "DSA/SHA-1 (deprecated)".to_string(),
5 => "RSA/SHA-1 (deprecated)".to_string(),
6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
8 => "RSA/SHA-256".to_string(),
10 => "RSA/SHA-512".to_string(),
12 => "ECC-GOST (deprecated)".to_string(),
13 => "ECDSA P-256/SHA-256".to_string(),
14 => "ECDSA P-384/SHA-384".to_string(),
15 => "Ed25519".to_string(),
16 => "Ed448".to_string(),
_ => format!("Unknown ({})", algo),
}
}
fn digest_type_name(dtype: u8) -> String {
match dtype {
1 => "SHA-1".to_string(),
2 => "SHA-256".to_string(),
4 => "SHA-384".to_string(),
_ => format!("Unknown ({})", dtype),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_algorithm_names() {
assert_eq!(algorithm_name(8), "RSA/SHA-256");
assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
assert_eq!(algorithm_name(15), "Ed25519");
assert!(algorithm_name(5).contains("deprecated"));
}
#[test]
fn test_digest_type_names() {
assert_eq!(digest_type_name(1), "SHA-1");
assert_eq!(digest_type_name(2), "SHA-256");
}
#[test]
fn default_tier_is_digest_only() {
assert_eq!(
default_authentication_tier(),
AuthenticationTier::DigestOnly
);
}
#[test]
fn from_config_applies_dns_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.dns_secs = 9;
assert_eq!(
DnssecChecker::from_config(&config).timeout,
Duration::from_secs(9)
);
}
#[test]
fn test_report_serialization() {
let report = DnssecReport {
domain: "example.com".to_string(),
enabled: true,
has_ds_records: true,
has_dnskey_records: true,
ds_records: vec![DsInfo {
key_tag: 12345,
algorithm: 13,
digest_type: 2,
digest: "ABCDEF".to_string(),
algorithm_name: "ECDSA P-256/SHA-256".to_string(),
digest_type_name: "SHA-256".to_string(),
matched_key: true,
digest_verified: true,
}],
dnskey_records: vec![DnskeyInfo {
flags: 257,
protocol: 3,
algorithm: 13,
key_tag: 12345,
is_ksk: true,
is_zsk: false,
algorithm_name: "ECDSA P-256/SHA-256".to_string(),
}],
issues: vec![],
status: "signed".to_string(),
chain_valid: true,
authentication_tier: AuthenticationTier::DigestOnly,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"enabled\":true"));
assert!(json.contains("\"chain_valid\":true"));
assert!(json.contains("\"matched_key\":true"));
assert!(json.contains("\"digest_verified\":true"));
assert!(json.contains("\"key_tag\":12345"));
}
fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
DsInfo {
key_tag,
algorithm,
digest_type,
digest: "ABCDEF".to_string(),
algorithm_name: algorithm_name(algorithm),
digest_type_name: digest_type_name(digest_type),
matched_key: matched,
digest_verified: verified,
}
}
#[test]
fn chain_status_signed_when_every_computable_ds_verifies() {
let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
assert_eq!(derive_chain_status(&all, true), (true, "signed"));
}
#[test]
fn chain_status_misconfigured_when_ds_unmatched() {
let unmatched = [ds(65000, 13, 2, false, false)];
assert_eq!(
derive_chain_status(&unmatched, true),
(false, "misconfigured")
);
}
#[test]
fn chain_status_misconfigured_on_digest_mismatch() {
let mismatch = [ds(12345, 13, 2, true, false)];
assert_eq!(
derive_chain_status(&mismatch, true),
(false, "misconfigured")
);
}
#[test]
fn chain_status_one_authenticating_ds_is_enough() {
let rollover = [ds(1, 13, 2, true, true), ds(2, 13, 2, false, false)];
assert_eq!(derive_chain_status(&rollover, true), (true, "signed"));
let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
assert_eq!(derive_chain_status(&mixed, true), (true, "signed"));
}
#[test]
fn chain_status_uncomputable_digest_alone_is_partial() {
let gost_only = [ds(1, 13, 3, true, false)];
assert_eq!(derive_chain_status(&gost_only, true), (false, "partial"));
assert_eq!(derive_chain_status(&gost_only, false), (false, "partial"));
let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
}
#[test]
fn chain_status_ds_without_dnskey_is_misconfigured() {
let stale = [ds(12345, 13, 2, false, false)];
assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
}
#[test]
fn chain_status_ds_validators_ignore_stays_partial() {
let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
let unknown_digest = [ds(3, 13, 99, false, false)];
assert_eq!(
derive_chain_status(&unknown_digest, false),
(false, "partial")
);
}
#[test]
fn chain_status_island_and_unsigned() {
assert_eq!(derive_chain_status(&[], true), (false, "partial"));
assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
}
#[test]
fn deprecated_algorithms_include_7_and_12() {
let key = |algorithm: u8| DnskeyInfo {
flags: 257,
protocol: 3,
algorithm,
key_tag: 1,
is_ksk: true,
is_zsk: false,
algorithm_name: algorithm_name(algorithm),
};
let issues = deprecation_issues(
&[
ds(1, 7, 2, true, true),
ds(2, 12, 2, true, true),
ds(3, 13, 2, true, true),
],
&[key(7), key(12), key(8)],
);
assert_eq!(
issues
.iter()
.filter(|i| i.contains("deprecated algorithm"))
.count(),
4,
"{issues:?}"
);
assert!(issues
.iter()
.any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
assert!(issues
.iter()
.any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
}
#[test]
fn soa_apex_matches_self_or_ancestor_only() {
assert!(is_self_or_ancestor("example.com", "example.com"));
assert!(is_self_or_ancestor("example.com", "api.example.com"));
assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
assert!(!is_self_or_ancestor("api.example.com", "example.com"));
let soa = |owner: &str| {
Record::from_rdata(
Name::from_ascii(owner).unwrap(),
300,
soa_rdata(owner.trim_end_matches('.')),
)
};
let records = [soa("cdn-target.test."), soa("Example.COM.")];
assert_eq!(
soa_apex("api.example.com", records.iter()),
Some("example.com".to_string()),
"a CNAME target's SOA must be skipped; the enclosing one is found"
);
assert_eq!(soa_apex("api.other.test", records.iter()), None);
}
use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
use hickory_resolver::proto::rr::RecordType as WireType;
use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
fn test_key() -> DNSKEY {
DNSKEY::new(
true,
true,
false,
PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
)
}
fn ds_for(zone: &str, key: &DNSKEY) -> DS {
let digest = key
.to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
.unwrap();
DS::new(
key.calculate_key_tag().unwrap(),
Algorithm::ED25519,
DigestType::SHA256,
digest.as_ref().to_vec(),
)
}
fn dnskey_rdata(key: DNSKEY) -> RData {
RData::DNSSEC(DNSSECRData::DNSKEY(key))
}
fn ds_rdata(ds: DS) -> RData {
RData::DNSSEC(DNSSECRData::DS(ds))
}
fn mock_checker(port: u16) -> DnssecChecker {
DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
}
#[tokio::test]
async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
("signed.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
}
("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
_ => MockReply::NoData,
})
.await;
let report = mock_checker(port).check("api.signed.test").await.unwrap();
assert_eq!(report.domain, "api.signed.test");
assert_eq!(report.status, "signed", "{report:?}");
assert!(report.chain_valid);
assert!(
report.issues[0].contains("enclosing zone signed.test"),
"the evaluated zone must be named: {:?}",
report.issues
);
assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
let tag = test_key().calculate_key_tag().unwrap();
assert_eq!(report.dnskey_records[0].key_tag, tag);
assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
}
#[tokio::test]
async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
("api.signed.test", _) => MockReply::Rcode(ResponseCode::ServFail),
("signed.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
}
("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
_ => MockReply::NoData,
})
.await;
let report = mock_checker(port)
.check("deep.api.signed.test")
.await
.unwrap();
assert_eq!(report.status, "signed", "{report:?}");
assert!(report.issues[0].contains("enclosing zone signed.test"));
}
#[tokio::test]
async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("broken.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
}
("broken.test", _) => MockReply::Rcode(ResponseCode::ServFail),
_ => MockReply::NoData,
})
.await;
let report = mock_checker(port).check("broken.test").await.unwrap();
assert!(report.has_ds_records);
assert!(!report.has_dnskey_records);
assert_eq!(report.status, "misconfigured", "{report:?}");
assert!(!report.chain_valid);
assert!(
report
.issues
.iter()
.any(|i| i.contains("DNSKEY query failed")),
"{:?}",
report.issues
);
assert!(
!report.issues.iter().any(|i| i.contains("not a zone apex")),
"{:?}",
report.issues
);
}
#[tokio::test]
async fn both_queries_failing_is_an_error_not_unsigned() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
("signed.test", WireType::DS | WireType::DNSKEY) => {
MockReply::Rcode(ResponseCode::ServFail)
}
_ => MockReply::NoData,
})
.await;
let err = mock_checker(port).check("signed.test").await.unwrap_err();
assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
assert!(err.to_string().contains("neither DS"), "{err}");
}
#[tokio::test]
async fn both_queries_cut_off_by_the_deadline_is_a_timeout() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
(_, WireType::DS | WireType::DNSKEY) => MockReply::NoReply,
_ => MockReply::NoData,
})
.await;
let err = mock_checker(port)
.with_timeout(Duration::from_millis(100))
.check("signed.test")
.await
.unwrap_err();
assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
}
#[tokio::test]
async fn a_dangling_cname_is_not_read_as_a_nonexistent_name() {
use crate::dns::test_support::{cname_rdata, record};
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("www.signed.test", _) => MockReply::Negative {
code: ResponseCode::NXDomain,
chain: vec![record(
"www.signed.test",
300,
cname_rdata("gone.elsewhere.test"),
)],
soa: "elsewhere.test",
},
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
("signed.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
}
("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
_ => MockReply::NoData,
})
.await;
let report = mock_checker(port).check("www.signed.test").await.unwrap();
assert!(
!report.issues.iter().any(|i| i.contains("does not exist")),
"www.signed.test owns a CNAME, it exists: {:?}",
report.issues
);
assert_eq!(report.status, "signed", "{report:?}");
}
#[tokio::test]
async fn a_truncated_dnskey_rrset_is_asked_over_tcp() {
use crate::dns::resolver::single_server_config;
use crate::dns::test_support::spawn_mock_dns_split;
use hickory_resolver::config::ResolverConfig;
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
for _ in 0..2 {
let answer = |qname: &str, qtype| match (qname, qtype) {
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
("signed.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
}
("signed.test", WireType::DNSKEY) => {
MockReply::Answer(vec![dnskey_rdata(test_key())])
}
_ => MockReply::NoData,
};
let port = spawn_mock_dns_split(
move |qname, qtype| match qtype {
WireType::DNSKEY => MockReply::Truncated,
_ => answer(qname, qtype),
},
answer,
)
.await;
for server in
single_server_config("127.0.0.1".parse().unwrap(), port, true).name_servers()
{
config.add_name_server(server.clone());
}
}
let checker = DnssecChecker::with_config(config, Duration::from_secs(2));
let report = checker.check("signed.test").await.unwrap();
assert!(
!report
.issues
.iter()
.any(|i| i.contains("DNSKEY query failed")),
"{:?}",
report.issues
);
assert_eq!(report.status, "signed", "{report:?}");
}
#[tokio::test]
async fn a_cname_targets_soa_is_not_taken_for_the_names_apex() {
use crate::dns::test_support::{cname_rdata, record};
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("cdn.shop.signed.test", _) => MockReply::Negative {
code: ResponseCode::NoError,
chain: vec![record(
"cdn.shop.signed.test",
300,
cname_rdata("lb.signed.test"),
)],
soa: "signed.test",
},
("lb.signed.test", _) => MockReply::NoDataWithSoa("signed.test"),
("shop.signed.test", WireType::SOA) => {
MockReply::Answer(vec![soa_rdata("shop.signed.test")])
}
("shop.signed.test", WireType::DS) => {
MockReply::Answer(vec![ds_rdata(ds_for("shop.signed.test", &test_key()))])
}
("shop.signed.test", WireType::DNSKEY) => {
MockReply::Answer(vec![dnskey_rdata(test_key())])
}
("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
_ => MockReply::NoData,
})
.await;
let report = mock_checker(port)
.check("cdn.shop.signed.test")
.await
.unwrap();
assert!(
report.issues[0].contains("enclosing zone shop.signed.test"),
"evaluated the wrong zone: {:?}",
report.issues
);
assert_eq!(report.status, "signed", "{report:?}");
}
#[tokio::test]
async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
let report = mock_checker(port).check("nope.signed.test").await.unwrap();
assert_eq!(report.status, "unsigned");
assert!(
report.issues.iter().any(|i| i.contains("does not exist")),
"{:?}",
report.issues
);
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn test_live_dnssec_check_cloudflare() {
let checker = DnssecChecker::new();
let report = checker.check("cloudflare.com").await.unwrap();
assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
assert!(report.has_ds_records, "should have DS records");
assert!(report.has_dnskey_records, "should have DNSKEY records");
assert!(report.chain_valid, "cloudflare.com chain should be valid");
assert_eq!(report.status, "signed");
for ds in &report.ds_records {
assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
assert!(
ds.digest_verified,
"DS key_tag={} digest should verify",
ds.key_tag
);
}
for key in &report.dnskey_records {
assert!(key.key_tag > 0, "key_tag should be computed");
}
}
#[tokio::test]
#[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
async fn test_live_dnssec_check_insecure() {
let checker = DnssecChecker::new();
let report = checker.check("wikipedia.org").await.unwrap();
assert!(!report.chain_valid);
assert_eq!(report.status, "unsigned");
}
#[tokio::test]
async fn the_zone_walk_is_held_to_its_budget() {
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use hickory_resolver::config::ResolverConfig;
use crate::dns::resolver::single_server_config;
use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
let timeout = Duration::from_millis(200);
let seen: Arc<Mutex<HashMap<String, usize>>> = Arc::default();
let log = seen.clone();
let silent = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
let answering = spawn_mock_dns_fn(move |qname, qtype| {
*log.lock()
.unwrap()
.entry(format!("{qname} {qtype}"))
.or_insert(0) += 1;
MockReply::NoData
})
.await;
let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
for port in [silent, answering] {
for server in
single_server_config(Ipv4Addr::LOCALHOST.into(), port, false).name_servers()
{
config.add_name_server(server.clone());
}
}
let checker = DnssecChecker::with_config(config, timeout);
let started = std::time::Instant::now();
let err = checker
.check("a.b.c.d.e.f.g.h.i.seer.test")
.await
.expect_err("the walk runs out of time");
let elapsed = started.elapsed();
assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
assert!(err.to_string().contains("zone"), "{err}");
let walk_budget = timeout * ZONE_WALK_BUDGET_TIMEOUTS;
assert!(elapsed < walk_budget + timeout, "took {elapsed:?}");
assert!(
!seen.lock().unwrap().keys().any(|k| k.ends_with(" DNSKEY")),
"{:?}",
seen.lock().unwrap().keys()
);
}
}