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.
5
6use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use hickory_resolver::config::{ResolveHosts, ResolverConfig, GOOGLE};
10use hickory_resolver::net::runtime::TokioRuntimeProvider;
11use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY};
12use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
13use hickory_resolver::proto::rr::{Name, RData, RecordType as HickoryRecordType};
14use hickory_resolver::TokioResolver;
15use serde::{Deserialize, Serialize};
16use tracing::{debug, instrument};
17
18use super::records::{RecordData, RecordType};
19use super::resolver::DnsResolver;
20use crate::error::Result;
21
22/// DNSSEC validation report for a domain.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DnssecReport {
25    /// The domain that was checked.
26    pub domain: String,
27    /// Whether the domain has DNSSEC enabled.
28    pub enabled: bool,
29    /// Whether DS records exist at the parent zone.
30    pub has_ds_records: bool,
31    /// Whether DNSKEY records exist at the domain.
32    pub has_dnskey_records: bool,
33    /// DS records found at the parent zone.
34    pub ds_records: Vec<DsInfo>,
35    /// DNSKEY records found at the domain.
36    pub dnskey_records: Vec<DnskeyInfo>,
37    /// Validation issues found.
38    pub issues: Vec<String>,
39    /// Overall status: "signed", "unsigned", "partial", or "misconfigured".
40    ///
41    /// IMPORTANT: this reflects DS↔DNSKEY *digest consistency* (RFC 4509)
42    /// observed over plain, unauthenticated DNS. It does NOT verify any RRSIG
43    /// signatures, signature validity periods, or a chain of trust to the root
44    /// anchor. "signed" therefore means "the published DS and DNSKEY are
45    /// digest-consistent", NOT "the records are cryptographically
46    /// authenticated" — an on-path or spoofing attacker can fabricate a
47    /// self-consistent DS+DNSKEY pair. Do not treat this as proof of
48    /// authenticity.
49    pub status: String,
50    /// Whether every DS record's digest matches a published DNSKEY (RFC 4509
51    /// digest consistency). This is NOT signature / chain-of-trust validation —
52    /// see the caveat on `status`.
53    pub chain_valid: bool,
54    /// Machine-readable tier describing the DEPTH of checking that was
55    /// performed, so consumers don't over-trust a digest-only "signed" result.
56    /// The RESULT of those checks lives in `chain_valid` / `status` / `issues`;
57    /// this field says only *what was checked*.
58    #[serde(default = "default_authentication_tier")]
59    pub authentication_tier: AuthenticationTier,
60    /// RRSIG signatures observed over the zone apex, populated only when RRSIG
61    /// validation is enabled (`DnssecChecker::with_rrsig_validation(true)`).
62    /// Empty in the default fast path.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub rrsig_records: Vec<RrsigInfo>,
65}
66
67/// The depth of DNSSEC verification performed for a [`DnssecReport`].
68///
69/// This describes *what was checked*, not whether it passed — the pass/fail
70/// result is carried by [`DnssecReport::chain_valid`] and `status`. It exists
71/// so a consumer (or an MCP-driven LLM) does not read digest-consistency as
72/// full cryptographic authentication.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum AuthenticationTier {
76    /// No DNSSEC records are published.
77    Unsigned,
78    /// Only DS↔DNSKEY digest consistency (RFC 4509) was checked — the default
79    /// fast path. Does NOT inspect RRSIG signatures or their validity windows.
80    DigestOnly,
81    /// RRSIG signatures over the apex were additionally fetched and their
82    /// validity windows inspected (expired / near-expiry surfaced in
83    /// `issues`). Still not full cryptographic chain validation to the root.
84    RrsigChecked,
85}
86
87fn default_authentication_tier() -> AuthenticationTier {
88    AuthenticationTier::DigestOnly
89}
90
91/// Summary of a single RRSIG record's coverage and validity window.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RrsigInfo {
94    /// The record type this signature covers (e.g. "DNSKEY", "SOA").
95    pub type_covered: String,
96    /// DNSSEC algorithm number.
97    pub algorithm: u8,
98    /// Human-readable algorithm name.
99    pub algorithm_name: String,
100    /// Key tag of the signing key.
101    pub key_tag: u16,
102    /// The signer (zone) name.
103    pub signer_name: String,
104    /// Signature inception time.
105    pub inception: Option<DateTime<Utc>>,
106    /// Signature expiration time.
107    pub expiration: Option<DateTime<Utc>>,
108    /// Whether the signature is currently outside its validity window.
109    pub expired: bool,
110    /// Days until the signature expires (negative if already expired).
111    pub expires_in_days: i64,
112}
113
114/// Days-until-expiry threshold below which an RRSIG is flagged as near-expiry.
115const RRSIG_EXPIRY_WARN_DAYS: i64 = 7;
116
117/// Given an RRSIG's inception/expiration and the current time (all Unix
118/// seconds), returns `(expired, expires_in_days)`. `expired` is true when
119/// `now` is outside `[inception, expiration]`. Pure, so it is unit-testable.
120fn rrsig_validity(inception: i64, expiration: i64, now: i64) -> (bool, i64) {
121    let expired = now > expiration || now < inception;
122    let expires_in_days = (expiration - now).div_euclid(86_400);
123    (expired, expires_in_days)
124}
125
126/// Summary of a DS record.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct DsInfo {
129    pub key_tag: u16,
130    pub algorithm: u8,
131    pub digest_type: u8,
132    pub digest: String,
133    pub algorithm_name: String,
134    pub digest_type_name: String,
135    /// Whether this DS record's key_tag+algorithm matched a DNSKEY.
136    pub matched_key: bool,
137    /// Whether the computed digest from the matched DNSKEY equals this DS digest.
138    pub digest_verified: bool,
139}
140
141/// Summary of a DNSKEY record.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DnskeyInfo {
144    pub flags: u16,
145    pub protocol: u8,
146    pub algorithm: u8,
147    /// The RFC 4034 computed key tag.
148    pub key_tag: u16,
149    pub is_ksk: bool,
150    pub is_zsk: bool,
151    pub algorithm_name: String,
152}
153
154/// Checks DNSSEC configuration for a domain.
155pub struct DnssecChecker {
156    resolver: DnsResolver,
157    raw_resolver: TokioResolver,
158    /// When true, `check` additionally fetches RRSIG signatures and inspects
159    /// their validity windows (opt-in; adds a network round-trip).
160    check_rrsig: bool,
161}
162
163impl Default for DnssecChecker {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl DnssecChecker {
170    pub fn new() -> Self {
171        let raw_resolver = Self::build_resolver(false);
172
173        Self {
174            resolver: DnsResolver::new(),
175            raw_resolver,
176            check_rrsig: false,
177        }
178    }
179
180    /// Enables (or disables) the opt-in RRSIG validity check. When enabled,
181    /// `check` fetches RRSIG records for a signed zone and flags expired /
182    /// near-expiry signatures — the most common real-world DNSSEC outage that
183    /// the default digest-consistency check is blind to.
184    pub fn with_rrsig_validation(mut self, on: bool) -> Self {
185        self.check_rrsig = on;
186        self
187    }
188
189    /// Builds a hickory resolver against Google DNS. When `validating` is true
190    /// the DNSSEC-OK (DO) bit is set (`opts.validate`), which is required for
191    /// upstream resolvers to return RRSIG records.
192    fn build_resolver(validating: bool) -> TokioResolver {
193        let mut builder = TokioResolver::builder_with_config(
194            ResolverConfig::udp_and_tcp(&GOOGLE),
195            TokioRuntimeProvider::default(),
196        );
197        {
198            let opts = builder.options_mut();
199            opts.timeout = std::time::Duration::from_secs(5);
200            opts.attempts = 2;
201            opts.use_hosts_file = ResolveHosts::Never;
202            if validating {
203                // Sets the DO bit so RRSIGs are returned (and asks hickory to
204                // validate). A validation failure surfaces as a lookup error,
205                // which the RRSIG path treats as "no data" and degrades to the
206                // digest-only tier — never a false "validated" claim.
207                opts.validate = true;
208                opts.edns0 = true;
209            }
210        }
211        builder
212            .build()
213            .expect("hickory resolver build is infallible without TLS features")
214    }
215
216    /// Fetches RRSIG records covering the zone apex and summarizes each one's
217    /// coverage and validity window. Best-effort: returns an empty vec if the
218    /// resolver path does not surface RRSIGs (e.g. DO stripped upstream), so
219    /// the caller never over-claims validation.
220    async fn resolve_rrsigs(&self, domain: &str) -> Vec<RrsigInfo> {
221        let resolver = Self::build_resolver(true);
222        let Ok(lookup) = resolver.lookup(domain, HickoryRecordType::RRSIG).await else {
223            return vec![];
224        };
225        let now = Utc::now().timestamp();
226        lookup
227            .answers()
228            .iter()
229            .filter_map(|record| {
230                let RData::DNSSEC(DNSSECRData::RRSIG(rrsig)) = &record.data else {
231                    return None;
232                };
233                let input = rrsig.input();
234                let inception = input.sig_inception.get() as i64;
235                let expiration = input.sig_expiration.get() as i64;
236                let (expired, expires_in_days) = rrsig_validity(inception, expiration, now);
237                let algorithm = u8::from(input.algorithm);
238                Some(RrsigInfo {
239                    type_covered: input.type_covered.to_string(),
240                    algorithm,
241                    algorithm_name: algorithm_name(algorithm),
242                    key_tag: input.key_tag,
243                    signer_name: input.signer_name.to_string(),
244                    inception: DateTime::from_timestamp(inception, 0),
245                    expiration: DateTime::from_timestamp(expiration, 0),
246                    expired,
247                    expires_in_days,
248                })
249            })
250            .collect()
251    }
252
253    /// Resolves raw hickory DNSKEY records for crypto operations.
254    /// Returns a vec of (DNSKEY, computed_key_tag) pairs.
255    async fn resolve_raw_dnskeys(&self, domain: &str) -> Vec<(DNSKEY, u16)> {
256        let Ok(lookup) = self
257            .raw_resolver
258            .lookup(domain, HickoryRecordType::DNSKEY)
259            .await
260        else {
261            return vec![];
262        };
263
264        lookup
265            .answers()
266            .iter()
267            .filter_map(|record| {
268                if let RData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
269                    match dnskey.calculate_key_tag() {
270                        Ok(tag) => Some((dnskey.clone(), tag)),
271                        Err(_) => None,
272                    }
273                } else {
274                    None
275                }
276            })
277            .collect()
278    }
279
280    /// Converts a DS digest type number to hickory's DigestType.
281    ///
282    /// hickory 0.26 made `DigestType` `non_exhaustive` and removed the
283    /// fallible `from_u8` constructor in favour of `From<u8>`, which
284    /// returns `DigestType::Unknown(_)` for unsupported types. We
285    /// preserve the original 0.24 behaviour of refusing to attempt
286    /// digest computation for unsupported types.
287    fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
288        let dt = DigestType::from(digest_type);
289        if dt.is_supported() {
290            Some(dt)
291        } else {
292            None
293        }
294    }
295
296    /// Generate a DNSSEC validation report for a domain.
297    #[instrument(skip(self), fields(domain = %domain))]
298    pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
299        let domain = crate::validation::normalize_domain(domain)?;
300        debug!(domain = %domain, "Checking DNSSEC");
301
302        let mut issues = Vec::new();
303
304        // Query DS records (at parent zone)
305        let ds_records: Vec<crate::dns::DnsRecord> =
306            match self.resolver.resolve(&domain, RecordType::DS, None).await {
307                Ok(records) => records,
308                Err(e) => {
309                    issues.push(format!("DS query failed: {}", e));
310                    vec![]
311                }
312            };
313
314        // Query DNSKEY records (at the domain itself)
315        let dnskey_records: Vec<crate::dns::DnsRecord> = match self
316            .resolver
317            .resolve(&domain, RecordType::DNSKEY, None)
318            .await
319        {
320            Ok(records) => records,
321            Err(e) => {
322                issues.push(format!("DNSKEY query failed: {}", e));
323                vec![]
324            }
325        };
326
327        let has_ds = !ds_records.is_empty();
328        let has_dnskey = !dnskey_records.is_empty();
329
330        // Resolve raw hickory DNSKEYs for crypto operations
331        let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
332
333        // Build lookup map: (key_tag, algorithm) -> vec of raw DNSKEYs
334        // Multiple DNSKEYs can share the same key tag (RFC 4034 Section 5.1).
335        let dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = {
336            let mut map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
337            for (dnskey, tag) in &raw_dnskeys {
338                map.entry((*tag, u8::from(dnskey.public_key().algorithm())))
339                    .or_default()
340                    .push(dnskey);
341            }
342            map
343        };
344
345        // Build set of DS key_tags for KSK orphan detection
346        let ds_key_tags: std::collections::HashSet<u16> = ds_records
347            .iter()
348            .filter_map(|r| {
349                if let RecordData::DS { key_tag, .. } = r.data {
350                    Some(key_tag)
351                } else {
352                    None
353                }
354            })
355            .collect();
356
357        // Build a map from (flags, algorithm) -> computed key_tags to attach a
358        // key tag to each RecordData DNSKEY. NOTE: this correlates two
359        // independent DNSKEY queries (dnskey_records vs raw_dnskeys) by
360        // position within each (flags, algorithm) group. That is correct when a
361        // group has a single key (the common case) but can mis-assign tags if a
362        // zone has >= 2 keys sharing identical flags+algorithm AND the two
363        // queries returned them in different orders. Removing this assumption
364        // requires a single DNSKEY query that carries both the display fields
365        // and the computed tag; deferred to keep this crypto path stable.
366        let key_tag_by_algo_flags: HashMap<(u16, u8), Vec<u16>> = {
367            let mut map: HashMap<(u16, u8), Vec<u16>> = HashMap::new();
368            for (dnskey, tag) in &raw_dnskeys {
369                map.entry((dnskey.flags(), u8::from(dnskey.public_key().algorithm())))
370                    .or_default()
371                    .push(*tag);
372            }
373            map
374        };
375
376        // Parse DNSKEY record info with computed key tags.
377        //
378        // NOTE (#61): the per-DNSKEY key_tag is attached by position-correlating
379        // two separate query result sets (DNSKEY records vs the computed-tag
380        // list). When ≥2 keys share the same (flags, algorithm), an ordering
381        // difference between those result sets can mislabel which tag belongs to
382        // which key. This affects only the DISPLAY tag and the KSK-orphan
383        // heuristic — `chain_valid` is computed from DS↔DNSKEY digest matching
384        // (below) and is unaffected.
385        let mut dnskey_tag_indices: HashMap<(u16, u8), usize> = HashMap::new();
386        let dnskey_info: Vec<DnskeyInfo> = dnskey_records
387            .iter()
388            .filter_map(|r| {
389                if let RecordData::DNSKEY {
390                    flags,
391                    protocol,
392                    algorithm,
393                    ..
394                } = r.data
395                {
396                    let is_sep = flags & 0x0001 != 0;
397                    let is_zone = flags & 0x0100 != 0;
398                    let is_ksk = is_sep && is_zone;
399                    let is_zsk = is_zone && !is_sep;
400
401                    // Find the computed key tag for this DNSKEY
402                    let idx = dnskey_tag_indices.entry((flags, algorithm)).or_insert(0);
403                    let key_tag = key_tag_by_algo_flags
404                        .get(&(flags, algorithm))
405                        .and_then(|tags| tags.get(*idx))
406                        .copied()
407                        .unwrap_or(0);
408                    *idx += 1;
409
410                    Some(DnskeyInfo {
411                        flags,
412                        protocol,
413                        algorithm,
414                        key_tag,
415                        is_ksk,
416                        is_zsk,
417                        algorithm_name: algorithm_name(algorithm),
418                    })
419                } else {
420                    None
421                }
422            })
423            .collect();
424
425        // Build Name for digest computation
426        let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
427            Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
428        });
429
430        // Parse DS record info with cross-validation
431        let ds_info: Vec<DsInfo> = ds_records
432            .iter()
433            .map(|r| {
434                if let RecordData::DS {
435                    key_tag,
436                    algorithm,
437                    digest_type,
438                    ref digest,
439                } = r.data
440                {
441                    let mut matched_key = false;
442                    let mut digest_verified = false;
443
444                    // Try to match this DS to a DNSKEY (multiple candidates possible
445                    // due to key tag collisions per RFC 4034 Section 5.1)
446                    if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
447                        matched_key = true;
448
449                        // Try each candidate DNSKEY until one verifies. Only a
450                        // digest type our crypto backend can compute yields a
451                        // meaningful verified/mismatch verdict; an unsupported
452                        // type (e.g. GOST) is "not evaluated", NOT a mismatch —
453                        // flagging it as a mismatch would mark an otherwise-valid
454                        // signed zone as misconfigured.
455                        match Self::to_hickory_digest_type(digest_type) {
456                            Some(hickory_dt) => {
457                                for candidate in candidates {
458                                    if let Ok(computed) =
459                                        candidate.to_digest(&domain_name, hickory_dt)
460                                    {
461                                        let computed_hex: String = computed
462                                            .as_ref()
463                                            .iter()
464                                            .map(|b| format!("{:02X}", b))
465                                            .collect();
466                                        if computed_hex.eq_ignore_ascii_case(digest) {
467                                            digest_verified = true;
468                                            break;
469                                        }
470                                    }
471                                }
472
473                                if !digest_verified {
474                                    issues.push(format!(
475                                        "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
476                                        key_tag
477                                    ));
478                                }
479                            }
480                            None => {
481                                issues.push(format!(
482                                    "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
483                                    key_tag, digest_type
484                                ));
485                            }
486                        }
487                    } else if has_dnskey {
488                        issues.push(format!(
489                            "DS record (key_tag={}) has no matching DNSKEY",
490                            key_tag
491                        ));
492                    }
493
494                    DsInfo {
495                        key_tag,
496                        algorithm,
497                        digest_type,
498                        digest: digest.clone(),
499                        algorithm_name: algorithm_name(algorithm),
500                        digest_type_name: digest_type_name(digest_type),
501                        matched_key,
502                        digest_verified,
503                    }
504                } else {
505                    // Should not happen — we only have DS records here
506                    DsInfo {
507                        key_tag: 0,
508                        algorithm: 0,
509                        digest_type: 0,
510                        digest: String::new(),
511                        algorithm_name: String::new(),
512                        digest_type_name: String::new(),
513                        matched_key: false,
514                        digest_verified: false,
515                    }
516                }
517            })
518            .collect();
519
520        // Check for KSK orphans (DNSKEY KSKs with no corresponding DS)
521        for key in &dnskey_info {
522            if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
523                issues.push(format!(
524                    "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
525                    key.key_tag
526                ));
527            }
528        }
529
530        // Check for deprecated algorithms in DS records
531        for ds in &ds_info {
532            if ds.algorithm == 1 || ds.algorithm == 3 || ds.algorithm == 5 || ds.algorithm == 6 {
533                issues.push(format!(
534                    "DS record uses deprecated algorithm {} ({})",
535                    ds.algorithm, ds.algorithm_name
536                ));
537            }
538            if ds.digest_type == 1 {
539                issues.push(
540                    "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
541                        .to_string(),
542                );
543            }
544        }
545
546        // Check for deprecated algorithms in DNSKEY records
547        for key in &dnskey_info {
548            if key.algorithm == 1 || key.algorithm == 3 || key.algorithm == 5 || key.algorithm == 6
549            {
550                issues.push(format!(
551                    "DNSKEY record uses deprecated algorithm {} ({})",
552                    key.algorithm, key.algorithm_name
553                ));
554            }
555        }
556
557        // Derive chain_valid. A DS whose digest type we cannot compute is
558        // excluded from the "all must verify" check (we can't judge it), but we
559        // still require at least one computable DS to have actually verified —
560        // otherwise a zone we can't evaluate at all would be reported as valid.
561        let chain_valid = has_ds
562            && has_dnskey
563            && !ds_info.is_empty()
564            && ds_info
565                .iter()
566                .any(|ds| ds.matched_key && ds.digest_verified)
567            && ds_info
568                .iter()
569                .filter(|ds| Self::to_hickory_digest_type(ds.digest_type).is_some())
570                .all(|ds| ds.matched_key && ds.digest_verified);
571
572        // Derive status from chain validity (not from issues list).
573        //
574        // Vocabulary deliberately avoids "secure"/"insecure": we verify only
575        // DS<->DNSKEY digest consistency, NOT RRSIG signatures, so we report
576        // the observable FACT (the zone is signed / unsigned) rather than a
577        // validated security state. See the `status` field doc on
578        // DnssecReport.
579        let enabled = has_ds || has_dnskey;
580        let status = if has_ds && has_dnskey {
581            if chain_valid {
582                "signed".to_string()
583            } else {
584                "misconfigured".to_string()
585            }
586        } else if !has_ds && !has_dnskey {
587            "unsigned".to_string()
588        } else {
589            "partial".to_string()
590        };
591
592        // Also flag the old structural issues
593        if has_ds && !has_dnskey {
594            issues.push(
595                "DS records exist but no DNSKEY records found - DNSSEC may be broken".to_string(),
596            );
597        }
598        if !has_ds && has_dnskey {
599            issues.push(
600                "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
601                    .to_string(),
602            );
603        }
604
605        // Opt-in RRSIG validity inspection (the default check is blind to
606        // expired signatures — the most common real-world DNSSEC outage).
607        let mut rrsig_records = Vec::new();
608        if self.check_rrsig && enabled {
609            rrsig_records = self.resolve_rrsigs(&domain).await;
610            for r in &rrsig_records {
611                if r.expired {
612                    issues.push(format!(
613                        "RRSIG (key_tag={}, covers {}) is outside its validity window (expires_in_days={})",
614                        r.key_tag, r.type_covered, r.expires_in_days
615                    ));
616                } else if r.expires_in_days <= RRSIG_EXPIRY_WARN_DAYS {
617                    issues.push(format!(
618                        "RRSIG (key_tag={}, covers {}) expires in {} day(s)",
619                        r.key_tag, r.type_covered, r.expires_in_days
620                    ));
621                }
622            }
623        }
624
625        // Report the DEPTH of checking performed. RrsigChecked only when we
626        // actually observed RRSIGs — never claim more than we verified.
627        let authentication_tier = if !enabled {
628            AuthenticationTier::Unsigned
629        } else if !rrsig_records.is_empty() {
630            AuthenticationTier::RrsigChecked
631        } else {
632            AuthenticationTier::DigestOnly
633        };
634
635        Ok(DnssecReport {
636            domain,
637            enabled,
638            has_ds_records: has_ds,
639            has_dnskey_records: has_dnskey,
640            ds_records: ds_info,
641            dnskey_records: dnskey_info,
642            issues,
643            status,
644            chain_valid,
645            authentication_tier,
646            rrsig_records,
647        })
648    }
649}
650
651/// Maps a DNSSEC algorithm number to a human-readable name. Numbers come
652/// from the IANA "DNSSEC Algorithm Numbers" registry. Algorithm 9 is
653/// reserved (not assigned). 7 and 12 are operationally discouraged per
654/// RFC 8624; we flag both as deprecated.
655fn algorithm_name(algo: u8) -> String {
656    match algo {
657        1 => "RSA/MD5 (deprecated)".to_string(),
658        3 => "DSA/SHA-1 (deprecated)".to_string(),
659        5 => "RSA/SHA-1 (deprecated)".to_string(),
660        6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
661        7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
662        8 => "RSA/SHA-256".to_string(),
663        10 => "RSA/SHA-512".to_string(),
664        12 => "ECC-GOST (deprecated)".to_string(),
665        13 => "ECDSA P-256/SHA-256".to_string(),
666        14 => "ECDSA P-384/SHA-384".to_string(),
667        15 => "Ed25519".to_string(),
668        16 => "Ed448".to_string(),
669        _ => format!("Unknown ({})", algo),
670    }
671}
672
673fn digest_type_name(dtype: u8) -> String {
674    match dtype {
675        1 => "SHA-1".to_string(),
676        2 => "SHA-256".to_string(),
677        4 => "SHA-384".to_string(),
678        _ => format!("Unknown ({})", dtype),
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn test_algorithm_names() {
688        assert_eq!(algorithm_name(8), "RSA/SHA-256");
689        assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
690        assert_eq!(algorithm_name(15), "Ed25519");
691        assert!(algorithm_name(5).contains("deprecated"));
692    }
693
694    #[test]
695    fn test_digest_type_names() {
696        assert_eq!(digest_type_name(1), "SHA-1");
697        assert_eq!(digest_type_name(2), "SHA-256");
698    }
699
700    #[test]
701    fn rrsig_validity_classifies_windows() {
702        let day = 86_400i64;
703        let now = 1_000_000 * day; // arbitrary fixed "now"
704
705        // Comfortably valid: 30 days left.
706        let (expired, days) = rrsig_validity(now - day, now + 30 * day, now);
707        assert!(!expired);
708        assert_eq!(days, 30);
709
710        // Already past expiration.
711        let (expired, days) = rrsig_validity(now - 40 * day, now - day, now);
712        assert!(expired);
713        assert_eq!(days, -1);
714
715        // Not yet valid (inception in the future) also counts as expired/out-of-window.
716        let (expired, _days) = rrsig_validity(now + day, now + 30 * day, now);
717        assert!(expired);
718    }
719
720    #[test]
721    fn default_tier_is_digest_only() {
722        // Reports deserialized without the field default to digest-only, and
723        // a fresh checker does not enable RRSIG validation.
724        assert_eq!(
725            default_authentication_tier(),
726            AuthenticationTier::DigestOnly
727        );
728        let checker = DnssecChecker::new();
729        assert!(!checker.check_rrsig);
730        assert!(checker.with_rrsig_validation(true).check_rrsig);
731    }
732
733    #[test]
734    fn test_report_serialization() {
735        let report = DnssecReport {
736            domain: "example.com".to_string(),
737            enabled: true,
738            has_ds_records: true,
739            has_dnskey_records: true,
740            ds_records: vec![DsInfo {
741                key_tag: 12345,
742                algorithm: 13,
743                digest_type: 2,
744                digest: "ABCDEF".to_string(),
745                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
746                digest_type_name: "SHA-256".to_string(),
747                matched_key: true,
748                digest_verified: true,
749            }],
750            dnskey_records: vec![DnskeyInfo {
751                flags: 257,
752                protocol: 3,
753                algorithm: 13,
754                key_tag: 12345,
755                is_ksk: true,
756                is_zsk: false,
757                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
758            }],
759            issues: vec![],
760            status: "signed".to_string(),
761            chain_valid: true,
762            authentication_tier: AuthenticationTier::DigestOnly,
763            rrsig_records: vec![],
764        };
765        let json = serde_json::to_string(&report).unwrap();
766        assert!(json.contains("\"enabled\":true"));
767        assert!(json.contains("\"chain_valid\":true"));
768        assert!(json.contains("\"matched_key\":true"));
769        assert!(json.contains("\"digest_verified\":true"));
770        assert!(json.contains("\"key_tag\":12345"));
771    }
772
773    #[test]
774    fn test_chain_valid_all_verified() {
775        let report = DnssecReport {
776            domain: "example.com".to_string(),
777            enabled: true,
778            has_ds_records: true,
779            has_dnskey_records: true,
780            ds_records: vec![
781                DsInfo {
782                    key_tag: 12345,
783                    algorithm: 13,
784                    digest_type: 2,
785                    digest: "ABCDEF".to_string(),
786                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
787                    digest_type_name: "SHA-256".to_string(),
788                    matched_key: true,
789                    digest_verified: true,
790                },
791                DsInfo {
792                    key_tag: 12345,
793                    algorithm: 13,
794                    digest_type: 4,
795                    digest: "FEDCBA".to_string(),
796                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
797                    digest_type_name: "SHA-384".to_string(),
798                    matched_key: true,
799                    digest_verified: true,
800                },
801            ],
802            dnskey_records: vec![DnskeyInfo {
803                flags: 257,
804                protocol: 3,
805                algorithm: 13,
806                key_tag: 12345,
807                is_ksk: true,
808                is_zsk: false,
809                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
810            }],
811            issues: vec![],
812            status: "signed".to_string(),
813            chain_valid: true,
814            authentication_tier: AuthenticationTier::DigestOnly,
815            rrsig_records: vec![],
816        };
817        assert!(report.chain_valid);
818        assert_eq!(report.status, "signed");
819    }
820
821    #[test]
822    fn test_chain_valid_ds_unmatched() {
823        let report = DnssecReport {
824            domain: "broken.com".to_string(),
825            enabled: true,
826            has_ds_records: true,
827            has_dnskey_records: true,
828            ds_records: vec![DsInfo {
829                key_tag: 65000,
830                algorithm: 13,
831                digest_type: 2,
832                digest: "ABCDEF".to_string(),
833                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
834                digest_type_name: "SHA-256".to_string(),
835                matched_key: false,
836                digest_verified: false,
837            }],
838            dnskey_records: vec![DnskeyInfo {
839                flags: 257,
840                protocol: 3,
841                algorithm: 13,
842                key_tag: 12345,
843                is_ksk: true,
844                is_zsk: false,
845                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
846            }],
847            issues: vec!["DS record (key_tag=65000) has no matching DNSKEY".to_string()],
848            status: "misconfigured".to_string(),
849            chain_valid: false,
850            authentication_tier: AuthenticationTier::DigestOnly,
851            rrsig_records: vec![],
852        };
853        assert!(!report.chain_valid);
854        assert_eq!(report.status, "misconfigured");
855    }
856
857    #[test]
858    fn test_chain_valid_digest_mismatch() {
859        let report = DnssecReport {
860            domain: "mismatch.com".to_string(),
861            enabled: true,
862            has_ds_records: true,
863            has_dnskey_records: true,
864            ds_records: vec![DsInfo {
865                key_tag: 12345,
866                algorithm: 13,
867                digest_type: 2,
868                digest: "WRONG".to_string(),
869                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
870                digest_type_name: "SHA-256".to_string(),
871                matched_key: true,
872                digest_verified: false,
873            }],
874            dnskey_records: vec![DnskeyInfo {
875                flags: 257,
876                protocol: 3,
877                algorithm: 13,
878                key_tag: 12345,
879                is_ksk: true,
880                is_zsk: false,
881                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
882            }],
883            issues: vec![],
884            status: "misconfigured".to_string(),
885            chain_valid: false,
886            authentication_tier: AuthenticationTier::DigestOnly,
887            rrsig_records: vec![],
888        };
889        assert!(!report.chain_valid);
890        assert!(report.ds_records[0].matched_key);
891        assert!(!report.ds_records[0].digest_verified);
892    }
893
894    #[tokio::test]
895    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
896    async fn test_live_dnssec_check_cloudflare() {
897        let checker = DnssecChecker::new();
898        let report = checker.check("cloudflare.com").await.unwrap();
899
900        // cloudflare.com has DNSSEC enabled
901        assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
902        assert!(report.has_ds_records, "should have DS records");
903        assert!(report.has_dnskey_records, "should have DNSKEY records");
904        assert!(report.chain_valid, "cloudflare.com chain should be valid");
905        assert_eq!(report.status, "signed");
906
907        // All DS records should be verified
908        for ds in &report.ds_records {
909            assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
910            assert!(
911                ds.digest_verified,
912                "DS key_tag={} digest should verify",
913                ds.key_tag
914            );
915        }
916
917        // Should have computed key tags on DNSKEYs
918        for key in &report.dnskey_records {
919            assert!(key.key_tag > 0, "key_tag should be computed");
920        }
921    }
922
923    #[tokio::test]
924    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
925    async fn test_live_dnssec_rrsig_validation_cloudflare() {
926        let checker = DnssecChecker::new().with_rrsig_validation(true);
927        let report = checker.check("cloudflare.com").await.unwrap();
928        assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
929        // When the resolver surfaces RRSIGs, the tier upgrades and every
930        // signature must be within its validity window for a healthy zone.
931        if !report.rrsig_records.is_empty() {
932            assert_eq!(report.authentication_tier, AuthenticationTier::RrsigChecked);
933            for r in &report.rrsig_records {
934                assert!(r.expiration.is_some(), "RRSIG should carry an expiration");
935                assert!(
936                    !r.expired,
937                    "cloudflare.com RRSIG (covers {}) should be within its validity window",
938                    r.type_covered
939                );
940            }
941        }
942    }
943
944    #[tokio::test]
945    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
946    async fn test_live_dnssec_check_insecure() {
947        let checker = DnssecChecker::new();
948        // wikipedia.org does not have DNSSEC (no DS or DNSKEY records)
949        let report = checker.check("wikipedia.org").await.unwrap();
950
951        assert!(!report.chain_valid);
952        assert_eq!(report.status, "unsigned");
953    }
954}