Skip to main content

seer_core/dns/
dnssec.rs

1//! DNSSEC validation reporting.
2//!
3//! Checks the DNSSEC chain for a domain by querying DS and DNSKEY records
4//! and reporting on the validation status. The records are read at the apex
5//! of the zone that holds the name (found by an SOA walk), so a host inside
6//! a signed zone — `api.cloudflare.com` — reports on `cloudflare.com` rather
7//! than as "unsigned".
8//!
9//! **Bounds:** the whole check runs under one deadline of
10//! [`CHECK_BUDGET_TIMEOUTS`] per-query timeouts (30s at the default 5s): the
11//! SOA walk gets [`ZONE_WALK_BUDGET_TIMEOUTS`] of them, and a walk still
12//! running then fails the check with a timeout rather than evaluate a guessed
13//! zone; the concurrent DS and DNSKEY reads get the rest, and one cut off
14//! there is reported as a failed query in `issues`.
15//!
16//! **No verdict without evidence:** when both the DS and the DNSKEY read fail
17//! the check fails too, rather than read the empty result as "unsigned".
18
19use std::collections::HashMap;
20use std::net::IpAddr;
21use std::time::Duration;
22
23use hickory_resolver::net::runtime::TokioRuntimeProvider;
24use hickory_resolver::net::{DnsError, NetError};
25use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
26use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
27use hickory_resolver::proto::op::ResponseCode;
28use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as HickoryRecordType};
29use hickory_resolver::TokioResolver;
30use serde::{Deserialize, Serialize};
31use tracing::{debug, instrument};
32
33use super::resolver::{apply_standard_opts, fqdn, google_or_pinned};
34use super::DEFAULT_DNS_TIMEOUT;
35use crate::error::{Result, SeerError};
36
37/// DNSSEC validation report for a domain.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DnssecReport {
40    /// The domain that was checked.
41    pub domain: String,
42    /// Whether the domain has DNSSEC enabled.
43    pub enabled: bool,
44    /// Whether DS records exist at the parent zone.
45    pub has_ds_records: bool,
46    /// Whether DNSKEY records exist at the domain.
47    pub has_dnskey_records: bool,
48    /// DS records found at the parent zone.
49    pub ds_records: Vec<DsInfo>,
50    /// DNSKEY records found at the domain.
51    pub dnskey_records: Vec<DnskeyInfo>,
52    /// Validation issues found.
53    pub issues: Vec<String>,
54    /// Overall status: "signed", "unsigned", "partial", or "misconfigured".
55    ///
56    /// "signed" needs one DS that authenticates a published DNSKEY — all a
57    /// validator needs (RFC 4035 §5.2), so a DS left over from a key
58    /// rollover (RFC 7583) is an issue, not a failure. "misconfigured" is a
59    /// DS set validators would use in which no DS matches a DNSKEY, or such a
60    /// DS at the parent with no (or an unobtainable) DNSKEY behind it —
61    /// validating resolvers fail the zone. "partial" is a DNSKEY with no DS
62    /// (an island of security), or a DS set validators ignore entirely
63    /// (unsupported algorithms or digest types), which leaves the zone
64    /// unsigned in their eyes.
65    ///
66    /// IMPORTANT: this reflects DS↔DNSKEY *digest consistency* (RFC 4509)
67    /// observed over plain, unauthenticated DNS. It does NOT verify any RRSIG
68    /// signatures, signature validity periods, or a chain of trust to the root
69    /// anchor. "signed" therefore means "the published DS and DNSKEY are
70    /// digest-consistent", NOT "the records are cryptographically
71    /// authenticated" — an on-path or spoofing attacker can fabricate a
72    /// self-consistent DS+DNSKEY pair. Do not treat this as proof of
73    /// authenticity.
74    pub status: String,
75    /// Whether at least one DS record's digest matches a published DNSKEY
76    /// (RFC 4509 digest consistency) — the link a validator follows. DS
77    /// records that match nothing are listed in `issues`. This is NOT
78    /// signature / chain-of-trust validation — see the caveat on `status`.
79    pub chain_valid: bool,
80    /// Machine-readable tier describing the DEPTH of checking that was
81    /// performed, so consumers don't over-trust a digest-only "signed" result.
82    /// The RESULT of those checks lives in `chain_valid` / `status` / `issues`;
83    /// this field says only *what was checked*.
84    #[serde(default = "default_authentication_tier")]
85    pub authentication_tier: AuthenticationTier,
86}
87
88/// The depth of DNSSEC verification performed for a [`DnssecReport`].
89///
90/// This describes *what was checked*, not whether it passed — the pass/fail
91/// result is carried by [`DnssecReport::chain_valid`] and `status`. It exists
92/// so a consumer (or an MCP-driven LLM) does not read digest-consistency as
93/// full cryptographic authentication.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum AuthenticationTier {
97    /// No DNSSEC records are published.
98    Unsigned,
99    /// Only DS↔DNSKEY digest consistency (RFC 4509) was checked. Does NOT
100    /// inspect RRSIG signatures or their validity windows.
101    DigestOnly,
102}
103
104fn default_authentication_tier() -> AuthenticationTier {
105    AuthenticationTier::DigestOnly
106}
107
108/// Summary of a DS record.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct DsInfo {
111    pub key_tag: u16,
112    pub algorithm: u8,
113    pub digest_type: u8,
114    pub digest: String,
115    pub algorithm_name: String,
116    pub digest_type_name: String,
117    /// Whether this DS record's key_tag+algorithm matched a DNSKEY.
118    pub matched_key: bool,
119    /// Whether the computed digest from the matched DNSKEY equals this DS digest.
120    pub digest_verified: bool,
121}
122
123/// Summary of a DNSKEY record.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct DnskeyInfo {
126    pub flags: u16,
127    pub protocol: u8,
128    pub algorithm: u8,
129    /// The RFC 4034 computed key tag.
130    pub key_tag: u16,
131    pub is_ksk: bool,
132    pub is_zsk: bool,
133    pub algorithm_name: String,
134}
135
136/// Upper bound on names probed while walking up to the enclosing zone apex.
137/// A negative answer usually names the apex directly (the SOA in its
138/// AUTHORITY section), so the walk rarely takes more than one step.
139const MAX_ZONE_WALK: usize = 8;
140
141/// The SOA walk's deadline, in per-query timeouts: a lookup hickory
142/// retransmits (`attempts = 2`) takes two, so this covers two such steps —
143/// or the whole walk when the upstream answers promptly. Without it a slow
144/// upstream held the caller for up to [`MAX_ZONE_WALK`] × 2 timeouts before
145/// the records were even asked for.
146const ZONE_WALK_BUDGET_TIMEOUTS: u32 = 4;
147
148/// The whole check's deadline, in per-query timeouts: the walk, then the
149/// concurrent DS and DNSKEY reads (two: one retransmitted lookup).
150const CHECK_BUDGET_TIMEOUTS: u32 = ZONE_WALK_BUDGET_TIMEOUTS + 2;
151
152/// Where [`DnssecChecker::find_zone_apex`] landed.
153#[derive(Debug, PartialEq, Eq)]
154enum ZoneApex {
155    /// The zone apex that owns (or encloses) the name.
156    Found(String),
157    /// The name does not exist (NXDOMAIN) — there is no zone to walk to.
158    NxDomain,
159    /// The apex could not be determined; the reason, when a query failed.
160    Unknown(Option<String>),
161}
162
163/// Checks DNSSEC configuration for a domain.
164pub struct DnssecChecker {
165    /// Plain (non-validating) resolver for the SOA / DS / DNSKEY reads. A
166    /// single resolver and a single DNSKEY query feed both the displayed key
167    /// list and the digest verification, so the two can never disagree.
168    resolver: TokioResolver,
169    /// The per-query timeout the resolver was built with.
170    timeout: Duration,
171    /// Test-only: pin every resolver this checker builds to a loopback mock.
172    #[cfg(test)]
173    upstream: Option<(IpAddr, u16)>,
174}
175
176impl Default for DnssecChecker {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182/// A failed DS or DNSKEY read: what went wrong, and whether it was the
183/// resolver or the check's deadline running out of time.
184#[derive(Debug)]
185struct ReadFailure {
186    detail: String,
187    timed_out: bool,
188}
189
190impl From<&NetError> for ReadFailure {
191    fn from(e: &NetError) -> Self {
192        Self {
193            detail: e.to_string(),
194            timed_out: matches!(e, NetError::Timeout),
195        }
196    }
197}
198
199impl std::fmt::Display for ReadFailure {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.write_str(&self.detail)
202    }
203}
204
205impl DnssecChecker {
206    pub fn new() -> Self {
207        Self {
208            resolver: Self::build_resolver(None, DEFAULT_DNS_TIMEOUT),
209            timeout: DEFAULT_DNS_TIMEOUT,
210            #[cfg(test)]
211            upstream: None,
212        }
213    }
214
215    /// Builds a checker honoring `~/.seer/config.toml` (`timeouts.dns_secs`).
216    /// The records are always read through the default upstream, so the
217    /// configured nameserver does not apply.
218    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
219        Self::new().with_timeout(config.dns_timeout())
220    }
221
222    /// Sets the per-query timeout.
223    pub fn with_timeout(mut self, timeout: Duration) -> Self {
224        self.timeout = timeout;
225        self.resolver = Self::build_resolver(self.upstream(), timeout);
226        self
227    }
228
229    /// Test-only: point the checker at a loopback mock server.
230    #[cfg(test)]
231    fn with_upstream(mut self, ip: IpAddr, port: u16) -> Self {
232        self.upstream = Some((ip, port));
233        self.resolver = Self::build_resolver(self.upstream, self.timeout);
234        self
235    }
236
237    #[cfg(test)]
238    fn upstream(&self) -> Option<(IpAddr, u16)> {
239        self.upstream
240    }
241
242    #[cfg(not(test))]
243    fn upstream(&self) -> Option<(IpAddr, u16)> {
244        None
245    }
246
247    /// Builds a hickory resolver against Google DNS (or, in tests only, the
248    /// pinned loopback `upstream`) with the shared option set from
249    /// [`apply_standard_opts`] (hand-copied here it had drifted and lacked
250    /// the pinned server ordering).
251    fn build_resolver(upstream: Option<(IpAddr, u16)>, timeout: Duration) -> TokioResolver {
252        let mut builder = TokioResolver::builder_with_config(
253            google_or_pinned(upstream),
254            TokioRuntimeProvider::default(),
255        );
256        apply_standard_opts(builder.options_mut(), timeout);
257        builder
258            .build()
259            .expect("hickory resolver build is infallible without TLS features")
260    }
261
262    /// Queries the DS RRset for `zone` (served by the parent zone).
263    /// NXDOMAIN/NODATA fold to an empty set; a failed query is an `Err` with
264    /// a human-readable reason.
265    async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, ReadFailure> {
266        match self
267            .resolver
268            .lookup(fqdn(zone), HickoryRecordType::DS)
269            .await
270        {
271            Ok(lookup) => Ok(lookup
272                .answers()
273                .iter()
274                .filter_map(|record| match &record.data {
275                    RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
276                    _ => None,
277                })
278                .collect()),
279            Err(e) if e.is_no_records_found() => Ok(Vec::new()),
280            Err(e) => Err(ReadFailure::from(&e)),
281        }
282    }
283
284    /// Queries the DNSKEY RRset for `zone`, pairing each key with its
285    /// RFC 4034 key tag (`None` when the tag cannot be computed). This one
286    /// answer feeds both the report's key list and the DS digest checks —
287    /// they used to come from two independent queries, so a failure of only
288    /// the second silently turned every DS into "no matching DNSKEY".
289    async fn lookup_dnskeys(
290        &self,
291        zone: &str,
292    ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, ReadFailure> {
293        match self
294            .resolver
295            .lookup(fqdn(zone), HickoryRecordType::DNSKEY)
296            .await
297        {
298            Ok(lookup) => Ok(lookup
299                .answers()
300                .iter()
301                .filter_map(|record| match &record.data {
302                    RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
303                        Some((key.clone(), key.calculate_key_tag().ok()))
304                    }
305                    _ => None,
306                })
307                .collect()),
308            Err(e) if e.is_no_records_found() => Ok(Vec::new()),
309            Err(e) => Err(ReadFailure::from(&e)),
310        }
311    }
312
313    /// Finds the apex of the zone that holds `name`.
314    ///
315    /// DS lives at a zone cut and DNSKEY at a zone apex, so querying them at
316    /// a name inside a zone (`api.cloudflare.com`) finds neither and used to
317    /// report a signed zone as "unsigned". Each step asks for the SOA:
318    /// an SOA owned by the candidate makes it the apex, and a negative
319    /// answer's AUTHORITY-section SOA names the enclosing apex directly.
320    /// Otherwise the walk moves up one label (bounded by [`MAX_ZONE_WALK`],
321    /// never above two labels).
322    ///
323    /// A failed SOA query is not trusted blindly: a validating upstream
324    /// SERVFAILs every name in a zone with a broken chain, apex included —
325    /// but the DS RRset is served by the healthy parent, so a DS answer still
326    /// identifies the (broken) zone cut. Without one the walk stops rather
327    /// than guess past the failure.
328    async fn find_zone_apex(&self, name: &str) -> ZoneApex {
329        let mut candidate = name;
330        for step in 0..MAX_ZONE_WALK {
331            match self
332                .resolver
333                .lookup(fqdn(candidate), HickoryRecordType::SOA)
334                .await
335            {
336                Ok(lookup) => {
337                    if let Some(apex) = soa_apex(
338                        candidate,
339                        lookup.answers().iter().chain(lookup.authorities()),
340                    ) {
341                        return ZoneApex::Found(apex);
342                    }
343                }
344                Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => {
345                    if no_records.response_code == ResponseCode::NXDomain && step == 0 {
346                        return ZoneApex::NxDomain;
347                    }
348                    let soa_owner = no_records.soa.as_ref().map(|soa| &soa.name);
349                    if let Some(apex) = soa_owner.and_then(|owner| {
350                        let owner = normalize_owner(owner);
351                        is_self_or_ancestor(&owner, candidate).then_some(owner)
352                    }) {
353                        return ZoneApex::Found(apex);
354                    }
355                }
356                Err(e) => {
357                    return match self.lookup_ds(candidate).await {
358                        Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
359                        _ => ZoneApex::Unknown(Some(format!(
360                            "SOA query for {candidate} failed: {e}"
361                        ))),
362                    };
363                }
364            }
365            match candidate.split_once('.') {
366                Some((_, parent)) if parent.contains('.') => candidate = parent,
367                _ => break,
368            }
369        }
370        ZoneApex::Unknown(None)
371    }
372
373    /// Generate a DNSSEC validation report for a domain.
374    ///
375    /// The DS/DNSKEY checks run at the apex of the zone enclosing `domain`,
376    /// found by walking up its labels with SOA queries; when that is not
377    /// `domain` itself, the first entry of `issues` names the zone that was
378    /// evaluated. `DnssecReport::domain` is always the (normalized) name that
379    /// was asked about.
380    ///
381    /// Bounded by six per-query timeouts overall (`CHECK_BUDGET_TIMEOUTS`):
382    /// a walk that has not found the zone within four of them
383    /// (`ZONE_WALK_BUDGET_TIMEOUTS`) is a [`SeerError::Timeout`], and a DS or
384    /// DNSKEY read the deadline cuts off is a failed query in `issues`.
385    ///
386    /// When both the DS and the DNSKEY query fail there is nothing to judge,
387    /// so the check fails ([`SeerError::Timeout`] if both ran out of time,
388    /// else [`SeerError::DnsError`]) rather than report "unsigned".
389    #[instrument(skip(self), fields(domain = %domain))]
390    pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
391        // `normalize_host`, not `normalize_domain`: the zone walk below
392        // decides where the records live, so `www.` must not be pre-stripped
393        // (a `www` zone cut, however rare, would otherwise be skipped).
394        let domain = crate::validation::normalize_host(domain)?;
395        debug!(domain = %domain, "Checking DNSSEC");
396
397        // One deadline for the whole check; the walk must end early enough
398        // to leave the DS/DNSKEY reads theirs.
399        let started = tokio::time::Instant::now();
400        let walk_budget = self.timeout.saturating_mul(ZONE_WALK_BUDGET_TIMEOUTS);
401        let budget = self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS);
402        let apex = tokio::time::timeout_at(started + walk_budget, self.find_zone_apex(&domain))
403            .await
404            .map_err(|_| {
405                // No zone to report on: evaluating a guessed one could read
406                // a signed zone as unsigned.
407                SeerError::Timeout(format!(
408                    "DNSSEC check for {domain} gave up after {walk_budget:?} without finding \
409                     the zone that holds it"
410                ))
411            })?;
412
413        let mut issues = Vec::new();
414        let zone = match apex {
415            ZoneApex::Found(zone) => {
416                if zone != domain {
417                    issues.push(format!(
418                        "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
419                         enclosing zone {zone}"
420                    ));
421                }
422                zone
423            }
424            ZoneApex::NxDomain => {
425                issues.push(format!("{domain} does not exist (NXDOMAIN)"));
426                domain.clone()
427            }
428            ZoneApex::Unknown(reason) => {
429                // Fall back to the pre-walk behaviour: the name with `www.`
430                // stripped (`www` is virtually never a zone cut).
431                let fallback = crate::validation::normalize_domain(&domain)?;
432                if let Some(reason) = reason {
433                    issues.push(format!(
434                        "could not determine the zone enclosing {domain} ({reason}); \
435                         evaluated {fallback}"
436                    ));
437                }
438                fallback
439            }
440        };
441
442        // DS (at the parent) and DNSKEY (at the apex), concurrently, by the
443        // check's deadline; one cut off is a failed query.
444        let deadline = started + budget;
445        let out_of_time = || ReadFailure {
446            detail: format!("timed out \u{2014} the check's {budget:?} budget ran out"),
447            timed_out: true,
448        };
449        let (ds_result, dnskey_result) = tokio::join!(
450            tokio::time::timeout_at(deadline, self.lookup_ds(&zone)),
451            tokio::time::timeout_at(deadline, self.lookup_dnskeys(&zone)),
452        );
453        let ds_result = ds_result.unwrap_or_else(|_| Err(out_of_time()));
454        let dnskey_result = dnskey_result.unwrap_or_else(|_| Err(out_of_time()));
455        // With neither record set read there is no evidence either way:
456        // "unsigned" would be a verdict on a zone never seen. One failed
457        // query still leaves the other's answer to report on.
458        if let (Err(ds), Err(dnskey)) = (&ds_result, &dnskey_result) {
459            let detail =
460                format!("DNSSEC check for {zone} read neither DS ({ds}) nor DNSKEY ({dnskey})");
461            return Err(if ds.timed_out && dnskey.timed_out {
462                SeerError::Timeout(detail)
463            } else {
464                SeerError::DnsError(detail)
465            });
466        }
467        let ds_records = ds_result.unwrap_or_else(|e| {
468            issues.push(format!("DS query failed: {e}"));
469            Vec::new()
470        });
471        let dnskey_query_failed = dnskey_result.is_err();
472        let dnskeys = dnskey_result.unwrap_or_else(|e| {
473            issues.push(format!("DNSKEY query failed: {e}"));
474            Vec::new()
475        });
476
477        let has_ds = !ds_records.is_empty();
478        let has_dnskey = !dnskeys.is_empty();
479
480        // (key_tag, algorithm) -> candidate DNSKEYs. Multiple DNSKEYs can
481        // share a key tag (RFC 4034 §5.1). Keys whose tag cannot be computed
482        // cannot be matched.
483        let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
484        for (key, tag) in &dnskeys {
485            if let Some(tag) = tag {
486                dnskey_map
487                    .entry((*tag, u8::from(key.public_key().algorithm())))
488                    .or_default()
489                    .push(key);
490            }
491        }
492
493        let dnskey_info: Vec<DnskeyInfo> = dnskeys
494            .iter()
495            .map(|(key, tag)| {
496                let flags = key.flags();
497                let algorithm = u8::from(key.public_key().algorithm());
498                let is_sep = flags & 0x0001 != 0;
499                let is_zone = flags & 0x0100 != 0;
500                DnskeyInfo {
501                    flags,
502                    // Protocol is always 3 for DNSSEC (RFC 4034)
503                    protocol: 3,
504                    algorithm,
505                    key_tag: tag.unwrap_or(0),
506                    is_ksk: is_sep && is_zone,
507                    is_zsk: is_zone && !is_sep,
508                    algorithm_name: algorithm_name(algorithm),
509                }
510            })
511            .collect();
512
513        // Build Name for digest computation (the DNSKEY owner = the apex).
514        let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
515            Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
516        });
517
518        // Parse DS record info with cross-validation
519        let ds_info: Vec<DsInfo> = ds_records
520            .iter()
521            .map(|ds| {
522                let key_tag = ds.key_tag();
523                let algorithm = u8::from(ds.algorithm());
524                let digest_type = u8::from(ds.digest_type());
525                let mut matched_key = false;
526                let mut digest_verified = false;
527
528                // Try to match this DS to a DNSKEY (multiple candidates possible
529                // due to key tag collisions per RFC 4034 Section 5.1)
530                if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
531                    matched_key = true;
532
533                    // Try each candidate DNSKEY until one verifies. Only a
534                    // digest type our crypto backend can compute yields a
535                    // meaningful verified/mismatch verdict; an unsupported
536                    // type (e.g. GOST) is "not evaluated", NOT a mismatch —
537                    // flagging it as a mismatch would mark an otherwise-valid
538                    // signed zone as misconfigured.
539                    match Self::to_hickory_digest_type(digest_type) {
540                        Some(hickory_dt) => {
541                            digest_verified = candidates.iter().any(|candidate| {
542                                candidate
543                                    .to_digest(&zone_name, hickory_dt)
544                                    .is_ok_and(|computed| computed.as_ref() == ds.digest())
545                            });
546                            if !digest_verified {
547                                issues.push(format!(
548                                    "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
549                                    key_tag
550                                ));
551                            }
552                        }
553                        None => {
554                            issues.push(format!(
555                                "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
556                                key_tag, digest_type
557                            ));
558                        }
559                    }
560                } else if has_dnskey {
561                    issues.push(format!(
562                        "DS record (key_tag={}) has no matching DNSKEY",
563                        key_tag
564                    ));
565                }
566
567                DsInfo {
568                    key_tag,
569                    algorithm,
570                    digest_type,
571                    digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
572                    algorithm_name: algorithm_name(algorithm),
573                    digest_type_name: digest_type_name(digest_type),
574                    matched_key,
575                    digest_verified,
576                }
577            })
578            .collect();
579
580        // Check for KSK orphans (DNSKEY KSKs with no corresponding DS)
581        let ds_key_tags: std::collections::HashSet<u16> =
582            ds_info.iter().map(|ds| ds.key_tag).collect();
583        for key in &dnskey_info {
584            if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
585                issues.push(format!(
586                    "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
587                    key.key_tag
588                ));
589            }
590        }
591
592        issues.extend(deprecation_issues(&ds_info, &dnskey_info));
593
594        let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
595
596        // Structural explanations for the non-"signed" verdicts.
597        if has_ds && status == "partial" {
598            issues.push(
599                "DS records exist at the parent, but every one uses an algorithm or digest \
600                 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
601                    .to_string(),
602            );
603        } else if has_ds && !has_dnskey {
604            issues.push(if dnskey_query_failed {
605                "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
606                 validating upstream returns SERVFAIL when the chain is broken, so validating \
607                 resolvers are likely failing this zone"
608                    .to_string()
609            } else {
610                "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
611                 validating resolvers will fail (SERVFAIL) this zone"
612                    .to_string()
613            });
614        }
615        if !has_ds && has_dnskey {
616            issues.push(
617                "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
618                    .to_string(),
619            );
620        }
621
622        let enabled = has_ds || has_dnskey;
623        let authentication_tier = if enabled {
624            AuthenticationTier::DigestOnly
625        } else {
626            AuthenticationTier::Unsigned
627        };
628
629        Ok(DnssecReport {
630            domain,
631            enabled,
632            has_ds_records: has_ds,
633            has_dnskey_records: has_dnskey,
634            ds_records: ds_info,
635            dnskey_records: dnskey_info,
636            issues,
637            status: status.to_string(),
638            chain_valid,
639            authentication_tier,
640        })
641    }
642
643    /// Converts a DS digest type number to hickory's DigestType.
644    ///
645    /// hickory 0.26 made `DigestType` `non_exhaustive` and removed the
646    /// fallible `from_u8` constructor in favour of `From<u8>`, which
647    /// returns `DigestType::Unknown(_)` for unsupported types. We
648    /// preserve the original 0.24 behaviour of refusing to attempt
649    /// digest computation for unsupported types.
650    fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
651        let dt = DigestType::from(digest_type);
652        if dt.is_supported() {
653            Some(dt)
654        } else {
655            None
656        }
657    }
658}
659
660/// Derives `(chain_valid, status)` from the DS cross-validation results and
661/// whether the zone publishes a DNSKEY RRset. Pure, so the verdict policy is
662/// unit-testable without DNS.
663///
664/// Vocabulary deliberately avoids "secure"/"insecure": we verify only
665/// DS<->DNSKEY digest consistency, NOT RRSIG signatures, so we report the
666/// observable FACT (the zone is signed / unsigned) rather than a validated
667/// security state. See the `status` field doc on [`DnssecReport`].
668fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
669    let has_ds = !ds_info.is_empty();
670
671    // One DS that authenticates a DNSKEY is all a validator needs (RFC 4035
672    // §5.2): during a double-DS rollover (RFC 7583) the parent lists a DS
673    // for a key the zone no longer (or does not yet) publish, and the zone
674    // still validates. Unmatched DS records are reported as issues.
675    let chain_valid = has_dnskey
676        && ds_info
677            .iter()
678            .any(|ds| ds.matched_key && ds.digest_verified);
679
680    let status = match (has_ds, has_dnskey) {
681        (true, true) if chain_valid => "signed",
682        // A DS the parent publishes tells validating resolvers to expect a
683        // signed zone; when none authenticates a DNSKEY (no key behind them,
684        // their query failed — a validating upstream SERVFAILs a broken
685        // chain — or every digest mismatches) they fail the zone outright.
686        // That is a broken zone, not a "partial" one — unless no DS is
687        // usable by validators, in which case they treat the zone as
688        // unsigned (RFC 4035 §5.2), with or without a DNSKEY.
689        (true, _) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
690        (true, _) => "partial",
691        // An island of security: signed, but no chain from the parent.
692        (false, true) => "partial",
693        (false, false) => "unsigned",
694    };
695    (chain_valid, status)
696}
697
698/// Whether a validating resolver would act on this DS: its DNSKEY algorithm
699/// is one validators implement (RFC 8624 §3.1 MUST/RECOMMENDED/commonly
700/// deployed: RSASHA1, RSASHA1-NSEC3-SHA1, RSASHA256, RSASHA512, ECDSA
701/// P-256/P-384, Ed25519, Ed448) and its digest type is SHA-1/256/384. A DS
702/// failing either is ignored by validators (RFC 4035 §5.2, RFC 6840 §5.2).
703fn ds_is_validatable(ds: &DsInfo) -> bool {
704    matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
705        && matches!(ds.digest_type, 1 | 2 | 4)
706}
707
708/// DNSSEC algorithms that must not (1, 3, 6) or should no longer (5, 7, 12)
709/// be used for signing (RFC 8624 §3.1), matching the "(deprecated)" labels
710/// from [`algorithm_name`].
711fn is_deprecated_algorithm(algorithm: u8) -> bool {
712    matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
713}
714
715/// Deprecated-algorithm and SHA-1-digest advisories for the published DS and
716/// DNSKEY records. Pure.
717fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
718    let mut issues = Vec::new();
719    for ds in ds_info {
720        if is_deprecated_algorithm(ds.algorithm) {
721            issues.push(format!(
722                "DS record uses deprecated algorithm {} ({})",
723                ds.algorithm, ds.algorithm_name
724            ));
725        }
726        if ds.digest_type == 1 {
727            issues.push(
728                "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
729                    .to_string(),
730            );
731        }
732    }
733    for key in dnskey_info {
734        if is_deprecated_algorithm(key.algorithm) {
735            issues.push(format!(
736                "DNSKEY record uses deprecated algorithm {} ({})",
737                key.algorithm, key.algorithm_name
738            ));
739        }
740    }
741    issues
742}
743
744/// The ASCII owner name of a DNS record: lowercase, no trailing root dot.
745/// `to_ascii`, not `to_string` — `Display` decodes `xn--` labels to Unicode.
746fn normalize_owner(name: &Name) -> String {
747    name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
748}
749
750/// True when `owner` is `name` itself or one of its ancestors (label-wise).
751/// Both are lowercase ASCII without a trailing dot.
752fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
753    !owner.is_empty()
754        && (owner == name
755            || name
756                .strip_suffix(owner)
757                .is_some_and(|prefix| prefix.ends_with('.')))
758}
759
760/// The zone apex named by an SOA among `records`: an SOA owned by `name`
761/// itself, or (in a negative answer's AUTHORITY section) by an ancestor of
762/// `name`. Anything else — e.g. the SOA of a CNAME target's zone — is ignored.
763fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
764    records
765        .filter(|record| matches!(record.data, RData::SOA(_)))
766        .map(|record| normalize_owner(&record.name))
767        .find(|owner| is_self_or_ancestor(owner, name))
768}
769
770/// Maps a DNSSEC algorithm number to a human-readable name. Numbers come
771/// from the IANA "DNSSEC Algorithm Numbers" registry. Algorithm 9 is
772/// reserved (not assigned). 7 and 12 are operationally discouraged per
773/// RFC 8624; we flag both as deprecated.
774fn algorithm_name(algo: u8) -> String {
775    match algo {
776        1 => "RSA/MD5 (deprecated)".to_string(),
777        3 => "DSA/SHA-1 (deprecated)".to_string(),
778        5 => "RSA/SHA-1 (deprecated)".to_string(),
779        6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
780        7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
781        8 => "RSA/SHA-256".to_string(),
782        10 => "RSA/SHA-512".to_string(),
783        12 => "ECC-GOST (deprecated)".to_string(),
784        13 => "ECDSA P-256/SHA-256".to_string(),
785        14 => "ECDSA P-384/SHA-384".to_string(),
786        15 => "Ed25519".to_string(),
787        16 => "Ed448".to_string(),
788        _ => format!("Unknown ({})", algo),
789    }
790}
791
792fn digest_type_name(dtype: u8) -> String {
793    match dtype {
794        1 => "SHA-1".to_string(),
795        2 => "SHA-256".to_string(),
796        4 => "SHA-384".to_string(),
797        _ => format!("Unknown ({})", dtype),
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn test_algorithm_names() {
807        assert_eq!(algorithm_name(8), "RSA/SHA-256");
808        assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
809        assert_eq!(algorithm_name(15), "Ed25519");
810        assert!(algorithm_name(5).contains("deprecated"));
811    }
812
813    #[test]
814    fn test_digest_type_names() {
815        assert_eq!(digest_type_name(1), "SHA-1");
816        assert_eq!(digest_type_name(2), "SHA-256");
817    }
818
819    #[test]
820    fn default_tier_is_digest_only() {
821        // Reports deserialized without the field default to digest-only.
822        assert_eq!(
823            default_authentication_tier(),
824            AuthenticationTier::DigestOnly
825        );
826    }
827
828    #[test]
829    fn from_config_applies_dns_timeout() {
830        let mut config = crate::config::SeerConfig::default();
831        config.timeouts.dns_secs = 9;
832        assert_eq!(
833            DnssecChecker::from_config(&config).timeout,
834            Duration::from_secs(9)
835        );
836    }
837
838    #[test]
839    fn test_report_serialization() {
840        let report = DnssecReport {
841            domain: "example.com".to_string(),
842            enabled: true,
843            has_ds_records: true,
844            has_dnskey_records: true,
845            ds_records: vec![DsInfo {
846                key_tag: 12345,
847                algorithm: 13,
848                digest_type: 2,
849                digest: "ABCDEF".to_string(),
850                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
851                digest_type_name: "SHA-256".to_string(),
852                matched_key: true,
853                digest_verified: true,
854            }],
855            dnskey_records: vec![DnskeyInfo {
856                flags: 257,
857                protocol: 3,
858                algorithm: 13,
859                key_tag: 12345,
860                is_ksk: true,
861                is_zsk: false,
862                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
863            }],
864            issues: vec![],
865            status: "signed".to_string(),
866            chain_valid: true,
867            authentication_tier: AuthenticationTier::DigestOnly,
868        };
869        let json = serde_json::to_string(&report).unwrap();
870        assert!(json.contains("\"enabled\":true"));
871        assert!(json.contains("\"chain_valid\":true"));
872        assert!(json.contains("\"matched_key\":true"));
873        assert!(json.contains("\"digest_verified\":true"));
874        assert!(json.contains("\"key_tag\":12345"));
875    }
876
877    fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
878        DsInfo {
879            key_tag,
880            algorithm,
881            digest_type,
882            digest: "ABCDEF".to_string(),
883            algorithm_name: algorithm_name(algorithm),
884            digest_type_name: digest_type_name(digest_type),
885            matched_key: matched,
886            digest_verified: verified,
887        }
888    }
889
890    // --- derive_chain_status: the verdict policy, pure --------------------
891    //
892    // These replace three tests that built a `DnssecReport` with
893    // `chain_valid`/`status` already filled in and asserted them back (they
894    // could not fail). The derivation is now a pure function under test.
895
896    #[test]
897    fn chain_status_signed_when_every_computable_ds_verifies() {
898        let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
899        assert_eq!(derive_chain_status(&all, true), (true, "signed"));
900    }
901
902    #[test]
903    fn chain_status_misconfigured_when_ds_unmatched() {
904        let unmatched = [ds(65000, 13, 2, false, false)];
905        assert_eq!(
906            derive_chain_status(&unmatched, true),
907            (false, "misconfigured")
908        );
909    }
910
911    #[test]
912    fn chain_status_misconfigured_on_digest_mismatch() {
913        let mismatch = [ds(12345, 13, 2, true, false)];
914        assert_eq!(
915            derive_chain_status(&mismatch, true),
916            (false, "misconfigured")
917        );
918    }
919
920    #[test]
921    fn chain_status_one_authenticating_ds_is_enough() {
922        // Regression: any computable DS that failed made the zone
923        // "misconfigured", although validators need only one DS that
924        // authenticates a DNSKEY (RFC 4035 §5.2) — the parent's DS set during
925        // a double-DS key rollover (RFC 7583) holds one for a key the zone
926        // does not publish yet (or any more).
927        let rollover = [ds(1, 13, 2, true, true), ds(2, 13, 2, false, false)];
928        assert_eq!(derive_chain_status(&rollover, true), (true, "signed"));
929        let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
930        assert_eq!(derive_chain_status(&mixed, true), (true, "signed"));
931    }
932
933    #[test]
934    fn chain_status_uncomputable_digest_alone_is_partial() {
935        // Regression: a DS set whose only digest is one validators do not
936        // implement (GOST, type 3) was "misconfigured" beside a DNSKEY but
937        // "partial" without one. Validators ignore such a set (RFC 4035
938        // §5.2) and treat the zone as unsigned either way.
939        let gost_only = [ds(1, 13, 3, true, false)];
940        assert_eq!(derive_chain_status(&gost_only, true), (false, "partial"));
941        assert_eq!(derive_chain_status(&gost_only, false), (false, "partial"));
942        let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
943        assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
944    }
945
946    #[test]
947    fn chain_status_ds_without_dnskey_is_misconfigured() {
948        // Regression: this was "partial" (yellow), yet validating resolvers
949        // SERVFAIL a zone whose parent publishes a DS with no DNSKEY behind
950        // it — including when a validating upstream SERVFAILs the DNSKEY
951        // query itself, which folds to "no DNSKEY" here.
952        let stale = [ds(12345, 13, 2, false, false)];
953        assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
954    }
955
956    #[test]
957    fn chain_status_ds_validators_ignore_stays_partial() {
958        // Every DS uses an algorithm (RSAMD5, ECC-GOST, private) or digest
959        // type validators don't implement → they treat the zone as unsigned.
960        let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
961        assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
962        let unknown_digest = [ds(3, 13, 99, false, false)];
963        assert_eq!(
964            derive_chain_status(&unknown_digest, false),
965            (false, "partial")
966        );
967    }
968
969    #[test]
970    fn chain_status_island_and_unsigned() {
971        assert_eq!(derive_chain_status(&[], true), (false, "partial"));
972        assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
973    }
974
975    #[test]
976    fn deprecated_algorithms_include_7_and_12() {
977        // Regression: the check was `1|3|5|6` although algorithm_name already
978        // labelled 7 (RSASHA1-NSEC3-SHA1) and 12 (ECC-GOST) deprecated.
979        let key = |algorithm: u8| DnskeyInfo {
980            flags: 257,
981            protocol: 3,
982            algorithm,
983            key_tag: 1,
984            is_ksk: true,
985            is_zsk: false,
986            algorithm_name: algorithm_name(algorithm),
987        };
988        let issues = deprecation_issues(
989            &[
990                ds(1, 7, 2, true, true),
991                ds(2, 12, 2, true, true),
992                ds(3, 13, 2, true, true),
993            ],
994            &[key(7), key(12), key(8)],
995        );
996        assert_eq!(
997            issues
998                .iter()
999                .filter(|i| i.contains("deprecated algorithm"))
1000                .count(),
1001            4,
1002            "{issues:?}"
1003        );
1004        assert!(issues
1005            .iter()
1006            .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
1007        assert!(issues
1008            .iter()
1009            .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
1010        assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
1011        assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
1012    }
1013
1014    #[test]
1015    fn soa_apex_matches_self_or_ancestor_only() {
1016        assert!(is_self_or_ancestor("example.com", "example.com"));
1017        assert!(is_self_or_ancestor("example.com", "api.example.com"));
1018        assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
1019        assert!(!is_self_or_ancestor("api.example.com", "example.com"));
1020
1021        let soa = |owner: &str| {
1022            Record::from_rdata(
1023                Name::from_ascii(owner).unwrap(),
1024                300,
1025                soa_rdata(owner.trim_end_matches('.')),
1026            )
1027        };
1028        let records = [soa("cdn-target.test."), soa("Example.COM.")];
1029        assert_eq!(
1030            soa_apex("api.example.com", records.iter()),
1031            Some("example.com".to_string()),
1032            "a CNAME target's SOA must be skipped; the enclosing one is found"
1033        );
1034        assert_eq!(soa_apex("api.other.test", records.iter()), None);
1035    }
1036
1037    // --- Hermetic end-to-end checks against the scripted mock fixture -----
1038
1039    use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
1040    use hickory_resolver::proto::rr::RecordType as WireType;
1041
1042    use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
1043
1044    /// A KSK for the tests. Any 32 bytes serve: key tags and DS digests are
1045    /// computed over the wire form and never checked as a curve point.
1046    fn test_key() -> DNSKEY {
1047        DNSKEY::new(
1048            true,
1049            true,
1050            false,
1051            PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
1052        )
1053    }
1054
1055    /// The DS the parent of `zone` would publish for `key` (SHA-256).
1056    fn ds_for(zone: &str, key: &DNSKEY) -> DS {
1057        let digest = key
1058            .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
1059            .unwrap();
1060        DS::new(
1061            key.calculate_key_tag().unwrap(),
1062            Algorithm::ED25519,
1063            DigestType::SHA256,
1064            digest.as_ref().to_vec(),
1065        )
1066    }
1067
1068    fn dnskey_rdata(key: DNSKEY) -> RData {
1069        RData::DNSSEC(DNSSECRData::DNSKEY(key))
1070    }
1071
1072    fn ds_rdata(ds: DS) -> RData {
1073        RData::DNSSEC(DNSSECRData::DS(ds))
1074    }
1075
1076    fn mock_checker(port: u16) -> DnssecChecker {
1077        DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
1078    }
1079
1080    /// Regression: DS/DNSKEY were queried at the name itself, so a host
1081    /// inside a signed zone (api.cloudflare.com) reported "unsigned". The
1082    /// walk climbs to the SOA owner and reports on that zone.
1083    #[tokio::test]
1084    async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
1085        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1086            ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1087            ("signed.test", WireType::DS) => {
1088                MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1089            }
1090            ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1091            // Nothing lives at the name itself.
1092            _ => MockReply::NoData,
1093        })
1094        .await;
1095
1096        let report = mock_checker(port).check("api.signed.test").await.unwrap();
1097        assert_eq!(report.domain, "api.signed.test");
1098        assert_eq!(report.status, "signed", "{report:?}");
1099        assert!(report.chain_valid);
1100        assert!(
1101            report.issues[0].contains("enclosing zone signed.test"),
1102            "the evaluated zone must be named: {:?}",
1103            report.issues
1104        );
1105        assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1106        // One DNSKEY query feeds both the listing and the verification.
1107        let tag = test_key().calculate_key_tag().unwrap();
1108        assert_eq!(report.dnskey_records[0].key_tag, tag);
1109        assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1110    }
1111
1112    /// A negative answer's AUTHORITY-section SOA names the apex in one step.
1113    /// SERVFAIL on the intermediate name proves the walk never needed it.
1114    #[tokio::test]
1115    async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1116        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1117            ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1118            ("api.signed.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1119            ("signed.test", WireType::DS) => {
1120                MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1121            }
1122            ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1123            _ => MockReply::NoData,
1124        })
1125        .await;
1126
1127        let report = mock_checker(port)
1128            .check("deep.api.signed.test")
1129            .await
1130            .unwrap();
1131        assert_eq!(report.status, "signed", "{report:?}");
1132        assert!(report.issues[0].contains("enclosing zone signed.test"));
1133    }
1134
1135    /// Regression (finding: stale DS behind a validating upstream): Google
1136    /// SERVFAILs every query into a zone whose DS matches no key, DNSKEY
1137    /// included. That used to read as "partial"; it is "misconfigured". The
1138    /// SOA SERVFAIL must not derail zone discovery either — the parent still
1139    /// serves the DS, which identifies the zone cut.
1140    #[tokio::test]
1141    async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1142        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1143            ("broken.test", WireType::DS) => {
1144                MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1145            }
1146            ("broken.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1147            _ => MockReply::NoData,
1148        })
1149        .await;
1150
1151        let report = mock_checker(port).check("broken.test").await.unwrap();
1152        assert!(report.has_ds_records);
1153        assert!(!report.has_dnskey_records);
1154        assert_eq!(report.status, "misconfigured", "{report:?}");
1155        assert!(!report.chain_valid);
1156        assert!(
1157            report
1158                .issues
1159                .iter()
1160                .any(|i| i.contains("DNSKEY query failed")),
1161            "{:?}",
1162            report.issues
1163        );
1164        assert!(
1165            !report.issues.iter().any(|i| i.contains("not a zone apex")),
1166            "{:?}",
1167            report.issues
1168        );
1169    }
1170
1171    /// Regression: with both DS and DNSKEY failing, the empty record sets
1172    /// read as "unsigned", so a signed zone behind a flaky upstream got a
1173    /// `dns.dnssec.unsigned` audit finding instead of an incomplete check.
1174    #[tokio::test]
1175    async fn both_queries_failing_is_an_error_not_unsigned() {
1176        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1177            ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1178            ("signed.test", WireType::DS | WireType::DNSKEY) => {
1179                MockReply::Rcode(ResponseCode::ServFail)
1180            }
1181            _ => MockReply::NoData,
1182        })
1183        .await;
1184
1185        let err = mock_checker(port).check("signed.test").await.unwrap_err();
1186        assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
1187        assert!(err.to_string().contains("neither DS"), "{err}");
1188    }
1189
1190    /// Both reads cut off by the check's deadline are a timeout, not a
1191    /// verdict.
1192    #[tokio::test]
1193    async fn both_queries_cut_off_by_the_deadline_is_a_timeout() {
1194        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1195            ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1196            (_, WireType::DS | WireType::DNSKEY) => MockReply::NoReply,
1197            _ => MockReply::NoData,
1198        })
1199        .await;
1200
1201        let err = mock_checker(port)
1202            .with_timeout(Duration::from_millis(100))
1203            .check("signed.test")
1204            .await
1205            .unwrap_err();
1206        assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1207    }
1208
1209    #[tokio::test]
1210    async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1211        let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1212        let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1213        assert_eq!(report.status, "unsigned");
1214        assert!(
1215            report.issues.iter().any(|i| i.contains("does not exist")),
1216            "{:?}",
1217            report.issues
1218        );
1219    }
1220
1221    #[tokio::test]
1222    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1223    async fn test_live_dnssec_check_cloudflare() {
1224        let checker = DnssecChecker::new();
1225        let report = checker.check("cloudflare.com").await.unwrap();
1226
1227        // cloudflare.com has DNSSEC enabled
1228        assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1229        assert!(report.has_ds_records, "should have DS records");
1230        assert!(report.has_dnskey_records, "should have DNSKEY records");
1231        assert!(report.chain_valid, "cloudflare.com chain should be valid");
1232        assert_eq!(report.status, "signed");
1233
1234        // All DS records should be verified
1235        for ds in &report.ds_records {
1236            assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1237            assert!(
1238                ds.digest_verified,
1239                "DS key_tag={} digest should verify",
1240                ds.key_tag
1241            );
1242        }
1243
1244        // Should have computed key tags on DNSKEYs
1245        for key in &report.dnskey_records {
1246            assert!(key.key_tag > 0, "key_tag should be computed");
1247        }
1248    }
1249
1250    #[tokio::test]
1251    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1252    async fn test_live_dnssec_check_insecure() {
1253        let checker = DnssecChecker::new();
1254        // wikipedia.org does not have DNSSEC (no DS or DNSKEY records)
1255        let report = checker.check("wikipedia.org").await.unwrap();
1256
1257        assert!(!report.chain_valid);
1258        assert_eq!(report.status, "unsigned");
1259    }
1260
1261    /// Regression: the SOA walk had no overall deadline, so an upstream
1262    /// that answers each name only on hickory's retransmission (NODATA
1263    /// without an SOA) held the check for ~`MAX_ZONE_WALK` + 1 lookups, and
1264    /// then evaluated a guessed zone. The walk now ends at its budget, with
1265    /// a timeout rather than a made-up verdict.
1266    #[tokio::test]
1267    async fn the_zone_walk_is_held_to_its_budget() {
1268        use std::net::Ipv4Addr;
1269        use std::sync::{Arc, Mutex};
1270
1271        use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
1272
1273        let timeout = Duration::from_millis(200);
1274        // Per (name, type): the first query is dropped, the retransmission
1275        // gets NODATA with no SOA in AUTHORITY.
1276        let seen: Arc<Mutex<HashMap<String, usize>>> = Arc::default();
1277        let log = seen.clone();
1278        let port = spawn_mock_dns_fn(move |qname, qtype| {
1279            let mut seen = log.lock().unwrap();
1280            let n = seen.entry(format!("{qname} {qtype}")).or_insert(0);
1281            *n += 1;
1282            if *n == 1 {
1283                MockReply::NoReply
1284            } else {
1285                MockReply::NoData
1286            }
1287        })
1288        .await;
1289        let checker = DnssecChecker::new()
1290            .with_timeout(timeout)
1291            .with_upstream(Ipv4Addr::LOCALHOST.into(), port);
1292
1293        let started = std::time::Instant::now();
1294        let err = checker
1295            .check("a.b.c.d.e.f.g.h.i.seer.test")
1296            .await
1297            .expect_err("the walk runs out of time");
1298        let elapsed = started.elapsed();
1299        assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1300        assert!(err.to_string().contains("zone"), "{err}");
1301        // The walk's budget, with slack — never the whole check's.
1302        let walk_budget = timeout * ZONE_WALK_BUDGET_TIMEOUTS;
1303        assert!(elapsed < walk_budget + timeout, "took {elapsed:?}");
1304        // Neither DS nor DNSKEY was asked for a guessed zone.
1305        assert!(
1306            !seen.lock().unwrap().keys().any(|k| k.ends_with(" DNSKEY")),
1307            "{:?}",
1308            seen.lock().unwrap().keys()
1309        );
1310    }
1311}