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,
40 pub chain_valid: bool,
43}
44
45#[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 pub matched_key: bool,
56 pub digest_verified: bool,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DnskeyInfo {
63 pub flags: u16,
64 pub protocol: u8,
65 pub algorithm: u8,
66 pub key_tag: u16,
68 pub is_ksk: bool,
69 pub is_zsk: bool,
70 pub algorithm_name: String,
71}
72
73pub 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 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 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 #[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 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 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 let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
186
187 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 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 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 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 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 let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
266 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
267 });
268
269 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 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
286 matched_key = true;
287
288 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 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 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 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 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 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 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 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 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 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 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 let report = checker.check("wikipedia.org").await.unwrap();
664
665 assert!(!report.chain_valid);
666 assert_eq!(report.status, "insecure");
667 }
668}