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