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