1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DnssecReport {
23 pub domain: String,
25 pub enabled: bool,
27 pub has_ds_records: bool,
29 pub has_dnskey_records: bool,
31 pub ds_records: Vec<DsInfo>,
33 pub dnskey_records: Vec<DnskeyInfo>,
35 pub issues: Vec<String>,
37 pub status: String,
39 pub chain_valid: bool,
42}
43
44#[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 pub matched_key: bool,
55 pub digest_verified: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DnskeyInfo {
62 pub flags: u16,
63 pub protocol: u8,
64 pub algorithm: u8,
65 pub key_tag: u16,
67 pub is_ksk: bool,
68 pub is_zsk: bool,
69 pub algorithm_name: String,
70}
71
72pub 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 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 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
125 DigestType::from_u8(digest_type).ok()
126 }
127
128 #[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 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 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 let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
164
165 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 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 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 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 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 let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
244 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
245 });
246
247 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 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
264 matched_key = true;
265
266 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 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 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 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 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 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 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 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 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 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 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 let report = checker.check("wikipedia.org").await.unwrap();
640
641 assert!(!report.chain_valid);
642 assert_eq!(report.status, "insecure");
643 }
644}