1use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use hickory_resolver::config::{ResolveHosts, ResolverConfig, GOOGLE};
10use hickory_resolver::net::runtime::TokioRuntimeProvider;
11use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY};
12use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
13use hickory_resolver::proto::rr::{Name, RData, RecordType as HickoryRecordType};
14use hickory_resolver::TokioResolver;
15use serde::{Deserialize, Serialize};
16use tracing::{debug, instrument};
17
18use super::records::{RecordData, RecordType};
19use super::resolver::DnsResolver;
20use crate::error::Result;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DnssecReport {
25 pub domain: String,
27 pub enabled: bool,
29 pub has_ds_records: bool,
31 pub has_dnskey_records: bool,
33 pub ds_records: Vec<DsInfo>,
35 pub dnskey_records: Vec<DnskeyInfo>,
37 pub issues: Vec<String>,
39 pub status: String,
50 pub chain_valid: bool,
54 #[serde(default = "default_authentication_tier")]
59 pub authentication_tier: AuthenticationTier,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub rrsig_records: Vec<RrsigInfo>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum AuthenticationTier {
76 Unsigned,
78 DigestOnly,
81 RrsigChecked,
85}
86
87fn default_authentication_tier() -> AuthenticationTier {
88 AuthenticationTier::DigestOnly
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RrsigInfo {
94 pub type_covered: String,
96 pub algorithm: u8,
98 pub algorithm_name: String,
100 pub key_tag: u16,
102 pub signer_name: String,
104 pub inception: Option<DateTime<Utc>>,
106 pub expiration: Option<DateTime<Utc>>,
108 pub expired: bool,
110 pub expires_in_days: i64,
112}
113
114const RRSIG_EXPIRY_WARN_DAYS: i64 = 7;
116
117fn rrsig_validity(inception: i64, expiration: i64, now: i64) -> (bool, i64) {
121 let expired = now > expiration || now < inception;
122 let expires_in_days = (expiration - now).div_euclid(86_400);
123 (expired, expires_in_days)
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct DsInfo {
129 pub key_tag: u16,
130 pub algorithm: u8,
131 pub digest_type: u8,
132 pub digest: String,
133 pub algorithm_name: String,
134 pub digest_type_name: String,
135 pub matched_key: bool,
137 pub digest_verified: bool,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DnskeyInfo {
144 pub flags: u16,
145 pub protocol: u8,
146 pub algorithm: u8,
147 pub key_tag: u16,
149 pub is_ksk: bool,
150 pub is_zsk: bool,
151 pub algorithm_name: String,
152}
153
154pub struct DnssecChecker {
156 resolver: DnsResolver,
157 raw_resolver: TokioResolver,
158 check_rrsig: bool,
161}
162
163impl Default for DnssecChecker {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl DnssecChecker {
170 pub fn new() -> Self {
171 let raw_resolver = Self::build_resolver(false);
172
173 Self {
174 resolver: DnsResolver::new(),
175 raw_resolver,
176 check_rrsig: false,
177 }
178 }
179
180 pub fn with_rrsig_validation(mut self, on: bool) -> Self {
185 self.check_rrsig = on;
186 self
187 }
188
189 fn build_resolver(validating: bool) -> TokioResolver {
193 let mut builder = TokioResolver::builder_with_config(
194 ResolverConfig::udp_and_tcp(&GOOGLE),
195 TokioRuntimeProvider::default(),
196 );
197 {
198 let opts = builder.options_mut();
199 opts.timeout = std::time::Duration::from_secs(5);
200 opts.attempts = 2;
201 opts.use_hosts_file = ResolveHosts::Never;
202 if validating {
203 opts.validate = true;
208 opts.edns0 = true;
209 }
210 }
211 builder
212 .build()
213 .expect("hickory resolver build is infallible without TLS features")
214 }
215
216 async fn resolve_rrsigs(&self, domain: &str) -> Vec<RrsigInfo> {
221 let resolver = Self::build_resolver(true);
222 let Ok(lookup) = resolver.lookup(domain, HickoryRecordType::RRSIG).await else {
223 return vec![];
224 };
225 let now = Utc::now().timestamp();
226 lookup
227 .answers()
228 .iter()
229 .filter_map(|record| {
230 let RData::DNSSEC(DNSSECRData::RRSIG(rrsig)) = &record.data else {
231 return None;
232 };
233 let input = rrsig.input();
234 let inception = input.sig_inception.get() as i64;
235 let expiration = input.sig_expiration.get() as i64;
236 let (expired, expires_in_days) = rrsig_validity(inception, expiration, now);
237 let algorithm = u8::from(input.algorithm);
238 Some(RrsigInfo {
239 type_covered: input.type_covered.to_string(),
240 algorithm,
241 algorithm_name: algorithm_name(algorithm),
242 key_tag: input.key_tag,
243 signer_name: input.signer_name.to_string(),
244 inception: DateTime::from_timestamp(inception, 0),
245 expiration: DateTime::from_timestamp(expiration, 0),
246 expired,
247 expires_in_days,
248 })
249 })
250 .collect()
251 }
252
253 async fn resolve_raw_dnskeys(&self, domain: &str) -> Vec<(DNSKEY, u16)> {
256 let Ok(lookup) = self
257 .raw_resolver
258 .lookup(domain, HickoryRecordType::DNSKEY)
259 .await
260 else {
261 return vec![];
262 };
263
264 lookup
265 .answers()
266 .iter()
267 .filter_map(|record| {
268 if let RData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
269 match dnskey.calculate_key_tag() {
270 Ok(tag) => Some((dnskey.clone(), tag)),
271 Err(_) => None,
272 }
273 } else {
274 None
275 }
276 })
277 .collect()
278 }
279
280 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
288 let dt = DigestType::from(digest_type);
289 if dt.is_supported() {
290 Some(dt)
291 } else {
292 None
293 }
294 }
295
296 #[instrument(skip(self), fields(domain = %domain))]
298 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
299 let domain = crate::validation::normalize_domain(domain)?;
300 debug!(domain = %domain, "Checking DNSSEC");
301
302 let mut issues = Vec::new();
303
304 let ds_records: Vec<crate::dns::DnsRecord> =
306 match self.resolver.resolve(&domain, RecordType::DS, None).await {
307 Ok(records) => records,
308 Err(e) => {
309 issues.push(format!("DS query failed: {}", e));
310 vec![]
311 }
312 };
313
314 let dnskey_records: Vec<crate::dns::DnsRecord> = match self
316 .resolver
317 .resolve(&domain, RecordType::DNSKEY, None)
318 .await
319 {
320 Ok(records) => records,
321 Err(e) => {
322 issues.push(format!("DNSKEY query failed: {}", e));
323 vec![]
324 }
325 };
326
327 let has_ds = !ds_records.is_empty();
328 let has_dnskey = !dnskey_records.is_empty();
329
330 let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
332
333 let dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = {
336 let mut map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
337 for (dnskey, tag) in &raw_dnskeys {
338 map.entry((*tag, u8::from(dnskey.public_key().algorithm())))
339 .or_default()
340 .push(dnskey);
341 }
342 map
343 };
344
345 let ds_key_tags: std::collections::HashSet<u16> = ds_records
347 .iter()
348 .filter_map(|r| {
349 if let RecordData::DS { key_tag, .. } = r.data {
350 Some(key_tag)
351 } else {
352 None
353 }
354 })
355 .collect();
356
357 let key_tag_by_algo_flags: HashMap<(u16, u8), Vec<u16>> = {
367 let mut map: HashMap<(u16, u8), Vec<u16>> = HashMap::new();
368 for (dnskey, tag) in &raw_dnskeys {
369 map.entry((dnskey.flags(), u8::from(dnskey.public_key().algorithm())))
370 .or_default()
371 .push(*tag);
372 }
373 map
374 };
375
376 let mut dnskey_tag_indices: HashMap<(u16, u8), usize> = HashMap::new();
386 let dnskey_info: Vec<DnskeyInfo> = dnskey_records
387 .iter()
388 .filter_map(|r| {
389 if let RecordData::DNSKEY {
390 flags,
391 protocol,
392 algorithm,
393 ..
394 } = r.data
395 {
396 let is_sep = flags & 0x0001 != 0;
397 let is_zone = flags & 0x0100 != 0;
398 let is_ksk = is_sep && is_zone;
399 let is_zsk = is_zone && !is_sep;
400
401 let idx = dnskey_tag_indices.entry((flags, algorithm)).or_insert(0);
403 let key_tag = key_tag_by_algo_flags
404 .get(&(flags, algorithm))
405 .and_then(|tags| tags.get(*idx))
406 .copied()
407 .unwrap_or(0);
408 *idx += 1;
409
410 Some(DnskeyInfo {
411 flags,
412 protocol,
413 algorithm,
414 key_tag,
415 is_ksk,
416 is_zsk,
417 algorithm_name: algorithm_name(algorithm),
418 })
419 } else {
420 None
421 }
422 })
423 .collect();
424
425 let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
427 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
428 });
429
430 let ds_info: Vec<DsInfo> = ds_records
432 .iter()
433 .map(|r| {
434 if let RecordData::DS {
435 key_tag,
436 algorithm,
437 digest_type,
438 ref digest,
439 } = r.data
440 {
441 let mut matched_key = false;
442 let mut digest_verified = false;
443
444 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
447 matched_key = true;
448
449 match Self::to_hickory_digest_type(digest_type) {
456 Some(hickory_dt) => {
457 for candidate in candidates {
458 if let Ok(computed) =
459 candidate.to_digest(&domain_name, hickory_dt)
460 {
461 let computed_hex: String = computed
462 .as_ref()
463 .iter()
464 .map(|b| format!("{:02X}", b))
465 .collect();
466 if computed_hex.eq_ignore_ascii_case(digest) {
467 digest_verified = true;
468 break;
469 }
470 }
471 }
472
473 if !digest_verified {
474 issues.push(format!(
475 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
476 key_tag
477 ));
478 }
479 }
480 None => {
481 issues.push(format!(
482 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
483 key_tag, digest_type
484 ));
485 }
486 }
487 } else if has_dnskey {
488 issues.push(format!(
489 "DS record (key_tag={}) has no matching DNSKEY",
490 key_tag
491 ));
492 }
493
494 DsInfo {
495 key_tag,
496 algorithm,
497 digest_type,
498 digest: digest.clone(),
499 algorithm_name: algorithm_name(algorithm),
500 digest_type_name: digest_type_name(digest_type),
501 matched_key,
502 digest_verified,
503 }
504 } else {
505 DsInfo {
507 key_tag: 0,
508 algorithm: 0,
509 digest_type: 0,
510 digest: String::new(),
511 algorithm_name: String::new(),
512 digest_type_name: String::new(),
513 matched_key: false,
514 digest_verified: false,
515 }
516 }
517 })
518 .collect();
519
520 for key in &dnskey_info {
522 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
523 issues.push(format!(
524 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
525 key.key_tag
526 ));
527 }
528 }
529
530 for ds in &ds_info {
532 if ds.algorithm == 1 || ds.algorithm == 3 || ds.algorithm == 5 || ds.algorithm == 6 {
533 issues.push(format!(
534 "DS record uses deprecated algorithm {} ({})",
535 ds.algorithm, ds.algorithm_name
536 ));
537 }
538 if ds.digest_type == 1 {
539 issues.push(
540 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
541 .to_string(),
542 );
543 }
544 }
545
546 for key in &dnskey_info {
548 if key.algorithm == 1 || key.algorithm == 3 || key.algorithm == 5 || key.algorithm == 6
549 {
550 issues.push(format!(
551 "DNSKEY record uses deprecated algorithm {} ({})",
552 key.algorithm, key.algorithm_name
553 ));
554 }
555 }
556
557 let chain_valid = has_ds
562 && has_dnskey
563 && !ds_info.is_empty()
564 && ds_info
565 .iter()
566 .any(|ds| ds.matched_key && ds.digest_verified)
567 && ds_info
568 .iter()
569 .filter(|ds| Self::to_hickory_digest_type(ds.digest_type).is_some())
570 .all(|ds| ds.matched_key && ds.digest_verified);
571
572 let enabled = has_ds || has_dnskey;
580 let status = if has_ds && has_dnskey {
581 if chain_valid {
582 "signed".to_string()
583 } else {
584 "misconfigured".to_string()
585 }
586 } else if !has_ds && !has_dnskey {
587 "unsigned".to_string()
588 } else {
589 "partial".to_string()
590 };
591
592 if has_ds && !has_dnskey {
594 issues.push(
595 "DS records exist but no DNSKEY records found - DNSSEC may be broken".to_string(),
596 );
597 }
598 if !has_ds && has_dnskey {
599 issues.push(
600 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
601 .to_string(),
602 );
603 }
604
605 let mut rrsig_records = Vec::new();
608 if self.check_rrsig && enabled {
609 rrsig_records = self.resolve_rrsigs(&domain).await;
610 for r in &rrsig_records {
611 if r.expired {
612 issues.push(format!(
613 "RRSIG (key_tag={}, covers {}) is outside its validity window (expires_in_days={})",
614 r.key_tag, r.type_covered, r.expires_in_days
615 ));
616 } else if r.expires_in_days <= RRSIG_EXPIRY_WARN_DAYS {
617 issues.push(format!(
618 "RRSIG (key_tag={}, covers {}) expires in {} day(s)",
619 r.key_tag, r.type_covered, r.expires_in_days
620 ));
621 }
622 }
623 }
624
625 let authentication_tier = if !enabled {
628 AuthenticationTier::Unsigned
629 } else if !rrsig_records.is_empty() {
630 AuthenticationTier::RrsigChecked
631 } else {
632 AuthenticationTier::DigestOnly
633 };
634
635 Ok(DnssecReport {
636 domain,
637 enabled,
638 has_ds_records: has_ds,
639 has_dnskey_records: has_dnskey,
640 ds_records: ds_info,
641 dnskey_records: dnskey_info,
642 issues,
643 status,
644 chain_valid,
645 authentication_tier,
646 rrsig_records,
647 })
648 }
649}
650
651fn algorithm_name(algo: u8) -> String {
656 match algo {
657 1 => "RSA/MD5 (deprecated)".to_string(),
658 3 => "DSA/SHA-1 (deprecated)".to_string(),
659 5 => "RSA/SHA-1 (deprecated)".to_string(),
660 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
661 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
662 8 => "RSA/SHA-256".to_string(),
663 10 => "RSA/SHA-512".to_string(),
664 12 => "ECC-GOST (deprecated)".to_string(),
665 13 => "ECDSA P-256/SHA-256".to_string(),
666 14 => "ECDSA P-384/SHA-384".to_string(),
667 15 => "Ed25519".to_string(),
668 16 => "Ed448".to_string(),
669 _ => format!("Unknown ({})", algo),
670 }
671}
672
673fn digest_type_name(dtype: u8) -> String {
674 match dtype {
675 1 => "SHA-1".to_string(),
676 2 => "SHA-256".to_string(),
677 4 => "SHA-384".to_string(),
678 _ => format!("Unknown ({})", dtype),
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn test_algorithm_names() {
688 assert_eq!(algorithm_name(8), "RSA/SHA-256");
689 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
690 assert_eq!(algorithm_name(15), "Ed25519");
691 assert!(algorithm_name(5).contains("deprecated"));
692 }
693
694 #[test]
695 fn test_digest_type_names() {
696 assert_eq!(digest_type_name(1), "SHA-1");
697 assert_eq!(digest_type_name(2), "SHA-256");
698 }
699
700 #[test]
701 fn rrsig_validity_classifies_windows() {
702 let day = 86_400i64;
703 let now = 1_000_000 * day; let (expired, days) = rrsig_validity(now - day, now + 30 * day, now);
707 assert!(!expired);
708 assert_eq!(days, 30);
709
710 let (expired, days) = rrsig_validity(now - 40 * day, now - day, now);
712 assert!(expired);
713 assert_eq!(days, -1);
714
715 let (expired, _days) = rrsig_validity(now + day, now + 30 * day, now);
717 assert!(expired);
718 }
719
720 #[test]
721 fn default_tier_is_digest_only() {
722 assert_eq!(
725 default_authentication_tier(),
726 AuthenticationTier::DigestOnly
727 );
728 let checker = DnssecChecker::new();
729 assert!(!checker.check_rrsig);
730 assert!(checker.with_rrsig_validation(true).check_rrsig);
731 }
732
733 #[test]
734 fn test_report_serialization() {
735 let report = DnssecReport {
736 domain: "example.com".to_string(),
737 enabled: true,
738 has_ds_records: true,
739 has_dnskey_records: true,
740 ds_records: vec![DsInfo {
741 key_tag: 12345,
742 algorithm: 13,
743 digest_type: 2,
744 digest: "ABCDEF".to_string(),
745 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
746 digest_type_name: "SHA-256".to_string(),
747 matched_key: true,
748 digest_verified: true,
749 }],
750 dnskey_records: vec![DnskeyInfo {
751 flags: 257,
752 protocol: 3,
753 algorithm: 13,
754 key_tag: 12345,
755 is_ksk: true,
756 is_zsk: false,
757 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
758 }],
759 issues: vec![],
760 status: "signed".to_string(),
761 chain_valid: true,
762 authentication_tier: AuthenticationTier::DigestOnly,
763 rrsig_records: vec![],
764 };
765 let json = serde_json::to_string(&report).unwrap();
766 assert!(json.contains("\"enabled\":true"));
767 assert!(json.contains("\"chain_valid\":true"));
768 assert!(json.contains("\"matched_key\":true"));
769 assert!(json.contains("\"digest_verified\":true"));
770 assert!(json.contains("\"key_tag\":12345"));
771 }
772
773 #[test]
774 fn test_chain_valid_all_verified() {
775 let report = DnssecReport {
776 domain: "example.com".to_string(),
777 enabled: true,
778 has_ds_records: true,
779 has_dnskey_records: true,
780 ds_records: vec![
781 DsInfo {
782 key_tag: 12345,
783 algorithm: 13,
784 digest_type: 2,
785 digest: "ABCDEF".to_string(),
786 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
787 digest_type_name: "SHA-256".to_string(),
788 matched_key: true,
789 digest_verified: true,
790 },
791 DsInfo {
792 key_tag: 12345,
793 algorithm: 13,
794 digest_type: 4,
795 digest: "FEDCBA".to_string(),
796 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
797 digest_type_name: "SHA-384".to_string(),
798 matched_key: true,
799 digest_verified: true,
800 },
801 ],
802 dnskey_records: vec![DnskeyInfo {
803 flags: 257,
804 protocol: 3,
805 algorithm: 13,
806 key_tag: 12345,
807 is_ksk: true,
808 is_zsk: false,
809 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
810 }],
811 issues: vec![],
812 status: "signed".to_string(),
813 chain_valid: true,
814 authentication_tier: AuthenticationTier::DigestOnly,
815 rrsig_records: vec![],
816 };
817 assert!(report.chain_valid);
818 assert_eq!(report.status, "signed");
819 }
820
821 #[test]
822 fn test_chain_valid_ds_unmatched() {
823 let report = DnssecReport {
824 domain: "broken.com".to_string(),
825 enabled: true,
826 has_ds_records: true,
827 has_dnskey_records: true,
828 ds_records: vec![DsInfo {
829 key_tag: 65000,
830 algorithm: 13,
831 digest_type: 2,
832 digest: "ABCDEF".to_string(),
833 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
834 digest_type_name: "SHA-256".to_string(),
835 matched_key: false,
836 digest_verified: false,
837 }],
838 dnskey_records: vec![DnskeyInfo {
839 flags: 257,
840 protocol: 3,
841 algorithm: 13,
842 key_tag: 12345,
843 is_ksk: true,
844 is_zsk: false,
845 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
846 }],
847 issues: vec!["DS record (key_tag=65000) has no matching DNSKEY".to_string()],
848 status: "misconfigured".to_string(),
849 chain_valid: false,
850 authentication_tier: AuthenticationTier::DigestOnly,
851 rrsig_records: vec![],
852 };
853 assert!(!report.chain_valid);
854 assert_eq!(report.status, "misconfigured");
855 }
856
857 #[test]
858 fn test_chain_valid_digest_mismatch() {
859 let report = DnssecReport {
860 domain: "mismatch.com".to_string(),
861 enabled: true,
862 has_ds_records: true,
863 has_dnskey_records: true,
864 ds_records: vec![DsInfo {
865 key_tag: 12345,
866 algorithm: 13,
867 digest_type: 2,
868 digest: "WRONG".to_string(),
869 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
870 digest_type_name: "SHA-256".to_string(),
871 matched_key: true,
872 digest_verified: false,
873 }],
874 dnskey_records: vec![DnskeyInfo {
875 flags: 257,
876 protocol: 3,
877 algorithm: 13,
878 key_tag: 12345,
879 is_ksk: true,
880 is_zsk: false,
881 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
882 }],
883 issues: vec![],
884 status: "misconfigured".to_string(),
885 chain_valid: false,
886 authentication_tier: AuthenticationTier::DigestOnly,
887 rrsig_records: vec![],
888 };
889 assert!(!report.chain_valid);
890 assert!(report.ds_records[0].matched_key);
891 assert!(!report.ds_records[0].digest_verified);
892 }
893
894 #[tokio::test]
895 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
896 async fn test_live_dnssec_check_cloudflare() {
897 let checker = DnssecChecker::new();
898 let report = checker.check("cloudflare.com").await.unwrap();
899
900 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
902 assert!(report.has_ds_records, "should have DS records");
903 assert!(report.has_dnskey_records, "should have DNSKEY records");
904 assert!(report.chain_valid, "cloudflare.com chain should be valid");
905 assert_eq!(report.status, "signed");
906
907 for ds in &report.ds_records {
909 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
910 assert!(
911 ds.digest_verified,
912 "DS key_tag={} digest should verify",
913 ds.key_tag
914 );
915 }
916
917 for key in &report.dnskey_records {
919 assert!(key.key_tag > 0, "key_tag should be computed");
920 }
921 }
922
923 #[tokio::test]
924 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
925 async fn test_live_dnssec_rrsig_validation_cloudflare() {
926 let checker = DnssecChecker::new().with_rrsig_validation(true);
927 let report = checker.check("cloudflare.com").await.unwrap();
928 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
929 if !report.rrsig_records.is_empty() {
932 assert_eq!(report.authentication_tier, AuthenticationTier::RrsigChecked);
933 for r in &report.rrsig_records {
934 assert!(r.expiration.is_some(), "RRSIG should carry an expiration");
935 assert!(
936 !r.expired,
937 "cloudflare.com RRSIG (covers {}) should be within its validity window",
938 r.type_covered
939 );
940 }
941 }
942 }
943
944 #[tokio::test]
945 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
946 async fn test_live_dnssec_check_insecure() {
947 let checker = DnssecChecker::new();
948 let report = checker.check("wikipedia.org").await.unwrap();
950
951 assert!(!report.chain_valid);
952 assert_eq!(report.status, "unsigned");
953 }
954}