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    /// Informational note about CAA semantics. Always populated.
68    pub note: String,
69}
70
71impl CaaPolicy {
72    /// Empty policy — used when no CAA records were found anywhere in the tree.
73    pub fn empty() -> Self {
74        Self {
75            records: Vec::new(),
76            effective_domain: None,
77            has_policy: false,
78            issuer_match: None,
79            note: ISSUANCE_TIME_NOTE.to_string(),
80        }
81    }
82}
83
84/// Looks up CAA records for `domain`, climbing the DNS tree per RFC 8659
85/// section 3 until a record set is found or only a TLD remains.
86///
87/// Returns an [`CaaPolicy::empty`] on resolver errors — CAA is advisory,
88/// so we never want to fail a higher-level check just because a CAA query
89/// did not return.
90pub async fn lookup_caa(resolver: &DnsResolver, domain: &str) -> CaaPolicy {
91    let mut current = domain.trim_end_matches('.').to_ascii_lowercase();
92
93    loop {
94        match resolver.resolve(&current, RecordType::CAA, None).await {
95            Ok(records) if !records.is_empty() => {
96                let caa: Vec<CaaRecord> = records
97                    .into_iter()
98                    .filter_map(|r| match r.data {
99                        RecordData::CAA { flags, tag, value } => Some(CaaRecord {
100                            flags,
101                            tag: tag.to_ascii_lowercase(),
102                            value,
103                        }),
104                        _ => None,
105                    })
106                    .collect();
107
108                if !caa.is_empty() {
109                    return CaaPolicy {
110                        has_policy: true,
111                        records: caa,
112                        effective_domain: Some(current),
113                        issuer_match: None,
114                        note: ISSUANCE_TIME_NOTE.to_string(),
115                    };
116                }
117            }
118            Ok(_) | Err(_) => {}
119        }
120
121        // Strip the leftmost label. Stop when only one label (TLD) remains.
122        match current.split_once('.') {
123            Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
124            _ => return CaaPolicy::empty(),
125        }
126    }
127}
128
129/// Compares a presented certificate's issuer string against a CAA policy
130/// and returns a classification. Pure function — no I/O.
131pub fn classify_issuer(issuer: &str, policy: &CaaPolicy) -> IssuerCaaMatch {
132    if !policy.has_policy {
133        return IssuerCaaMatch::NoPolicy;
134    }
135
136    // RFC 8659 §4.1: if any CAA record has the Issuer Critical flag (bit 7,
137    // 0x80) set AND its tag is unknown to us, the spec mandates that
138    // issuance be treated as forbidden — we cannot honor a critical
139    // property we don't understand. Surface that as `Mismatch` so callers
140    // see a non-permitted verdict.
141    const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
142    let critical_unknown = policy
143        .records
144        .iter()
145        .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
146    if critical_unknown {
147        return IssuerCaaMatch::Mismatch;
148    }
149
150    let issue_values: Vec<String> = policy
151        .records
152        .iter()
153        .filter(|r| r.tag == "issue" || r.tag == "issuewild")
154        .map(|r| {
155            // RFC 8659 §4.2: value is "<CA domain> [; <parameters>]". We
156            // only need the domain portion for matching.
157            r.value
158                .split(';')
159                .next()
160                .unwrap_or(&r.value)
161                .trim()
162                .to_ascii_lowercase()
163        })
164        .collect();
165
166    if issue_values.is_empty() {
167        return IssuerCaaMatch::Indeterminate;
168    }
169
170    let issuer_lc = issuer.to_ascii_lowercase();
171    let allowed_any = issue_values.iter().any(|v| !v.is_empty());
172
173    let matched = issue_values
174        .iter()
175        .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc));
176
177    if matched {
178        IssuerCaaMatch::Permitted
179    } else if allowed_any {
180        IssuerCaaMatch::Mismatch
181    } else {
182        // Only entries are empty-value (";") — issuance is explicitly forbidden,
183        // yet a cert exists. Report as mismatch with the informational note.
184        IssuerCaaMatch::Mismatch
185    }
186}
187
188/// Best-effort comparison between a CAA `issue` value (a CA's domain) and a
189/// certificate issuer string (typically a CN/O like "Let's Encrypt").
190///
191/// CAA values are short reverse-DNS-ish labels; issuer strings vary by CA.
192/// We use a small alias table for the common public CAs and fall back to a
193/// direct substring check.
194fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
195    // 1. The CAA value appears verbatim in the issuer (e.g. "ssl.com").
196    if issuer_lc.contains(caa_value) {
197        return true;
198    }
199    // 2. Curated aliases for well-known CAs — preferred over the generic base
200    //    fallback so a precise mapping wins (e.g. "ssl.com" -> "ssl.com",
201    //    never bare "ssl").
202    for (cv, aliases) in CA_ALIASES {
203        if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
204            return true;
205        }
206    }
207    // 3. Generic fallback for CAs not in the alias table: the base label (the
208    //    CAA value minus its trailing TLD) appears in the issuer AS A WHOLE
209    //    WORD. Guarded by a minimum length so a short, ambiguous base — "ssl"
210    //    from "ssl.com" — can't collide with unrelated issuer text like
211    //    "...SSL CA..." and report a genuine mismatch as Permitted (issue #56).
212    let base = caa_value
213        .rsplit_once('.')
214        .map(|(b, _)| b)
215        .unwrap_or(caa_value);
216    base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
217}
218
219/// Minimum base-label length for the generic substring fallback in
220/// [`ca_value_matches_issuer`]. Shorter bases (e.g. "ssl", "ca", "pki") are too
221/// ambiguous to match by substring and must go through the curated alias table
222/// or a verbatim value match instead.
223const MIN_FALLBACK_BASE_LEN: usize = 6;
224
225/// Returns true if `needle` occurs in `haystack` as a whole word — i.e. bounded
226/// by string start/end or a non-alphanumeric character on each side — so a base
227/// like "examplecorp" matches "ExampleCorp Root" but not "NotExamplecorporated".
228/// Both arguments are expected lowercase.
229fn contains_word(haystack: &str, needle: &str) -> bool {
230    if needle.is_empty() {
231        return false;
232    }
233    let bytes = haystack.as_bytes();
234    let mut from = 0;
235    while let Some(rel) = haystack[from..].find(needle) {
236        let start = from + rel;
237        let end = start + needle.len();
238        let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
239        let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
240        if before_ok && after_ok {
241            return true;
242        }
243        from = start + 1;
244    }
245    false
246}
247
248/// Hand-maintained map from common CAA `issue` values to substrings that
249/// frequently appear in the issuer CN/O of certs from that CA.
250const CA_ALIASES: &[(&str, &[&str])] = &[
251    ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
252    ("pki.goog", &["google trust services", "gts "]),
253    ("digicert.com", &["digicert"]),
254    ("sectigo.com", &["sectigo", "comodo"]),
255    ("globalsign.com", &["globalsign"]),
256    ("amazon.com", &["amazon"]),
257    ("amazontrust.com", &["amazon"]),
258    ("zerossl.com", &["zerossl"]),
259    ("buypass.com", &["buypass"]),
260    ("entrust.net", &["entrust"]),
261    ("ssl.com", &["ssl.com"]),
262    ("certum.pl", &["certum"]),
263    ("identrust.com", &["identrust"]),
264];
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
271        CaaPolicy {
272            records: records
273                .into_iter()
274                .map(|(tag, value)| CaaRecord {
275                    flags: 0,
276                    tag: tag.to_string(),
277                    value: value.to_string(),
278                })
279                .collect(),
280            effective_domain: Some("example.com".to_string()),
281            has_policy: true,
282            issuer_match: None,
283            note: ISSUANCE_TIME_NOTE.to_string(),
284        }
285    }
286
287    #[test]
288    fn classify_no_policy() {
289        assert_eq!(
290            classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
291            IssuerCaaMatch::NoPolicy
292        );
293    }
294
295    #[test]
296    fn classify_indeterminate_when_only_iodef() {
297        let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
298        assert_eq!(
299            classify_issuer("Let's Encrypt R3", &policy),
300            IssuerCaaMatch::Indeterminate
301        );
302    }
303
304    #[test]
305    fn classify_permitted_letsencrypt() {
306        let policy = policy_with(vec![("issue", "letsencrypt.org")]);
307        assert_eq!(
308            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
309            IssuerCaaMatch::Permitted
310        );
311    }
312
313    #[test]
314    fn classify_permitted_via_alias() {
315        // Issuer CN/O does not literally contain "pki.goog"; alias table
316        // maps it to "Google Trust Services".
317        let policy = policy_with(vec![("issue", "pki.goog")]);
318        assert_eq!(
319            classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
320            IssuerCaaMatch::Permitted
321        );
322    }
323
324    #[test]
325    fn classify_mismatch_when_only_other_ca_allowed() {
326        let policy = policy_with(vec![("issue", "digicert.com")]);
327        assert_eq!(
328            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
329            IssuerCaaMatch::Mismatch
330        );
331    }
332
333    #[test]
334    fn classify_does_not_overmatch_short_base_substring() {
335        // CAA `issue "ssl.com"` must NOT mark an unrelated issuer that merely
336        // contains the substring "ssl" as permitted — the base "ssl" is too
337        // short/ambiguous for the generic fallback (issue #56).
338        let policy = policy_with(vec![("issue", "ssl.com")]);
339        assert_eq!(
340            classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy),
341            IssuerCaaMatch::Mismatch,
342            "bare 'ssl' substring must not over-match"
343        );
344        // The genuine SSL.com issuer (value appears verbatim) is still permitted.
345        assert_eq!(
346            classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy),
347            IssuerCaaMatch::Permitted
348        );
349    }
350
351    #[test]
352    fn classify_unknown_ca_base_matches_only_on_word_boundary() {
353        // A long, distinctive base still matches via the guarded fallback — but
354        // only as a whole word, never buried inside a larger token (issue #56).
355        let policy = policy_with(vec![("issue", "examplecorp.test")]);
356        assert_eq!(
357            classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy),
358            IssuerCaaMatch::Permitted
359        );
360        assert_eq!(
361            classify_issuer("CN=NotExamplecorporated CA", &policy),
362            IssuerCaaMatch::Mismatch,
363            "base inside a larger word must not match"
364        );
365    }
366
367    #[test]
368    fn classify_mismatch_when_issuance_forbidden() {
369        // A bare `issue ";"` forbids all issuance, yet a cert exists.
370        let policy = policy_with(vec![("issue", ";")]);
371        assert_eq!(
372            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
373            IssuerCaaMatch::Mismatch
374        );
375    }
376
377    #[test]
378    fn classify_issuewild_treated_like_issue() {
379        let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
380        assert_eq!(
381            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
382            IssuerCaaMatch::Permitted
383        );
384    }
385
386    #[test]
387    fn empty_policy_has_no_issuer_match_set() {
388        let p = CaaPolicy::empty();
389        assert!(p.records.is_empty());
390        assert!(!p.has_policy);
391        assert!(p.issuer_match.is_none());
392        assert_eq!(p.note, ISSUANCE_TIME_NOTE);
393    }
394
395    /// RFC 8659 §4.1: a CAA record carrying an unknown tag with the Issuer
396    /// Critical flag (bit 7 of `flags`) set MUST be treated as forbidding
397    /// issuance — we cannot honor a critical property we don't understand.
398    #[test]
399    fn classify_unknown_critical_tag_forces_mismatch() {
400        let policy = CaaPolicy {
401            records: vec![
402                // Valid issue that would otherwise match Let's Encrypt.
403                CaaRecord {
404                    flags: 0,
405                    tag: "issue".to_string(),
406                    value: "letsencrypt.org".to_string(),
407                },
408                // Unknown tag with critical flag — must veto issuance.
409                CaaRecord {
410                    flags: 0x80,
411                    tag: "auth".to_string(),
412                    value: "future-extension".to_string(),
413                },
414            ],
415            effective_domain: Some("example.com".to_string()),
416            has_policy: true,
417            issuer_match: None,
418            note: ISSUANCE_TIME_NOTE.to_string(),
419        };
420        assert_eq!(
421            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
422            IssuerCaaMatch::Mismatch,
423            "critical unknown tag must veto otherwise-matching issue"
424        );
425    }
426
427    #[test]
428    fn classify_unknown_non_critical_tag_does_not_veto() {
429        // Same shape but flags = 0 (non-critical). Per RFC the unknown tag
430        // is ignored; the matching `issue` carries through.
431        let policy = CaaPolicy {
432            records: vec![
433                CaaRecord {
434                    flags: 0,
435                    tag: "issue".to_string(),
436                    value: "letsencrypt.org".to_string(),
437                },
438                CaaRecord {
439                    flags: 0,
440                    tag: "auth".to_string(),
441                    value: "future-extension".to_string(),
442                },
443            ],
444            effective_domain: Some("example.com".to_string()),
445            has_policy: true,
446            issuer_match: None,
447            note: ISSUANCE_TIME_NOTE.to_string(),
448        };
449        assert_eq!(
450            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
451            IssuerCaaMatch::Permitted
452        );
453    }
454
455    #[test]
456    fn classify_critical_known_tag_does_not_veto() {
457        // A critical `issue` (a known tag) is just a normal critical-issue.
458        // It must NOT trip the unknown-critical veto.
459        let policy = CaaPolicy {
460            records: vec![CaaRecord {
461                flags: 0x80,
462                tag: "issue".to_string(),
463                value: "letsencrypt.org".to_string(),
464            }],
465            effective_domain: Some("example.com".to_string()),
466            has_policy: true,
467            issuer_match: None,
468            note: ISSUANCE_TIME_NOTE.to_string(),
469        };
470        assert_eq!(
471            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
472            IssuerCaaMatch::Permitted
473        );
474    }
475}