1use std::collections::HashMap;
10use std::net::IpAddr;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts, GOOGLE};
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;
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 config = match upstream {
248 None => ResolverConfig::udp_and_tcp(&GOOGLE),
249 Some((ip, port)) => {
250 let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
251 let mut ns = NameServerConfig::udp(ip);
252 for connection in &mut ns.connections {
253 connection.port = port;
254 }
255 config.add_name_server(ns);
256 config
257 }
258 };
259 let mut builder =
260 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
261 apply_dnssec_opts(builder.options_mut(), validating);
262 builder
263 .build()
264 .expect("hickory resolver build is infallible without TLS features")
265 }
266
267 async fn resolve_rrsigs(&self, zone: &str) -> Vec<RrsigInfo> {
272 let resolver = Self::build_resolver(true, self.upstream());
273 let Ok(lookup) = resolver.lookup(fqdn(zone), HickoryRecordType::RRSIG).await else {
274 return vec![];
275 };
276 let now = Utc::now().timestamp();
277 lookup
278 .answers()
279 .iter()
280 .filter_map(|record| {
281 let RData::DNSSEC(DNSSECRData::RRSIG(rrsig)) = &record.data else {
282 return None;
283 };
284 let input = rrsig.input();
285 let inception = input.sig_inception.get() as i64;
286 let expiration = input.sig_expiration.get() as i64;
287 let (expired, expires_in_days) = rrsig_validity(inception, expiration, now);
288 let algorithm = u8::from(input.algorithm);
289 Some(RrsigInfo {
290 type_covered: input.type_covered.to_string(),
291 algorithm,
292 algorithm_name: algorithm_name(algorithm),
293 key_tag: input.key_tag,
294 signer_name: input.signer_name.to_string(),
295 inception: DateTime::from_timestamp(inception, 0),
296 expiration: DateTime::from_timestamp(expiration, 0),
297 expired,
298 expires_in_days,
299 })
300 })
301 .collect()
302 }
303
304 async fn lookup_ds(&self, zone: &str) -> std::result::Result<Vec<DS>, String> {
308 match self
309 .resolver
310 .lookup(fqdn(zone), HickoryRecordType::DS)
311 .await
312 {
313 Ok(lookup) => Ok(lookup
314 .answers()
315 .iter()
316 .filter_map(|record| match &record.data {
317 RData::DNSSEC(DNSSECRData::DS(ds)) => Some(ds.clone()),
318 _ => None,
319 })
320 .collect()),
321 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
322 Err(e) => Err(e.to_string()),
323 }
324 }
325
326 async fn lookup_dnskeys(
332 &self,
333 zone: &str,
334 ) -> std::result::Result<Vec<(DNSKEY, Option<u16>)>, String> {
335 match self
336 .resolver
337 .lookup(fqdn(zone), HickoryRecordType::DNSKEY)
338 .await
339 {
340 Ok(lookup) => Ok(lookup
341 .answers()
342 .iter()
343 .filter_map(|record| match &record.data {
344 RData::DNSSEC(DNSSECRData::DNSKEY(key)) => {
345 Some((key.clone(), key.calculate_key_tag().ok()))
346 }
347 _ => None,
348 })
349 .collect()),
350 Err(e) if e.is_no_records_found() => Ok(Vec::new()),
351 Err(e) => Err(e.to_string()),
352 }
353 }
354
355 async fn find_zone_apex(&self, name: &str) -> ZoneApex {
371 let mut candidate = name;
372 for step in 0..MAX_ZONE_WALK {
373 match self
374 .resolver
375 .lookup(fqdn(candidate), HickoryRecordType::SOA)
376 .await
377 {
378 Ok(lookup) => {
379 if let Some(apex) = soa_apex(
380 candidate,
381 lookup.answers().iter().chain(lookup.authorities()),
382 ) {
383 return ZoneApex::Found(apex);
384 }
385 }
386 Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => {
387 if no_records.response_code == ResponseCode::NXDomain && step == 0 {
388 return ZoneApex::NxDomain;
389 }
390 let soa_owner = no_records.soa.as_ref().map(|soa| &soa.name);
391 if let Some(apex) = soa_owner.and_then(|owner| {
392 let owner = normalize_owner(owner);
393 is_self_or_ancestor(&owner, candidate).then_some(owner)
394 }) {
395 return ZoneApex::Found(apex);
396 }
397 }
398 Err(e) => {
399 return match self.lookup_ds(candidate).await {
400 Ok(ds) if !ds.is_empty() => ZoneApex::Found(candidate.to_string()),
401 _ => ZoneApex::Unknown(Some(format!(
402 "SOA query for {candidate} failed: {e}"
403 ))),
404 };
405 }
406 }
407 match candidate.split_once('.') {
408 Some((_, parent)) if parent.contains('.') => candidate = parent,
409 _ => break,
410 }
411 }
412 ZoneApex::Unknown(None)
413 }
414
415 #[instrument(skip(self), fields(domain = %domain))]
423 pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
424 let domain = crate::validation::normalize_host(domain)?;
428 debug!(domain = %domain, "Checking DNSSEC");
429
430 let mut issues = Vec::new();
431 let zone = match self.find_zone_apex(&domain).await {
432 ZoneApex::Found(zone) => {
433 if zone != domain {
434 issues.push(format!(
435 "{domain} is not a zone apex \u{2014} DS/DNSKEY were evaluated at its \
436 enclosing zone {zone}"
437 ));
438 }
439 zone
440 }
441 ZoneApex::NxDomain => {
442 issues.push(format!("{domain} does not exist (NXDOMAIN)"));
443 domain.clone()
444 }
445 ZoneApex::Unknown(reason) => {
446 let fallback = crate::validation::normalize_domain(&domain)?;
449 if let Some(reason) = reason {
450 issues.push(format!(
451 "could not determine the zone enclosing {domain} ({reason}); \
452 evaluated {fallback}"
453 ));
454 }
455 fallback
456 }
457 };
458
459 let (ds_result, dnskey_result) =
461 tokio::join!(self.lookup_ds(&zone), self.lookup_dnskeys(&zone));
462 let ds_records = ds_result.unwrap_or_else(|e| {
463 issues.push(format!("DS query failed: {e}"));
464 Vec::new()
465 });
466 let dnskey_query_failed = dnskey_result.is_err();
467 let dnskeys = dnskey_result.unwrap_or_else(|e| {
468 issues.push(format!("DNSKEY query failed: {e}"));
469 Vec::new()
470 });
471
472 let has_ds = !ds_records.is_empty();
473 let has_dnskey = !dnskeys.is_empty();
474
475 let mut dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
479 for (key, tag) in &dnskeys {
480 if let Some(tag) = tag {
481 dnskey_map
482 .entry((*tag, u8::from(key.public_key().algorithm())))
483 .or_default()
484 .push(key);
485 }
486 }
487
488 let dnskey_info: Vec<DnskeyInfo> = dnskeys
489 .iter()
490 .map(|(key, tag)| {
491 let flags = key.flags();
492 let algorithm = u8::from(key.public_key().algorithm());
493 let is_sep = flags & 0x0001 != 0;
494 let is_zone = flags & 0x0100 != 0;
495 DnskeyInfo {
496 flags,
497 protocol: 3,
499 algorithm,
500 key_tag: tag.unwrap_or(0),
501 is_ksk: is_sep && is_zone,
502 is_zsk: is_zone && !is_sep,
503 algorithm_name: algorithm_name(algorithm),
504 }
505 })
506 .collect();
507
508 let zone_name = Name::from_ascii(&zone).unwrap_or_else(|_| {
510 Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
511 });
512
513 let ds_info: Vec<DsInfo> = ds_records
515 .iter()
516 .map(|ds| {
517 let key_tag = ds.key_tag();
518 let algorithm = u8::from(ds.algorithm());
519 let digest_type = u8::from(ds.digest_type());
520 let mut matched_key = false;
521 let mut digest_verified = false;
522
523 if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
526 matched_key = true;
527
528 match Self::to_hickory_digest_type(digest_type) {
535 Some(hickory_dt) => {
536 digest_verified = candidates.iter().any(|candidate| {
537 candidate
538 .to_digest(&zone_name, hickory_dt)
539 .is_ok_and(|computed| computed.as_ref() == ds.digest())
540 });
541 if !digest_verified {
542 issues.push(format!(
543 "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
544 key_tag
545 ));
546 }
547 }
548 None => {
549 issues.push(format!(
550 "DS record (key_tag={}) uses unsupported digest type {} \u{2014} cannot verify",
551 key_tag, digest_type
552 ));
553 }
554 }
555 } else if has_dnskey {
556 issues.push(format!(
557 "DS record (key_tag={}) has no matching DNSKEY",
558 key_tag
559 ));
560 }
561
562 DsInfo {
563 key_tag,
564 algorithm,
565 digest_type,
566 digest: ds.digest().iter().map(|b| format!("{:02X}", b)).collect(),
567 algorithm_name: algorithm_name(algorithm),
568 digest_type_name: digest_type_name(digest_type),
569 matched_key,
570 digest_verified,
571 }
572 })
573 .collect();
574
575 let ds_key_tags: std::collections::HashSet<u16> =
577 ds_info.iter().map(|ds| ds.key_tag).collect();
578 for key in &dnskey_info {
579 if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
580 issues.push(format!(
581 "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
582 key.key_tag
583 ));
584 }
585 }
586
587 issues.extend(deprecation_issues(&ds_info, &dnskey_info));
588
589 let (chain_valid, status) = derive_chain_status(&ds_info, has_dnskey);
590
591 if has_ds && !has_dnskey {
593 issues.push(if status == "partial" {
594 "DS records exist at the parent, but every one uses an algorithm or digest \
595 type validating resolvers do not support \u{2014} the zone is treated as unsigned"
596 .to_string()
597 } else if dnskey_query_failed {
598 "DS records exist at the parent but the DNSKEY query failed \u{2014} a \
599 validating upstream returns SERVFAIL when the chain is broken, so validating \
600 resolvers are likely failing this zone"
601 .to_string()
602 } else {
603 "DS records exist at the parent but the zone publishes no DNSKEY \u{2014} \
604 validating resolvers will fail (SERVFAIL) this zone"
605 .to_string()
606 });
607 }
608 if !has_ds && has_dnskey {
609 issues.push(
610 "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
611 .to_string(),
612 );
613 }
614
615 let enabled = has_ds || has_dnskey;
618 let mut rrsig_records = Vec::new();
619 if self.check_rrsig && enabled {
620 rrsig_records = self.resolve_rrsigs(&zone).await;
621 for r in &rrsig_records {
622 if r.expired {
623 issues.push(format!(
624 "RRSIG (key_tag={}, covers {}) is outside its validity window (expires_in_days={})",
625 r.key_tag, r.type_covered, r.expires_in_days
626 ));
627 } else if r.expires_in_days <= RRSIG_EXPIRY_WARN_DAYS {
628 issues.push(format!(
629 "RRSIG (key_tag={}, covers {}) expires in {} day(s)",
630 r.key_tag, r.type_covered, r.expires_in_days
631 ));
632 }
633 }
634 }
635
636 let authentication_tier = if !enabled {
639 AuthenticationTier::Unsigned
640 } else if !rrsig_records.is_empty() {
641 AuthenticationTier::RrsigChecked
642 } else {
643 AuthenticationTier::DigestOnly
644 };
645
646 Ok(DnssecReport {
647 domain,
648 enabled,
649 has_ds_records: has_ds,
650 has_dnskey_records: has_dnskey,
651 ds_records: ds_info,
652 dnskey_records: dnskey_info,
653 issues,
654 status: status.to_string(),
655 chain_valid,
656 authentication_tier,
657 rrsig_records,
658 })
659 }
660
661 fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
669 let dt = DigestType::from(digest_type);
670 if dt.is_supported() {
671 Some(dt)
672 } else {
673 None
674 }
675 }
676}
677
678fn apply_dnssec_opts(opts: &mut ResolverOpts, validating: bool) {
682 apply_standard_opts(opts, DEFAULT_TIMEOUT);
683 if validating {
684 opts.validate = true;
689 opts.edns0 = true;
690 }
691}
692
693fn derive_chain_status(ds_info: &[DsInfo], has_dnskey: bool) -> (bool, &'static str) {
702 let has_ds = !ds_info.is_empty();
703
704 let chain_valid = has_ds
709 && has_dnskey
710 && ds_info
711 .iter()
712 .any(|ds| ds.matched_key && ds.digest_verified)
713 && ds_info
714 .iter()
715 .filter(|ds| DnssecChecker::to_hickory_digest_type(ds.digest_type).is_some())
716 .all(|ds| ds.matched_key && ds.digest_verified);
717
718 let status = match (has_ds, has_dnskey) {
719 (true, true) if chain_valid => "signed",
720 (true, true) => "misconfigured",
721 (true, false) if ds_info.iter().any(ds_is_validatable) => "misconfigured",
728 (true, false) => "partial",
729 (false, true) => "partial",
731 (false, false) => "unsigned",
732 };
733 (chain_valid, status)
734}
735
736fn ds_is_validatable(ds: &DsInfo) -> bool {
742 matches!(ds.algorithm, 5 | 7 | 8 | 10 | 13 | 14 | 15 | 16)
743 && matches!(ds.digest_type, 1 | 2 | 4)
744}
745
746fn is_deprecated_algorithm(algorithm: u8) -> bool {
750 matches!(algorithm, 1 | 3 | 5 | 6 | 7 | 12)
751}
752
753fn deprecation_issues(ds_info: &[DsInfo], dnskey_info: &[DnskeyInfo]) -> Vec<String> {
756 let mut issues = Vec::new();
757 for ds in ds_info {
758 if is_deprecated_algorithm(ds.algorithm) {
759 issues.push(format!(
760 "DS record uses deprecated algorithm {} ({})",
761 ds.algorithm, ds.algorithm_name
762 ));
763 }
764 if ds.digest_type == 1 {
765 issues.push(
766 "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
767 .to_string(),
768 );
769 }
770 }
771 for key in dnskey_info {
772 if is_deprecated_algorithm(key.algorithm) {
773 issues.push(format!(
774 "DNSKEY record uses deprecated algorithm {} ({})",
775 key.algorithm, key.algorithm_name
776 ));
777 }
778 }
779 issues
780}
781
782fn normalize_owner(name: &Name) -> String {
785 name.to_ascii().trim_end_matches('.').to_ascii_lowercase()
786}
787
788fn is_self_or_ancestor(owner: &str, name: &str) -> bool {
791 !owner.is_empty()
792 && (owner == name
793 || name
794 .strip_suffix(owner)
795 .is_some_and(|prefix| prefix.ends_with('.')))
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 fqdn(name: &str) -> String {
810 if name.ends_with('.') {
811 name.to_string()
812 } else {
813 format!("{name}.")
814 }
815}
816
817fn algorithm_name(algo: u8) -> String {
822 match algo {
823 1 => "RSA/MD5 (deprecated)".to_string(),
824 3 => "DSA/SHA-1 (deprecated)".to_string(),
825 5 => "RSA/SHA-1 (deprecated)".to_string(),
826 6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
827 7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
828 8 => "RSA/SHA-256".to_string(),
829 10 => "RSA/SHA-512".to_string(),
830 12 => "ECC-GOST (deprecated)".to_string(),
831 13 => "ECDSA P-256/SHA-256".to_string(),
832 14 => "ECDSA P-384/SHA-384".to_string(),
833 15 => "Ed25519".to_string(),
834 16 => "Ed448".to_string(),
835 _ => format!("Unknown ({})", algo),
836 }
837}
838
839fn digest_type_name(dtype: u8) -> String {
840 match dtype {
841 1 => "SHA-1".to_string(),
842 2 => "SHA-256".to_string(),
843 4 => "SHA-384".to_string(),
844 _ => format!("Unknown ({})", dtype),
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 #[test]
853 fn test_algorithm_names() {
854 assert_eq!(algorithm_name(8), "RSA/SHA-256");
855 assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
856 assert_eq!(algorithm_name(15), "Ed25519");
857 assert!(algorithm_name(5).contains("deprecated"));
858 }
859
860 #[test]
861 fn test_digest_type_names() {
862 assert_eq!(digest_type_name(1), "SHA-1");
863 assert_eq!(digest_type_name(2), "SHA-256");
864 }
865
866 #[test]
867 fn rrsig_validity_classifies_windows() {
868 let day = 86_400i64;
869 let now = 1_000_000 * day; let (expired, days) = rrsig_validity(now - day, now + 30 * day, now);
873 assert!(!expired);
874 assert_eq!(days, 30);
875
876 let (expired, days) = rrsig_validity(now - 40 * day, now - day, now);
878 assert!(expired);
879 assert_eq!(days, -1);
880
881 let (expired, _days) = rrsig_validity(now + day, now + 30 * day, now);
883 assert!(expired);
884 }
885
886 #[test]
887 fn default_tier_is_digest_only() {
888 assert_eq!(
891 default_authentication_tier(),
892 AuthenticationTier::DigestOnly
893 );
894 let checker = DnssecChecker::new();
895 assert!(!checker.check_rrsig);
896 assert!(checker.with_rrsig_validation(true).check_rrsig);
897 }
898
899 #[test]
900 fn test_report_serialization() {
901 let report = DnssecReport {
902 domain: "example.com".to_string(),
903 enabled: true,
904 has_ds_records: true,
905 has_dnskey_records: true,
906 ds_records: vec![DsInfo {
907 key_tag: 12345,
908 algorithm: 13,
909 digest_type: 2,
910 digest: "ABCDEF".to_string(),
911 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
912 digest_type_name: "SHA-256".to_string(),
913 matched_key: true,
914 digest_verified: true,
915 }],
916 dnskey_records: vec![DnskeyInfo {
917 flags: 257,
918 protocol: 3,
919 algorithm: 13,
920 key_tag: 12345,
921 is_ksk: true,
922 is_zsk: false,
923 algorithm_name: "ECDSA P-256/SHA-256".to_string(),
924 }],
925 issues: vec![],
926 status: "signed".to_string(),
927 chain_valid: true,
928 authentication_tier: AuthenticationTier::DigestOnly,
929 rrsig_records: vec![],
930 };
931 let json = serde_json::to_string(&report).unwrap();
932 assert!(json.contains("\"enabled\":true"));
933 assert!(json.contains("\"chain_valid\":true"));
934 assert!(json.contains("\"matched_key\":true"));
935 assert!(json.contains("\"digest_verified\":true"));
936 assert!(json.contains("\"key_tag\":12345"));
937 }
938
939 fn ds(key_tag: u16, algorithm: u8, digest_type: u8, matched: bool, verified: bool) -> DsInfo {
940 DsInfo {
941 key_tag,
942 algorithm,
943 digest_type,
944 digest: "ABCDEF".to_string(),
945 algorithm_name: algorithm_name(algorithm),
946 digest_type_name: digest_type_name(digest_type),
947 matched_key: matched,
948 digest_verified: verified,
949 }
950 }
951
952 #[test]
959 fn chain_status_signed_when_every_computable_ds_verifies() {
960 let all = [ds(12345, 13, 2, true, true), ds(12345, 13, 4, true, true)];
961 assert_eq!(derive_chain_status(&all, true), (true, "signed"));
962 }
963
964 #[test]
965 fn chain_status_misconfigured_when_ds_unmatched() {
966 let unmatched = [ds(65000, 13, 2, false, false)];
967 assert_eq!(
968 derive_chain_status(&unmatched, true),
969 (false, "misconfigured")
970 );
971 }
972
973 #[test]
974 fn chain_status_misconfigured_on_digest_mismatch() {
975 let mismatch = [ds(12345, 13, 2, true, false)];
976 assert_eq!(
977 derive_chain_status(&mismatch, true),
978 (false, "misconfigured")
979 );
980 let mixed = [ds(1, 13, 2, true, true), ds(2, 13, 2, true, false)];
982 assert_eq!(derive_chain_status(&mixed, true), (false, "misconfigured"));
983 }
984
985 #[test]
986 fn chain_status_uncomputable_digest_is_excluded_but_not_sufficient() {
987 let gost_only = [ds(1, 13, 3, true, false)];
990 assert_eq!(
991 derive_chain_status(&gost_only, true),
992 (false, "misconfigured")
993 );
994 let with_sha256 = [ds(1, 13, 3, true, false), ds(1, 13, 2, true, true)];
995 assert_eq!(derive_chain_status(&with_sha256, true), (true, "signed"));
996 }
997
998 #[test]
999 fn chain_status_ds_without_dnskey_is_misconfigured() {
1000 let stale = [ds(12345, 13, 2, false, false)];
1005 assert_eq!(derive_chain_status(&stale, false), (false, "misconfigured"));
1006 }
1007
1008 #[test]
1009 fn chain_status_ds_validators_ignore_stays_partial() {
1010 let unusable = [ds(1, 1, 2, false, false), ds(2, 12, 3, false, false)];
1013 assert_eq!(derive_chain_status(&unusable, false), (false, "partial"));
1014 let unknown_digest = [ds(3, 13, 99, false, false)];
1015 assert_eq!(
1016 derive_chain_status(&unknown_digest, false),
1017 (false, "partial")
1018 );
1019 }
1020
1021 #[test]
1022 fn chain_status_island_and_unsigned() {
1023 assert_eq!(derive_chain_status(&[], true), (false, "partial"));
1024 assert_eq!(derive_chain_status(&[], false), (false, "unsigned"));
1025 }
1026
1027 #[test]
1028 fn resolver_opts_share_the_standard_set() {
1029 for validating in [false, true] {
1032 let mut opts = ResolverOpts::default();
1033 apply_dnssec_opts(&mut opts, validating);
1034 assert_eq!(
1035 opts.server_ordering_strategy,
1036 hickory_resolver::config::ServerOrderingStrategy::UserProvidedOrder
1037 );
1038 assert_eq!(opts.validate, validating);
1039 }
1040 }
1041
1042 #[test]
1043 fn deprecated_algorithms_include_7_and_12() {
1044 let key = |algorithm: u8| DnskeyInfo {
1047 flags: 257,
1048 protocol: 3,
1049 algorithm,
1050 key_tag: 1,
1051 is_ksk: true,
1052 is_zsk: false,
1053 algorithm_name: algorithm_name(algorithm),
1054 };
1055 let issues = deprecation_issues(
1056 &[
1057 ds(1, 7, 2, true, true),
1058 ds(2, 12, 2, true, true),
1059 ds(3, 13, 2, true, true),
1060 ],
1061 &[key(7), key(12), key(8)],
1062 );
1063 assert_eq!(
1064 issues
1065 .iter()
1066 .filter(|i| i.contains("deprecated algorithm"))
1067 .count(),
1068 4,
1069 "{issues:?}"
1070 );
1071 assert!(issues
1072 .iter()
1073 .any(|i| i.starts_with("DS") && i.contains("algorithm 7")));
1074 assert!(issues
1075 .iter()
1076 .any(|i| i.starts_with("DS") && i.contains("algorithm 12")));
1077 assert!(!issues.iter().any(|i| i.contains("algorithm 13")));
1078 assert!(!issues.iter().any(|i| i.contains("algorithm 8 ")));
1079 }
1080
1081 #[test]
1082 fn soa_apex_matches_self_or_ancestor_only() {
1083 assert!(is_self_or_ancestor("example.com", "example.com"));
1084 assert!(is_self_or_ancestor("example.com", "api.example.com"));
1085 assert!(!is_self_or_ancestor("ample.com", "api.example.com"));
1086 assert!(!is_self_or_ancestor("api.example.com", "example.com"));
1087
1088 let soa = |owner: &str| {
1089 Record::from_rdata(
1090 Name::from_ascii(owner).unwrap(),
1091 300,
1092 soa_rdata(owner.trim_end_matches('.')),
1093 )
1094 };
1095 let records = [soa("cdn-target.test."), soa("Example.COM.")];
1096 assert_eq!(
1097 soa_apex("api.example.com", records.iter()),
1098 Some("example.com".to_string()),
1099 "a CNAME target's SOA must be skipped; the enclosing one is found"
1100 );
1101 assert_eq!(soa_apex("api.other.test", records.iter()), None);
1102 }
1103
1104 use hickory_resolver::proto::dnssec::{Algorithm, PublicKeyBuf};
1107 use hickory_resolver::proto::rr::RecordType as WireType;
1108
1109 use crate::dns::test_support::{soa_rdata, spawn_mock_dns_fn, MockReply};
1110
1111 fn test_key() -> DNSKEY {
1114 DNSKEY::new(
1115 true,
1116 true,
1117 false,
1118 PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
1119 )
1120 }
1121
1122 fn ds_for(zone: &str, key: &DNSKEY) -> DS {
1124 let digest = key
1125 .to_digest(&Name::from_ascii(zone).unwrap(), DigestType::SHA256)
1126 .unwrap();
1127 DS::new(
1128 key.calculate_key_tag().unwrap(),
1129 Algorithm::ED25519,
1130 DigestType::SHA256,
1131 digest.as_ref().to_vec(),
1132 )
1133 }
1134
1135 fn dnskey_rdata(key: DNSKEY) -> RData {
1136 RData::DNSSEC(DNSSECRData::DNSKEY(key))
1137 }
1138
1139 fn ds_rdata(ds: DS) -> RData {
1140 RData::DNSSEC(DNSSECRData::DS(ds))
1141 }
1142
1143 fn mock_checker(port: u16) -> DnssecChecker {
1144 DnssecChecker::new().with_upstream("127.0.0.1".parse().unwrap(), port)
1145 }
1146
1147 #[tokio::test]
1151 async fn check_evaluates_the_enclosing_zone_of_a_non_apex_name() {
1152 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1153 ("signed.test", WireType::SOA) => MockReply::Answer(vec![soa_rdata("signed.test")]),
1154 ("signed.test", WireType::DS) => {
1155 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1156 }
1157 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1158 _ => MockReply::NoData,
1160 })
1161 .await;
1162
1163 let report = mock_checker(port).check("api.signed.test").await.unwrap();
1164 assert_eq!(report.domain, "api.signed.test");
1165 assert_eq!(report.status, "signed", "{report:?}");
1166 assert!(report.chain_valid);
1167 assert!(
1168 report.issues[0].contains("enclosing zone signed.test"),
1169 "the evaluated zone must be named: {:?}",
1170 report.issues
1171 );
1172 assert_eq!(report.issues.len(), 1, "{:?}", report.issues);
1173 let tag = test_key().calculate_key_tag().unwrap();
1175 assert_eq!(report.dnskey_records[0].key_tag, tag);
1176 assert!(report.ds_records[0].matched_key && report.ds_records[0].digest_verified);
1177 }
1178
1179 #[tokio::test]
1182 async fn check_uses_the_negative_answer_soa_to_find_the_apex() {
1183 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1184 ("deep.api.signed.test", WireType::SOA) => MockReply::NoDataWithSoa("signed.test"),
1185 ("api.signed.test", _) => MockReply::ServFail,
1186 ("signed.test", WireType::DS) => {
1187 MockReply::Answer(vec![ds_rdata(ds_for("signed.test", &test_key()))])
1188 }
1189 ("signed.test", WireType::DNSKEY) => MockReply::Answer(vec![dnskey_rdata(test_key())]),
1190 _ => MockReply::NoData,
1191 })
1192 .await;
1193
1194 let report = mock_checker(port)
1195 .check("deep.api.signed.test")
1196 .await
1197 .unwrap();
1198 assert_eq!(report.status, "signed", "{report:?}");
1199 assert!(report.issues[0].contains("enclosing zone signed.test"));
1200 }
1201
1202 #[tokio::test]
1208 async fn check_reports_stale_ds_with_servfailing_dnskey_as_misconfigured() {
1209 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1210 ("broken.test", WireType::DS) => {
1211 MockReply::Answer(vec![ds_rdata(ds_for("broken.test", &test_key()))])
1212 }
1213 ("broken.test", _) => MockReply::ServFail,
1214 _ => MockReply::NoData,
1215 })
1216 .await;
1217
1218 let report = mock_checker(port).check("broken.test").await.unwrap();
1219 assert!(report.has_ds_records);
1220 assert!(!report.has_dnskey_records);
1221 assert_eq!(report.status, "misconfigured", "{report:?}");
1222 assert!(!report.chain_valid);
1223 assert!(
1224 report
1225 .issues
1226 .iter()
1227 .any(|i| i.contains("DNSKEY query failed")),
1228 "{:?}",
1229 report.issues
1230 );
1231 assert!(
1232 !report.issues.iter().any(|i| i.contains("not a zone apex")),
1233 "{:?}",
1234 report.issues
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn check_reports_nxdomain_names_as_unsigned_without_walking() {
1240 let port = spawn_mock_dns_fn(|_, _| MockReply::NxDomain).await;
1241 let report = mock_checker(port).check("nope.signed.test").await.unwrap();
1242 assert_eq!(report.status, "unsigned");
1243 assert!(
1244 report.issues.iter().any(|i| i.contains("does not exist")),
1245 "{:?}",
1246 report.issues
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_cloudflare() {
1253 let checker = DnssecChecker::new();
1254 let report = checker.check("cloudflare.com").await.unwrap();
1255
1256 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1258 assert!(report.has_ds_records, "should have DS records");
1259 assert!(report.has_dnskey_records, "should have DNSKEY records");
1260 assert!(report.chain_valid, "cloudflare.com chain should be valid");
1261 assert_eq!(report.status, "signed");
1262
1263 for ds in &report.ds_records {
1265 assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
1266 assert!(
1267 ds.digest_verified,
1268 "DS key_tag={} digest should verify",
1269 ds.key_tag
1270 );
1271 }
1272
1273 for key in &report.dnskey_records {
1275 assert!(key.key_tag > 0, "key_tag should be computed");
1276 }
1277 }
1278
1279 #[tokio::test]
1280 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1281 async fn test_live_dnssec_rrsig_validation_cloudflare() {
1282 let checker = DnssecChecker::new().with_rrsig_validation(true);
1283 let report = checker.check("cloudflare.com").await.unwrap();
1284 assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
1285 if !report.rrsig_records.is_empty() {
1288 assert_eq!(report.authentication_tier, AuthenticationTier::RrsigChecked);
1289 for r in &report.rrsig_records {
1290 assert!(r.expiration.is_some(), "RRSIG should carry an expiration");
1291 assert!(
1292 !r.expired,
1293 "cloudflare.com RRSIG (covers {}) should be within its validity window",
1294 r.type_covered
1295 );
1296 }
1297 }
1298 }
1299
1300 #[tokio::test]
1301 #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
1302 async fn test_live_dnssec_check_insecure() {
1303 let checker = DnssecChecker::new();
1304 let report = checker.check("wikipedia.org").await.unwrap();
1306
1307 assert!(!report.chain_valid);
1308 assert_eq!(report.status, "unsigned");
1309 }
1310}