1use std::collections::HashMap;
20use std::net::IpAddr;
21use std::time::Duration;
22
23use hickory_resolver::net::runtime::TokioRuntimeProvider;
24use hickory_resolver::net::{DnsError, NetError};
25use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
26use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
27use hickory_resolver::proto::op::ResponseCode;
28use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as HickoryRecordType};
29use hickory_resolver::TokioResolver;
30use serde::{Deserialize, Serialize};
31use tracing::{debug, instrument};
32
33use super::resolver::{apply_standard_opts, fqdn, google_or_pinned};
34use super::DEFAULT_DNS_TIMEOUT;
35use crate::error::{Result, SeerError};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DnssecReport {
40 pub domain: String,
42 pub enabled: bool,
44 pub has_ds_records: bool,
46 pub has_dnskey_records: bool,
48 pub ds_records: Vec<DsInfo>,
50 pub dnskey_records: Vec<DnskeyInfo>,
52 pub issues: Vec<String>,
54 pub status: String,
75 pub chain_valid: bool,
80 #[serde(default = "default_authentication_tier")]
85 pub authentication_tier: AuthenticationTier,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum AuthenticationTier {
97 Unsigned,
99 DigestOnly,
102}
103
104fn default_authentication_tier() -> AuthenticationTier {
105 AuthenticationTier::DigestOnly
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct DsInfo {
111 pub key_tag: u16,
112 pub algorithm: u8,
113 pub digest_type: u8,
114 pub digest: String,
115 pub algorithm_name: String,
116 pub digest_type_name: String,
117 pub matched_key: bool,
119 pub digest_verified: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct DnskeyInfo {
126 pub flags: u16,
127 pub protocol: u8,
128 pub algorithm: u8,
129 pub key_tag: u16,
131 pub is_ksk: bool,
132 pub is_zsk: bool,
133 pub algorithm_name: String,
134}
135
136const MAX_ZONE_WALK: usize = 8;
140
141const ZONE_WALK_BUDGET_TIMEOUTS: u32 = 4;
147
148const CHECK_BUDGET_TIMEOUTS: u32 = ZONE_WALK_BUDGET_TIMEOUTS + 2;
151
152#[derive(Debug, PartialEq, Eq)]
154enum ZoneApex {
155 Found(String),
157 NxDomain,
159 Unknown(Option<String>),
161}
162
163pub struct DnssecChecker {
165 resolver: TokioResolver,
169 timeout: Duration,
171 #[cfg(test)]
173 upstream: Option<(IpAddr, u16)>,
174}
175
176impl Default for DnssecChecker {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182#[derive(Debug)]
185struct ReadFailure {
186 detail: String,
187 timed_out: bool,
188}
189
190impl From<&NetError> for ReadFailure {
191 fn from(e: &NetError) -> Self {
192 Self {
193 detail: e.to_string(),
194 timed_out: matches!(e, NetError::Timeout),
195 }
196 }
197}
198
199impl std::fmt::Display for ReadFailure {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.write_str(&self.detail)
202 }
203}
204
205impl DnssecChecker {
206 pub fn new() -> Self {
207 Self {
208 resolver: Self::build_resolver(None, DEFAULT_DNS_TIMEOUT),
209 timeout: DEFAULT_DNS_TIMEOUT,
210 #[cfg(test)]
211 upstream: None,
212 }
213 }
214
215 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
219 Self::new().with_timeout(config.dns_timeout())
220 }
221
222 pub fn with_timeout(mut self, timeout: Duration) -> Self {
224 self.timeout = timeout;
225 self.resolver = Self::build_resolver(self.upstream(), timeout);
226 self
227 }
228
229 #[cfg(test)]
231 fn with_upstream(mut self, ip: IpAddr, port: u16) -> Self {
232 self.upstream = Some((ip, port));
233 self.resolver = Self::build_resolver(self.upstream, self.timeout);
234 self
235 }
236
237 #[cfg(test)]
238 fn upstream(&self) -> Option<(IpAddr, u16)> {
239 self.upstream
240 }
241
242 #[cfg(not(test))]
243 fn upstream(&self) -> Option<(IpAddr, u16)> {
244 None
245 }
246
247 fn build_resolver(upstream: Option<(IpAddr, u16)>, timeout: Duration) -> TokioResolver {
252 let mut builder = TokioResolver::builder_with_config(
253 google_or_pinned(upstream),
254 TokioRuntimeProvider::default(),
255 );
256 apply_standard_opts(builder.options_mut(), timeout);
257 builder
258 .build()
259 .expect("hickory resolver build is infallible without TLS features")
260 }
261
262 async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, ReadFailure> {
266 match self
267 .resolver
268 .lookup(fqdn(zone), HickoryRecordType::DS)
269 .await
270 {
271 Ok(lookup) => Ok(lookup
272 .answers()
273 .iter()
274 .filter_map(|record| match &record.data {
275 RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
276 _ => None,
277 })
278 .collect()),
279 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
280 Err(e) => Err(ReadFailure::from(&e)),
281 }
282 }
283
284 async fn lookup_dnskeys(
290 &self,
291 zone: &str,
292 ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, ReadFailure> {
293 match self
294 .resolver
295 .lookup(fqdn(zone), HickoryRecordType::DNSKEY)
296 .await
297 {
298 Ok(lookup) => Ok(lookup
299 .answers()
300 .iter()
301 .filter_map(|record| match &record.data {
302 RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
303 Some((key.clone(), key.calculate_key_tag().ok()))
304 }
305 _ => None,
306 })
307 .collect()),
308 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
309 Err(e) => Err(ReadFailure::from(&e)),
310 }
311 }
312
313 async fn find_zone_apex(&self, name: &str) -> ZoneApex {
329 let mut candidate = name;
330 for step in 0..MAX_ZONE_WALK {
331 match self
332 .resolver
333 .lookup(fqdn(candidate), HickoryRecordType::SOA)
334 .await
335 {
336 Ok(lookup) => {
337 if let Some(apex) = soa_apex(
338 candidate,
339 lookup.answers().iter().chain(lookup.authorities()),
340 ) {
341 return ZoneApex::Found(apex);
342 }
343 }
344 Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => {
345 if no_records.response_code == ResponseCode::NXDomain && step == 0 {
346 return ZoneApex::NxDomain;
347 }
348 let soa_owner = no_records.soa.as_ref().map(|soa| &soa.name);
349 if let Some(apex) = soa_owner.and_then(|owner| {
350 let owner = normalize_owner(owner);
351 is_self_or_ancestor(&owner, candidate).then_some(owner)
352 }) {
353 return ZoneApex::Found(apex);
354 }
355 }
356 Err(e) => {
357 return match self.lookup_ds(candidate).await {
358 Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
359 _ => ZoneApex::Unknown(Some(format!(
360 "SOA query for {candidate} failed: {e}"
361 ))),
362 };
363 }
364 }
365 match candidate.split_once('.') {
366 Some((_, parent)) if parent.contains('.') => candidate = parent,
367 _ => break,
368 }
369 }
370 ZoneApex::Unknown(None)
371 }
372
373 #[instrument(skip(self), fields(domain = %domain))]
390 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
391 let domain = crate::validation::normalize_host(domain)?;
395 debug!(domain = %domain, "Checking DNSSEC");
396
397 let started = tokio::time::Instant::now();
400 let walk_budget = self.timeout.saturating_mul(ZONE_WALK_BUDGET_TIMEOUTS);
401 let budget = self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS);
402 let apex = tokio::time::timeout_at(started + walk_budget, self.find_zone_apex(&domain))
403 .await
404 .map_err(|_| {
405 SeerError::Timeout(format!(
408 "DNSSEC check for {domain} gave up after {walk_budget:?} without finding \
409 the zone that holds it"
410 ))
411 })?;
412
413 let mut issues = Vec::new();
414 let zone = match apex {
415 ZoneApex::Found(zone) => {
416 if zone != domain {
417 issues.push(format!(
418 "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
419 enclosing zone {zone}"
420 ));
421 }
422 zone
423 }
424 ZoneApex::NxDomain => {
425 issues.push(format!("{domain} does not exist (NXDOMAIN)"));
426 domain.clone()
427 }
428 ZoneApex::Unknown(reason) => {
429 let fallback = crate::validation::normalize_domain(&domain)?;
432 if let Some(reason) = reason {
433 issues.push(format!(
434 "could not determine the zone enclosing {domain} ({reason}); \
435 evaluated {fallback}"
436 ));
437 }
438 fallback
439 }
440 };
441
442 let deadline = started + budget;
445 let out_of_time = || ReadFailure {
446 detail: format!("timed out \u{2014} the check's {budget:?} budget ran out"),
447 timed_out: true,
448 };
449 let (ds_result, dnskey_result) = tokio::join!(
450 tokio::time::timeout_at(deadline, self.lookup_ds(&zone)),
451 tokio::time::timeout_at(deadline, self.lookup_dnskeys(&zone)),
452 );
453 let ds_result = ds_result.unwrap_or_else(|_| Err(out_of_time()));
454 let dnskey_result = dnskey_result.unwrap_or_else(|_| Err(out_of_time()));
455 if let (Err(ds), Err(dnskey)) = (&ds_result, &dnskey_result) {
459 let detail =
460 format!("DNSSEC check for {zone} read neither DS ({ds}) nor DNSKEY ({dnskey})");
461 return Err(if ds.timed_out && dnskey.timed_out {
462 SeerError::Timeout(detail)
463 } else {
464 SeerError::DnsError(detail)
465 });
466 }
467 let ds_records = ds_result.unwrap_or_else(|e| {
468 issues.push(format!("DS query failed: {e}"));
469 Vec::new()
470 });
471 let dnskey_query_failed = dnskey_result.is_err();
472 let dnskeys = dnskey_result.unwrap_or_else(|e| {
473 issues.push(format!("DNSKEY query failed: {e}"));
474 Vec::new()
475 });
476
477 let has_ds = !ds_records.is_empty();
478 let has_dnskey = !dnskeys.is_empty();
479
480 let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
484 for (key, tag) in &dnskeys {
485 if let Some(tag) = tag {
486 dnskey_map
487 .entry((*tag, u8::from(key.public_key().algorithm())))
488 .or_default()
489 .push(key);
490 }
491 }
492
493 let dnskey_info: Vec<DnskeyInfo> = dnskeys
494 .iter()
495 .map(|(key, tag)| {
496 let flags = key.flags();
497 let algorithm = u8::from(key.public_key().algorithm());
498 let is_sep = flags & 0x0001 != 0;
499 let is_zone = flags & 0x0100 != 0;
500 DnskeyInfo {
501 flags,
502 protocol: 3,
504 algorithm,
505 key_tag: tag.unwrap_or(0),
506 is_ksk: is_sep && is_zone,
507 is_zsk: is_zone && !is_sep,
508 algorithm_name: algorithm_name(algorithm),
509 }
510 })
511 .collect();
512
513 let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
515 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
516 });
517
518 let ds_info: Vec<DsInfo> = ds_records
520 .iter()
521 .map(|ds| {
522 let key_tag = ds.key_tag();
523 let algorithm = u8::from(ds.algorithm());
524 let digest_type = u8::from(ds.digest_type());
525 let mut matched_key = false;
526 let mut digest_verified = false;
527
528 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
531 matched_key = true;
532
533 match Self::to_hickory_digest_type(digest_type) {
540 Some(hickory_dt) => {
541 digest_verified = candidates.iter().any(|candidate| {
542 candidate
543 .to_digest(&zone_name, hickory_dt)
544 .is_ok_and(|computed| computed.as_ref() == ds.digest())
545 });
546 if !digest_verified {
547 issues.push(format!(
548 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
549 key_tag
550 ));
551 }
552 }
553 None => {
554 issues.push(format!(
555 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
556 key_tag, digest_type
557 ));
558 }
559 }
560 } else if has_dnskey {
561 issues.push(format!(
562 "DS record (key_tag={}) has no matching DNSKEY",
563 key_tag
564 ));
565 }
566
567 DsInfo {
568 key_tag,
569 algorithm,
570 digest_type,
571 digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
572 algorithm_name: algorithm_name(algorithm),
573 digest_type_name: digest_type_name(digest_type),
574 matched_key,
575 digest_verified,
576 }
577 })
578 .collect();
579
580 let ds_key_tags: std::collections::HashSet<u16> =
582 ds_info.iter().map(|ds| ds.key_tag).collect();
583 for key in &dnskey_info {
584 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
585 issues.push(format!(
586 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
587 key.key_tag
588 ));
589 }
590 }
591
592 issues.extend(deprecation_issues(&ds_info, &dnskey_info));
593
594 let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
595
596 if has_ds && status == "partial" {
598 issues.push(
599 "DS records exist at the parent, but every one uses an algorithm or digest \
600 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
601 .to_string(),
602 );
603 } else if has_ds && !has_dnskey {
604 issues.push(if dnskey_query_failed {
605 "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
606 validating upstream returns SERVFAIL when the chain is broken, so validating \
607 resolvers are likely failing this zone"
608 .to_string()
609 } else {
610 "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
611 validating resolvers will fail (SERVFAIL) this zone"
612 .to_string()
613 });
614 }
615 if !has_ds && has_dnskey {
616 issues.push(
617 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
618 .to_string(),
619 );
620 }
621
622 let enabled = has_ds || has_dnskey;
623 let authentication_tier = if enabled {
624 AuthenticationTier::DigestOnly
625 } else {
626 AuthenticationTier::Unsigned
627 };
628
629 Ok(DnssecReport {
630 domain,
631 enabled,
632 has_ds_records: has_ds,
633 has_dnskey_records: has_dnskey,
634 ds_records: ds_info,
635 dnskey_records: dnskey_info,
636 issues,
637 status: status.to_string(),
638 chain_valid,
639 authentication_tier,
640 })
641 }
642
643 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
651 let dt = DigestType::from(digest_type);
652 if dt.is_supported() {
653 Some(dt)
654 } else {
655 None
656 }
657 }
658}
659
660fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
669 let has_ds = !ds_info.is_empty();
670
671 let chain_valid = has_dnskey
676 && ds_info
677 .iter()
678 .any(|ds| ds.matched_key && ds.digest_verified);
679
680 let status = match (has_ds, has_dnskey) {
681 (true, true) if chain_valid => "signed",
682 (true, _) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
690 (true, _) => "partial",
691 (false, true) => "partial",
693 (false, false) => "unsigned",
694 };
695 (chain_valid, status)
696}
697
698fn ds_is_validatable(ds: &DsInfo) -> bool {
704 matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
705 && matches!(ds.digest_type, 1 | 2 | 4)
706}
707
708fn is_deprecated_algorithm(algorithm: u8) -> bool {
712 matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
713}
714
715fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
718 let mut issues = Vec::new();
719 for ds in ds_info {
720 if is_deprecated_algorithm(ds.algorithm) {
721 issues.push(format!(
722 "DS record uses deprecated algorithm {} ({})",
723 ds.algorithm, ds.algorithm_name
724 ));
725 }
726 if ds.digest_type == 1 {
727 issues.push(
728 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
729 .to_string(),
730 );
731 }
732 }
733 for key in dnskey_info {
734 if is_deprecated_algorithm(key.algorithm) {
735 issues.push(format!(
736 "DNSKEY record uses deprecated algorithm {} ({})",
737 key.algorithm, key.algorithm_name
738 ));
739 }
740 }
741 issues
742}
743
744fn normalize_owner(name: &Name) -> String {
747 name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
748}
749
750fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
753 !owner.is_empty()
754 && (owner == name
755 || name
756 .strip_suffix(owner)
757 .is_some_and(|prefix| prefix.ends_with('.')))
758}
759
760fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
764 records
765 .filter(|record| matches!(record.data, RData::SOA(_)))
766 .map(|record| normalize_owner(&record.name))
767 .find(|owner| is_self_or_ancestor(owner, name))
768}
769
770fn algorithm_name(algo: u8) -> String {
775 match algo {
776 1 => "RSA/MD5 (deprecated)".to_string(),
777 3 => "DSA/SHA-1 (deprecated)".to_string(),
778 5 => "RSA/SHA-1 (deprecated)".to_string(),
779 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
780 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
781 8 => "RSA/SHA-256".to_string(),
782 10 => "RSA/SHA-512".to_string(),
783 12 => "ECC-GOST (deprecated)".to_string(),
784 13 => "ECDSA P-256/SHA-256".to_string(),
785 14 => "ECDSA P-384/SHA-384".to_string(),
786 15 => "Ed25519".to_string(),
787 16 => "Ed448".to_string(),
788 _ => format!("Unknown ({})", algo),
789 }
790}
791
792fn digest_type_name(dtype: u8) -> String {
793 match dtype {
794 1 => "SHA-1".to_string(),
795 2 => "SHA-256".to_string(),
796 4 => "SHA-384".to_string(),
797 _ => format!("Unknown ({})", dtype),
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn test_algorithm_names() {
807 assert_eq!(algorithm_name(8), "RSA/SHA-256");
808 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
809 assert_eq!(algorithm_name(15), "Ed25519");
810 assert!(algorithm_name(5).contains("deprecated"));
811 }
812
813 #[test]
814 fn test_digest_type_names() {
815 assert_eq!(digest_type_name(1), "SHA-1");
816 assert_eq!(digest_type_name(2), "SHA-256");
817 }
818
819 #[test]
820 fn default_tier_is_digest_only() {
821 assert_eq!(
823 default_authentication_tier(),
824 AuthenticationTier::DigestOnly
825 );
826 }
827
828 #[test]
829 fn from_config_applies_dns_timeout() {
830 let mut config = crate::config::SeerConfig::default();
831 config.timeouts.dns_secs = 9;
832 assert_eq!(
833 DnssecChecker::from_config(&config).timeout,
834 Duration::from_secs(9)
835 );
836 }
837
838 #[test]
839 fn test_report_serialization() {
840 let report = DnssecReport {
841 domain: "example.com".to_string(),
842 enabled: true,
843 has_ds_records: true,
844 has_dnskey_records: true,
845 ds_records: vec![DsInfo {
846 key_tag: 12345,
847 algorithm: 13,
848 digest_type: 2,
849 digest: "ABCDEF".to_string(),
850 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
851 digest_type_name: "SHA-256".to_string(),
852 matched_key: true,
853 digest_verified: true,
854 }],
855 dnskey_records: vec![DnskeyInfo {
856 flags: 257,
857 protocol: 3,
858 algorithm: 13,
859 key_tag: 12345,
860 is_ksk: true,
861 is_zsk: false,
862 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
863 }],
864 issues: vec![],
865 status: "signed".to_string(),
866 chain_valid: true,
867 authentication_tier: AuthenticationTier::DigestOnly,
868 };
869 let json = serde_json::to_string(&report).unwrap();
870 assert!(json.contains("\"enabled\":true"));
871 assert!(json.contains("\"chain_valid\":true"));
872 assert!(json.contains("\"matched_key\":true"));
873 assert!(json.contains("\"digest_verified\":true"));
874 assert!(json.contains("\"key_tag\":12345"));
875 }
876
877 fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
878 DsInfo {
879 key_tag,
880 algorithm,
881 digest_type,
882 digest: "ABCDEF".to_string(),
883 algorithm_name: algorithm_name(algorithm),
884 digest_type_name: digest_type_name(digest_type),
885 matched_key: matched,
886 digest_verified: verified,
887 }
888 }
889
890 #[test]
897 fn chain_status_signed_when_every_computable_ds_verifies() {
898 let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
899 assert_eq!(derive_chain_status(&all, true), (true, "signed"));
900 }
901
902 #[test]
903 fn chain_status_misconfigured_when_ds_unmatched() {
904 let unmatched = [ds(65000, 13, 2, false, false)];
905 assert_eq!(
906 derive_chain_status(&unmatched, true),
907 (false, "misconfigured")
908 );
909 }
910
911 #[test]
912 fn chain_status_misconfigured_on_digest_mismatch() {
913 let mismatch = [ds(12345, 13, 2, true, false)];
914 assert_eq!(
915 derive_chain_status(&mismatch, true),
916 (false, "misconfigured")
917 );
918 }
919
920 #[test]
921 fn chain_status_one_authenticating_ds_is_enough() {
922 let rollover = [ds(1, 13, 2, true, true), ds(2, 13, 2, false, false)];
928 assert_eq!(derive_chain_status(&rollover, true), (true, "signed"));
929 let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
930 assert_eq!(derive_chain_status(&mixed, true), (true, "signed"));
931 }
932
933 #[test]
934 fn chain_status_uncomputable_digest_alone_is_partial() {
935 let gost_only = [ds(1, 13, 3, true, false)];
940 assert_eq!(derive_chain_status(&gost_only, true), (false, "partial"));
941 assert_eq!(derive_chain_status(&gost_only, false), (false, "partial"));
942 let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
943 assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
944 }
945
946 #[test]
947 fn chain_status_ds_without_dnskey_is_misconfigured() {
948 let stale = [ds(12345, 13, 2, false, false)];
953 assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
954 }
955
956 #[test]
957 fn chain_status_ds_validators_ignore_stays_partial() {
958 let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
961 assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
962 let unknown_digest = [ds(3, 13, 99, false, false)];
963 assert_eq!(
964 derive_chain_status(&unknown_digest, false),
965 (false, "partial")
966 );
967 }
968
969 #[test]
970 fn chain_status_island_and_unsigned() {
971 assert_eq!(derive_chain_status(&[], true), (false, "partial"));
972 assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
973 }
974
975 #[test]
976 fn deprecated_algorithms_include_7_and_12() {
977 let key = |algorithm: u8| DnskeyInfo {
980 flags: 257,
981 protocol: 3,
982 algorithm,
983 key_tag: 1,
984 is_ksk: true,
985 is_zsk: false,
986 algorithm_name: algorithm_name(algorithm),
987 };
988 let issues = deprecation_issues(
989 &[
990 ds(1, 7, 2, true, true),
991 ds(2, 12, 2, true, true),
992 ds(3, 13, 2, true, true),
993 ],
994 &[key(7), key(12), key(8)],
995 );
996 assert_eq!(
997 issues
998 .iter()
999 .filter(|i| i.contains("deprecated algorithm"))
1000 .count(),
1001 4,
1002 "{issues:?}"
1003 );
1004 assert!(issues
1005 .iter()
1006 .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
1007 assert!(issues
1008 .iter()
1009 .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
1010 assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
1011 assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
1012 }
1013
1014 #[test]
1015 fn soa_apex_matches_self_or_ancestor_only() {
1016 assert!(is_self_or_ancestor("example.com", "example.com"));
1017 assert!(is_self_or_ancestor("example.com", "api.example.com"));
1018 assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
1019 assert!(!is_self_or_ancestor("api.example.com", "example.com"));
1020
1021 let soa = |owner: &str| {
1022 Record::from_rdata(
1023 Name::from_ascii(owner).unwrap(),
1024 300,
1025 soa_rdata(owner.trim_end_matches('.')),
1026 )
1027 };
1028 let records = [soa("cdn-target.test."), soa("Example.COM.")];
1029 assert_eq!(
1030 soa_apex("api.example.com", records.iter()),
1031 Some("example.com".to_string()),
1032 "a CNAME target's SOA must be skipped; the enclosing one is found"
1033 );
1034 assert_eq!(soa_apex("api.other.test", records.iter()), None);
1035 }
1036
1037 use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
1040 use hickory_resolver::proto::rr::RecordType as WireType;
1041
1042 use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
1043
1044 fn test_key() -> DNSKEY {
1047 DNSKEY::new(
1048 true,
1049 true,
1050 false,
1051 PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
1052 )
1053 }
1054
1055 fn ds_for(zone: &str, key: &DNSKEY) -> DS {
1057 let digest = key
1058 .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
1059 .unwrap();
1060 DS::new(
1061 key.calculate_key_tag().unwrap(),
1062 Algorithm::ED25519,
1063 DigestType::SHA256,
1064 digest.as_ref().to_vec(),
1065 )
1066 }
1067
1068 fn dnskey_rdata(key: DNSKEY) -> RData {
1069 RData::DNSSEC(DNSSECRData::DNSKEY(key))
1070 }
1071
1072 fn ds_rdata(ds: DS) -> RData {
1073 RData::DNSSEC(DNSSECRData::DS(ds))
1074 }
1075
1076 fn mock_checker(port: u16) -> DnssecChecker {
1077 DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
1078 }
1079
1080 #[tokio::test]
1084 async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
1085 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1086 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1087 ("signed.test", WireType::DS) => {
1088 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1089 }
1090 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1091 _ => MockReply::NoData,
1093 })
1094 .await;
1095
1096 let report = mock_checker(port).check("api.signed.test").await.unwrap();
1097 assert_eq!(report.domain, "api.signed.test");
1098 assert_eq!(report.status, "signed", "{report:?}");
1099 assert!(report.chain_valid);
1100 assert!(
1101 report.issues[0].contains("enclosing zone signed.test"),
1102 "the evaluated zone must be named: {:?}",
1103 report.issues
1104 );
1105 assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1106 let tag = test_key().calculate_key_tag().unwrap();
1108 assert_eq!(report.dnskey_records[0].key_tag, tag);
1109 assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1110 }
1111
1112 #[tokio::test]
1115 async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1116 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1117 ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1118 ("api.signed.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1119 ("signed.test", WireType::DS) => {
1120 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1121 }
1122 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1123 _ => MockReply::NoData,
1124 })
1125 .await;
1126
1127 let report = mock_checker(port)
1128 .check("deep.api.signed.test")
1129 .await
1130 .unwrap();
1131 assert_eq!(report.status, "signed", "{report:?}");
1132 assert!(report.issues[0].contains("enclosing zone signed.test"));
1133 }
1134
1135 #[tokio::test]
1141 async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1142 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1143 ("broken.test", WireType::DS) => {
1144 MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1145 }
1146 ("broken.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1147 _ => MockReply::NoData,
1148 })
1149 .await;
1150
1151 let report = mock_checker(port).check("broken.test").await.unwrap();
1152 assert!(report.has_ds_records);
1153 assert!(!report.has_dnskey_records);
1154 assert_eq!(report.status, "misconfigured", "{report:?}");
1155 assert!(!report.chain_valid);
1156 assert!(
1157 report
1158 .issues
1159 .iter()
1160 .any(|i| i.contains("DNSKEY query failed")),
1161 "{:?}",
1162 report.issues
1163 );
1164 assert!(
1165 !report.issues.iter().any(|i| i.contains("not a zone apex")),
1166 "{:?}",
1167 report.issues
1168 );
1169 }
1170
1171 #[tokio::test]
1175 async fn both_queries_failing_is_an_error_not_unsigned() {
1176 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1177 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1178 ("signed.test", WireType::DS | WireType::DNSKEY) => {
1179 MockReply::Rcode(ResponseCode::ServFail)
1180 }
1181 _ => MockReply::NoData,
1182 })
1183 .await;
1184
1185 let err = mock_checker(port).check("signed.test").await.unwrap_err();
1186 assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
1187 assert!(err.to_string().contains("neither DS"), "{err}");
1188 }
1189
1190 #[tokio::test]
1193 async fn both_queries_cut_off_by_the_deadline_is_a_timeout() {
1194 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1195 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1196 (_, WireType::DS | WireType::DNSKEY) => MockReply::NoReply,
1197 _ => MockReply::NoData,
1198 })
1199 .await;
1200
1201 let err = mock_checker(port)
1202 .with_timeout(Duration::from_millis(100))
1203 .check("signed.test")
1204 .await
1205 .unwrap_err();
1206 assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1207 }
1208
1209 #[tokio::test]
1210 async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1211 let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1212 let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1213 assert_eq!(report.status, "unsigned");
1214 assert!(
1215 report.issues.iter().any(|i| i.contains("does not exist")),
1216 "{:?}",
1217 report.issues
1218 );
1219 }
1220
1221 #[tokio::test]
1222 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1223 async fn test_live_dnssec_check_cloudflare() {
1224 let checker = DnssecChecker::new();
1225 let report = checker.check("cloudflare.com").await.unwrap();
1226
1227 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1229 assert!(report.has_ds_records, "should have DS records");
1230 assert!(report.has_dnskey_records, "should have DNSKEY records");
1231 assert!(report.chain_valid, "cloudflare.com chain should be valid");
1232 assert_eq!(report.status, "signed");
1233
1234 for ds in &report.ds_records {
1236 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1237 assert!(
1238 ds.digest_verified,
1239 "DS key_tag={} digest should verify",
1240 ds.key_tag
1241 );
1242 }
1243
1244 for key in &report.dnskey_records {
1246 assert!(key.key_tag > 0, "key_tag should be computed");
1247 }
1248 }
1249
1250 #[tokio::test]
1251 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1252 async fn test_live_dnssec_check_insecure() {
1253 let checker = DnssecChecker::new();
1254 let report = checker.check("wikipedia.org").await.unwrap();
1256
1257 assert!(!report.chain_valid);
1258 assert_eq!(report.status, "unsigned");
1259 }
1260
1261 #[tokio::test]
1267 async fn the_zone_walk_is_held_to_its_budget() {
1268 use std::net::Ipv4Addr;
1269 use std::sync::{Arc, Mutex};
1270
1271 use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
1272
1273 let timeout = Duration::from_millis(200);
1274 let seen: Arc<Mutex<HashMap<String, usize>>> = Arc::default();
1277 let log = seen.clone();
1278 let port = spawn_mock_dns_fn(move |qname, qtype| {
1279 let mut seen = log.lock().unwrap();
1280 let n = seen.entry(format!("{qname} {qtype}")).or_insert(0);
1281 *n += 1;
1282 if *n == 1 {
1283 MockReply::NoReply
1284 } else {
1285 MockReply::NoData
1286 }
1287 })
1288 .await;
1289 let checker = DnssecChecker::new()
1290 .with_timeout(timeout)
1291 .with_upstream(Ipv4Addr::LOCALHOST.into(), port);
1292
1293 let started = std::time::Instant::now();
1294 let err = checker
1295 .check("a.b.c.d.e.f.g.h.i.seer.test")
1296 .await
1297 .expect_err("the walk runs out of time");
1298 let elapsed = started.elapsed();
1299 assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1300 assert!(err.to_string().contains("zone"), "{err}");
1301 let walk_budget = timeout * ZONE_WALK_BUDGET_TIMEOUTS;
1303 assert!(elapsed < walk_budget + timeout, "took {elapsed:?}");
1304 assert!(
1306 !seen.lock().unwrap().keys().any(|k| k.ends_with(" DNSKEY")),
1307 "{:?}",
1308 seen.lock().unwrap().keys()
1309 );
1310 }
1311}