1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DnssecReport {
24 pub domain: String,
26 pub enabled: bool,
28 pub has_ds_records: bool,
30 pub has_dnskey_records: bool,
32 pub ds_records: Vec<DsInfo>,
34 pub dnskey_records: Vec<DnskeyInfo>,
36 pub issues: Vec<String>,
38 pub status: String,
49 pub chain_valid: bool,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DsInfo {
58 pub key_tag: u16,
59 pub algorithm: u8,
60 pub digest_type: u8,
61 pub digest: String,
62 pub algorithm_name: String,
63 pub digest_type_name: String,
64 pub matched_key: bool,
66 pub digest_verified: bool,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DnskeyInfo {
73 pub flags: u16,
74 pub protocol: u8,
75 pub algorithm: u8,
76 pub key_tag: u16,
78 pub is_ksk: bool,
79 pub is_zsk: bool,
80 pub algorithm_name: String,
81}
82
83pub struct DnssecChecker {
85 resolver: DnsResolver,
86 raw_resolver: TokioResolver,
87}
88
89impl Default for DnssecChecker {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl DnssecChecker {
96 pub fn new() -> Self {
97 let mut builder = TokioResolver::builder_with_config(
98 ResolverConfig::udp_and_tcp(&GOOGLE),
99 TokioRuntimeProvider::default(),
100 );
101 {
102 let opts = builder.options_mut();
103 opts.timeout = std::time::Duration::from_secs(5);
104 opts.attempts = 2;
105 opts.use_hosts_file = ResolveHosts::Never;
106 }
107 let raw_resolver = builder
108 .build()
109 .expect("hickory resolver build is infallible without TLS features");
110
111 Self {
112 resolver: DnsResolver::new(),
113 raw_resolver,
114 }
115 }
116
117 async fn resolve_raw_dnskeys(&self, domain: &str) -> Vec<(DNSKEY, u16)> {
120 let Ok(lookup) = self
121 .raw_resolver
122 .lookup(domain, HickoryRecordType::DNSKEY)
123 .await
124 else {
125 return vec![];
126 };
127
128 lookup
129 .answers()
130 .iter()
131 .filter_map(|record| {
132 if let RData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
133 match dnskey.calculate_key_tag() {
134 Ok(tag) => Some((dnskey.clone(), tag)),
135 Err(_) => None,
136 }
137 } else {
138 None
139 }
140 })
141 .collect()
142 }
143
144 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
152 let dt = DigestType::from(digest_type);
153 if dt.is_supported() {
154 Some(dt)
155 } else {
156 None
157 }
158 }
159
160 #[instrument(skip(self), fields(domain = %domain))]
162 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
163 let domain = crate::validation::normalize_domain(domain)?;
164 debug!(domain = %domain, "Checking DNSSEC");
165
166 let mut issues = Vec::new();
167
168 let ds_records: Vec<crate::dns::DnsRecord> =
170 match self.resolver.resolve(&domain, RecordType::DS, None).await {
171 Ok(records) => records,
172 Err(e) => {
173 issues.push(format!("DS query failed: {}", e));
174 vec![]
175 }
176 };
177
178 let dnskey_records: Vec<crate::dns::DnsRecord> = match self
180 .resolver
181 .resolve(&domain, RecordType::DNSKEY, None)
182 .await
183 {
184 Ok(records) => records,
185 Err(e) => {
186 issues.push(format!("DNSKEY query failed: {}", e));
187 vec![]
188 }
189 };
190
191 let has_ds = !ds_records.is_empty();
192 let has_dnskey = !dnskey_records.is_empty();
193
194 let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
196
197 let dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = {
200 let mut map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
201 for (dnskey, tag) in &raw_dnskeys {
202 map.entry((*tag, u8::from(dnskey.public_key().algorithm())))
203 .or_default()
204 .push(dnskey);
205 }
206 map
207 };
208
209 let ds_key_tags: std::collections::HashSet<u16> = ds_records
211 .iter()
212 .filter_map(|r| {
213 if let RecordData::DS { key_tag, .. } = r.data {
214 Some(key_tag)
215 } else {
216 None
217 }
218 })
219 .collect();
220
221 let key_tag_by_algo_flags: HashMap<(u16, u8), Vec<u16>> = {
231 let mut map: HashMap<(u16, u8), Vec<u16>> = HashMap::new();
232 for (dnskey, tag) in &raw_dnskeys {
233 map.entry((dnskey.flags(), u8::from(dnskey.public_key().algorithm())))
234 .or_default()
235 .push(*tag);
236 }
237 map
238 };
239
240 let mut dnskey_tag_indices: HashMap<(u16, u8), usize> = HashMap::new();
250 let dnskey_info: Vec<DnskeyInfo> = dnskey_records
251 .iter()
252 .filter_map(|r| {
253 if let RecordData::DNSKEY {
254 flags,
255 protocol,
256 algorithm,
257 ..
258 } = r.data
259 {
260 let is_sep = flags & 0x0001 != 0;
261 let is_zone = flags & 0x0100 != 0;
262 let is_ksk = is_sep && is_zone;
263 let is_zsk = is_zone && !is_sep;
264
265 let idx = dnskey_tag_indices.entry((flags, algorithm)).or_insert(0);
267 let key_tag = key_tag_by_algo_flags
268 .get(&(flags, algorithm))
269 .and_then(|tags| tags.get(*idx))
270 .copied()
271 .unwrap_or(0);
272 *idx += 1;
273
274 Some(DnskeyInfo {
275 flags,
276 protocol,
277 algorithm,
278 key_tag,
279 is_ksk,
280 is_zsk,
281 algorithm_name: algorithm_name(algorithm),
282 })
283 } else {
284 None
285 }
286 })
287 .collect();
288
289 let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
291 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
292 });
293
294 let ds_info: Vec<DsInfo> = ds_records
296 .iter()
297 .map(|r| {
298 if let RecordData::DS {
299 key_tag,
300 algorithm,
301 digest_type,
302 ref digest,
303 } = r.data
304 {
305 let mut matched_key = false;
306 let mut digest_verified = false;
307
308 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
311 matched_key = true;
312
313 if let Some(hickory_dt) = Self::to_hickory_digest_type(digest_type) {
315 for candidate in candidates {
316 if let Ok(computed) =
317 candidate.to_digest(&domain_name, hickory_dt)
318 {
319 let computed_hex: String = computed
320 .as_ref()
321 .iter()
322 .map(|b| format!("{:02X}", b))
323 .collect();
324 if computed_hex.eq_ignore_ascii_case(digest) {
325 digest_verified = true;
326 break;
327 }
328 }
329 }
330 }
331
332 if !digest_verified {
333 issues.push(format!(
334 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
335 key_tag
336 ));
337 }
338 } else if has_dnskey {
339 issues.push(format!(
340 "DS record (key_tag={}) has no matching DNSKEY",
341 key_tag
342 ));
343 }
344
345 DsInfo {
346 key_tag,
347 algorithm,
348 digest_type,
349 digest: digest.clone(),
350 algorithm_name: algorithm_name(algorithm),
351 digest_type_name: digest_type_name(digest_type),
352 matched_key,
353 digest_verified,
354 }
355 } else {
356 DsInfo {
358 key_tag: 0,
359 algorithm: 0,
360 digest_type: 0,
361 digest: String::new(),
362 algorithm_name: String::new(),
363 digest_type_name: String::new(),
364 matched_key: false,
365 digest_verified: false,
366 }
367 }
368 })
369 .collect();
370
371 for key in &dnskey_info {
373 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
374 issues.push(format!(
375 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
376 key.key_tag
377 ));
378 }
379 }
380
381 for ds in &ds_info {
383 if ds.algorithm == 1 || ds.algorithm == 3 || ds.algorithm == 5 || ds.algorithm == 6 {
384 issues.push(format!(
385 "DS record uses deprecated algorithm {} ({})",
386 ds.algorithm, ds.algorithm_name
387 ));
388 }
389 if ds.digest_type == 1 {
390 issues.push(
391 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
392 .to_string(),
393 );
394 }
395 }
396
397 for key in &dnskey_info {
399 if key.algorithm == 1 || key.algorithm == 3 || key.algorithm == 5 || key.algorithm == 6
400 {
401 issues.push(format!(
402 "DNSKEY record uses deprecated algorithm {} ({})",
403 key.algorithm, key.algorithm_name
404 ));
405 }
406 }
407
408 let chain_valid = has_ds
410 && has_dnskey
411 && !ds_info.is_empty()
412 && ds_info
413 .iter()
414 .all(|ds| ds.matched_key && ds.digest_verified);
415
416 let enabled = has_ds || has_dnskey;
424 let status = if has_ds && has_dnskey {
425 if chain_valid {
426 "signed".to_string()
427 } else {
428 "misconfigured".to_string()
429 }
430 } else if !has_ds && !has_dnskey {
431 "unsigned".to_string()
432 } else {
433 "partial".to_string()
434 };
435
436 if has_ds && !has_dnskey {
438 issues.push(
439 "DS records exist but no DNSKEY records found - DNSSEC may be broken".to_string(),
440 );
441 }
442 if !has_ds && has_dnskey {
443 issues.push(
444 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
445 .to_string(),
446 );
447 }
448
449 Ok(DnssecReport {
450 domain,
451 enabled,
452 has_ds_records: has_ds,
453 has_dnskey_records: has_dnskey,
454 ds_records: ds_info,
455 dnskey_records: dnskey_info,
456 issues,
457 status,
458 chain_valid,
459 })
460 }
461}
462
463fn algorithm_name(algo: u8) -> String {
468 match algo {
469 1 => "RSA/MD5 (deprecated)".to_string(),
470 3 => "DSA/SHA-1 (deprecated)".to_string(),
471 5 => "RSA/SHA-1 (deprecated)".to_string(),
472 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
473 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
474 8 => "RSA/SHA-256".to_string(),
475 10 => "RSA/SHA-512".to_string(),
476 12 => "ECC-GOST (deprecated)".to_string(),
477 13 => "ECDSA P-256/SHA-256".to_string(),
478 14 => "ECDSA P-384/SHA-384".to_string(),
479 15 => "Ed25519".to_string(),
480 16 => "Ed448".to_string(),
481 _ => format!("Unknown ({})", algo),
482 }
483}
484
485fn digest_type_name(dtype: u8) -> String {
486 match dtype {
487 1 => "SHA-1".to_string(),
488 2 => "SHA-256".to_string(),
489 4 => "SHA-384".to_string(),
490 _ => format!("Unknown ({})", dtype),
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_algorithm_names() {
500 assert_eq!(algorithm_name(8), "RSA/SHA-256");
501 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
502 assert_eq!(algorithm_name(15), "Ed25519");
503 assert!(algorithm_name(5).contains("deprecated"));
504 }
505
506 #[test]
507 fn test_digest_type_names() {
508 assert_eq!(digest_type_name(1), "SHA-1");
509 assert_eq!(digest_type_name(2), "SHA-256");
510 }
511
512 #[test]
513 fn test_report_serialization() {
514 let report = DnssecReport {
515 domain: "example.com".to_string(),
516 enabled: true,
517 has_ds_records: true,
518 has_dnskey_records: true,
519 ds_records: vec![DsInfo {
520 key_tag: 12345,
521 algorithm: 13,
522 digest_type: 2,
523 digest: "ABCDEF".to_string(),
524 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
525 digest_type_name: "SHA-256".to_string(),
526 matched_key: true,
527 digest_verified: true,
528 }],
529 dnskey_records: vec![DnskeyInfo {
530 flags: 257,
531 protocol: 3,
532 algorithm: 13,
533 key_tag: 12345,
534 is_ksk: true,
535 is_zsk: false,
536 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
537 }],
538 issues: vec![],
539 status: "signed".to_string(),
540 chain_valid: true,
541 };
542 let json = serde_json::to_string(&report).unwrap();
543 assert!(json.contains("\"enabled\":true"));
544 assert!(json.contains("\"chain_valid\":true"));
545 assert!(json.contains("\"matched_key\":true"));
546 assert!(json.contains("\"digest_verified\":true"));
547 assert!(json.contains("\"key_tag\":12345"));
548 }
549
550 #[test]
551 fn test_chain_valid_all_verified() {
552 let report = DnssecReport {
553 domain: "example.com".to_string(),
554 enabled: true,
555 has_ds_records: true,
556 has_dnskey_records: true,
557 ds_records: vec![
558 DsInfo {
559 key_tag: 12345,
560 algorithm: 13,
561 digest_type: 2,
562 digest: "ABCDEF".to_string(),
563 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
564 digest_type_name: "SHA-256".to_string(),
565 matched_key: true,
566 digest_verified: true,
567 },
568 DsInfo {
569 key_tag: 12345,
570 algorithm: 13,
571 digest_type: 4,
572 digest: "FEDCBA".to_string(),
573 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
574 digest_type_name: "SHA-384".to_string(),
575 matched_key: true,
576 digest_verified: true,
577 },
578 ],
579 dnskey_records: vec![DnskeyInfo {
580 flags: 257,
581 protocol: 3,
582 algorithm: 13,
583 key_tag: 12345,
584 is_ksk: true,
585 is_zsk: false,
586 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
587 }],
588 issues: vec![],
589 status: "signed".to_string(),
590 chain_valid: true,
591 };
592 assert!(report.chain_valid);
593 assert_eq!(report.status, "signed");
594 }
595
596 #[test]
597 fn test_chain_valid_ds_unmatched() {
598 let report = DnssecReport {
599 domain: "broken.com".to_string(),
600 enabled: true,
601 has_ds_records: true,
602 has_dnskey_records: true,
603 ds_records: vec![DsInfo {
604 key_tag: 65000,
605 algorithm: 13,
606 digest_type: 2,
607 digest: "ABCDEF".to_string(),
608 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
609 digest_type_name: "SHA-256".to_string(),
610 matched_key: false,
611 digest_verified: false,
612 }],
613 dnskey_records: vec![DnskeyInfo {
614 flags: 257,
615 protocol: 3,
616 algorithm: 13,
617 key_tag: 12345,
618 is_ksk: true,
619 is_zsk: false,
620 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
621 }],
622 issues: vec!["DS record (key_tag=65000) has no matching DNSKEY".to_string()],
623 status: "misconfigured".to_string(),
624 chain_valid: false,
625 };
626 assert!(!report.chain_valid);
627 assert_eq!(report.status, "misconfigured");
628 }
629
630 #[test]
631 fn test_chain_valid_digest_mismatch() {
632 let report = DnssecReport {
633 domain: "mismatch.com".to_string(),
634 enabled: true,
635 has_ds_records: true,
636 has_dnskey_records: true,
637 ds_records: vec![DsInfo {
638 key_tag: 12345,
639 algorithm: 13,
640 digest_type: 2,
641 digest: "WRONG".to_string(),
642 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
643 digest_type_name: "SHA-256".to_string(),
644 matched_key: true,
645 digest_verified: false,
646 }],
647 dnskey_records: vec![DnskeyInfo {
648 flags: 257,
649 protocol: 3,
650 algorithm: 13,
651 key_tag: 12345,
652 is_ksk: true,
653 is_zsk: false,
654 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
655 }],
656 issues: vec![],
657 status: "misconfigured".to_string(),
658 chain_valid: false,
659 };
660 assert!(!report.chain_valid);
661 assert!(report.ds_records[0].matched_key);
662 assert!(!report.ds_records[0].digest_verified);
663 }
664
665 #[tokio::test]
666 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
667 async fn test_live_dnssec_check_cloudflare() {
668 let checker = DnssecChecker::new();
669 let report = checker.check("cloudflare.com").await.unwrap();
670
671 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
673 assert!(report.has_ds_records, "should have DS records");
674 assert!(report.has_dnskey_records, "should have DNSKEY records");
675 assert!(report.chain_valid, "cloudflare.com chain should be valid");
676 assert_eq!(report.status, "signed");
677
678 for ds in &report.ds_records {
680 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
681 assert!(
682 ds.digest_verified,
683 "DS key_tag={} digest should verify",
684 ds.key_tag
685 );
686 }
687
688 for key in &report.dnskey_records {
690 assert!(key.key_tag > 0, "key_tag should be computed");
691 }
692 }
693
694 #[tokio::test]
695 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
696 async fn test_live_dnssec_check_insecure() {
697 let checker = DnssecChecker::new();
698 let report = checker.check("wikipedia.org").await.unwrap();
700
701 assert!(!report.chain_valid);
702 assert_eq!(report.status, "unsigned");
703 }
704}