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