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