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