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 hickory_resolver::config::{ResolveHosts, ResolverConfig, GOOGLE};
9use hickory_resolver::net::runtime::TokioRuntimeProvider;
10use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY};
11use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
12use hickory_resolver::proto::rr::{Name, RData, RecordType as HickoryRecordType};
13use hickory_resolver::TokioResolver;
14use serde::{Deserialize, Serialize};
15use tracing::{debug, instrument};
16
17use super::records::{RecordData, RecordType};
18use super::resolver::DnsResolver;
19use crate::error::Result;
20
21/// DNSSEC validation report for a domain.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DnssecReport {
24    /// The domain that was checked.
25    pub domain: String,
26    /// Whether the domain has DNSSEC enabled.
27    pub enabled: bool,
28    /// Whether DS records exist at the parent zone.
29    pub has_ds_records: bool,
30    /// Whether DNSKEY records exist at the domain.
31    pub has_dnskey_records: bool,
32    /// DS records found at the parent zone.
33    pub ds_records: Vec<DsInfo>,
34    /// DNSKEY records found at the domain.
35    pub dnskey_records: Vec<DnskeyInfo>,
36    /// Validation issues found.
37    pub issues: Vec<String>,
38    /// Overall status: "secure", "insecure", "partial", or "misconfigured".
39    pub status: String,
40    /// Whether the full DS-to-DNSKEY chain validates.
41    /// True only when every DS record matches a DNSKEY and all digests verify.
42    pub chain_valid: bool,
43}
44
45/// Summary of a DS record.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DsInfo {
48    pub key_tag: u16,
49    pub algorithm: u8,
50    pub digest_type: u8,
51    pub digest: String,
52    pub algorithm_name: String,
53    pub digest_type_name: String,
54    /// Whether this DS record's key_tag+algorithm matched a DNSKEY.
55    pub matched_key: bool,
56    /// Whether the computed digest from the matched DNSKEY equals this DS digest.
57    pub digest_verified: bool,
58}
59
60/// Summary of a DNSKEY record.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DnskeyInfo {
63    pub flags: u16,
64    pub protocol: u8,
65    pub algorithm: u8,
66    /// The RFC 4034 computed key tag.
67    pub key_tag: u16,
68    pub is_ksk: bool,
69    pub is_zsk: bool,
70    pub algorithm_name: String,
71}
72
73/// Checks DNSSEC configuration for a domain.
74pub struct DnssecChecker {
75    resolver: DnsResolver,
76    raw_resolver: TokioResolver,
77}
78
79impl Default for DnssecChecker {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl DnssecChecker {
86    pub fn new() -> Self {
87        let mut builder = TokioResolver::builder_with_config(
88            ResolverConfig::udp_and_tcp(&GOOGLE),
89            TokioRuntimeProvider::default(),
90        );
91        {
92            let opts = builder.options_mut();
93            opts.timeout = std::time::Duration::from_secs(5);
94            opts.attempts = 2;
95            opts.use_hosts_file = ResolveHosts::Never;
96        }
97        let raw_resolver = builder
98            .build()
99            .expect("hickory resolver build is infallible without TLS features");
100
101        Self {
102            resolver: DnsResolver::new(),
103            raw_resolver,
104        }
105    }
106
107    /// Resolves raw hickory DNSKEY records for crypto operations.
108    /// Returns a vec of (DNSKEY, computed_key_tag) pairs.
109    async fn resolve_raw_dnskeys(&self, domain: &str) -> Vec<(DNSKEY, u16)> {
110        let Ok(lookup) = self
111            .raw_resolver
112            .lookup(domain, HickoryRecordType::DNSKEY)
113            .await
114        else {
115            return vec![];
116        };
117
118        lookup
119            .answers()
120            .iter()
121            .filter_map(|record| {
122                if let RData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
123                    match dnskey.calculate_key_tag() {
124                        Ok(tag) => Some((dnskey.clone(), tag)),
125                        Err(_) => None,
126                    }
127                } else {
128                    None
129                }
130            })
131            .collect()
132    }
133
134    /// Converts a DS digest type number to hickory's DigestType.
135    ///
136    /// hickory 0.26 made `DigestType` `non_exhaustive` and removed the
137    /// fallible `from_u8` constructor in favour of `From<u8>`, which
138    /// returns `DigestType::Unknown(_)` for unsupported types. We
139    /// preserve the original 0.24 behaviour of refusing to attempt
140    /// digest computation for unsupported types.
141    fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
142        let dt = DigestType::from(digest_type);
143        if dt.is_supported() {
144            Some(dt)
145        } else {
146            None
147        }
148    }
149
150    /// Generate a DNSSEC validation report for a domain.
151    #[instrument(skip(self), fields(domain = %domain))]
152    pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
153        let domain = crate::validation::normalize_domain(domain)?;
154        debug!(domain = %domain, "Checking DNSSEC");
155
156        let mut issues = Vec::new();
157
158        // Query DS records (at parent zone)
159        let ds_records: Vec<crate::dns::DnsRecord> =
160            match self.resolver.resolve(&domain, RecordType::DS, None).await {
161                Ok(records) => records,
162                Err(e) => {
163                    issues.push(format!("DS query failed: {}", e));
164                    vec![]
165                }
166            };
167
168        // Query DNSKEY records (at the domain itself)
169        let dnskey_records: Vec<crate::dns::DnsRecord> = match self
170            .resolver
171            .resolve(&domain, RecordType::DNSKEY, None)
172            .await
173        {
174            Ok(records) => records,
175            Err(e) => {
176                issues.push(format!("DNSKEY query failed: {}", e));
177                vec![]
178            }
179        };
180
181        let has_ds = !ds_records.is_empty();
182        let has_dnskey = !dnskey_records.is_empty();
183
184        // Resolve raw hickory DNSKEYs for crypto operations
185        let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
186
187        // Build lookup map: (key_tag, algorithm) -> vec of raw DNSKEYs
188        // Multiple DNSKEYs can share the same key tag (RFC 4034 Section 5.1).
189        let dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = {
190            let mut map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
191            for (dnskey, tag) in &raw_dnskeys {
192                map.entry((*tag, u8::from(dnskey.public_key().algorithm())))
193                    .or_default()
194                    .push(dnskey);
195            }
196            map
197        };
198
199        // Build set of DS key_tags for KSK orphan detection
200        let ds_key_tags: std::collections::HashSet<u16> = ds_records
201            .iter()
202            .filter_map(|r| {
203                if let RecordData::DS { key_tag, .. } = r.data {
204                    Some(key_tag)
205                } else {
206                    None
207                }
208            })
209            .collect();
210
211        // Build a map from (flags, algorithm) -> vec of computed key_tags
212        // to reliably match our RecordData DNSKEYs to the raw hickory ones.
213        let key_tag_by_algo_flags: HashMap<(u16, u8), Vec<u16>> = {
214            let mut map: HashMap<(u16, u8), Vec<u16>> = HashMap::new();
215            for (dnskey, tag) in &raw_dnskeys {
216                map.entry((dnskey.flags(), u8::from(dnskey.public_key().algorithm())))
217                    .or_default()
218                    .push(*tag);
219            }
220            map
221        };
222
223        // Parse DNSKEY record info with computed key tags
224        let mut dnskey_tag_indices: HashMap<(u16, u8), usize> = HashMap::new();
225        let dnskey_info: Vec<DnskeyInfo> = dnskey_records
226            .iter()
227            .filter_map(|r| {
228                if let RecordData::DNSKEY {
229                    flags,
230                    protocol,
231                    algorithm,
232                    ..
233                } = r.data
234                {
235                    let is_sep = flags & 0x0001 != 0;
236                    let is_zone = flags & 0x0100 != 0;
237                    let is_ksk = is_sep && is_zone;
238                    let is_zsk = is_zone && !is_sep;
239
240                    // Find the computed key tag for this DNSKEY
241                    let idx = dnskey_tag_indices.entry((flags, algorithm)).or_insert(0);
242                    let key_tag = key_tag_by_algo_flags
243                        .get(&(flags, algorithm))
244                        .and_then(|tags| tags.get(*idx))
245                        .copied()
246                        .unwrap_or(0);
247                    *idx += 1;
248
249                    Some(DnskeyInfo {
250                        flags,
251                        protocol,
252                        algorithm,
253                        key_tag,
254                        is_ksk,
255                        is_zsk,
256                        algorithm_name: algorithm_name(algorithm),
257                    })
258                } else {
259                    None
260                }
261            })
262            .collect();
263
264        // Build Name for digest computation
265        let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
266            Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
267        });
268
269        // Parse DS record info with cross-validation
270        let ds_info: Vec<DsInfo> = ds_records
271            .iter()
272            .map(|r| {
273                if let RecordData::DS {
274                    key_tag,
275                    algorithm,
276                    digest_type,
277                    ref digest,
278                } = r.data
279                {
280                    let mut matched_key = false;
281                    let mut digest_verified = false;
282
283                    // Try to match this DS to a DNSKEY (multiple candidates possible
284                    // due to key tag collisions per RFC 4034 Section 5.1)
285                    if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
286                        matched_key = true;
287
288                        // Try each candidate DNSKEY until one verifies
289                        if let Some(hickory_dt) = Self::to_hickory_digest_type(digest_type) {
290                            for candidate in candidates {
291                                if let Ok(computed) =
292                                    candidate.to_digest(&domain_name, hickory_dt)
293                                {
294                                    let computed_hex: String = computed
295                                        .as_ref()
296                                        .iter()
297                                        .map(|b| format!("{:02X}", b))
298                                        .collect();
299                                    if computed_hex.eq_ignore_ascii_case(digest) {
300                                        digest_verified = true;
301                                        break;
302                                    }
303                                }
304                            }
305                        }
306
307                        if !digest_verified {
308                            issues.push(format!(
309                                "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
310                                key_tag
311                            ));
312                        }
313                    } else if has_dnskey {
314                        issues.push(format!(
315                            "DS record (key_tag={}) has no matching DNSKEY",
316                            key_tag
317                        ));
318                    }
319
320                    DsInfo {
321                        key_tag,
322                        algorithm,
323                        digest_type,
324                        digest: digest.clone(),
325                        algorithm_name: algorithm_name(algorithm),
326                        digest_type_name: digest_type_name(digest_type),
327                        matched_key,
328                        digest_verified,
329                    }
330                } else {
331                    // Should not happen — we only have DS records here
332                    DsInfo {
333                        key_tag: 0,
334                        algorithm: 0,
335                        digest_type: 0,
336                        digest: String::new(),
337                        algorithm_name: String::new(),
338                        digest_type_name: String::new(),
339                        matched_key: false,
340                        digest_verified: false,
341                    }
342                }
343            })
344            .collect();
345
346        // Check for KSK orphans (DNSKEY KSKs with no corresponding DS)
347        for key in &dnskey_info {
348            if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
349                issues.push(format!(
350                    "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
351                    key.key_tag
352                ));
353            }
354        }
355
356        // Check for deprecated algorithms in DS records
357        for ds in &ds_info {
358            if ds.algorithm == 1 || ds.algorithm == 3 || ds.algorithm == 5 || ds.algorithm == 6 {
359                issues.push(format!(
360                    "DS record uses deprecated algorithm {} ({})",
361                    ds.algorithm, ds.algorithm_name
362                ));
363            }
364            if ds.digest_type == 1 {
365                issues.push(
366                    "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
367                        .to_string(),
368                );
369            }
370        }
371
372        // Check for deprecated algorithms in DNSKEY records
373        for key in &dnskey_info {
374            if key.algorithm == 1 || key.algorithm == 3 || key.algorithm == 5 || key.algorithm == 6
375            {
376                issues.push(format!(
377                    "DNSKEY record uses deprecated algorithm {} ({})",
378                    key.algorithm, key.algorithm_name
379                ));
380            }
381        }
382
383        // Derive chain_valid
384        let chain_valid = has_ds
385            && has_dnskey
386            && !ds_info.is_empty()
387            && ds_info
388                .iter()
389                .all(|ds| ds.matched_key && ds.digest_verified);
390
391        // Derive status from chain validity (not from issues list)
392        let enabled = has_ds || has_dnskey;
393        let status = if has_ds && has_dnskey {
394            if chain_valid {
395                "secure".to_string()
396            } else {
397                "misconfigured".to_string()
398            }
399        } else if !has_ds && !has_dnskey {
400            "insecure".to_string()
401        } else {
402            "partial".to_string()
403        };
404
405        // Also flag the old structural issues
406        if has_ds && !has_dnskey {
407            issues.push(
408                "DS records exist but no DNSKEY records found - DNSSEC may be broken".to_string(),
409            );
410        }
411        if !has_ds && has_dnskey {
412            issues.push(
413                "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
414                    .to_string(),
415            );
416        }
417
418        Ok(DnssecReport {
419            domain,
420            enabled,
421            has_ds_records: has_ds,
422            has_dnskey_records: has_dnskey,
423            ds_records: ds_info,
424            dnskey_records: dnskey_info,
425            issues,
426            status,
427            chain_valid,
428        })
429    }
430}
431
432fn algorithm_name(algo: u8) -> String {
433    match algo {
434        1 => "RSA/MD5 (deprecated)".to_string(),
435        3 => "DSA/SHA-1 (deprecated)".to_string(),
436        5 => "RSA/SHA-1 (deprecated)".to_string(),
437        6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
438        7 => "RSASHA1-NSEC3-SHA1".to_string(),
439        8 => "RSA/SHA-256".to_string(),
440        10 => "RSA/SHA-512".to_string(),
441        13 => "ECDSA P-256/SHA-256".to_string(),
442        14 => "ECDSA P-384/SHA-384".to_string(),
443        15 => "Ed25519".to_string(),
444        16 => "Ed448".to_string(),
445        _ => format!("Unknown ({})", algo),
446    }
447}
448
449fn digest_type_name(dtype: u8) -> String {
450    match dtype {
451        1 => "SHA-1".to_string(),
452        2 => "SHA-256".to_string(),
453        4 => "SHA-384".to_string(),
454        _ => format!("Unknown ({})", dtype),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn test_algorithm_names() {
464        assert_eq!(algorithm_name(8), "RSA/SHA-256");
465        assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
466        assert_eq!(algorithm_name(15), "Ed25519");
467        assert!(algorithm_name(5).contains("deprecated"));
468    }
469
470    #[test]
471    fn test_digest_type_names() {
472        assert_eq!(digest_type_name(1), "SHA-1");
473        assert_eq!(digest_type_name(2), "SHA-256");
474    }
475
476    #[test]
477    fn test_report_serialization() {
478        let report = DnssecReport {
479            domain: "example.com".to_string(),
480            enabled: true,
481            has_ds_records: true,
482            has_dnskey_records: true,
483            ds_records: vec![DsInfo {
484                key_tag: 12345,
485                algorithm: 13,
486                digest_type: 2,
487                digest: "ABCDEF".to_string(),
488                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
489                digest_type_name: "SHA-256".to_string(),
490                matched_key: true,
491                digest_verified: true,
492            }],
493            dnskey_records: vec![DnskeyInfo {
494                flags: 257,
495                protocol: 3,
496                algorithm: 13,
497                key_tag: 12345,
498                is_ksk: true,
499                is_zsk: false,
500                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
501            }],
502            issues: vec![],
503            status: "secure".to_string(),
504            chain_valid: true,
505        };
506        let json = serde_json::to_string(&report).unwrap();
507        assert!(json.contains("\"enabled\":true"));
508        assert!(json.contains("\"chain_valid\":true"));
509        assert!(json.contains("\"matched_key\":true"));
510        assert!(json.contains("\"digest_verified\":true"));
511        assert!(json.contains("\"key_tag\":12345"));
512    }
513
514    #[test]
515    fn test_chain_valid_all_verified() {
516        let report = DnssecReport {
517            domain: "example.com".to_string(),
518            enabled: true,
519            has_ds_records: true,
520            has_dnskey_records: true,
521            ds_records: vec![
522                DsInfo {
523                    key_tag: 12345,
524                    algorithm: 13,
525                    digest_type: 2,
526                    digest: "ABCDEF".to_string(),
527                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
528                    digest_type_name: "SHA-256".to_string(),
529                    matched_key: true,
530                    digest_verified: true,
531                },
532                DsInfo {
533                    key_tag: 12345,
534                    algorithm: 13,
535                    digest_type: 4,
536                    digest: "FEDCBA".to_string(),
537                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
538                    digest_type_name: "SHA-384".to_string(),
539                    matched_key: true,
540                    digest_verified: true,
541                },
542            ],
543            dnskey_records: vec![DnskeyInfo {
544                flags: 257,
545                protocol: 3,
546                algorithm: 13,
547                key_tag: 12345,
548                is_ksk: true,
549                is_zsk: false,
550                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
551            }],
552            issues: vec![],
553            status: "secure".to_string(),
554            chain_valid: true,
555        };
556        assert!(report.chain_valid);
557        assert_eq!(report.status, "secure");
558    }
559
560    #[test]
561    fn test_chain_valid_ds_unmatched() {
562        let report = DnssecReport {
563            domain: "broken.com".to_string(),
564            enabled: true,
565            has_ds_records: true,
566            has_dnskey_records: true,
567            ds_records: vec![DsInfo {
568                key_tag: 65000,
569                algorithm: 13,
570                digest_type: 2,
571                digest: "ABCDEF".to_string(),
572                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
573                digest_type_name: "SHA-256".to_string(),
574                matched_key: false,
575                digest_verified: false,
576            }],
577            dnskey_records: vec![DnskeyInfo {
578                flags: 257,
579                protocol: 3,
580                algorithm: 13,
581                key_tag: 12345,
582                is_ksk: true,
583                is_zsk: false,
584                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
585            }],
586            issues: vec!["DS record (key_tag=65000) has no matching DNSKEY".to_string()],
587            status: "misconfigured".to_string(),
588            chain_valid: false,
589        };
590        assert!(!report.chain_valid);
591        assert_eq!(report.status, "misconfigured");
592    }
593
594    #[test]
595    fn test_chain_valid_digest_mismatch() {
596        let report = DnssecReport {
597            domain: "mismatch.com".to_string(),
598            enabled: true,
599            has_ds_records: true,
600            has_dnskey_records: true,
601            ds_records: vec![DsInfo {
602                key_tag: 12345,
603                algorithm: 13,
604                digest_type: 2,
605                digest: "WRONG".to_string(),
606                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
607                digest_type_name: "SHA-256".to_string(),
608                matched_key: true,
609                digest_verified: false,
610            }],
611            dnskey_records: vec![DnskeyInfo {
612                flags: 257,
613                protocol: 3,
614                algorithm: 13,
615                key_tag: 12345,
616                is_ksk: true,
617                is_zsk: false,
618                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
619            }],
620            issues: vec![],
621            status: "misconfigured".to_string(),
622            chain_valid: false,
623        };
624        assert!(!report.chain_valid);
625        assert!(report.ds_records[0].matched_key);
626        assert!(!report.ds_records[0].digest_verified);
627    }
628
629    #[tokio::test]
630    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
631    async fn test_live_dnssec_check_cloudflare() {
632        let checker = DnssecChecker::new();
633        let report = checker.check("cloudflare.com").await.unwrap();
634
635        // cloudflare.com has DNSSEC enabled
636        assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
637        assert!(report.has_ds_records, "should have DS records");
638        assert!(report.has_dnskey_records, "should have DNSKEY records");
639        assert!(report.chain_valid, "cloudflare.com chain should be valid");
640        assert_eq!(report.status, "secure");
641
642        // All DS records should be verified
643        for ds in &report.ds_records {
644            assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
645            assert!(
646                ds.digest_verified,
647                "DS key_tag={} digest should verify",
648                ds.key_tag
649            );
650        }
651
652        // Should have computed key tags on DNSKEYs
653        for key in &report.dnskey_records {
654            assert!(key.key_tag > 0, "key_tag should be computed");
655        }
656    }
657
658    #[tokio::test]
659    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
660    async fn test_live_dnssec_check_insecure() {
661        let checker = DnssecChecker::new();
662        // wikipedia.org does not have DNSSEC (no DS or DNSKEY records)
663        let report = checker.check("wikipedia.org").await.unwrap();
664
665        assert!(!report.chain_valid);
666        assert_eq!(report.status, "insecure");
667    }
668}