1use std::collections::HashMap;
10use std::net::IpAddr;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use hickory_resolver::config::ResolverOpts;
15use hickory_resolver::net::runtime::TokioRuntimeProvider;
16use hickory_resolver::net::{DnsError, NetError};
17use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY, DS};
18use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
19use hickory_resolver::proto::op::ResponseCode;
20use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as HickoryRecordType};
21use hickory_resolver::TokioResolver;
22use serde::{Deserialize, Serialize};
23use tracing::{debug, instrument};
24
25use super::resolver::{apply_standard_opts, fqdn, google_or_pinned};
26use crate::error::Result;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DnssecReport {
31 pub domain: String,
33 pub enabled: bool,
35 pub has_ds_records: bool,
37 pub has_dnskey_records: bool,
39 pub ds_records: Vec<DsInfo>,
41 pub dnskey_records: Vec<DnskeyInfo>,
43 pub issues: Vec<String>,
45 pub status: String,
61 pub chain_valid: bool,
65 #[serde(default = "default_authentication_tier")]
70 pub authentication_tier: AuthenticationTier,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub rrsig_records: Vec<RrsigInfo>,
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 RrsigChecked,
96}
97
98fn default_authentication_tier() -> AuthenticationTier {
99 AuthenticationTier::DigestOnly
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RrsigInfo {
105 pub type_covered: String,
107 pub algorithm: u8,
109 pub algorithm_name: String,
111 pub key_tag: u16,
113 pub signer_name: String,
115 pub inception: Option<DateTime<Utc>>,
117 pub expiration: Option<DateTime<Utc>>,
119 pub expired: bool,
121 pub expires_in_days: i64,
123}
124
125const RRSIG_EXPIRY_WARN_DAYS: i64 = 7;
127
128fn rrsig_validity(inception: i64, expiration: i64, now: i64) -> (bool, i64) {
132 let expired = now > expiration || now < inception;
133 let expires_in_days = (expiration - now).div_euclid(86_400);
134 (expired, expires_in_days)
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct DsInfo {
140 pub key_tag: u16,
141 pub algorithm: u8,
142 pub digest_type: u8,
143 pub digest: String,
144 pub algorithm_name: String,
145 pub digest_type_name: String,
146 pub matched_key: bool,
148 pub digest_verified: bool,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct DnskeyInfo {
155 pub flags: u16,
156 pub protocol: u8,
157 pub algorithm: u8,
158 pub key_tag: u16,
160 pub is_ksk: bool,
161 pub is_zsk: bool,
162 pub algorithm_name: String,
163}
164
165const MAX_ZONE_WALK: usize = 8;
169
170const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
172
173#[derive(Debug, PartialEq, Eq)]
175enum ZoneApex {
176 Found(String),
178 NxDomain,
180 Unknown(Option<String>),
182}
183
184pub struct DnssecChecker {
186 resolver: TokioResolver,
190 check_rrsig: bool,
193 #[cfg(test)]
195 upstream: Option<(IpAddr, u16)>,
196}
197
198impl Default for DnssecChecker {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl DnssecChecker {
205 pub fn new() -> Self {
206 Self {
207 resolver: Self::build_resolver(false, None),
208 check_rrsig: false,
209 #[cfg(test)]
210 upstream: None,
211 }
212 }
213
214 pub fn with_rrsig_validation(mut self, on: bool) -> Self {
219 self.check_rrsig = on;
220 self
221 }
222
223 #[cfg(test)]
225 fn with_upstream(mut self, ip: IpAddr, port: u16) -> Self {
226 self.upstream = Some((ip, port));
227 self.resolver = Self::build_resolver(false, self.upstream);
228 self
229 }
230
231 #[cfg(test)]
232 fn upstream(&self) -> Option<(IpAddr, u16)> {
233 self.upstream
234 }
235
236 #[cfg(not(test))]
237 fn upstream(&self) -> Option<(IpAddr, u16)> {
238 None
239 }
240
241 fn build_resolver(validating: bool, upstream: Option<(IpAddr, u16)>) -> TokioResolver {
247 let mut builder = TokioResolver::builder_with_config(
248 google_or_pinned(upstream),
249 TokioRuntimeProvider::default(),
250 );
251 apply_dnssec_opts(builder.options_mut(), validating);
252 builder
253 .build()
254 .expect("hickory resolver build is infallible without TLS features")
255 }
256
257 async fn resolve_rrsigs(&self, zone: &str) -> Vec<RrsigInfo> {
262 let resolver = Self::build_resolver(true, self.upstream());
263 let Ok(lookup) = resolver.lookup(fqdn(zone), HickoryRecordType::RRSIG).await else {
264 return vec![];
265 };
266 let now = Utc::now().timestamp();
267 lookup
268 .answers()
269 .iter()
270 .filter_map(|record| {
271 let RData::DNSSEC(DNSSECRData::RRSIG(rrsig)) = &record.data else {
272 return None;
273 };
274 let input = rrsig.input();
275 let inception = input.sig_inception.get() as i64;
276 let expiration = input.sig_expiration.get() as i64;
277 let (expired, expires_in_days) = rrsig_validity(inception, expiration, now);
278 let algorithm = u8::from(input.algorithm);
279 Some(RrsigInfo {
280 type_covered: input.type_covered.to_string(),
281 algorithm,
282 algorithm_name: algorithm_name(algorithm),
283 key_tag: input.key_tag,
284 signer_name: input.signer_name.to_string(),
285 inception: DateTime::from_timestamp(inception, 0),
286 expiration: DateTime::from_timestamp(expiration, 0),
287 expired,
288 expires_in_days,
289 })
290 })
291 .collect()
292 }
293
294 async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, String> {
298 match self
299 .resolver
300 .lookup(fqdn(zone), HickoryRecordType::DS)
301 .await
302 {
303 Ok(lookup) => Ok(lookup
304 .answers()
305 .iter()
306 .filter_map(|record| match &record.data {
307 RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
308 _ => None,
309 })
310 .collect()),
311 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
312 Err(e) => Err(e.to_string()),
313 }
314 }
315
316 async fn lookup_dnskeys(
322 &self,
323 zone: &str,
324 ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, String> {
325 match self
326 .resolver
327 .lookup(fqdn(zone), HickoryRecordType::DNSKEY)
328 .await
329 {
330 Ok(lookup) => Ok(lookup
331 .answers()
332 .iter()
333 .filter_map(|record| match &record.data {
334 RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
335 Some((key.clone(), key.calculate_key_tag().ok()))
336 }
337 _ => None,
338 })
339 .collect()),
340 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
341 Err(e) => Err(e.to_string()),
342 }
343 }
344
345 async fn find_zone_apex(&self, name: &str) -> ZoneApex {
361 let mut candidate = name;
362 for step in 0..MAX_ZONE_WALK {
363 match self
364 .resolver
365 .lookup(fqdn(candidate), HickoryRecordType::SOA)
366 .await
367 {
368 Ok(lookup) => {
369 if let Some(apex) = soa_apex(
370 candidate,
371 lookup.answers().iter().chain(lookup.authorities()),
372 ) {
373 return ZoneApex::Found(apex);
374 }
375 }
376 Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => {
377 if no_records.response_code == ResponseCode::NXDomain && step == 0 {
378 return ZoneApex::NxDomain;
379 }
380 let soa_owner = no_records.soa.as_ref().map(|soa| &soa.name);
381 if let Some(apex) = soa_owner.and_then(|owner| {
382 let owner = normalize_owner(owner);
383 is_self_or_ancestor(&owner, candidate).then_some(owner)
384 }) {
385 return ZoneApex::Found(apex);
386 }
387 }
388 Err(e) => {
389 return match self.lookup_ds(candidate).await {
390 Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
391 _ => ZoneApex::Unknown(Some(format!(
392 "SOA query for {candidate} failed: {e}"
393 ))),
394 };
395 }
396 }
397 match candidate.split_once('.') {
398 Some((_, parent)) if parent.contains('.') => candidate = parent,
399 _ => break,
400 }
401 }
402 ZoneApex::Unknown(None)
403 }
404
405 #[instrument(skip(self), fields(domain = %domain))]
413 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
414 let domain = crate::validation::normalize_host(domain)?;
418 debug!(domain = %domain, "Checking DNSSEC");
419
420 let mut issues = Vec::new();
421 let zone = match self.find_zone_apex(&domain).await {
422 ZoneApex::Found(zone) => {
423 if zone != domain {
424 issues.push(format!(
425 "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
426 enclosing zone {zone}"
427 ));
428 }
429 zone
430 }
431 ZoneApex::NxDomain => {
432 issues.push(format!("{domain} does not exist (NXDOMAIN)"));
433 domain.clone()
434 }
435 ZoneApex::Unknown(reason) => {
436 let fallback = crate::validation::normalize_domain(&domain)?;
439 if let Some(reason) = reason {
440 issues.push(format!(
441 "could not determine the zone enclosing {domain} ({reason}); \
442 evaluated {fallback}"
443 ));
444 }
445 fallback
446 }
447 };
448
449 let (ds_result, dnskey_result) =
451 tokio::join!(self.lookup_ds(&zone), self.lookup_dnskeys(&zone));
452 let ds_records = ds_result.unwrap_or_else(|e| {
453 issues.push(format!("DS query failed: {e}"));
454 Vec::new()
455 });
456 let dnskey_query_failed = dnskey_result.is_err();
457 let dnskeys = dnskey_result.unwrap_or_else(|e| {
458 issues.push(format!("DNSKEY query failed: {e}"));
459 Vec::new()
460 });
461
462 let has_ds = !ds_records.is_empty();
463 let has_dnskey = !dnskeys.is_empty();
464
465 let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
469 for (key, tag) in &dnskeys {
470 if let Some(tag) = tag {
471 dnskey_map
472 .entry((*tag, u8::from(key.public_key().algorithm())))
473 .or_default()
474 .push(key);
475 }
476 }
477
478 let dnskey_info: Vec<DnskeyInfo> = dnskeys
479 .iter()
480 .map(|(key, tag)| {
481 let flags = key.flags();
482 let algorithm = u8::from(key.public_key().algorithm());
483 let is_sep = flags & 0x0001 != 0;
484 let is_zone = flags & 0x0100 != 0;
485 DnskeyInfo {
486 flags,
487 protocol: 3,
489 algorithm,
490 key_tag: tag.unwrap_or(0),
491 is_ksk: is_sep && is_zone,
492 is_zsk: is_zone && !is_sep,
493 algorithm_name: algorithm_name(algorithm),
494 }
495 })
496 .collect();
497
498 let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
500 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
501 });
502
503 let ds_info: Vec<DsInfo> = ds_records
505 .iter()
506 .map(|ds| {
507 let key_tag = ds.key_tag();
508 let algorithm = u8::from(ds.algorithm());
509 let digest_type = u8::from(ds.digest_type());
510 let mut matched_key = false;
511 let mut digest_verified = false;
512
513 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
516 matched_key = true;
517
518 match Self::to_hickory_digest_type(digest_type) {
525 Some(hickory_dt) => {
526 digest_verified = candidates.iter().any(|candidate| {
527 candidate
528 .to_digest(&zone_name, hickory_dt)
529 .is_ok_and(|computed| computed.as_ref() == ds.digest())
530 });
531 if !digest_verified {
532 issues.push(format!(
533 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
534 key_tag
535 ));
536 }
537 }
538 None => {
539 issues.push(format!(
540 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
541 key_tag, digest_type
542 ));
543 }
544 }
545 } else if has_dnskey {
546 issues.push(format!(
547 "DS record (key_tag={}) has no matching DNSKEY",
548 key_tag
549 ));
550 }
551
552 DsInfo {
553 key_tag,
554 algorithm,
555 digest_type,
556 digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
557 algorithm_name: algorithm_name(algorithm),
558 digest_type_name: digest_type_name(digest_type),
559 matched_key,
560 digest_verified,
561 }
562 })
563 .collect();
564
565 let ds_key_tags: std::collections::HashSet<u16> =
567 ds_info.iter().map(|ds| ds.key_tag).collect();
568 for key in &dnskey_info {
569 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
570 issues.push(format!(
571 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
572 key.key_tag
573 ));
574 }
575 }
576
577 issues.extend(deprecation_issues(&ds_info, &dnskey_info));
578
579 let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
580
581 if has_ds && !has_dnskey {
583 issues.push(if status == "partial" {
584 "DS records exist at the parent, but every one uses an algorithm or digest \
585 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
586 .to_string()
587 } else if dnskey_query_failed {
588 "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
589 validating upstream returns SERVFAIL when the chain is broken, so validating \
590 resolvers are likely failing this zone"
591 .to_string()
592 } else {
593 "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
594 validating resolvers will fail (SERVFAIL) this zone"
595 .to_string()
596 });
597 }
598 if !has_ds && has_dnskey {
599 issues.push(
600 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
601 .to_string(),
602 );
603 }
604
605 let enabled = has_ds || has_dnskey;
608 let mut rrsig_records = Vec::new();
609 if self.check_rrsig && enabled {
610 rrsig_records = self.resolve_rrsigs(&zone).await;
611 for r in &rrsig_records {
612 if r.expired {
613 issues.push(format!(
614 "RRSIG (key_tag={}, covers {}) is outside its validity window (expires_in_days={})",
615 r.key_tag, r.type_covered, r.expires_in_days
616 ));
617 } else if r.expires_in_days <= RRSIG_EXPIRY_WARN_DAYS {
618 issues.push(format!(
619 "RRSIG (key_tag={}, covers {}) expires in {} day(s)",
620 r.key_tag, r.type_covered, r.expires_in_days
621 ));
622 }
623 }
624 }
625
626 let authentication_tier = if !enabled {
629 AuthenticationTier::Unsigned
630 } else if !rrsig_records.is_empty() {
631 AuthenticationTier::RrsigChecked
632 } else {
633 AuthenticationTier::DigestOnly
634 };
635
636 Ok(DnssecReport {
637 domain,
638 enabled,
639 has_ds_records: has_ds,
640 has_dnskey_records: has_dnskey,
641 ds_records: ds_info,
642 dnskey_records: dnskey_info,
643 issues,
644 status: status.to_string(),
645 chain_valid,
646 authentication_tier,
647 rrsig_records,
648 })
649 }
650
651 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
659 let dt = DigestType::from(digest_type);
660 if dt.is_supported() {
661 Some(dt)
662 } else {
663 None
664 }
665 }
666}
667
668fn apply_dnssec_opts(opts: &mut ResolverOpts, validating: bool) {
672 apply_standard_opts(opts, DEFAULT_TIMEOUT);
673 if validating {
674 opts.validate = true;
679 opts.edns0 = true;
680 }
681}
682
683fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
692 let has_ds = !ds_info.is_empty();
693
694 let chain_valid = has_ds
699 && has_dnskey
700 && ds_info
701 .iter()
702 .any(|ds| ds.matched_key && ds.digest_verified)
703 && ds_info
704 .iter()
705 .filter(|ds| DnssecChecker::to_hickory_digest_type(ds.digest_type).is_some())
706 .all(|ds| ds.matched_key && ds.digest_verified);
707
708 let status = match (has_ds, has_dnskey) {
709 (true, true) if chain_valid => "signed",
710 (true, true) => "misconfigured",
711 (true, false) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
718 (true, false) => "partial",
719 (false, true) => "partial",
721 (false, false) => "unsigned",
722 };
723 (chain_valid, status)
724}
725
726fn ds_is_validatable(ds: &DsInfo) -> bool {
732 matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
733 && matches!(ds.digest_type, 1 | 2 | 4)
734}
735
736fn is_deprecated_algorithm(algorithm: u8) -> bool {
740 matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
741}
742
743fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
746 let mut issues = Vec::new();
747 for ds in ds_info {
748 if is_deprecated_algorithm(ds.algorithm) {
749 issues.push(format!(
750 "DS record uses deprecated algorithm {} ({})",
751 ds.algorithm, ds.algorithm_name
752 ));
753 }
754 if ds.digest_type == 1 {
755 issues.push(
756 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
757 .to_string(),
758 );
759 }
760 }
761 for key in dnskey_info {
762 if is_deprecated_algorithm(key.algorithm) {
763 issues.push(format!(
764 "DNSKEY record uses deprecated algorithm {} ({})",
765 key.algorithm, key.algorithm_name
766 ));
767 }
768 }
769 issues
770}
771
772fn normalize_owner(name: &Name) -> String {
775 name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
776}
777
778fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
781 !owner.is_empty()
782 && (owner == name
783 || name
784 .strip_suffix(owner)
785 .is_some_and(|prefix| prefix.ends_with('.')))
786}
787
788fn soa_apex<'a>(name: &str, records: impl Iterator<Item = &'a Record>) -> Option<String> {
792 records
793 .filter(|record| matches!(record.data, RData::SOA(_)))
794 .map(|record| normalize_owner(&record.name))
795 .find(|owner| is_self_or_ancestor(owner, name))
796}
797
798fn algorithm_name(algo: u8) -> String {
803 match algo {
804 1 => "RSA/MD5 (deprecated)".to_string(),
805 3 => "DSA/SHA-1 (deprecated)".to_string(),
806 5 => "RSA/SHA-1 (deprecated)".to_string(),
807 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
808 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
809 8 => "RSA/SHA-256".to_string(),
810 10 => "RSA/SHA-512".to_string(),
811 12 => "ECC-GOST (deprecated)".to_string(),
812 13 => "ECDSA P-256/SHA-256".to_string(),
813 14 => "ECDSA P-384/SHA-384".to_string(),
814 15 => "Ed25519".to_string(),
815 16 => "Ed448".to_string(),
816 _ => format!("Unknown ({})", algo),
817 }
818}
819
820fn digest_type_name(dtype: u8) -> String {
821 match dtype {
822 1 => "SHA-1".to_string(),
823 2 => "SHA-256".to_string(),
824 4 => "SHA-384".to_string(),
825 _ => format!("Unknown ({})", dtype),
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn test_algorithm_names() {
835 assert_eq!(algorithm_name(8), "RSA/SHA-256");
836 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
837 assert_eq!(algorithm_name(15), "Ed25519");
838 assert!(algorithm_name(5).contains("deprecated"));
839 }
840
841 #[test]
842 fn test_digest_type_names() {
843 assert_eq!(digest_type_name(1), "SHA-1");
844 assert_eq!(digest_type_name(2), "SHA-256");
845 }
846
847 #[test]
848 fn rrsig_validity_classifies_windows() {
849 let day = 86_400i64;
850 let now = 1_000_000 * day; let (expired, days) = rrsig_validity(now - day, now + 30 * day, now);
854 assert!(!expired);
855 assert_eq!(days, 30);
856
857 let (expired, days) = rrsig_validity(now - 40 * day, now - day, now);
859 assert!(expired);
860 assert_eq!(days, -1);
861
862 let (expired, _days) = rrsig_validity(now + day, now + 30 * day, now);
864 assert!(expired);
865 }
866
867 #[test]
868 fn default_tier_is_digest_only() {
869 assert_eq!(
872 default_authentication_tier(),
873 AuthenticationTier::DigestOnly
874 );
875 let checker = DnssecChecker::new();
876 assert!(!checker.check_rrsig);
877 assert!(checker.with_rrsig_validation(true).check_rrsig);
878 }
879
880 #[test]
881 fn test_report_serialization() {
882 let report = DnssecReport {
883 domain: "example.com".to_string(),
884 enabled: true,
885 has_ds_records: true,
886 has_dnskey_records: true,
887 ds_records: vec![DsInfo {
888 key_tag: 12345,
889 algorithm: 13,
890 digest_type: 2,
891 digest: "ABCDEF".to_string(),
892 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
893 digest_type_name: "SHA-256".to_string(),
894 matched_key: true,
895 digest_verified: true,
896 }],
897 dnskey_records: vec![DnskeyInfo {
898 flags: 257,
899 protocol: 3,
900 algorithm: 13,
901 key_tag: 12345,
902 is_ksk: true,
903 is_zsk: false,
904 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
905 }],
906 issues: vec![],
907 status: "signed".to_string(),
908 chain_valid: true,
909 authentication_tier: AuthenticationTier::DigestOnly,
910 rrsig_records: vec![],
911 };
912 let json = serde_json::to_string(&report).unwrap();
913 assert!(json.contains("\"enabled\":true"));
914 assert!(json.contains("\"chain_valid\":true"));
915 assert!(json.contains("\"matched_key\":true"));
916 assert!(json.contains("\"digest_verified\":true"));
917 assert!(json.contains("\"key_tag\":12345"));
918 }
919
920 fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
921 DsInfo {
922 key_tag,
923 algorithm,
924 digest_type,
925 digest: "ABCDEF".to_string(),
926 algorithm_name: algorithm_name(algorithm),
927 digest_type_name: digest_type_name(digest_type),
928 matched_key: matched,
929 digest_verified: verified,
930 }
931 }
932
933 #[test]
940 fn chain_status_signed_when_every_computable_ds_verifies() {
941 let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
942 assert_eq!(derive_chain_status(&all, true), (true, "signed"));
943 }
944
945 #[test]
946 fn chain_status_misconfigured_when_ds_unmatched() {
947 let unmatched = [ds(65000, 13, 2, false, false)];
948 assert_eq!(
949 derive_chain_status(&unmatched, true),
950 (false, "misconfigured")
951 );
952 }
953
954 #[test]
955 fn chain_status_misconfigured_on_digest_mismatch() {
956 let mismatch = [ds(12345, 13, 2, true, false)];
957 assert_eq!(
958 derive_chain_status(&mismatch, true),
959 (false, "misconfigured")
960 );
961 let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
963 assert_eq!(derive_chain_status(&mixed, true), (false, "misconfigured"));
964 }
965
966 #[test]
967 fn chain_status_uncomputable_digest_is_excluded_but_not_sufficient() {
968 let gost_only = [ds(1, 13, 3, true, false)];
971 assert_eq!(
972 derive_chain_status(&gost_only, true),
973 (false, "misconfigured")
974 );
975 let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
976 assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
977 }
978
979 #[test]
980 fn chain_status_ds_without_dnskey_is_misconfigured() {
981 let stale = [ds(12345, 13, 2, false, false)];
986 assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
987 }
988
989 #[test]
990 fn chain_status_ds_validators_ignore_stays_partial() {
991 let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
994 assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
995 let unknown_digest = [ds(3, 13, 99, false, false)];
996 assert_eq!(
997 derive_chain_status(&unknown_digest, false),
998 (false, "partial")
999 );
1000 }
1001
1002 #[test]
1003 fn chain_status_island_and_unsigned() {
1004 assert_eq!(derive_chain_status(&[], true), (false, "partial"));
1005 assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
1006 }
1007
1008 #[test]
1009 fn resolver_opts_share_the_standard_set() {
1010 for validating in [false, true] {
1013 let mut opts = ResolverOpts::default();
1014 apply_dnssec_opts(&mut opts, validating);
1015 assert_eq!(
1016 opts.server_ordering_strategy,
1017 hickory_resolver::config::ServerOrderingStrategy::UserProvidedOrder
1018 );
1019 assert_eq!(opts.validate, validating);
1020 }
1021 }
1022
1023 #[test]
1024 fn deprecated_algorithms_include_7_and_12() {
1025 let key = |algorithm: u8| DnskeyInfo {
1028 flags: 257,
1029 protocol: 3,
1030 algorithm,
1031 key_tag: 1,
1032 is_ksk: true,
1033 is_zsk: false,
1034 algorithm_name: algorithm_name(algorithm),
1035 };
1036 let issues = deprecation_issues(
1037 &[
1038 ds(1, 7, 2, true, true),
1039 ds(2, 12, 2, true, true),
1040 ds(3, 13, 2, true, true),
1041 ],
1042 &[key(7), key(12), key(8)],
1043 );
1044 assert_eq!(
1045 issues
1046 .iter()
1047 .filter(|i| i.contains("deprecated algorithm"))
1048 .count(),
1049 4,
1050 "{issues:?}"
1051 );
1052 assert!(issues
1053 .iter()
1054 .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
1055 assert!(issues
1056 .iter()
1057 .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
1058 assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
1059 assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
1060 }
1061
1062 #[test]
1063 fn soa_apex_matches_self_or_ancestor_only() {
1064 assert!(is_self_or_ancestor("example.com", "example.com"));
1065 assert!(is_self_or_ancestor("example.com", "api.example.com"));
1066 assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
1067 assert!(!is_self_or_ancestor("api.example.com", "example.com"));
1068
1069 let soa = |owner: &str| {
1070 Record::from_rdata(
1071 Name::from_ascii(owner).unwrap(),
1072 300,
1073 soa_rdata(owner.trim_end_matches('.')),
1074 )
1075 };
1076 let records = [soa("cdn-target.test."), soa("Example.COM.")];
1077 assert_eq!(
1078 soa_apex("api.example.com", records.iter()),
1079 Some("example.com".to_string()),
1080 "a CNAME target's SOA must be skipped; the enclosing one is found"
1081 );
1082 assert_eq!(soa_apex("api.other.test", records.iter()), None);
1083 }
1084
1085 use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
1088 use hickory_resolver::proto::rr::RecordType as WireType;
1089
1090 use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
1091
1092 fn test_key() -> DNSKEY {
1095 DNSKEY::new(
1096 true,
1097 true,
1098 false,
1099 PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
1100 )
1101 }
1102
1103 fn ds_for(zone: &str, key: &DNSKEY) -> DS {
1105 let digest = key
1106 .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
1107 .unwrap();
1108 DS::new(
1109 key.calculate_key_tag().unwrap(),
1110 Algorithm::ED25519,
1111 DigestType::SHA256,
1112 digest.as_ref().to_vec(),
1113 )
1114 }
1115
1116 fn dnskey_rdata(key: DNSKEY) -> RData {
1117 RData::DNSSEC(DNSSECRData::DNSKEY(key))
1118 }
1119
1120 fn ds_rdata(ds: DS) -> RData {
1121 RData::DNSSEC(DNSSECRData::DS(ds))
1122 }
1123
1124 fn mock_checker(port: u16) -> DnssecChecker {
1125 DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
1126 }
1127
1128 #[tokio::test]
1132 async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
1133 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1134 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1135 ("signed.test", WireType::DS) => {
1136 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1137 }
1138 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1139 _ => MockReply::NoData,
1141 })
1142 .await;
1143
1144 let report = mock_checker(port).check("api.signed.test").await.unwrap();
1145 assert_eq!(report.domain, "api.signed.test");
1146 assert_eq!(report.status, "signed", "{report:?}");
1147 assert!(report.chain_valid);
1148 assert!(
1149 report.issues[0].contains("enclosing zone signed.test"),
1150 "the evaluated zone must be named: {:?}",
1151 report.issues
1152 );
1153 assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1154 let tag = test_key().calculate_key_tag().unwrap();
1156 assert_eq!(report.dnskey_records[0].key_tag, tag);
1157 assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1158 }
1159
1160 #[tokio::test]
1163 async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1164 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1165 ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1166 ("api.signed.test", _) => MockReply::ServFail,
1167 ("signed.test", WireType::DS) => {
1168 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1169 }
1170 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1171 _ => MockReply::NoData,
1172 })
1173 .await;
1174
1175 let report = mock_checker(port)
1176 .check("deep.api.signed.test")
1177 .await
1178 .unwrap();
1179 assert_eq!(report.status, "signed", "{report:?}");
1180 assert!(report.issues[0].contains("enclosing zone signed.test"));
1181 }
1182
1183 #[tokio::test]
1189 async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1190 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1191 ("broken.test", WireType::DS) => {
1192 MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1193 }
1194 ("broken.test", _) => MockReply::ServFail,
1195 _ => MockReply::NoData,
1196 })
1197 .await;
1198
1199 let report = mock_checker(port).check("broken.test").await.unwrap();
1200 assert!(report.has_ds_records);
1201 assert!(!report.has_dnskey_records);
1202 assert_eq!(report.status, "misconfigured", "{report:?}");
1203 assert!(!report.chain_valid);
1204 assert!(
1205 report
1206 .issues
1207 .iter()
1208 .any(|i| i.contains("DNSKEY query failed")),
1209 "{:?}",
1210 report.issues
1211 );
1212 assert!(
1213 !report.issues.iter().any(|i| i.contains("not a zone apex")),
1214 "{:?}",
1215 report.issues
1216 );
1217 }
1218
1219 #[tokio::test]
1220 async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1221 let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1222 let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1223 assert_eq!(report.status, "unsigned");
1224 assert!(
1225 report.issues.iter().any(|i| i.contains("does not exist")),
1226 "{:?}",
1227 report.issues
1228 );
1229 }
1230
1231 #[tokio::test]
1232 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1233 async fn test_live_dnssec_check_cloudflare() {
1234 let checker = DnssecChecker::new();
1235 let report = checker.check("cloudflare.com").await.unwrap();
1236
1237 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1239 assert!(report.has_ds_records, "should have DS records");
1240 assert!(report.has_dnskey_records, "should have DNSKEY records");
1241 assert!(report.chain_valid, "cloudflare.com chain should be valid");
1242 assert_eq!(report.status, "signed");
1243
1244 for ds in &report.ds_records {
1246 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1247 assert!(
1248 ds.digest_verified,
1249 "DS key_tag={} digest should verify",
1250 ds.key_tag
1251 );
1252 }
1253
1254 for key in &report.dnskey_records {
1256 assert!(key.key_tag > 0, "key_tag should be computed");
1257 }
1258 }
1259
1260 #[tokio::test]
1261 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1262 async fn test_live_dnssec_rrsig_validation_cloudflare() {
1263 let checker = DnssecChecker::new().with_rrsig_validation(true);
1264 let report = checker.check("cloudflare.com").await.unwrap();
1265 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1266 if !report.rrsig_records.is_empty() {
1269 assert_eq!(report.authentication_tier, AuthenticationTier::RrsigChecked);
1270 for r in &report.rrsig_records {
1271 assert!(r.expiration.is_some(), "RRSIG should carry an expiration");
1272 assert!(
1273 !r.expired,
1274 "cloudflare.com RRSIG (covers {}) should be within its validity window",
1275 r.type_covered
1276 );
1277 }
1278 }
1279 }
1280
1281 #[tokio::test]
1282 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1283 async fn test_live_dnssec_check_insecure() {
1284 let checker = DnssecChecker::new();
1285 let report = checker.check("wikipedia.org").await.unwrap();
1287
1288 assert!(!report.chain_valid);
1289 assert_eq!(report.status, "unsigned");
1290 }
1291}