Skip to main content

seer_core/dns/
dnssec.rs

1//! DNSSEC validation reporting.
2//!
3//! Checks the DNSSEC chain for a domain by querying DS and DNSKEY records
4//! and reporting on the validation status.
5
6use std::collections::HashMap;
7
8use hickory_resolver::config::{ResolveHosts, ResolverConfig, GOOGLE};
9use hickory_resolver::net::runtime::TokioRuntimeProvider;
10use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, DNSKEY};
11use hickory_resolver::proto::dnssec::{DigestType, PublicKey};
12use hickory_resolver::proto::rr::{Name, RData, RecordType as HickoryRecordType};
13use hickory_resolver::TokioResolver;
14use serde::{Deserialize, Serialize};
15use tracing::{debug, instrument};
16
17use super::records::{RecordData, RecordType};
18use super::resolver::DnsResolver;
19use crate::error::Result;
20
21/// DNSSEC validation report for a domain.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DnssecReport {
24    /// The domain that was checked.
25    pub domain: String,
26    /// Whether the domain has DNSSEC enabled.
27    pub enabled: bool,
28    /// Whether DS records exist at the parent zone.
29    pub has_ds_records: bool,
30    /// Whether DNSKEY records exist at the domain.
31    pub has_dnskey_records: bool,
32    /// DS records found at the parent zone.
33    pub ds_records: Vec<DsInfo>,
34    /// DNSKEY records found at the domain.
35    pub dnskey_records: Vec<DnskeyInfo>,
36    /// Validation issues found.
37    pub issues: Vec<String>,
38    /// Overall status: "signed", "unsigned", "partial", or "misconfigured".
39    ///
40    /// IMPORTANT: this reflects DS↔DNSKEY *digest consistency* (RFC 4509)
41    /// observed over plain, unauthenticated DNS. It does NOT verify any RRSIG
42    /// signatures, signature validity periods, or a chain of trust to the root
43    /// anchor. "signed" therefore means "the published DS and DNSKEY are
44    /// digest-consistent", NOT "the records are cryptographically
45    /// authenticated" — an on-path or spoofing attacker can fabricate a
46    /// self-consistent DS+DNSKEY pair. Do not treat this as proof of
47    /// authenticity.
48    pub status: String,
49    /// Whether every DS record's digest matches a published DNSKEY (RFC 4509
50    /// digest consistency). This is NOT signature / chain-of-trust validation —
51    /// see the caveat on `status`.
52    pub chain_valid: bool,
53}
54
55/// Summary of a DS record.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DsInfo {
58    pub key_tag: u16,
59    pub algorithm: u8,
60    pub digest_type: u8,
61    pub digest: String,
62    pub algorithm_name: String,
63    pub digest_type_name: String,
64    /// Whether this DS record's key_tag+algorithm matched a DNSKEY.
65    pub matched_key: bool,
66    /// Whether the computed digest from the matched DNSKEY equals this DS digest.
67    pub digest_verified: bool,
68}
69
70/// Summary of a DNSKEY record.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DnskeyInfo {
73    pub flags: u16,
74    pub protocol: u8,
75    pub algorithm: u8,
76    /// The RFC 4034 computed key tag.
77    pub key_tag: u16,
78    pub is_ksk: bool,
79    pub is_zsk: bool,
80    pub algorithm_name: String,
81}
82
83/// Checks DNSSEC configuration for a domain.
84pub struct DnssecChecker {
85    resolver: DnsResolver,
86    raw_resolver: TokioResolver,
87}
88
89impl Default for DnssecChecker {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl DnssecChecker {
96    pub fn new() -> Self {
97        let mut builder = TokioResolver::builder_with_config(
98            ResolverConfig::udp_and_tcp(&GOOGLE),
99            TokioRuntimeProvider::default(),
100        );
101        {
102            let opts = builder.options_mut();
103            opts.timeout = std::time::Duration::from_secs(5);
104            opts.attempts = 2;
105            opts.use_hosts_file = ResolveHosts::Never;
106        }
107        let raw_resolver = builder
108            .build()
109            .expect("hickory resolver build is infallible without TLS features");
110
111        Self {
112            resolver: DnsResolver::new(),
113            raw_resolver,
114        }
115    }
116
117    /// Resolves raw hickory DNSKEY records for crypto operations.
118    /// Returns a vec of (DNSKEY, computed_key_tag) pairs.
119    async fn resolve_raw_dnskeys(&self, domain: &str) -> Vec<(DNSKEY, u16)> {
120        let Ok(lookup) = self
121            .raw_resolver
122            .lookup(domain, HickoryRecordType::DNSKEY)
123            .await
124        else {
125            return vec![];
126        };
127
128        lookup
129            .answers()
130            .iter()
131            .filter_map(|record| {
132                if let RData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
133                    match dnskey.calculate_key_tag() {
134                        Ok(tag) => Some((dnskey.clone(), tag)),
135                        Err(_) => None,
136                    }
137                } else {
138                    None
139                }
140            })
141            .collect()
142    }
143
144    /// Converts a DS digest type number to hickory's DigestType.
145    ///
146    /// hickory 0.26 made `DigestType` `non_exhaustive` and removed the
147    /// fallible `from_u8` constructor in favour of `From<u8>`, which
148    /// returns `DigestType::Unknown(_)` for unsupported types. We
149    /// preserve the original 0.24 behaviour of refusing to attempt
150    /// digest computation for unsupported types.
151    fn to_hickory_digest_type(digest_type: u8) -> Option<DigestType> {
152        let dt = DigestType::from(digest_type);
153        if dt.is_supported() {
154            Some(dt)
155        } else {
156            None
157        }
158    }
159
160    /// Generate a DNSSEC validation report for a domain.
161    #[instrument(skip(self), fields(domain = %domain))]
162    pub async fn check(&self, domain: &str) -> Result<DnssecReport> {
163        let domain = crate::validation::normalize_domain(domain)?;
164        debug!(domain = %domain, "Checking DNSSEC");
165
166        let mut issues = Vec::new();
167
168        // Query DS records (at parent zone)
169        let ds_records: Vec<crate::dns::DnsRecord> =
170            match self.resolver.resolve(&domain, RecordType::DS, None).await {
171                Ok(records) => records,
172                Err(e) => {
173                    issues.push(format!("DS query failed: {}", e));
174                    vec![]
175                }
176            };
177
178        // Query DNSKEY records (at the domain itself)
179        let dnskey_records: Vec<crate::dns::DnsRecord> = match self
180            .resolver
181            .resolve(&domain, RecordType::DNSKEY, None)
182            .await
183        {
184            Ok(records) => records,
185            Err(e) => {
186                issues.push(format!("DNSKEY query failed: {}", e));
187                vec![]
188            }
189        };
190
191        let has_ds = !ds_records.is_empty();
192        let has_dnskey = !dnskey_records.is_empty();
193
194        // Resolve raw hickory DNSKEYs for crypto operations
195        let raw_dnskeys = self.resolve_raw_dnskeys(&domain).await;
196
197        // Build lookup map: (key_tag, algorithm) -> vec of raw DNSKEYs
198        // Multiple DNSKEYs can share the same key tag (RFC 4034 Section 5.1).
199        let dnskey_map: HashMap<(u16, u8), Vec<&DNSKEY>> = {
200            let mut map: HashMap<(u16, u8), Vec<&DNSKEY>> = HashMap::new();
201            for (dnskey, tag) in &raw_dnskeys {
202                map.entry((*tag, u8::from(dnskey.public_key().algorithm())))
203                    .or_default()
204                    .push(dnskey);
205            }
206            map
207        };
208
209        // Build set of DS key_tags for KSK orphan detection
210        let ds_key_tags: std::collections::HashSet<u16> = ds_records
211            .iter()
212            .filter_map(|r| {
213                if let RecordData::DS { key_tag, .. } = r.data {
214                    Some(key_tag)
215                } else {
216                    None
217                }
218            })
219            .collect();
220
221        // Build a map from (flags, algorithm) -> computed key_tags to attach a
222        // key tag to each RecordData DNSKEY. NOTE: this correlates two
223        // independent DNSKEY queries (dnskey_records vs raw_dnskeys) by
224        // position within each (flags, algorithm) group. That is correct when a
225        // group has a single key (the common case) but can mis-assign tags if a
226        // zone has >= 2 keys sharing identical flags+algorithm AND the two
227        // queries returned them in different orders. Removing this assumption
228        // requires a single DNSKEY query that carries both the display fields
229        // and the computed tag; deferred to keep this crypto path stable.
230        let key_tag_by_algo_flags: HashMap<(u16, u8), Vec<u16>> = {
231            let mut map: HashMap<(u16, u8), Vec<u16>> = HashMap::new();
232            for (dnskey, tag) in &raw_dnskeys {
233                map.entry((dnskey.flags(), u8::from(dnskey.public_key().algorithm())))
234                    .or_default()
235                    .push(*tag);
236            }
237            map
238        };
239
240        // Parse DNSKEY record info with computed key tags.
241        //
242        // NOTE (#61): the per-DNSKEY key_tag is attached by position-correlating
243        // two separate query result sets (DNSKEY records vs the computed-tag
244        // list). When ≥2 keys share the same (flags, algorithm), an ordering
245        // difference between those result sets can mislabel which tag belongs to
246        // which key. This affects only the DISPLAY tag and the KSK-orphan
247        // heuristic — `chain_valid` is computed from DS↔DNSKEY digest matching
248        // (below) and is unaffected.
249        let mut dnskey_tag_indices: HashMap<(u16, u8), usize> = HashMap::new();
250        let dnskey_info: Vec<DnskeyInfo> = dnskey_records
251            .iter()
252            .filter_map(|r| {
253                if let RecordData::DNSKEY {
254                    flags,
255                    protocol,
256                    algorithm,
257                    ..
258                } = r.data
259                {
260                    let is_sep = flags & 0x0001 != 0;
261                    let is_zone = flags & 0x0100 != 0;
262                    let is_ksk = is_sep && is_zone;
263                    let is_zsk = is_zone && !is_sep;
264
265                    // Find the computed key tag for this DNSKEY
266                    let idx = dnskey_tag_indices.entry((flags, algorithm)).or_insert(0);
267                    let key_tag = key_tag_by_algo_flags
268                        .get(&(flags, algorithm))
269                        .and_then(|tags| tags.get(*idx))
270                        .copied()
271                        .unwrap_or(0);
272                    *idx += 1;
273
274                    Some(DnskeyInfo {
275                        flags,
276                        protocol,
277                        algorithm,
278                        key_tag,
279                        is_ksk,
280                        is_zsk,
281                        algorithm_name: algorithm_name(algorithm),
282                    })
283                } else {
284                    None
285                }
286            })
287            .collect();
288
289        // Build Name for digest computation
290        let domain_name = Name::from_ascii(&domain).unwrap_or_else(|_| {
291            Name::from_ascii("invalid.").expect("hardcoded fallback name is valid")
292        });
293
294        // Parse DS record info with cross-validation
295        let ds_info: Vec<DsInfo> = ds_records
296            .iter()
297            .map(|r| {
298                if let RecordData::DS {
299                    key_tag,
300                    algorithm,
301                    digest_type,
302                    ref digest,
303                } = r.data
304                {
305                    let mut matched_key = false;
306                    let mut digest_verified = false;
307
308                    // Try to match this DS to a DNSKEY (multiple candidates possible
309                    // due to key tag collisions per RFC 4034 Section 5.1)
310                    if let Some(candidates) = dnskey_map.get(&(key_tag, algorithm)) {
311                        matched_key = true;
312
313                        // Try each candidate DNSKEY until one verifies
314                        if let Some(hickory_dt) = Self::to_hickory_digest_type(digest_type) {
315                            for candidate in candidates {
316                                if let Ok(computed) =
317                                    candidate.to_digest(&domain_name, hickory_dt)
318                                {
319                                    let computed_hex: String = computed
320                                        .as_ref()
321                                        .iter()
322                                        .map(|b| format!("{:02X}", b))
323                                        .collect();
324                                    if computed_hex.eq_ignore_ascii_case(digest) {
325                                        digest_verified = true;
326                                        break;
327                                    }
328                                }
329                            }
330                        }
331
332                        if !digest_verified {
333                            issues.push(format!(
334                                "DS record (key_tag={}) digest mismatch \u{2014} registry and DNS keys do not match",
335                                key_tag
336                            ));
337                        }
338                    } else if has_dnskey {
339                        issues.push(format!(
340                            "DS record (key_tag={}) has no matching DNSKEY",
341                            key_tag
342                        ));
343                    }
344
345                    DsInfo {
346                        key_tag,
347                        algorithm,
348                        digest_type,
349                        digest: digest.clone(),
350                        algorithm_name: algorithm_name(algorithm),
351                        digest_type_name: digest_type_name(digest_type),
352                        matched_key,
353                        digest_verified,
354                    }
355                } else {
356                    // Should not happen — we only have DS records here
357                    DsInfo {
358                        key_tag: 0,
359                        algorithm: 0,
360                        digest_type: 0,
361                        digest: String::new(),
362                        algorithm_name: String::new(),
363                        digest_type_name: String::new(),
364                        matched_key: false,
365                        digest_verified: false,
366                    }
367                }
368            })
369            .collect();
370
371        // Check for KSK orphans (DNSKEY KSKs with no corresponding DS)
372        for key in &dnskey_info {
373            if key.is_ksk && !ds_key_tags.contains(&key.key_tag) {
374                issues.push(format!(
375                    "DNSKEY (key_tag={}) is a KSK with no corresponding DS record",
376                    key.key_tag
377                ));
378            }
379        }
380
381        // Check for deprecated algorithms in DS records
382        for ds in &ds_info {
383            if ds.algorithm == 1 || ds.algorithm == 3 || ds.algorithm == 5 || ds.algorithm == 6 {
384                issues.push(format!(
385                    "DS record uses deprecated algorithm {} ({})",
386                    ds.algorithm, ds.algorithm_name
387                ));
388            }
389            if ds.digest_type == 1 {
390                issues.push(
391                    "DS record uses SHA-1 digest (type 1) - consider upgrading to SHA-256 (type 2)"
392                        .to_string(),
393                );
394            }
395        }
396
397        // Check for deprecated algorithms in DNSKEY records
398        for key in &dnskey_info {
399            if key.algorithm == 1 || key.algorithm == 3 || key.algorithm == 5 || key.algorithm == 6
400            {
401                issues.push(format!(
402                    "DNSKEY record uses deprecated algorithm {} ({})",
403                    key.algorithm, key.algorithm_name
404                ));
405            }
406        }
407
408        // Derive chain_valid
409        let chain_valid = has_ds
410            && has_dnskey
411            && !ds_info.is_empty()
412            && ds_info
413                .iter()
414                .all(|ds| ds.matched_key && ds.digest_verified);
415
416        // Derive status from chain validity (not from issues list).
417        //
418        // Vocabulary deliberately avoids "secure"/"insecure": we verify only
419        // DS<->DNSKEY digest consistency, NOT RRSIG signatures, so we report
420        // the observable FACT (the zone is signed / unsigned) rather than a
421        // validated security state. See the `status` field doc on
422        // DnssecReport.
423        let enabled = has_ds || has_dnskey;
424        let status = if has_ds && has_dnskey {
425            if chain_valid {
426                "signed".to_string()
427            } else {
428                "misconfigured".to_string()
429            }
430        } else if !has_ds && !has_dnskey {
431            "unsigned".to_string()
432        } else {
433            "partial".to_string()
434        };
435
436        // Also flag the old structural issues
437        if has_ds && !has_dnskey {
438            issues.push(
439                "DS records exist but no DNSKEY records found - DNSSEC may be broken".to_string(),
440            );
441        }
442        if !has_ds && has_dnskey {
443            issues.push(
444                "DNSKEY records exist but no DS records at parent - DNSSEC chain incomplete"
445                    .to_string(),
446            );
447        }
448
449        Ok(DnssecReport {
450            domain,
451            enabled,
452            has_ds_records: has_ds,
453            has_dnskey_records: has_dnskey,
454            ds_records: ds_info,
455            dnskey_records: dnskey_info,
456            issues,
457            status,
458            chain_valid,
459        })
460    }
461}
462
463/// Maps a DNSSEC algorithm number to a human-readable name. Numbers come
464/// from the IANA "DNSSEC Algorithm Numbers" registry. Algorithm 9 is
465/// reserved (not assigned). 7 and 12 are operationally discouraged per
466/// RFC 8624; we flag both as deprecated.
467fn algorithm_name(algo: u8) -> String {
468    match algo {
469        1 => "RSA/MD5 (deprecated)".to_string(),
470        3 => "DSA/SHA-1 (deprecated)".to_string(),
471        5 => "RSA/SHA-1 (deprecated)".to_string(),
472        6 => "DSA-NSEC3-SHA1 (deprecated)".to_string(),
473        7 => "RSASHA1-NSEC3-SHA1 (deprecated)".to_string(),
474        8 => "RSA/SHA-256".to_string(),
475        10 => "RSA/SHA-512".to_string(),
476        12 => "ECC-GOST (deprecated)".to_string(),
477        13 => "ECDSA P-256/SHA-256".to_string(),
478        14 => "ECDSA P-384/SHA-384".to_string(),
479        15 => "Ed25519".to_string(),
480        16 => "Ed448".to_string(),
481        _ => format!("Unknown ({})", algo),
482    }
483}
484
485fn digest_type_name(dtype: u8) -> String {
486    match dtype {
487        1 => "SHA-1".to_string(),
488        2 => "SHA-256".to_string(),
489        4 => "SHA-384".to_string(),
490        _ => format!("Unknown ({})", dtype),
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_algorithm_names() {
500        assert_eq!(algorithm_name(8), "RSA/SHA-256");
501        assert_eq!(algorithm_name(13), "ECDSA P-256/SHA-256");
502        assert_eq!(algorithm_name(15), "Ed25519");
503        assert!(algorithm_name(5).contains("deprecated"));
504    }
505
506    #[test]
507    fn test_digest_type_names() {
508        assert_eq!(digest_type_name(1), "SHA-1");
509        assert_eq!(digest_type_name(2), "SHA-256");
510    }
511
512    #[test]
513    fn test_report_serialization() {
514        let report = DnssecReport {
515            domain: "example.com".to_string(),
516            enabled: true,
517            has_ds_records: true,
518            has_dnskey_records: true,
519            ds_records: vec![DsInfo {
520                key_tag: 12345,
521                algorithm: 13,
522                digest_type: 2,
523                digest: "ABCDEF".to_string(),
524                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
525                digest_type_name: "SHA-256".to_string(),
526                matched_key: true,
527                digest_verified: true,
528            }],
529            dnskey_records: vec![DnskeyInfo {
530                flags: 257,
531                protocol: 3,
532                algorithm: 13,
533                key_tag: 12345,
534                is_ksk: true,
535                is_zsk: false,
536                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
537            }],
538            issues: vec![],
539            status: "signed".to_string(),
540            chain_valid: true,
541        };
542        let json = serde_json::to_string(&report).unwrap();
543        assert!(json.contains("\"enabled\":true"));
544        assert!(json.contains("\"chain_valid\":true"));
545        assert!(json.contains("\"matched_key\":true"));
546        assert!(json.contains("\"digest_verified\":true"));
547        assert!(json.contains("\"key_tag\":12345"));
548    }
549
550    #[test]
551    fn test_chain_valid_all_verified() {
552        let report = DnssecReport {
553            domain: "example.com".to_string(),
554            enabled: true,
555            has_ds_records: true,
556            has_dnskey_records: true,
557            ds_records: vec![
558                DsInfo {
559                    key_tag: 12345,
560                    algorithm: 13,
561                    digest_type: 2,
562                    digest: "ABCDEF".to_string(),
563                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
564                    digest_type_name: "SHA-256".to_string(),
565                    matched_key: true,
566                    digest_verified: true,
567                },
568                DsInfo {
569                    key_tag: 12345,
570                    algorithm: 13,
571                    digest_type: 4,
572                    digest: "FEDCBA".to_string(),
573                    algorithm_name: "ECDSA P-256/SHA-256".to_string(),
574                    digest_type_name: "SHA-384".to_string(),
575                    matched_key: true,
576                    digest_verified: true,
577                },
578            ],
579            dnskey_records: vec![DnskeyInfo {
580                flags: 257,
581                protocol: 3,
582                algorithm: 13,
583                key_tag: 12345,
584                is_ksk: true,
585                is_zsk: false,
586                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
587            }],
588            issues: vec![],
589            status: "signed".to_string(),
590            chain_valid: true,
591        };
592        assert!(report.chain_valid);
593        assert_eq!(report.status, "signed");
594    }
595
596    #[test]
597    fn test_chain_valid_ds_unmatched() {
598        let report = DnssecReport {
599            domain: "broken.com".to_string(),
600            enabled: true,
601            has_ds_records: true,
602            has_dnskey_records: true,
603            ds_records: vec![DsInfo {
604                key_tag: 65000,
605                algorithm: 13,
606                digest_type: 2,
607                digest: "ABCDEF".to_string(),
608                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
609                digest_type_name: "SHA-256".to_string(),
610                matched_key: false,
611                digest_verified: false,
612            }],
613            dnskey_records: vec![DnskeyInfo {
614                flags: 257,
615                protocol: 3,
616                algorithm: 13,
617                key_tag: 12345,
618                is_ksk: true,
619                is_zsk: false,
620                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
621            }],
622            issues: vec!["DS record (key_tag=65000) has no matching DNSKEY".to_string()],
623            status: "misconfigured".to_string(),
624            chain_valid: false,
625        };
626        assert!(!report.chain_valid);
627        assert_eq!(report.status, "misconfigured");
628    }
629
630    #[test]
631    fn test_chain_valid_digest_mismatch() {
632        let report = DnssecReport {
633            domain: "mismatch.com".to_string(),
634            enabled: true,
635            has_ds_records: true,
636            has_dnskey_records: true,
637            ds_records: vec![DsInfo {
638                key_tag: 12345,
639                algorithm: 13,
640                digest_type: 2,
641                digest: "WRONG".to_string(),
642                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
643                digest_type_name: "SHA-256".to_string(),
644                matched_key: true,
645                digest_verified: false,
646            }],
647            dnskey_records: vec![DnskeyInfo {
648                flags: 257,
649                protocol: 3,
650                algorithm: 13,
651                key_tag: 12345,
652                is_ksk: true,
653                is_zsk: false,
654                algorithm_name: "ECDSA P-256/SHA-256".to_string(),
655            }],
656            issues: vec![],
657            status: "misconfigured".to_string(),
658            chain_valid: false,
659        };
660        assert!(!report.chain_valid);
661        assert!(report.ds_records[0].matched_key);
662        assert!(!report.ds_records[0].digest_verified);
663    }
664
665    #[tokio::test]
666    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
667    async fn test_live_dnssec_check_cloudflare() {
668        let checker = DnssecChecker::new();
669        let report = checker.check("cloudflare.com").await.unwrap();
670
671        // cloudflare.com has DNSSEC enabled
672        assert!(report.enabled, "cloudflare.com should have DNSSEC enabled");
673        assert!(report.has_ds_records, "should have DS records");
674        assert!(report.has_dnskey_records, "should have DNSKEY records");
675        assert!(report.chain_valid, "cloudflare.com chain should be valid");
676        assert_eq!(report.status, "signed");
677
678        // All DS records should be verified
679        for ds in &report.ds_records {
680            assert!(ds.matched_key, "DS key_tag={} should match", ds.key_tag);
681            assert!(
682                ds.digest_verified,
683                "DS key_tag={} digest should verify",
684                ds.key_tag
685            );
686        }
687
688        // Should have computed key tags on DNSKEYs
689        for key in &report.dnskey_records {
690            assert!(key.key_tag > 0, "key_tag should be computed");
691        }
692    }
693
694    #[tokio::test]
695    #[ignore = "live network; run with --ignored or SEER_LIVE_TESTS=1"]
696    async fn test_live_dnssec_check_insecure() {
697        let checker = DnssecChecker::new();
698        // wikipedia.org does not have DNSSEC (no DS or DNSKEY records)
699        let report = checker.check("wikipedia.org").await.unwrap();
700
701        assert!(!report.chain_valid);
702        assert_eq!(report.status, "unsigned");
703    }
704}