Skip to main content

seer_core/dns/
dnssec.rs

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