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