Skip to main content

seer_core/
caa.rs

1//! CAA (Certification Authority Authorization) lookup and policy comparison.
2//!
3//! CAA records (RFC 8659) let domain owners declare which Certificate
4//! Authorities may issue certificates for the domain. They are consulted
5//! by CAs at issuance time only; they are *not* part of certificate
6//! validation. A presented certificate whose issuer is not in the current
7//! CAA policy is therefore not necessarily invalid — it may have been
8//! issued before the policy was updated, or via a parent zone. See
9//! [`ISSUANCE_TIME_NOTE`].
10
11use serde::{Deserialize, Serialize};
12
13use crate::dns::{DnsResolver, RecordData, RecordType};
14use crate::error::Result;
15use crate::validation::normalize_host;
16
17/// Informational note surfaced alongside every CAA report.
18///
19/// Explains why an issuer/CAA mismatch is not the same as an invalid cert.
20pub const ISSUANCE_TIME_NOTE: &str = "CAA is checked by CAs at issuance time, not by \
21clients at validation time. A cert whose issuer is not in the current CAA policy is \
22not invalid — it may have been issued before the policy was set, or under a parent \
23zone. Treat mismatches as informational.";
24
25/// A single CAA resource record.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CaaRecord {
28    /// CAA flags (only `issuer_critical` = 128 is defined).
29    pub flags: u8,
30    /// Property tag (e.g., `issue`, `issuewild`, `iodef`).
31    pub tag: String,
32    /// Property value (e.g., `letsencrypt.org` or a URI for `iodef`).
33    pub value: String,
34}
35
36impl CaaRecord {
37    /// The lowercased CA-domain part of an `issue`/`issuewild` value — RFC 8659
38    /// §4.2 values are `<CA domain> [; <parameters>]`. Empty for a bare `";"`,
39    /// which means "forbid all".
40    fn ca_domain(&self) -> String {
41        self.value
42            .split(';')
43            .next()
44            .unwrap_or(&self.value)
45            .trim()
46            .to_ascii_lowercase()
47    }
48}
49
50/// Result of how a presented cert's issuer relates to the CAA policy.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "lowercase")]
53pub enum IssuerCaaMatch {
54    /// No CAA records exist (any CA may issue per default).
55    NoPolicy,
56    /// CAA records exist and at least one value of the tag governing the
57    /// certificate (`issue`, or `issuewild` for a wildcard — RFC 8659 §4.3)
58    /// plausibly matches the presented issuer.
59    Permitted,
60    /// CAA records exist but none of the allowed CAs appear to match the
61    /// presented issuer. Informational, not a validation failure.
62    Mismatch,
63    /// CAA records exist but none governs the certificate (only `iodef` /
64    /// unknown tags, or only `issuewild` for a non-wildcard certificate) — no
65    /// authoritative answer about issuance.
66    Indeterminate,
67}
68
69/// CAA policy collected for a domain, plus the informational note that
70/// callers should surface to users.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CaaPolicy {
73    /// Records discovered (may be empty if no policy is set).
74    pub records: Vec<CaaRecord>,
75    /// Domain at which the records were found. Per RFC 8659 the resolver
76    /// climbs the tree until a CAA RRset is encountered, so this may be a
77    /// parent of the queried name.
78    pub effective_domain: Option<String>,
79    /// True iff at least one CAA record was found in the tree-walk.
80    pub has_policy: bool,
81    /// Result of comparing a presented cert's issuer against the policy.
82    /// `None` if no cert was supplied for comparison.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub issuer_match: Option<IssuerCaaMatch>,
85    /// `iodef` incident-reporting contacts declared in the policy (RFC 8659
86    /// §4.4) — a takedown/abuse channel available nowhere else in seer.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub iodef: Vec<String>,
89    /// A note when the wildcard issuance policy (`issuewild`) is *broader* than
90    /// the base (`issue`) policy — i.e. a CA may issue wildcard certs it is not
91    /// permitted to issue named certs for. `None` when the policies are
92    /// consistent, or `issuewild`/`issue` is absent (RFC 8659 §4.3: absent
93    /// `issuewild` inherits `issue`, which is NOT a gap).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub wildcard_note: Option<String>,
96    /// True when no policy was found AND at least one level of the tree walk
97    /// failed to resolve — the absence is then unknown, not established.
98    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99    pub lookup_failed: bool,
100    /// Informational note about CAA semantics. Always populated.
101    pub note: String,
102}
103
104impl CaaPolicy {
105    /// Empty policy — used when no CAA records were found anywhere in the tree.
106    pub fn empty() -> Self {
107        Self {
108            records: Vec::new(),
109            effective_domain: None,
110            has_policy: false,
111            issuer_match: None,
112            iodef: Vec::new(),
113            wildcard_note: None,
114            lookup_failed: false,
115            note: ISSUANCE_TIME_NOTE.to_string(),
116        }
117    }
118
119    /// Builds a populated policy from discovered records, deriving the `iodef`
120    /// contacts and the wildcard-vs-base consistency note.
121    fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
122        let iodef = records
123            .iter()
124            .filter(|r| r.tag == "iodef")
125            .map(|r| r.value.clone())
126            .filter(|v| !v.is_empty())
127            .collect();
128        let wildcard_note = analyze_wildcard(&records);
129        Self {
130            has_policy: true,
131            records,
132            effective_domain: Some(effective_domain),
133            issuer_match: None,
134            iodef,
135            wildcard_note,
136            lookup_failed: false,
137            note: ISSUANCE_TIME_NOTE.to_string(),
138        }
139    }
140}
141
142/// The non-empty CA-domain values permitted by a given tag (`issue` /
143/// `issuewild`). An empty value (from a bare `";"`) means "forbid all" and is
144/// filtered out here so callers reason only about explicitly-permitted CAs.
145fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
146    records
147        .iter()
148        .filter(|r| r.tag == tag)
149        .map(CaaRecord::ca_domain)
150        .filter(|v| !v.is_empty())
151        .collect()
152}
153
154/// Detects the genuine wildcard-policy asymmetry: when both `issue` and
155/// `issuewild` are present and `issuewild` permits a CA that `issue` does not,
156/// wildcard issuance is broader than named issuance. Returns a describing note,
157/// or `None` when the policies are consistent.
158///
159/// Note the RFC 8659 §4.3 subtlety: an *absent* `issuewild` inherits the
160/// `issue` policy, so absence is NOT a gap and is deliberately not flagged.
161fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
162    let has_issue = records.iter().any(|r| r.tag == "issue");
163    let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
164    if !has_issue || !has_issuewild {
165        return None;
166    }
167    let issue = permitted_cas(records, "issue");
168    let issuewild = permitted_cas(records, "issuewild");
169    let looser: Vec<String> = issuewild
170        .iter()
171        .filter(|w| !issue.iter().any(|i| i == *w))
172        .cloned()
173        .collect();
174    if looser.is_empty() {
175        None
176    } else {
177        Some(format!(
178            "issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
179            looser.join(", ")
180        ))
181    }
182}
183
184/// Looks up CAA records for `host`, climbing the DNS tree per RFC 8659
185/// section 3 until a record set is found or only a TLD remains.
186///
187/// `host` is normalized here with [`normalize_host`]: CAA is a per-name
188/// policy, so `www.` is kept (it can carry its own records). `nameserver`
189/// is an optional nameserver spec, as for [`DnsResolver::resolve`].
190///
191/// Errors only on invalid input. A resolver error yields
192/// [`CaaPolicy::empty`] with `lookup_failed` set — CAA is advisory, so a CAA
193/// query that did not return never fails a higher-level check.
194pub async fn lookup_caa(
195    resolver: &DnsResolver,
196    host: &str,
197    nameserver: Option<&str>,
198) -> Result<CaaPolicy> {
199    let mut current = normalize_host(host)?;
200    let mut failed = false;
201
202    loop {
203        match resolver
204            .resolve(&current, RecordType::CAA, nameserver)
205            .await
206        {
207            Ok(records) if !records.is_empty() => {
208                let caa: Vec<CaaRecord> = records
209                    .into_iter()
210                    .filter_map(|r| match r.data {
211                        RecordData::CAA { flags, tag, value } => Some(CaaRecord {
212                            flags,
213                            tag: tag.to_ascii_lowercase(),
214                            value,
215                        }),
216                        _ => None,
217                    })
218                    .collect();
219
220                if !caa.is_empty() {
221                    return Ok(CaaPolicy::from_records(caa, current));
222                }
223            }
224            Ok(_) => {}
225            Err(_) => failed = true,
226        }
227
228        // Strip the leftmost label. Stop when only one label (TLD) remains.
229        match current.split_once('.') {
230            Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
231            _ => {
232                return Ok(CaaPolicy {
233                    lookup_failed: failed,
234                    ..CaaPolicy::empty()
235                })
236            }
237        }
238    }
239}
240
241/// Compares a presented certificate's issuer string against a CAA policy
242/// and returns a classification. Pure function — no I/O.
243///
244/// `wildcard` says whether the certificate names a wildcard
245/// (`*.example.com`). Per RFC 8659 §4.3, `issuewild` governs wildcard
246/// issuance — falling back to `issue` when the policy has no `issuewild` —
247/// and is ignored for every other name, which `issue` alone governs.
248pub fn classify_issuer(issuer: &str, policy: &CaaPolicy, wildcard: bool) -> IssuerCaaMatch {
249    if !policy.has_policy {
250        return IssuerCaaMatch::NoPolicy;
251    }
252
253    // RFC 8659 §4.1: if any CAA record has the Issuer Critical flag (bit 7,
254    // 0x80) set AND its tag is unknown to us, the spec mandates that
255    // issuance be treated as forbidden — we cannot honor a critical
256    // property we don't understand. Surface that as `Mismatch` so callers
257    // see a non-permitted verdict.
258    const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
259    let critical_unknown = policy
260        .records
261        .iter()
262        .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
263    if critical_unknown {
264        return IssuerCaaMatch::Mismatch;
265    }
266
267    let has_issuewild = policy.records.iter().any(|r| r.tag == "issuewild");
268    let governing = if wildcard && has_issuewild {
269        "issuewild"
270    } else {
271        "issue"
272    };
273    let issue_values: Vec<String> = policy
274        .records
275        .iter()
276        .filter(|r| r.tag == governing)
277        .map(CaaRecord::ca_domain)
278        .collect();
279
280    // Empty values are kept up to here: a policy of only `";"` entries has
281    // issuance tags (so it is not Indeterminate) yet forbids every CA.
282    if issue_values.is_empty() {
283        return IssuerCaaMatch::Indeterminate;
284    }
285
286    let issuer_lc = issuer.to_ascii_lowercase();
287    if issue_values
288        .iter()
289        .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc))
290    {
291        IssuerCaaMatch::Permitted
292    } else {
293        // Includes the forbid-all case: issuance is explicitly forbidden, yet a
294        // cert exists. Either way it is informational, not a validation failure.
295        IssuerCaaMatch::Mismatch
296    }
297}
298
299/// Best-effort comparison between a CAA `issue` value (a CA's domain) and a
300/// certificate issuer string (typically a CN/O like "Let's Encrypt").
301///
302/// CAA values are short reverse-DNS-ish labels; issuer strings vary by CA.
303/// We use a small alias table for the common public CAs and fall back to a
304/// direct substring check.
305fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
306    // 1. The CAA value appears verbatim in the issuer (e.g. "ssl.com"), but
307    //    only when it is long enough AND lands on a word boundary. A
308    //    pathologically short value ("ca", "ssl") would otherwise blanket-match
309    //    any issuer that merely contains those letters — the same over-match the
310    //    base-label fallback guards against (issue #56). Length is gated by
311    //    MIN_FALLBACK_BASE_LEN so the verbatim and base paths share one bar.
312    if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
313        return true;
314    }
315    // 2. Curated aliases for well-known CAs — preferred over the generic base
316    //    fallback so a precise mapping wins (e.g. "ssl.com" -> "ssl.com",
317    //    never bare "ssl"). Matched as whole words like everything else here:
318    //    a plain substring test let "entrust" match inside "IdenTrust".
319    for (cv, aliases) in CA_ALIASES {
320        if caa_value == *cv && aliases.iter().any(|a| contains_word(issuer_lc, a)) {
321            return true;
322        }
323    }
324    // 3. Generic fallback for CAs not in the alias table: the base label (the
325    //    CAA value minus its trailing TLD) appears in the issuer AS A WHOLE
326    //    WORD. Guarded by a minimum length so a short, ambiguous base — "ssl"
327    //    from "ssl.com" — can't collide with unrelated issuer text like
328    //    "...SSL CA..." and report a genuine mismatch as Permitted (issue #56).
329    let base = caa_value
330        .rsplit_once('.')
331        .map(|(b, _)| b)
332        .unwrap_or(caa_value);
333    base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
334}
335
336/// Minimum base-label length for the generic substring fallback in
337/// [`ca_value_matches_issuer`]. Shorter bases (e.g. "ssl", "ca", "pki") are too
338/// ambiguous to match by substring and must go through the curated alias table
339/// or a verbatim value match instead.
340const MIN_FALLBACK_BASE_LEN: usize = 6;
341
342/// Returns true if `needle` occurs in `haystack` as a whole word — i.e. bounded
343/// by string start/end or a non-alphanumeric character on each side — so a base
344/// like "examplecorp" matches "ExampleCorp Root" but not "NotExamplecorporated".
345/// Both arguments are expected lowercase.
346///
347/// Both strings come from untrusted data (a CAA record and a certificate
348/// issuer), so this must be panic-free on any UTF-8: after a rejected match
349/// the search resumes one whole *character* later, never mid-character.
350fn contains_word(haystack: &str, needle: &str) -> bool {
351    if needle.is_empty() {
352        return false;
353    }
354    let bytes = haystack.as_bytes();
355    let mut from = 0;
356    while let Some(rel) = haystack[from..].find(needle) {
357        let start = from + rel;
358        let end = start + needle.len();
359        let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
360        let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
361        if before_ok && after_ok {
362            return true;
363        }
364        // `start + 1` would split a multi-byte first character (e.g. `é`) and
365        // panic on the next slice. `start` is a char boundary (it begins a
366        // `find` match), so step over exactly that character.
367        let first_len = haystack[start..].chars().next().map_or(1, char::len_utf8);
368        from = start + first_len;
369    }
370    false
371}
372
373/// Hand-maintained map from common CAA `issue` values to substrings that
374/// frequently appear in the issuer CN/O of certs from that CA.
375const CA_ALIASES: &[(&str, &[&str])] = &[
376    ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
377    ("pki.goog", &["google trust services", "gts"]),
378    ("digicert.com", &["digicert"]),
379    ("sectigo.com", &["sectigo", "comodo"]),
380    ("globalsign.com", &["globalsign"]),
381    ("amazon.com", &["amazon"]),
382    ("amazontrust.com", &["amazon"]),
383    ("zerossl.com", &["zerossl"]),
384    ("buypass.com", &["buypass"]),
385    ("entrust.net", &["entrust"]),
386    ("ssl.com", &["ssl.com"]),
387    ("certum.pl", &["certum"]),
388    ("identrust.com", &["identrust"]),
389];
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    /// Regression: callers normalized with `normalize_domain`, which drops
396    /// `www.` — but CAA is per name, so `www` must be asked first.
397    #[tokio::test]
398    async fn lookup_keeps_www_and_rejects_invalid_input() {
399        use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
400        use std::sync::{Arc, Mutex};
401
402        let asked = Arc::new(Mutex::new(Vec::new()));
403        let log = asked.clone();
404        let port = spawn_mock_dns_fn(move |name, _| {
405            log.lock().unwrap().push(name.to_string());
406            MockReply::NoData
407        })
408        .await;
409        let resolver = mock_dns_resolver_default(port);
410
411        let policy = lookup_caa(&resolver, "HTTPS://WWW.Seer.Test/path", None)
412            .await
413            .unwrap();
414        assert!(!policy.has_policy);
415        let asked = asked.lock().unwrap().clone();
416        assert_eq!(
417            asked.first().map(|n| n.trim_end_matches('.')),
418            Some("www.seer.test"),
419            "{asked:?}"
420        );
421
422        assert!(lookup_caa(&resolver, "bad..name", None).await.is_err());
423    }
424
425    fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
426        // Route through the real constructor so tests also exercise iodef +
427        // wildcard-note derivation.
428        let records = records
429            .into_iter()
430            .map(|(tag, value)| CaaRecord {
431                flags: 0,
432                tag: tag.to_string(),
433                value: value.to_string(),
434            })
435            .collect();
436        CaaPolicy::from_records(records, "example.com".to_string())
437    }
438
439    #[test]
440    fn iodef_contacts_are_extracted() {
441        let policy = policy_with(vec![
442            ("issue", "letsencrypt.org"),
443            ("iodef", "mailto:security@example.com"),
444        ]);
445        assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
446    }
447
448    #[test]
449    fn wildcard_note_flags_broader_wildcard_policy() {
450        // issue locks named issuance to Let's Encrypt, but issuewild also
451        // permits DigiCert for wildcards — genuinely broader.
452        let policy = policy_with(vec![
453            ("issue", "letsencrypt.org"),
454            ("issuewild", "digicert.com"),
455        ]);
456        let note = policy
457            .wildcard_note
458            .expect("looser wildcard policy flagged");
459        assert!(
460            note.contains("digicert.com"),
461            "note names the extra CA: {note}"
462        );
463    }
464
465    #[test]
466    fn wildcard_note_absent_when_issuewild_missing() {
467        // RFC 8659 §4.3: absent issuewild inherits issue — NOT a gap.
468        let policy = policy_with(vec![("issue", "letsencrypt.org")]);
469        assert!(policy.wildcard_note.is_none());
470    }
471
472    #[test]
473    fn wildcard_note_absent_when_policies_consistent() {
474        let policy = policy_with(vec![
475            ("issue", "letsencrypt.org"),
476            ("issuewild", "letsencrypt.org"),
477        ]);
478        assert!(policy.wildcard_note.is_none());
479    }
480
481    #[test]
482    fn classify_no_policy() {
483        assert_eq!(
484            classify_issuer("Let's Encrypt R3", &CaaPolicy::empty(), false),
485            IssuerCaaMatch::NoPolicy
486        );
487    }
488
489    #[test]
490    fn classify_indeterminate_when_only_iodef() {
491        let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
492        assert_eq!(
493            classify_issuer("Let's Encrypt R3", &policy, false),
494            IssuerCaaMatch::Indeterminate
495        );
496    }
497
498    #[test]
499    fn classify_permitted_letsencrypt() {
500        let policy = policy_with(vec![("issue", "letsencrypt.org")]);
501        assert_eq!(
502            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
503            IssuerCaaMatch::Permitted
504        );
505    }
506
507    #[test]
508    fn classify_permitted_via_alias() {
509        // Issuer CN/O does not literally contain "pki.goog"; alias table
510        // maps it to "Google Trust Services".
511        let policy = policy_with(vec![("issue", "pki.goog")]);
512        assert_eq!(
513            classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy, false),
514            IssuerCaaMatch::Permitted
515        );
516    }
517
518    #[test]
519    fn classify_mismatch_when_only_other_ca_allowed() {
520        let policy = policy_with(vec![("issue", "digicert.com")]);
521        assert_eq!(
522            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
523            IssuerCaaMatch::Mismatch
524        );
525    }
526
527    #[test]
528    fn classify_verbatim_match_is_length_guarded() {
529        // A pathologically short CAA `issue` value (here "ca") must not
530        // blanket-match an unrelated issuer just because the two letters appear
531        // verbatim somewhere in the issuer string. The verbatim path is now
532        // length-guarded just like the base-label fallback (issue #56 follow-up).
533        let policy = policy_with(vec![("issue", "ca")]);
534        assert_eq!(
535            classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy, false),
536            IssuerCaaMatch::Mismatch,
537            "two-letter verbatim CAA value must not over-match unrelated issuers"
538        );
539    }
540
541    #[test]
542    fn classify_full_domain_verbatim_still_matches() {
543        // A real, full-domain CAA value that appears verbatim in the issuer is
544        // still Permitted — the length guard must not break the common case.
545        let policy = policy_with(vec![("issue", "letsencrypt.org")]);
546        assert_eq!(
547            classify_issuer("CN=R3, O=letsencrypt.org", &policy, false),
548            IssuerCaaMatch::Permitted,
549            "full-domain verbatim value must still match"
550        );
551    }
552
553    #[test]
554    fn classify_does_not_overmatch_short_base_substring() {
555        // CAA `issue "ssl.com"` must NOT mark an unrelated issuer that merely
556        // contains the substring "ssl" as permitted — the base "ssl" is too
557        // short/ambiguous for the generic fallback (issue #56).
558        let policy = policy_with(vec![("issue", "ssl.com")]);
559        assert_eq!(
560            classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy, false),
561            IssuerCaaMatch::Mismatch,
562            "bare 'ssl' substring must not over-match"
563        );
564        // The genuine SSL.com issuer (value appears verbatim) is still permitted.
565        assert_eq!(
566            classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy, false),
567            IssuerCaaMatch::Permitted
568        );
569    }
570
571    #[test]
572    fn classify_unknown_ca_base_matches_only_on_word_boundary() {
573        // A long, distinctive base still matches via the guarded fallback — but
574        // only as a whole word, never buried inside a larger token (issue #56).
575        let policy = policy_with(vec![("issue", "examplecorp.test")]);
576        assert_eq!(
577            classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy, false),
578            IssuerCaaMatch::Permitted
579        );
580        assert_eq!(
581            classify_issuer("CN=NotExamplecorporated CA", &policy, false),
582            IssuerCaaMatch::Mismatch,
583            "base inside a larger word must not match"
584        );
585    }
586
587    /// Regression: a remote-controlled crash. After a rejected match
588    /// `contains_word` resumed at `start + 1`, which splits a multi-byte
589    /// first character — CAA `issue "éabcdef.com"` plus a (self-signed) cert
590    /// issued by `CN=xéabcdef.com` panicked `seer ssl` / `seer status` with
591    /// "byte index N is not a char boundary".
592    #[test]
593    fn classify_does_not_panic_on_multibyte_rejected_match() {
594        let policy = policy_with(vec![("issue", "éabcdef.com")]);
595        assert_eq!(
596            classify_issuer("CN=xéabcdef.com", &policy, false),
597            IssuerCaaMatch::Mismatch
598        );
599        // Directly, including a later whole-word hit after the rejected one.
600        assert!(!contains_word("xéabc", "éabc"));
601        assert!(contains_word("xéabc éabc", "éabc"));
602    }
603
604    /// Regression: aliases were substring-matched, so the `entrust` alias of
605    /// CAA `issue "entrust.net"` matched an IdenTrust-issued cert.
606    #[test]
607    fn classify_alias_requires_whole_word() {
608        let policy = policy_with(vec![("issue", "entrust.net")]);
609        assert_eq!(
610            classify_issuer("CN=TrustID Server CA O1, O=IdenTrust", &policy, false),
611            IssuerCaaMatch::Mismatch,
612            "entrust must not match inside identrust"
613        );
614        assert_eq!(
615            classify_issuer(
616                "CN=Entrust Certification Authority - L1K, O=Entrust, Inc.",
617                &policy,
618                false
619            ),
620            IssuerCaaMatch::Permitted
621        );
622        // The trimmed `gts` alias still matches Google's intermediates.
623        let google = policy_with(vec![("issue", "pki.goog")]);
624        assert_eq!(
625            classify_issuer("CN=WR2, O=GTS", &google, false),
626            IssuerCaaMatch::Permitted
627        );
628    }
629
630    #[test]
631    fn classify_mismatch_when_issuance_forbidden() {
632        // A bare `issue ";"` forbids all issuance, yet a cert exists.
633        let policy = policy_with(vec![("issue", ";")]);
634        assert_eq!(
635            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
636            IssuerCaaMatch::Mismatch
637        );
638    }
639
640    /// Regression: `issue` and `issuewild` values were pooled, so a CA
641    /// permitted only for wildcards counted as permitted for any name.
642    #[test]
643    fn issuewild_governs_wildcards_and_only_wildcards() {
644        const LE: &str = "CN=R3, O=Let's Encrypt";
645        let split = policy_with(vec![
646            ("issue", "digicert.com"),
647            ("issuewild", "letsencrypt.org"),
648        ]);
649        assert_eq!(classify_issuer(LE, &split, true), IssuerCaaMatch::Permitted);
650        assert_eq!(classify_issuer(LE, &split, false), IssuerCaaMatch::Mismatch);
651
652        // issuewild alone says nothing about non-wildcard names.
653        let wild_only = policy_with(vec![("issuewild", "letsencrypt.org")]);
654        assert_eq!(
655            classify_issuer(LE, &wild_only, true),
656            IssuerCaaMatch::Permitted
657        );
658        assert_eq!(
659            classify_issuer(LE, &wild_only, false),
660            IssuerCaaMatch::Indeterminate
661        );
662
663        // No issuewild: a wildcard falls back to issue.
664        let issue_only = policy_with(vec![("issue", "letsencrypt.org")]);
665        assert_eq!(
666            classify_issuer(LE, &issue_only, true),
667            IssuerCaaMatch::Permitted
668        );
669
670        // issuewild ";" forbids wildcards even though issue permits the CA.
671        let no_wild = policy_with(vec![("issue", "letsencrypt.org"), ("issuewild", ";")]);
672        assert_eq!(
673            classify_issuer(LE, &no_wild, true),
674            IssuerCaaMatch::Mismatch
675        );
676        assert_eq!(
677            classify_issuer(LE, &no_wild, false),
678            IssuerCaaMatch::Permitted
679        );
680    }
681
682    #[test]
683    fn empty_policy_has_no_issuer_match_set() {
684        let p = CaaPolicy::empty();
685        assert!(p.records.is_empty());
686        assert!(!p.has_policy);
687        assert!(p.issuer_match.is_none());
688        assert_eq!(p.note, ISSUANCE_TIME_NOTE);
689    }
690
691    /// RFC 8659 §4.1: a CAA record carrying an unknown tag with the Issuer
692    /// Critical flag (bit 7 of `flags`) set MUST be treated as forbidding
693    /// issuance — we cannot honor a critical property we don't understand.
694    #[test]
695    fn classify_unknown_critical_tag_forces_mismatch() {
696        let policy = CaaPolicy {
697            records: vec![
698                // Valid issue that would otherwise match Let's Encrypt.
699                CaaRecord {
700                    flags: 0,
701                    tag: "issue".to_string(),
702                    value: "letsencrypt.org".to_string(),
703                },
704                // Unknown tag with critical flag — must veto issuance.
705                CaaRecord {
706                    flags: 0x80,
707                    tag: "auth".to_string(),
708                    value: "future-extension".to_string(),
709                },
710            ],
711            effective_domain: Some("example.com".to_string()),
712            has_policy: true,
713            issuer_match: None,
714            iodef: Vec::new(),
715            wildcard_note: None,
716            lookup_failed: false,
717            note: ISSUANCE_TIME_NOTE.to_string(),
718        };
719        assert_eq!(
720            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
721            IssuerCaaMatch::Mismatch,
722            "critical unknown tag must veto otherwise-matching issue"
723        );
724    }
725
726    #[test]
727    fn classify_unknown_non_critical_tag_does_not_veto() {
728        // Same shape but flags = 0 (non-critical). Per RFC the unknown tag
729        // is ignored; the matching `issue` carries through.
730        let policy = CaaPolicy {
731            records: vec![
732                CaaRecord {
733                    flags: 0,
734                    tag: "issue".to_string(),
735                    value: "letsencrypt.org".to_string(),
736                },
737                CaaRecord {
738                    flags: 0,
739                    tag: "auth".to_string(),
740                    value: "future-extension".to_string(),
741                },
742            ],
743            effective_domain: Some("example.com".to_string()),
744            has_policy: true,
745            issuer_match: None,
746            iodef: Vec::new(),
747            wildcard_note: None,
748            lookup_failed: false,
749            note: ISSUANCE_TIME_NOTE.to_string(),
750        };
751        assert_eq!(
752            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
753            IssuerCaaMatch::Permitted
754        );
755    }
756
757    #[test]
758    fn classify_critical_known_tag_does_not_veto() {
759        // A critical `issue` (a known tag) is just a normal critical-issue.
760        // It must NOT trip the unknown-critical veto.
761        let policy = CaaPolicy {
762            records: vec![CaaRecord {
763                flags: 0x80,
764                tag: "issue".to_string(),
765                value: "letsencrypt.org".to_string(),
766            }],
767            effective_domain: Some("example.com".to_string()),
768            has_policy: true,
769            issuer_match: None,
770            iodef: Vec::new(),
771            wildcard_note: None,
772            lookup_failed: false,
773            note: ISSUANCE_TIME_NOTE.to_string(),
774        };
775        assert_eq!(
776            classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
777            IssuerCaaMatch::Permitted
778        );
779    }
780
781    use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
782    use hickory_resolver::proto::op::ResponseCode;
783    use hickory_resolver::proto::rr::RecordType as WireType;
784
785    #[tokio::test]
786    async fn a_failed_level_with_no_policy_is_flagged_as_lookup_failed() {
787        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
788            ("www.caa.test", WireType::CAA) => MockReply::Rcode(ResponseCode::ServFail),
789            _ => MockReply::NoData,
790        })
791        .await;
792        let policy = lookup_caa(&mock_dns_resolver_default(port), "www.caa.test", None)
793            .await
794            .unwrap();
795        assert!(!policy.has_policy);
796        assert!(policy.lookup_failed, "{policy:?}");
797    }
798
799    #[tokio::test]
800    async fn a_clean_empty_walk_is_not_a_lookup_failure() {
801        let port = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
802        let policy = lookup_caa(&mock_dns_resolver_default(port), "www.caa.test", None)
803            .await
804            .unwrap();
805        assert!(!policy.has_policy);
806        assert!(!policy.lookup_failed);
807    }
808}