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