1use std::collections::HashMap;
20use std::time::Duration;
21
22use hickory_resolver::config::ResolverConfig;
23use hickory_resolver::lookup::Lookup;
24use hickory_resolver::net::NetError;
25use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
26use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
27use hickory_resolver::proto::op::{Message, 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::{build_plain_resolver, fqdn, google_or_pinned, lookup_with_tcp_fallback};
34use super::transport::Transport;
35use super::DEFAULT_DNS_TIMEOUT;
36use crate::error::{Result, SeerError};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DnssecReport {
41 pub domain: String,
43 pub enabled: bool,
45 pub has_ds_records: bool,
47 pub has_dnskey_records: bool,
49 pub ds_records: Vec<DsInfo>,
51 pub dnskey_records: Vec<DnskeyInfo>,
53 pub issues: Vec<String>,
55 pub status: String,
76 pub chain_valid: bool,
81 #[serde(default = "default_authentication_tier")]
86 pub authentication_tier: AuthenticationTier,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum AuthenticationTier {
98 Unsigned,
100 DigestOnly,
103}
104
105fn default_authentication_tier() -> AuthenticationTier {
106 AuthenticationTier::DigestOnly
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct DsInfo {
112 pub key_tag: u16,
113 pub algorithm: u8,
114 pub digest_type: u8,
115 pub digest: String,
116 pub algorithm_name: String,
117 pub digest_type_name: String,
118 pub matched_key: bool,
120 pub digest_verified: bool,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct DnskeyInfo {
127 pub flags: u16,
128 pub protocol: u8,
129 pub algorithm: u8,
130 pub key_tag: u16,
132 pub is_ksk: bool,
133 pub is_zsk: bool,
134 pub algorithm_name: String,
135}
136
137const MAX_ZONE_WALK: usize = 8;
141
142const ZONE_WALK_BUDGET_TIMEOUTS: u32 = 4;
148
149const CHECK_BUDGET_TIMEOUTS: u32 = ZONE_WALK_BUDGET_TIMEOUTS + 2;
152
153#[derive(Debug, PartialEq, Eq)]
155enum ZoneApex {
156 Found(String),
158 NxDomain,
160 Unknown(Option<String>),
162}
163
164pub struct DnssecChecker {
166 resolver: TokioResolver,
170 config: ResolverConfig,
174 transport: Transport,
177 timeout: Duration,
179}
180
181impl Default for DnssecChecker {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187#[derive(Debug)]
190struct ReadFailure {
191 detail: String,
192 timed_out: bool,
193}
194
195impl From<&NetError> for ReadFailure {
196 fn from(e: &NetError) -> Self {
197 Self {
198 detail: e.to_string(),
199 timed_out: matches!(e, NetError::Timeout),
200 }
201 }
202}
203
204impl std::fmt::Display for ReadFailure {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.write_str(&self.detail)
207 }
208}
209
210impl DnssecChecker {
211 pub fn new() -> Self {
212 Self::with_config(google_or_pinned(None), DEFAULT_DNS_TIMEOUT)
213 }
214
215 fn with_config(config: ResolverConfig, timeout: Duration) -> Self {
217 Self {
218 resolver: build_plain_resolver(config.clone(), timeout),
219 config,
220 transport: Transport::new(timeout),
221 timeout,
222 }
223 }
224
225 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
229 Self::new().with_timeout(config.dns_timeout())
230 }
231
232 pub fn with_timeout(self, timeout: Duration) -> Self {
234 Self::with_config(self.config, timeout)
235 }
236
237 #[cfg(test)]
239 fn with_upstream(self, ip: std::net::IpAddr, port: u16) -> Self {
240 Self::with_config(google_or_pinned(Some((ip, port))), self.timeout)
241 }
242
243 async fn lookup(
247 &self,
248 zone: &str,
249 wire_type: HickoryRecordType,
250 ) -> std::result::Result<Lookup, NetError> {
251 lookup_with_tcp_fallback(
252 &self.resolver,
253 &self.config,
254 self.timeout,
255 &fqdn(zone),
256 wire_type,
257 )
258 .await
259 }
260
261 async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, ReadFailure> {
265 match self.lookup(zone, HickoryRecordType::DS).await {
266 Ok(lookup) => Ok(lookup
267 .answers()
268 .iter()
269 .filter_map(|record| match &record.data {
270 RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
271 _ => None,
272 })
273 .collect()),
274 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
275 Err(e) => Err(ReadFailure::from(&e)),
276 }
277 }
278
279 async fn lookup_dnskeys(
285 &self,
286 zone: &str,
287 ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, ReadFailure> {
288 match self.lookup(zone, HickoryRecordType::DNSKEY).await {
289 Ok(lookup) => Ok(lookup
290 .answers()
291 .iter()
292 .filter_map(|record| match &record.data {
293 RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
294 Some((key.clone(), key.calculate_key_tag().ok()))
295 }
296 _ => None,
297 })
298 .collect()),
299 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
300 Err(e) => Err(ReadFailure::from(&e)),
301 }
302 }
303
304 async fn find_zone_apex(&self, name: &str) -> ZoneApex {
325 let mut candidate = name;
326 for step in 0..MAX_ZONE_WALK {
327 let qname = match Name::from_ascii(fqdn(candidate)) {
328 Ok(qname) => qname,
329 Err(e) => return ZoneApex::Unknown(Some(format!("invalid name {candidate}: {e}"))),
330 };
331 let request = self.transport.request(qname, HickoryRecordType::SOA, true);
332 let response = self
333 .transport
334 .query(
335 self.config.name_servers(),
336 &request,
337 self.transport.deadline(),
338 )
339 .await;
340 let failure = match response {
341 Ok(message) => match soa_step(candidate, &message, step == 0) {
342 SoaStep::Apex(apex) => return ZoneApex::Found(apex),
343 SoaStep::NxDomain => return ZoneApex::NxDomain,
344 SoaStep::Up => None,
345 SoaStep::Failed(code) => Some(code.to_string()),
346 },
347 Err(no_response) => Some(no_response.to_string()),
348 };
349 if let Some(reason) = failure {
350 return match self.lookup_ds(candidate).await {
351 Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
352 _ => ZoneApex::Unknown(Some(format!(
353 "SOA query for {candidate} failed: {reason}"
354 ))),
355 };
356 }
357 match candidate.split_once('.') {
358 Some((_, parent)) if parent.contains('.') => candidate = parent,
359 _ => break,
360 }
361 }
362 ZoneApex::Unknown(None)
363 }
364
365 #[instrument(skip(self), fields(domain = %domain))]
382 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
383 let domain = crate::validation::normalize_host(domain)?;
387 debug!(domain = %domain, "Checking DNSSEC");
388
389 let started = tokio::time::Instant::now();
392 let walk_budget = self.timeout.saturating_mul(ZONE_WALK_BUDGET_TIMEOUTS);
393 let budget = self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS);
394 let apex = tokio::time::timeout_at(started + walk_budget, self.find_zone_apex(&domain))
395 .await
396 .map_err(|_| {
397 SeerError::Timeout(format!(
400 "DNSSEC check for {domain} gave up after {walk_budget:?} without finding \
401 the zone that holds it"
402 ))
403 })?;
404
405 let mut issues = Vec::new();
406 let zone = match apex {
407 ZoneApex::Found(zone) => {
408 if zone != domain {
409 issues.push(format!(
410 "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
411 enclosing zone {zone}"
412 ));
413 }
414 zone
415 }
416 ZoneApex::NxDomain => {
417 issues.push(format!("{domain} does not exist (NXDOMAIN)"));
418 domain.clone()
419 }
420 ZoneApex::Unknown(reason) => {
421 let fallback = crate::validation::normalize_domain(&domain)?;
424 if let Some(reason) = reason {
425 issues.push(format!(
426 "could not determine the zone enclosing {domain} ({reason}); \
427 evaluated {fallback}"
428 ));
429 }
430 fallback
431 }
432 };
433
434 let deadline = started + budget;
437 let out_of_time = || ReadFailure {
438 detail: format!("timed out \u{2014} the check's {budget:?} budget ran out"),
439 timed_out: true,
440 };
441 let (ds_result, dnskey_result) = tokio::join!(
442 tokio::time::timeout_at(deadline, self.lookup_ds(&zone)),
443 tokio::time::timeout_at(deadline, self.lookup_dnskeys(&zone)),
444 );
445 let ds_result = ds_result.unwrap_or_else(|_| Err(out_of_time()));
446 let dnskey_result = dnskey_result.unwrap_or_else(|_| Err(out_of_time()));
447 if let (Err(ds), Err(dnskey)) = (&ds_result, &dnskey_result) {
451 let detail =
452 format!("DNSSEC check for {zone} read neither DS ({ds}) nor DNSKEY ({dnskey})");
453 return Err(if ds.timed_out && dnskey.timed_out {
454 SeerError::Timeout(detail)
455 } else {
456 SeerError::DnsError(detail)
457 });
458 }
459 let ds_records = ds_result.unwrap_or_else(|e| {
460 issues.push(format!("DS query failed: {e}"));
461 Vec::new()
462 });
463 let dnskey_query_failed = dnskey_result.is_err();
464 let dnskeys = dnskey_result.unwrap_or_else(|e| {
465 issues.push(format!("DNSKEY query failed: {e}"));
466 Vec::new()
467 });
468
469 let has_ds = !ds_records.is_empty();
470 let has_dnskey = !dnskeys.is_empty();
471
472 let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
476 for (key, tag) in &dnskeys {
477 if let Some(tag) = tag {
478 dnskey_map
479 .entry((*tag, u8::from(key.public_key().algorithm())))
480 .or_default()
481 .push(key);
482 }
483 }
484
485 let dnskey_info: Vec<DnskeyInfo> = dnskeys
486 .iter()
487 .map(|(key, tag)| {
488 let flags = key.flags();
489 let algorithm = u8::from(key.public_key().algorithm());
490 let is_sep = flags & 0x0001 != 0;
491 let is_zone = flags & 0x0100 != 0;
492 DnskeyInfo {
493 flags,
494 protocol: 3,
496 algorithm,
497 key_tag: tag.unwrap_or(0),
498 is_ksk: is_sep && is_zone,
499 is_zsk: is_zone && !is_sep,
500 algorithm_name: algorithm_name(algorithm),
501 }
502 })
503 .collect();
504
505 let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
507 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
508 });
509
510 let ds_info: Vec<DsInfo> = ds_records
512 .iter()
513 .map(|ds| {
514 let key_tag = ds.key_tag();
515 let algorithm = u8::from(ds.algorithm());
516 let digest_type = u8::from(ds.digest_type());
517 let mut matched_key = false;
518 let mut digest_verified = false;
519
520 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
523 matched_key = true;
524
525 match Self::to_hickory_digest_type(digest_type) {
532 Some(hickory_dt) => {
533 digest_verified = candidates.iter().any(|candidate| {
534 candidate
535 .to_digest(&zone_name, hickory_dt)
536 .is_ok_and(|computed| computed.as_ref() == ds.digest())
537 });
538 if !digest_verified {
539 issues.push(format!(
540 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
541 key_tag
542 ));
543 }
544 }
545 None => {
546 issues.push(format!(
547 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
548 key_tag, digest_type
549 ));
550 }
551 }
552 } else if has_dnskey {
553 issues.push(format!(
554 "DS record (key_tag={}) has no matching DNSKEY",
555 key_tag
556 ));
557 }
558
559 DsInfo {
560 key_tag,
561 algorithm,
562 digest_type,
563 digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
564 algorithm_name: algorithm_name(algorithm),
565 digest_type_name: digest_type_name(digest_type),
566 matched_key,
567 digest_verified,
568 }
569 })
570 .collect();
571
572 let ds_key_tags: std::collections::HashSet<u16> =
574 ds_info.iter().map(|ds| ds.key_tag).collect();
575 for key in &dnskey_info {
576 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
577 issues.push(format!(
578 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
579 key.key_tag
580 ));
581 }
582 }
583
584 issues.extend(deprecation_issues(&ds_info, &dnskey_info));
585
586 let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
587
588 if has_ds && status == "partial" {
590 issues.push(
591 "DS records exist at the parent, but every one uses an algorithm or digest \
592 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
593 .to_string(),
594 );
595 } else if has_ds && !has_dnskey {
596 issues.push(if dnskey_query_failed {
597 "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
598 validating upstream returns SERVFAIL when the chain is broken, so validating \
599 resolvers are likely failing this zone"
600 .to_string()
601 } else {
602 "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
603 validating resolvers will fail (SERVFAIL) this zone"
604 .to_string()
605 });
606 }
607 if !has_ds && has_dnskey {
608 issues.push(
609 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
610 .to_string(),
611 );
612 }
613
614 let enabled = has_ds || has_dnskey;
615 let authentication_tier = if enabled {
616 AuthenticationTier::DigestOnly
617 } else {
618 AuthenticationTier::Unsigned
619 };
620
621 Ok(DnssecReport {
622 domain,
623 enabled,
624 has_ds_records: has_ds,
625 has_dnskey_records: has_dnskey,
626 ds_records: ds_info,
627 dnskey_records: dnskey_info,
628 issues,
629 status: status.to_string(),
630 chain_valid,
631 authentication_tier,
632 })
633 }
634
635 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
643 let dt = DigestType::from(digest_type);
644 if dt.is_supported() {
645 Some(dt)
646 } else {
647 None
648 }
649 }
650}
651
652fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
661 let has_ds = !ds_info.is_empty();
662
663 let chain_valid = has_dnskey
668 && ds_info
669 .iter()
670 .any(|ds| ds.matched_key && ds.digest_verified);
671
672 let status = match (has_ds, has_dnskey) {
673 (true, true) if chain_valid => "signed",
674 (true, _) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
682 (true, _) => "partial",
683 (false, true) => "partial",
685 (false, false) => "unsigned",
686 };
687 (chain_valid, status)
688}
689
690fn ds_is_validatable(ds: &DsInfo) -> bool {
696 matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
697 && matches!(ds.digest_type, 1 | 2 | 4)
698}
699
700fn is_deprecated_algorithm(algorithm: u8) -> bool {
704 matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
705}
706
707fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
710 let mut issues = Vec::new();
711 for ds in ds_info {
712 if is_deprecated_algorithm(ds.algorithm) {
713 issues.push(format!(
714 "DS record uses deprecated algorithm {} ({})",
715 ds.algorithm, ds.algorithm_name
716 ));
717 }
718 if ds.digest_type == 1 {
719 issues.push(
720 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
721 .to_string(),
722 );
723 }
724 }
725 for key in dnskey_info {
726 if is_deprecated_algorithm(key.algorithm) {
727 issues.push(format!(
728 "DNSKEY record uses deprecated algorithm {} ({})",
729 key.algorithm, key.algorithm_name
730 ));
731 }
732 }
733 issues
734}
735
736fn normalize_owner(name: &Name) -> String {
739 name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
740}
741
742fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
745 !owner.is_empty()
746 && (owner == name
747 || name
748 .strip_suffix(owner)
749 .is_some_and(|prefix| prefix.ends_with('.')))
750}
751
752#[derive(Debug, PartialEq, Eq)]
754enum SoaStep {
755 Apex(String),
757 NxDomain,
759 Up,
761 Failed(ResponseCode),
763}
764
765fn soa_step(candidate: &str, message: &Message, first: bool) -> SoaStep {
774 let owns_cname = message
775 .answers
776 .iter()
777 .any(|r| matches!(r.data, RData::CNAME(_)) && normalize_owner(&r.name) == candidate);
778 if owns_cname {
779 return SoaStep::Up;
780 }
781 match message.metadata.response_code {
782 ResponseCode::NoError => {}
783 ResponseCode::NXDomain if first => return SoaStep::NxDomain,
784 ResponseCode::NXDomain => {}
787 code => return SoaStep::Failed(code),
788 }
789 match soa_apex(
790 candidate,
791 message.answers.iter().chain(&message.authorities),
792 ) {
793 Some(apex) => SoaStep::Apex(apex),
794 None => SoaStep::Up,
795 }
796}
797
798fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
802 records
803 .filter(|record| matches!(record.data, RData::SOA(_)))
804 .map(|record| normalize_owner(&record.name))
805 .find(|owner| is_self_or_ancestor(owner, name))
806}
807
808fn algorithm_name(algo: u8) -> String {
813 match algo {
814 1 => "RSA/MD5 (deprecated)".to_string(),
815 3 => "DSA/SHA-1 (deprecated)".to_string(),
816 5 => "RSA/SHA-1 (deprecated)".to_string(),
817 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
818 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
819 8 => "RSA/SHA-256".to_string(),
820 10 => "RSA/SHA-512".to_string(),
821 12 => "ECC-GOST (deprecated)".to_string(),
822 13 => "ECDSA P-256/SHA-256".to_string(),
823 14 => "ECDSA P-384/SHA-384".to_string(),
824 15 => "Ed25519".to_string(),
825 16 => "Ed448".to_string(),
826 _ => format!("Unknown ({})", algo),
827 }
828}
829
830fn digest_type_name(dtype: u8) -> String {
831 match dtype {
832 1 => "SHA-1".to_string(),
833 2 => "SHA-256".to_string(),
834 4 => "SHA-384".to_string(),
835 _ => format!("Unknown ({})", dtype),
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn test_algorithm_names() {
845 assert_eq!(algorithm_name(8), "RSA/SHA-256");
846 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
847 assert_eq!(algorithm_name(15), "Ed25519");
848 assert!(algorithm_name(5).contains("deprecated"));
849 }
850
851 #[test]
852 fn test_digest_type_names() {
853 assert_eq!(digest_type_name(1), "SHA-1");
854 assert_eq!(digest_type_name(2), "SHA-256");
855 }
856
857 #[test]
858 fn default_tier_is_digest_only() {
859 assert_eq!(
861 default_authentication_tier(),
862 AuthenticationTier::DigestOnly
863 );
864 }
865
866 #[test]
867 fn from_config_applies_dns_timeout() {
868 let mut config = crate::config::SeerConfig::default();
869 config.timeouts.dns_secs = 9;
870 assert_eq!(
871 DnssecChecker::from_config(&config).timeout,
872 Duration::from_secs(9)
873 );
874 }
875
876 #[test]
877 fn test_report_serialization() {
878 let report = DnssecReport {
879 domain: "example.com".to_string(),
880 enabled: true,
881 has_ds_records: true,
882 has_dnskey_records: true,
883 ds_records: vec![DsInfo {
884 key_tag: 12345,
885 algorithm: 13,
886 digest_type: 2,
887 digest: "ABCDEF".to_string(),
888 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
889 digest_type_name: "SHA-256".to_string(),
890 matched_key: true,
891 digest_verified: true,
892 }],
893 dnskey_records: vec![DnskeyInfo {
894 flags: 257,
895 protocol: 3,
896 algorithm: 13,
897 key_tag: 12345,
898 is_ksk: true,
899 is_zsk: false,
900 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
901 }],
902 issues: vec![],
903 status: "signed".to_string(),
904 chain_valid: true,
905 authentication_tier: AuthenticationTier::DigestOnly,
906 };
907 let json = serde_json::to_string(&report).unwrap();
908 assert!(json.contains("\"enabled\":true"));
909 assert!(json.contains("\"chain_valid\":true"));
910 assert!(json.contains("\"matched_key\":true"));
911 assert!(json.contains("\"digest_verified\":true"));
912 assert!(json.contains("\"key_tag\":12345"));
913 }
914
915 fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
916 DsInfo {
917 key_tag,
918 algorithm,
919 digest_type,
920 digest: "ABCDEF".to_string(),
921 algorithm_name: algorithm_name(algorithm),
922 digest_type_name: digest_type_name(digest_type),
923 matched_key: matched,
924 digest_verified: verified,
925 }
926 }
927
928 #[test]
935 fn chain_status_signed_when_every_computable_ds_verifies() {
936 let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
937 assert_eq!(derive_chain_status(&all, true), (true, "signed"));
938 }
939
940 #[test]
941 fn chain_status_misconfigured_when_ds_unmatched() {
942 let unmatched = [ds(65000, 13, 2, false, false)];
943 assert_eq!(
944 derive_chain_status(&unmatched, true),
945 (false, "misconfigured")
946 );
947 }
948
949 #[test]
950 fn chain_status_misconfigured_on_digest_mismatch() {
951 let mismatch = [ds(12345, 13, 2, true, false)];
952 assert_eq!(
953 derive_chain_status(&mismatch, true),
954 (false, "misconfigured")
955 );
956 }
957
958 #[test]
959 fn chain_status_one_authenticating_ds_is_enough() {
960 let rollover = [ds(1, 13, 2, true, true), ds(2, 13, 2, false, false)];
966 assert_eq!(derive_chain_status(&rollover, true), (true, "signed"));
967 let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
968 assert_eq!(derive_chain_status(&mixed, true), (true, "signed"));
969 }
970
971 #[test]
972 fn chain_status_uncomputable_digest_alone_is_partial() {
973 let gost_only = [ds(1, 13, 3, true, false)];
978 assert_eq!(derive_chain_status(&gost_only, true), (false, "partial"));
979 assert_eq!(derive_chain_status(&gost_only, false), (false, "partial"));
980 let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
981 assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
982 }
983
984 #[test]
985 fn chain_status_ds_without_dnskey_is_misconfigured() {
986 let stale = [ds(12345, 13, 2, false, false)];
991 assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
992 }
993
994 #[test]
995 fn chain_status_ds_validators_ignore_stays_partial() {
996 let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
999 assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
1000 let unknown_digest = [ds(3, 13, 99, false, false)];
1001 assert_eq!(
1002 derive_chain_status(&unknown_digest, false),
1003 (false, "partial")
1004 );
1005 }
1006
1007 #[test]
1008 fn chain_status_island_and_unsigned() {
1009 assert_eq!(derive_chain_status(&[], true), (false, "partial"));
1010 assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
1011 }
1012
1013 #[test]
1014 fn deprecated_algorithms_include_7_and_12() {
1015 let key = |algorithm: u8| DnskeyInfo {
1018 flags: 257,
1019 protocol: 3,
1020 algorithm,
1021 key_tag: 1,
1022 is_ksk: true,
1023 is_zsk: false,
1024 algorithm_name: algorithm_name(algorithm),
1025 };
1026 let issues = deprecation_issues(
1027 &[
1028 ds(1, 7, 2, true, true),
1029 ds(2, 12, 2, true, true),
1030 ds(3, 13, 2, true, true),
1031 ],
1032 &[key(7), key(12), key(8)],
1033 );
1034 assert_eq!(
1035 issues
1036 .iter()
1037 .filter(|i| i.contains("deprecated algorithm"))
1038 .count(),
1039 4,
1040 "{issues:?}"
1041 );
1042 assert!(issues
1043 .iter()
1044 .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
1045 assert!(issues
1046 .iter()
1047 .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
1048 assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
1049 assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
1050 }
1051
1052 #[test]
1053 fn soa_apex_matches_self_or_ancestor_only() {
1054 assert!(is_self_or_ancestor("example.com", "example.com"));
1055 assert!(is_self_or_ancestor("example.com", "api.example.com"));
1056 assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
1057 assert!(!is_self_or_ancestor("api.example.com", "example.com"));
1058
1059 let soa = |owner: &str| {
1060 Record::from_rdata(
1061 Name::from_ascii(owner).unwrap(),
1062 300,
1063 soa_rdata(owner.trim_end_matches('.')),
1064 )
1065 };
1066 let records = [soa("cdn-target.test."), soa("Example.COM.")];
1067 assert_eq!(
1068 soa_apex("api.example.com", records.iter()),
1069 Some("example.com".to_string()),
1070 "a CNAME target's SOA must be skipped; the enclosing one is found"
1071 );
1072 assert_eq!(soa_apex("api.other.test", records.iter()), None);
1073 }
1074
1075 use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
1078 use hickory_resolver::proto::rr::RecordType as WireType;
1079
1080 use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
1081
1082 fn test_key() -> DNSKEY {
1085 DNSKEY::new(
1086 true,
1087 true,
1088 false,
1089 PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
1090 )
1091 }
1092
1093 fn ds_for(zone: &str, key: &DNSKEY) -> DS {
1095 let digest = key
1096 .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
1097 .unwrap();
1098 DS::new(
1099 key.calculate_key_tag().unwrap(),
1100 Algorithm::ED25519,
1101 DigestType::SHA256,
1102 digest.as_ref().to_vec(),
1103 )
1104 }
1105
1106 fn dnskey_rdata(key: DNSKEY) -> RData {
1107 RData::DNSSEC(DNSSECRData::DNSKEY(key))
1108 }
1109
1110 fn ds_rdata(ds: DS) -> RData {
1111 RData::DNSSEC(DNSSECRData::DS(ds))
1112 }
1113
1114 fn mock_checker(port: u16) -> DnssecChecker {
1115 DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
1116 }
1117
1118 #[tokio::test]
1122 async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
1123 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1124 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1125 ("signed.test", WireType::DS) => {
1126 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1127 }
1128 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1129 _ => MockReply::NoData,
1131 })
1132 .await;
1133
1134 let report = mock_checker(port).check("api.signed.test").await.unwrap();
1135 assert_eq!(report.domain, "api.signed.test");
1136 assert_eq!(report.status, "signed", "{report:?}");
1137 assert!(report.chain_valid);
1138 assert!(
1139 report.issues[0].contains("enclosing zone signed.test"),
1140 "the evaluated zone must be named: {:?}",
1141 report.issues
1142 );
1143 assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1144 let tag = test_key().calculate_key_tag().unwrap();
1146 assert_eq!(report.dnskey_records[0].key_tag, tag);
1147 assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1148 }
1149
1150 #[tokio::test]
1153 async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1154 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1155 ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1156 ("api.signed.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1157 ("signed.test", WireType::DS) => {
1158 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1159 }
1160 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1161 _ => MockReply::NoData,
1162 })
1163 .await;
1164
1165 let report = mock_checker(port)
1166 .check("deep.api.signed.test")
1167 .await
1168 .unwrap();
1169 assert_eq!(report.status, "signed", "{report:?}");
1170 assert!(report.issues[0].contains("enclosing zone signed.test"));
1171 }
1172
1173 #[tokio::test]
1179 async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1180 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1181 ("broken.test", WireType::DS) => {
1182 MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1183 }
1184 ("broken.test", _) => MockReply::Rcode(ResponseCode::ServFail),
1185 _ => MockReply::NoData,
1186 })
1187 .await;
1188
1189 let report = mock_checker(port).check("broken.test").await.unwrap();
1190 assert!(report.has_ds_records);
1191 assert!(!report.has_dnskey_records);
1192 assert_eq!(report.status, "misconfigured", "{report:?}");
1193 assert!(!report.chain_valid);
1194 assert!(
1195 report
1196 .issues
1197 .iter()
1198 .any(|i| i.contains("DNSKEY query failed")),
1199 "{:?}",
1200 report.issues
1201 );
1202 assert!(
1203 !report.issues.iter().any(|i| i.contains("not a zone apex")),
1204 "{:?}",
1205 report.issues
1206 );
1207 }
1208
1209 #[tokio::test]
1213 async fn both_queries_failing_is_an_error_not_unsigned() {
1214 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1215 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1216 ("signed.test", WireType::DS | WireType::DNSKEY) => {
1217 MockReply::Rcode(ResponseCode::ServFail)
1218 }
1219 _ => MockReply::NoData,
1220 })
1221 .await;
1222
1223 let err = mock_checker(port).check("signed.test").await.unwrap_err();
1224 assert!(matches!(err, SeerError::DnsError(_)), "{err:?}");
1225 assert!(err.to_string().contains("neither DS"), "{err}");
1226 }
1227
1228 #[tokio::test]
1231 async fn both_queries_cut_off_by_the_deadline_is_a_timeout() {
1232 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1233 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1234 (_, WireType::DS | WireType::DNSKEY) => MockReply::NoReply,
1235 _ => MockReply::NoData,
1236 })
1237 .await;
1238
1239 let err = mock_checker(port)
1240 .with_timeout(Duration::from_millis(100))
1241 .check("signed.test")
1242 .await
1243 .unwrap_err();
1244 assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1245 }
1246
1247 #[tokio::test]
1253 async fn a_dangling_cname_is_not_read_as_a_nonexistent_name() {
1254 use crate::dns::test_support::{cname_rdata, record};
1255 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1256 ("www.signed.test", _) => MockReply::Negative {
1257 code: ResponseCode::NXDomain,
1258 chain: vec![record(
1259 "www.signed.test",
1260 300,
1261 cname_rdata("gone.elsewhere.test"),
1262 )],
1263 soa: "elsewhere.test",
1264 },
1265 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1266 ("signed.test", WireType::DS) => {
1267 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1268 }
1269 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1270 _ => MockReply::NoData,
1271 })
1272 .await;
1273
1274 let report = mock_checker(port).check("www.signed.test").await.unwrap();
1275 assert!(
1276 !report.issues.iter().any(|i| i.contains("does not exist")),
1277 "www.signed.test owns a CNAME, it exists: {:?}",
1278 report.issues
1279 );
1280 assert_eq!(report.status, "signed", "{report:?}");
1281 }
1282
1283 #[tokio::test]
1292 async fn a_truncated_dnskey_rrset_is_asked_over_tcp() {
1293 use crate::dns::resolver::single_server_config;
1294 use crate::dns::test_support::spawn_mock_dns_split;
1295 use hickory_resolver::config::ResolverConfig;
1296
1297 let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
1298 for _ in 0..2 {
1299 let answer = |qname: &str, qtype| match (qname, qtype) {
1300 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1301 ("signed.test", WireType::DS) => {
1302 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1303 }
1304 ("signed.test", WireType::DNSKEY) => {
1305 MockReply::Answer(vec![dnskey_rdata(test_key())])
1306 }
1307 _ => MockReply::NoData,
1308 };
1309 let port = spawn_mock_dns_split(
1310 move |qname, qtype| match qtype {
1311 WireType::DNSKEY => MockReply::Truncated,
1313 _ => answer(qname, qtype),
1314 },
1315 answer,
1316 )
1317 .await;
1318 for server in
1319 single_server_config("127.0.0.1".parse().unwrap(), port, true).name_servers()
1320 {
1321 config.add_name_server(server.clone());
1322 }
1323 }
1324 let checker = DnssecChecker::with_config(config, Duration::from_secs(2));
1325
1326 let report = checker.check("signed.test").await.unwrap();
1327 assert!(
1328 !report
1329 .issues
1330 .iter()
1331 .any(|i| i.contains("DNSKEY query failed")),
1332 "{:?}",
1333 report.issues
1334 );
1335 assert_eq!(report.status, "signed", "{report:?}");
1336 }
1337
1338 #[tokio::test]
1345 async fn a_cname_targets_soa_is_not_taken_for_the_names_apex() {
1346 use crate::dns::test_support::{cname_rdata, record};
1347 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1348 ("cdn.shop.signed.test", _) => MockReply::Negative {
1349 code: ResponseCode::NoError,
1350 chain: vec![record(
1351 "cdn.shop.signed.test",
1352 300,
1353 cname_rdata("lb.signed.test"),
1354 )],
1355 soa: "signed.test",
1356 },
1357 ("lb.signed.test", _) => MockReply::NoDataWithSoa("signed.test"),
1360 ("shop.signed.test", WireType::SOA) => {
1361 MockReply::Answer(vec![soa_rdata("shop.signed.test")])
1362 }
1363 ("shop.signed.test", WireType::DS) => {
1364 MockReply::Answer(vec![ds_rdata(ds_for("shop.signed.test", &test_key()))])
1365 }
1366 ("shop.signed.test", WireType::DNSKEY) => {
1367 MockReply::Answer(vec![dnskey_rdata(test_key())])
1368 }
1369 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1370 _ => MockReply::NoData,
1371 })
1372 .await;
1373
1374 let report = mock_checker(port)
1375 .check("cdn.shop.signed.test")
1376 .await
1377 .unwrap();
1378 assert!(
1379 report.issues[0].contains("enclosing zone shop.signed.test"),
1380 "evaluated the wrong zone: {:?}",
1381 report.issues
1382 );
1383 assert_eq!(report.status, "signed", "{report:?}");
1384 }
1385
1386 #[tokio::test]
1387 async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1388 let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1389 let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1390 assert_eq!(report.status, "unsigned");
1391 assert!(
1392 report.issues.iter().any(|i| i.contains("does not exist")),
1393 "{:?}",
1394 report.issues
1395 );
1396 }
1397
1398 #[tokio::test]
1399 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1400 async fn test_live_dnssec_check_cloudflare() {
1401 let checker = DnssecChecker::new();
1402 let report = checker.check("cloudflare.com").await.unwrap();
1403
1404 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1406 assert!(report.has_ds_records, "should have DS records");
1407 assert!(report.has_dnskey_records, "should have DNSKEY records");
1408 assert!(report.chain_valid, "cloudflare.com chain should be valid");
1409 assert_eq!(report.status, "signed");
1410
1411 for ds in &report.ds_records {
1413 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1414 assert!(
1415 ds.digest_verified,
1416 "DS key_tag={} digest should verify",
1417 ds.key_tag
1418 );
1419 }
1420
1421 for key in &report.dnskey_records {
1423 assert!(key.key_tag > 0, "key_tag should be computed");
1424 }
1425 }
1426
1427 #[tokio::test]
1428 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1429 async fn test_live_dnssec_check_insecure() {
1430 let checker = DnssecChecker::new();
1431 let report = checker.check("wikipedia.org").await.unwrap();
1433
1434 assert!(!report.chain_valid);
1435 assert_eq!(report.status, "unsigned");
1436 }
1437
1438 #[tokio::test]
1446 async fn the_zone_walk_is_held_to_its_budget() {
1447 use std::net::Ipv4Addr;
1448 use std::sync::{Arc, Mutex};
1449
1450 use hickory_resolver::config::ResolverConfig;
1451
1452 use crate::dns::resolver::single_server_config;
1453 use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
1454
1455 let timeout = Duration::from_millis(200);
1456 let seen: Arc<Mutex<HashMap<String, usize>>> = Arc::default();
1457 let log = seen.clone();
1458 let silent = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
1459 let answering = spawn_mock_dns_fn(move |qname, qtype| {
1460 *log.lock()
1461 .unwrap()
1462 .entry(format!("{qname} {qtype}"))
1463 .or_insert(0) += 1;
1464 MockReply::NoData
1465 })
1466 .await;
1467 let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
1468 for port in [silent, answering] {
1469 for server in
1470 single_server_config(Ipv4Addr::LOCALHOST.into(), port, false).name_servers()
1471 {
1472 config.add_name_server(server.clone());
1473 }
1474 }
1475 let checker = DnssecChecker::with_config(config, timeout);
1476
1477 let started = std::time::Instant::now();
1478 let err = checker
1479 .check("a.b.c.d.e.f.g.h.i.seer.test")
1480 .await
1481 .expect_err("the walk runs out of time");
1482 let elapsed = started.elapsed();
1483 assert!(matches!(err, SeerError::Timeout(_)), "{err:?}");
1484 assert!(err.to_string().contains("zone"), "{err}");
1485 let walk_budget = timeout * ZONE_WALK_BUDGET_TIMEOUTS;
1487 assert!(elapsed < walk_budget + timeout, "took {elapsed:?}");
1488 assert!(
1490 !seen.lock().unwrap().keys().any(|k| k.ends_with(" DNSKEY")),
1491 "{:?}",
1492 seen.lock().unwrap().keys()
1493 );
1494 }
1495}