1use std::collections::HashMap;
10use std::net::IpAddr;
11use std::time::Duration;
12
13use hickory_resolver::net::runtime::TokioRuntimeProvider;
14use hickory_resolver::net::{DnsError, NetError};
15use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
16use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
17use hickory_resolver::proto::op::ResponseCode;
18use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as HickoryRecordType};
19use hickory_resolver::TokioResolver;
20use serde::{Deserialize, Serialize};
21use tracing::{debug, instrument};
22
23use super::resolver::{apply_standard_opts, fqdn, google_or_pinned};
24use super::DEFAULT_DNS_TIMEOUT;
25use crate::error::Result;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DnssecReport {
30 pub domain: String,
32 pub enabled: bool,
34 pub has_ds_records: bool,
36 pub has_dnskey_records: bool,
38 pub ds_records: Vec<DsInfo>,
40 pub dnskey_records: Vec<DnskeyInfo>,
42 pub issues: Vec<String>,
44 pub status: String,
65 pub chain_valid: bool,
70 #[serde(default = "default_authentication_tier")]
75 pub authentication_tier: AuthenticationTier,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum AuthenticationTier {
87 Unsigned,
89 DigestOnly,
92}
93
94fn default_authentication_tier() -> AuthenticationTier {
95 AuthenticationTier::DigestOnly
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DsInfo {
101 pub key_tag: u16,
102 pub algorithm: u8,
103 pub digest_type: u8,
104 pub digest: String,
105 pub algorithm_name: String,
106 pub digest_type_name: String,
107 pub matched_key: bool,
109 pub digest_verified: bool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DnskeyInfo {
116 pub flags: u16,
117 pub protocol: u8,
118 pub algorithm: u8,
119 pub key_tag: u16,
121 pub is_ksk: bool,
122 pub is_zsk: bool,
123 pub algorithm_name: String,
124}
125
126const MAX_ZONE_WALK: usize = 8;
130
131#[derive(Debug, PartialEq, Eq)]
133enum ZoneApex {
134 Found(String),
136 NxDomain,
138 Unknown(Option<String>),
140}
141
142pub struct DnssecChecker {
144 resolver: TokioResolver,
148 timeout: Duration,
150 #[cfg(test)]
152 upstream: Option<(IpAddr, u16)>,
153}
154
155impl Default for DnssecChecker {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl DnssecChecker {
162 pub fn new() -> Self {
163 Self {
164 resolver: Self::build_resolver(None, DEFAULT_DNS_TIMEOUT),
165 timeout: DEFAULT_DNS_TIMEOUT,
166 #[cfg(test)]
167 upstream: None,
168 }
169 }
170
171 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
175 Self::new().with_timeout(config.dns_timeout())
176 }
177
178 pub fn with_timeout(mut self, timeout: Duration) -> Self {
180 self.timeout = timeout;
181 self.resolver = Self::build_resolver(self.upstream(), timeout);
182 self
183 }
184
185 #[cfg(test)]
187 fn with_upstream(mut self, ip: IpAddr, port: u16) -> Self {
188 self.upstream = Some((ip, port));
189 self.resolver = Self::build_resolver(self.upstream, self.timeout);
190 self
191 }
192
193 #[cfg(test)]
194 fn upstream(&self) -> Option<(IpAddr, u16)> {
195 self.upstream
196 }
197
198 #[cfg(not(test))]
199 fn upstream(&self) -> Option<(IpAddr, u16)> {
200 None
201 }
202
203 fn build_resolver(upstream: Option<(IpAddr, u16)>, timeout: Duration) -> TokioResolver {
208 let mut builder = TokioResolver::builder_with_config(
209 google_or_pinned(upstream),
210 TokioRuntimeProvider::default(),
211 );
212 apply_standard_opts(builder.options_mut(), timeout);
213 builder
214 .build()
215 .expect("hickory resolver build is infallible without TLS features")
216 }
217
218 async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, String> {
222 match self
223 .resolver
224 .lookup(fqdn(zone), HickoryRecordType::DS)
225 .await
226 {
227 Ok(lookup) => Ok(lookup
228 .answers()
229 .iter()
230 .filter_map(|record| match &record.data {
231 RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
232 _ => None,
233 })
234 .collect()),
235 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
236 Err(e) => Err(e.to_string()),
237 }
238 }
239
240 async fn lookup_dnskeys(
246 &self,
247 zone: &str,
248 ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, String> {
249 match self
250 .resolver
251 .lookup(fqdn(zone), HickoryRecordType::DNSKEY)
252 .await
253 {
254 Ok(lookup) => Ok(lookup
255 .answers()
256 .iter()
257 .filter_map(|record| match &record.data {
258 RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
259 Some((key.clone(), key.calculate_key_tag().ok()))
260 }
261 _ => None,
262 })
263 .collect()),
264 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
265 Err(e) => Err(e.to_string()),
266 }
267 }
268
269 async fn find_zone_apex(&self, name: &str) -> ZoneApex {
285 let mut candidate = name;
286 for step in 0..MAX_ZONE_WALK {
287 match self
288 .resolver
289 .lookup(fqdn(candidate), HickoryRecordType::SOA)
290 .await
291 {
292 Ok(lookup) => {
293 if let Some(apex) = soa_apex(
294 candidate,
295 lookup.answers().iter().chain(lookup.authorities()),
296 ) {
297 return ZoneApex::Found(apex);
298 }
299 }
300 Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => {
301 if no_records.response_code == ResponseCode::NXDomain && step == 0 {
302 return ZoneApex::NxDomain;
303 }
304 let soa_owner = no_records.soa.as_ref().map(|soa| &soa.name);
305 if let Some(apex) = soa_owner.and_then(|owner| {
306 let owner = normalize_owner(owner);
307 is_self_or_ancestor(&owner, candidate).then_some(owner)
308 }) {
309 return ZoneApex::Found(apex);
310 }
311 }
312 Err(e) => {
313 return match self.lookup_ds(candidate).await {
314 Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
315 _ => ZoneApex::Unknown(Some(format!(
316 "SOA query for {candidate} failed: {e}"
317 ))),
318 };
319 }
320 }
321 match candidate.split_once('.') {
322 Some((_, parent)) if parent.contains('.') => candidate = parent,
323 _ => break,
324 }
325 }
326 ZoneApex::Unknown(None)
327 }
328
329 #[instrument(skip(self), fields(domain = %domain))]
337 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
338 let domain = crate::validation::normalize_host(domain)?;
342 debug!(domain = %domain, "Checking DNSSEC");
343
344 let mut issues = Vec::new();
345 let zone = match self.find_zone_apex(&domain).await {
346 ZoneApex::Found(zone) => {
347 if zone != domain {
348 issues.push(format!(
349 "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
350 enclosing zone {zone}"
351 ));
352 }
353 zone
354 }
355 ZoneApex::NxDomain => {
356 issues.push(format!("{domain} does not exist (NXDOMAIN)"));
357 domain.clone()
358 }
359 ZoneApex::Unknown(reason) => {
360 let fallback = crate::validation::normalize_domain(&domain)?;
363 if let Some(reason) = reason {
364 issues.push(format!(
365 "could not determine the zone enclosing {domain} ({reason}); \
366 evaluated {fallback}"
367 ));
368 }
369 fallback
370 }
371 };
372
373 let (ds_result, dnskey_result) =
375 tokio::join!(self.lookup_ds(&zone), self.lookup_dnskeys(&zone));
376 let ds_records = ds_result.unwrap_or_else(|e| {
377 issues.push(format!("DS query failed: {e}"));
378 Vec::new()
379 });
380 let dnskey_query_failed = dnskey_result.is_err();
381 let dnskeys = dnskey_result.unwrap_or_else(|e| {
382 issues.push(format!("DNSKEY query failed: {e}"));
383 Vec::new()
384 });
385
386 let has_ds = !ds_records.is_empty();
387 let has_dnskey = !dnskeys.is_empty();
388
389 let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
393 for (key, tag) in &dnskeys {
394 if let Some(tag) = tag {
395 dnskey_map
396 .entry((*tag, u8::from(key.public_key().algorithm())))
397 .or_default()
398 .push(key);
399 }
400 }
401
402 let dnskey_info: Vec<DnskeyInfo> = dnskeys
403 .iter()
404 .map(|(key, tag)| {
405 let flags = key.flags();
406 let algorithm = u8::from(key.public_key().algorithm());
407 let is_sep = flags & 0x0001 != 0;
408 let is_zone = flags & 0x0100 != 0;
409 DnskeyInfo {
410 flags,
411 protocol: 3,
413 algorithm,
414 key_tag: tag.unwrap_or(0),
415 is_ksk: is_sep && is_zone,
416 is_zsk: is_zone && !is_sep,
417 algorithm_name: algorithm_name(algorithm),
418 }
419 })
420 .collect();
421
422 let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
424 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
425 });
426
427 let ds_info: Vec<DsInfo> = ds_records
429 .iter()
430 .map(|ds| {
431 let key_tag = ds.key_tag();
432 let algorithm = u8::from(ds.algorithm());
433 let digest_type = u8::from(ds.digest_type());
434 let mut matched_key = false;
435 let mut digest_verified = false;
436
437 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
440 matched_key = true;
441
442 match Self::to_hickory_digest_type(digest_type) {
449 Some(hickory_dt) => {
450 digest_verified = candidates.iter().any(|candidate| {
451 candidate
452 .to_digest(&zone_name, hickory_dt)
453 .is_ok_and(|computed| computed.as_ref() == ds.digest())
454 });
455 if !digest_verified {
456 issues.push(format!(
457 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
458 key_tag
459 ));
460 }
461 }
462 None => {
463 issues.push(format!(
464 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
465 key_tag, digest_type
466 ));
467 }
468 }
469 } else if has_dnskey {
470 issues.push(format!(
471 "DS record (key_tag={}) has no matching DNSKEY",
472 key_tag
473 ));
474 }
475
476 DsInfo {
477 key_tag,
478 algorithm,
479 digest_type,
480 digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
481 algorithm_name: algorithm_name(algorithm),
482 digest_type_name: digest_type_name(digest_type),
483 matched_key,
484 digest_verified,
485 }
486 })
487 .collect();
488
489 let ds_key_tags: std::collections::HashSet<u16> =
491 ds_info.iter().map(|ds| ds.key_tag).collect();
492 for key in &dnskey_info {
493 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
494 issues.push(format!(
495 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
496 key.key_tag
497 ));
498 }
499 }
500
501 issues.extend(deprecation_issues(&ds_info, &dnskey_info));
502
503 let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
504
505 if has_ds && status == "partial" {
507 issues.push(
508 "DS records exist at the parent, but every one uses an algorithm or digest \
509 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
510 .to_string(),
511 );
512 } else if has_ds && !has_dnskey {
513 issues.push(if dnskey_query_failed {
514 "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
515 validating upstream returns SERVFAIL when the chain is broken, so validating \
516 resolvers are likely failing this zone"
517 .to_string()
518 } else {
519 "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
520 validating resolvers will fail (SERVFAIL) this zone"
521 .to_string()
522 });
523 }
524 if !has_ds && has_dnskey {
525 issues.push(
526 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
527 .to_string(),
528 );
529 }
530
531 let enabled = has_ds || has_dnskey;
532 let authentication_tier = if enabled {
533 AuthenticationTier::DigestOnly
534 } else {
535 AuthenticationTier::Unsigned
536 };
537
538 Ok(DnssecReport {
539 domain,
540 enabled,
541 has_ds_records: has_ds,
542 has_dnskey_records: has_dnskey,
543 ds_records: ds_info,
544 dnskey_records: dnskey_info,
545 issues,
546 status: status.to_string(),
547 chain_valid,
548 authentication_tier,
549 })
550 }
551
552 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
560 let dt = DigestType::from(digest_type);
561 if dt.is_supported() {
562 Some(dt)
563 } else {
564 None
565 }
566 }
567}
568
569fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
578 let has_ds = !ds_info.is_empty();
579
580 let chain_valid = has_dnskey
585 && ds_info
586 .iter()
587 .any(|ds| ds.matched_key && ds.digest_verified);
588
589 let status = match (has_ds, has_dnskey) {
590 (true, true) if chain_valid => "signed",
591 (true, _) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
599 (true, _) => "partial",
600 (false, true) => "partial",
602 (false, false) => "unsigned",
603 };
604 (chain_valid, status)
605}
606
607fn ds_is_validatable(ds: &DsInfo) -> bool {
613 matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
614 && matches!(ds.digest_type, 1 | 2 | 4)
615}
616
617fn is_deprecated_algorithm(algorithm: u8) -> bool {
621 matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
622}
623
624fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
627 let mut issues = Vec::new();
628 for ds in ds_info {
629 if is_deprecated_algorithm(ds.algorithm) {
630 issues.push(format!(
631 "DS record uses deprecated algorithm {} ({})",
632 ds.algorithm, ds.algorithm_name
633 ));
634 }
635 if ds.digest_type == 1 {
636 issues.push(
637 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
638 .to_string(),
639 );
640 }
641 }
642 for key in dnskey_info {
643 if is_deprecated_algorithm(key.algorithm) {
644 issues.push(format!(
645 "DNSKEY record uses deprecated algorithm {} ({})",
646 key.algorithm, key.algorithm_name
647 ));
648 }
649 }
650 issues
651}
652
653fn normalize_owner(name: &Name) -> String {
656 name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
657}
658
659fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
662 !owner.is_empty()
663 && (owner == name
664 || name
665 .strip_suffix(owner)
666 .is_some_and(|prefix| prefix.ends_with('.')))
667}
668
669fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
673 records
674 .filter(|record| matches!(record.data, RData::SOA(_)))
675 .map(|record| normalize_owner(&record.name))
676 .find(|owner| is_self_or_ancestor(owner, name))
677}
678
679fn algorithm_name(algo: u8) -> String {
684 match algo {
685 1 => "RSA/MD5 (deprecated)".to_string(),
686 3 => "DSA/SHA-1 (deprecated)".to_string(),
687 5 => "RSA/SHA-1 (deprecated)".to_string(),
688 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
689 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
690 8 => "RSA/SHA-256".to_string(),
691 10 => "RSA/SHA-512".to_string(),
692 12 => "ECC-GOST (deprecated)".to_string(),
693 13 => "ECDSA P-256/SHA-256".to_string(),
694 14 => "ECDSA P-384/SHA-384".to_string(),
695 15 => "Ed25519".to_string(),
696 16 => "Ed448".to_string(),
697 _ => format!("Unknown ({})", algo),
698 }
699}
700
701fn digest_type_name(dtype: u8) -> String {
702 match dtype {
703 1 => "SHA-1".to_string(),
704 2 => "SHA-256".to_string(),
705 4 => "SHA-384".to_string(),
706 _ => format!("Unknown ({})", dtype),
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[test]
715 fn test_algorithm_names() {
716 assert_eq!(algorithm_name(8), "RSA/SHA-256");
717 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
718 assert_eq!(algorithm_name(15), "Ed25519");
719 assert!(algorithm_name(5).contains("deprecated"));
720 }
721
722 #[test]
723 fn test_digest_type_names() {
724 assert_eq!(digest_type_name(1), "SHA-1");
725 assert_eq!(digest_type_name(2), "SHA-256");
726 }
727
728 #[test]
729 fn default_tier_is_digest_only() {
730 assert_eq!(
732 default_authentication_tier(),
733 AuthenticationTier::DigestOnly
734 );
735 }
736
737 #[test]
738 fn from_config_applies_dns_timeout() {
739 let mut config = crate::config::SeerConfig::default();
740 config.timeouts.dns_secs = 9;
741 assert_eq!(
742 DnssecChecker::from_config(&config).timeout,
743 Duration::from_secs(9)
744 );
745 }
746
747 #[test]
748 fn test_report_serialization() {
749 let report = DnssecReport {
750 domain: "example.com".to_string(),
751 enabled: true,
752 has_ds_records: true,
753 has_dnskey_records: true,
754 ds_records: vec![DsInfo {
755 key_tag: 12345,
756 algorithm: 13,
757 digest_type: 2,
758 digest: "ABCDEF".to_string(),
759 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
760 digest_type_name: "SHA-256".to_string(),
761 matched_key: true,
762 digest_verified: true,
763 }],
764 dnskey_records: vec![DnskeyInfo {
765 flags: 257,
766 protocol: 3,
767 algorithm: 13,
768 key_tag: 12345,
769 is_ksk: true,
770 is_zsk: false,
771 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
772 }],
773 issues: vec![],
774 status: "signed".to_string(),
775 chain_valid: true,
776 authentication_tier: AuthenticationTier::DigestOnly,
777 };
778 let json = serde_json::to_string(&report).unwrap();
779 assert!(json.contains("\"enabled\":true"));
780 assert!(json.contains("\"chain_valid\":true"));
781 assert!(json.contains("\"matched_key\":true"));
782 assert!(json.contains("\"digest_verified\":true"));
783 assert!(json.contains("\"key_tag\":12345"));
784 }
785
786 fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
787 DsInfo {
788 key_tag,
789 algorithm,
790 digest_type,
791 digest: "ABCDEF".to_string(),
792 algorithm_name: algorithm_name(algorithm),
793 digest_type_name: digest_type_name(digest_type),
794 matched_key: matched,
795 digest_verified: verified,
796 }
797 }
798
799 #[test]
806 fn chain_status_signed_when_every_computable_ds_verifies() {
807 let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
808 assert_eq!(derive_chain_status(&all, true), (true, "signed"));
809 }
810
811 #[test]
812 fn chain_status_misconfigured_when_ds_unmatched() {
813 let unmatched = [ds(65000, 13, 2, false, false)];
814 assert_eq!(
815 derive_chain_status(&unmatched, true),
816 (false, "misconfigured")
817 );
818 }
819
820 #[test]
821 fn chain_status_misconfigured_on_digest_mismatch() {
822 let mismatch = [ds(12345, 13, 2, true, false)];
823 assert_eq!(
824 derive_chain_status(&mismatch, true),
825 (false, "misconfigured")
826 );
827 }
828
829 #[test]
830 fn chain_status_one_authenticating_ds_is_enough() {
831 let rollover = [ds(1, 13, 2, true, true), ds(2, 13, 2, false, false)];
837 assert_eq!(derive_chain_status(&rollover, true), (true, "signed"));
838 let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
839 assert_eq!(derive_chain_status(&mixed, true), (true, "signed"));
840 }
841
842 #[test]
843 fn chain_status_uncomputable_digest_alone_is_partial() {
844 let gost_only = [ds(1, 13, 3, true, false)];
849 assert_eq!(derive_chain_status(&gost_only, true), (false, "partial"));
850 assert_eq!(derive_chain_status(&gost_only, false), (false, "partial"));
851 let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
852 assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
853 }
854
855 #[test]
856 fn chain_status_ds_without_dnskey_is_misconfigured() {
857 let stale = [ds(12345, 13, 2, false, false)];
862 assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
863 }
864
865 #[test]
866 fn chain_status_ds_validators_ignore_stays_partial() {
867 let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
870 assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
871 let unknown_digest = [ds(3, 13, 99, false, false)];
872 assert_eq!(
873 derive_chain_status(&unknown_digest, false),
874 (false, "partial")
875 );
876 }
877
878 #[test]
879 fn chain_status_island_and_unsigned() {
880 assert_eq!(derive_chain_status(&[], true), (false, "partial"));
881 assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
882 }
883
884 #[test]
885 fn deprecated_algorithms_include_7_and_12() {
886 let key = |algorithm: u8| DnskeyInfo {
889 flags: 257,
890 protocol: 3,
891 algorithm,
892 key_tag: 1,
893 is_ksk: true,
894 is_zsk: false,
895 algorithm_name: algorithm_name(algorithm),
896 };
897 let issues = deprecation_issues(
898 &[
899 ds(1, 7, 2, true, true),
900 ds(2, 12, 2, true, true),
901 ds(3, 13, 2, true, true),
902 ],
903 &[key(7), key(12), key(8)],
904 );
905 assert_eq!(
906 issues
907 .iter()
908 .filter(|i| i.contains("deprecated algorithm"))
909 .count(),
910 4,
911 "{issues:?}"
912 );
913 assert!(issues
914 .iter()
915 .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
916 assert!(issues
917 .iter()
918 .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
919 assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
920 assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
921 }
922
923 #[test]
924 fn soa_apex_matches_self_or_ancestor_only() {
925 assert!(is_self_or_ancestor("example.com", "example.com"));
926 assert!(is_self_or_ancestor("example.com", "api.example.com"));
927 assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
928 assert!(!is_self_or_ancestor("api.example.com", "example.com"));
929
930 let soa = |owner: &str| {
931 Record::from_rdata(
932 Name::from_ascii(owner).unwrap(),
933 300,
934 soa_rdata(owner.trim_end_matches('.')),
935 )
936 };
937 let records = [soa("cdn-target.test."), soa("Example.COM.")];
938 assert_eq!(
939 soa_apex("api.example.com", records.iter()),
940 Some("example.com".to_string()),
941 "a CNAME target's SOA must be skipped; the enclosing one is found"
942 );
943 assert_eq!(soa_apex("api.other.test", records.iter()), None);
944 }
945
946 use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
949 use hickory_resolver::proto::rr::RecordType as WireType;
950
951 use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
952
953 fn test_key() -> DNSKEY {
956 DNSKEY::new(
957 true,
958 true,
959 false,
960 PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
961 )
962 }
963
964 fn ds_for(zone: &str, key: &DNSKEY) -> DS {
966 let digest = key
967 .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
968 .unwrap();
969 DS::new(
970 key.calculate_key_tag().unwrap(),
971 Algorithm::ED25519,
972 DigestType::SHA256,
973 digest.as_ref().to_vec(),
974 )
975 }
976
977 fn dnskey_rdata(key: DNSKEY) -> RData {
978 RData::DNSSEC(DNSSECRData::DNSKEY(key))
979 }
980
981 fn ds_rdata(ds: DS) -> RData {
982 RData::DNSSEC(DNSSECRData::DS(ds))
983 }
984
985 fn mock_checker(port: u16) -> DnssecChecker {
986 DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
987 }
988
989 #[tokio::test]
993 async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
994 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
995 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
996 ("signed.test", WireType::DS) => {
997 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
998 }
999 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1000 _ => MockReply::NoData,
1002 })
1003 .await;
1004
1005 let report = mock_checker(port).check("api.signed.test").await.unwrap();
1006 assert_eq!(report.domain, "api.signed.test");
1007 assert_eq!(report.status, "signed", "{report:?}");
1008 assert!(report.chain_valid);
1009 assert!(
1010 report.issues[0].contains("enclosing zone signed.test"),
1011 "the evaluated zone must be named: {:?}",
1012 report.issues
1013 );
1014 assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1015 let tag = test_key().calculate_key_tag().unwrap();
1017 assert_eq!(report.dnskey_records[0].key_tag, tag);
1018 assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1019 }
1020
1021 #[tokio::test]
1024 async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1025 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1026 ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1027 ("api.signed.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1028 ("signed.test", WireType::DS) => {
1029 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1030 }
1031 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1032 _ => MockReply::NoData,
1033 })
1034 .await;
1035
1036 let report = mock_checker(port)
1037 .check("deep.api.signed.test")
1038 .await
1039 .unwrap();
1040 assert_eq!(report.status, "signed", "{report:?}");
1041 assert!(report.issues[0].contains("enclosing zone signed.test"));
1042 }
1043
1044 #[tokio::test]
1050 async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1051 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1052 ("broken.test", WireType::DS) => {
1053 MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1054 }
1055 ("broken.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1056 _ => MockReply::NoData,
1057 })
1058 .await;
1059
1060 let report = mock_checker(port).check("broken.test").await.unwrap();
1061 assert!(report.has_ds_records);
1062 assert!(!report.has_dnskey_records);
1063 assert_eq!(report.status, "misconfigured", "{report:?}");
1064 assert!(!report.chain_valid);
1065 assert!(
1066 report
1067 .issues
1068 .iter()
1069 .any(|i| i.contains("DNSKEY query failed")),
1070 "{:?}",
1071 report.issues
1072 );
1073 assert!(
1074 !report.issues.iter().any(|i| i.contains("not a zone apex")),
1075 "{:?}",
1076 report.issues
1077 );
1078 }
1079
1080 #[tokio::test]
1081 async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1082 let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1083 let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1084 assert_eq!(report.status, "unsigned");
1085 assert!(
1086 report.issues.iter().any(|i| i.contains("does not exist")),
1087 "{:?}",
1088 report.issues
1089 );
1090 }
1091
1092 #[tokio::test]
1093 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1094 async fn test_live_dnssec_check_cloudflare() {
1095 let checker = DnssecChecker::new();
1096 let report = checker.check("cloudflare.com").await.unwrap();
1097
1098 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1100 assert!(report.has_ds_records, "should have DS records");
1101 assert!(report.has_dnskey_records, "should have DNSKEY records");
1102 assert!(report.chain_valid, "cloudflare.com chain should be valid");
1103 assert_eq!(report.status, "signed");
1104
1105 for ds in &report.ds_records {
1107 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1108 assert!(
1109 ds.digest_verified,
1110 "DS key_tag={} digest should verify",
1111 ds.key_tag
1112 );
1113 }
1114
1115 for key in &report.dnskey_records {
1117 assert!(key.key_tag > 0, "key_tag should be computed");
1118 }
1119 }
1120
1121 #[tokio::test]
1122 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1123 async fn test_live_dnssec_check_insecure() {
1124 let checker = DnssecChecker::new();
1125 let report = checker.check("wikipedia.org").await.unwrap();
1127
1128 assert!(!report.chain_valid);
1129 assert_eq!(report.status, "unsigned");
1130 }
1131}