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    let issue_values: Vec<String> = policy
137        .records
138        .iter()
139        .filter(|r| r.tag == "issue" || r.tag == "issuewild")
140        .map(|r| {
141            // RFC 8659 §4.2: value is "<CA domain> [; <parameters>]". We
142            // only need the domain portion for matching.
143            r.value
144                .split(';')
145                .next()
146                .unwrap_or(&r.value)
147                .trim()
148                .to_ascii_lowercase()
149        })
150        .collect();
151
152    if issue_values.is_empty() {
153        return IssuerCaaMatch::Indeterminate;
154    }
155
156    let issuer_lc = issuer.to_ascii_lowercase();
157    let allowed_any = issue_values.iter().any(|v| !v.is_empty());
158
159    let matched = issue_values
160        .iter()
161        .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc));
162
163    if matched {
164        IssuerCaaMatch::Permitted
165    } else if allowed_any {
166        IssuerCaaMatch::Mismatch
167    } else {
168        // Only entries are empty-value (";") — issuance is explicitly forbidden,
169        // yet a cert exists. Report as mismatch with the informational note.
170        IssuerCaaMatch::Mismatch
171    }
172}
173
174/// Best-effort comparison between a CAA `issue` value (a CA's domain) and a
175/// certificate issuer string (typically a CN/O like "Let's Encrypt").
176///
177/// CAA values are short reverse-DNS-ish labels; issuer strings vary by CA.
178/// We use a small alias table for the common public CAs and fall back to a
179/// direct substring check.
180fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
181    if issuer_lc.contains(caa_value) {
182        return true;
183    }
184    // Strip the registrable trailing label (e.g. ".org", ".com") to match
185    // base names — "letsencrypt" in "let's encrypt".
186    let base = caa_value
187        .rsplit_once('.')
188        .map(|(b, _)| b)
189        .unwrap_or(caa_value);
190    if !base.is_empty() && issuer_lc.contains(base) {
191        return true;
192    }
193    // Curated aliases for well-known CAs.
194    for (cv, aliases) in CA_ALIASES {
195        if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
196            return true;
197        }
198    }
199    false
200}
201
202/// Hand-maintained map from common CAA `issue` values to substrings that
203/// frequently appear in the issuer CN/O of certs from that CA.
204const CA_ALIASES: &[(&str, &[&str])] = &[
205    ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
206    ("pki.goog", &["google trust services", "gts "]),
207    ("digicert.com", &["digicert"]),
208    ("sectigo.com", &["sectigo", "comodo"]),
209    ("globalsign.com", &["globalsign"]),
210    ("amazon.com", &["amazon"]),
211    ("amazontrust.com", &["amazon"]),
212    ("zerossl.com", &["zerossl"]),
213    ("buypass.com", &["buypass"]),
214    ("entrust.net", &["entrust"]),
215    ("ssl.com", &["ssl.com"]),
216    ("certum.pl", &["certum"]),
217    ("identrust.com", &["identrust"]),
218];
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
225        CaaPolicy {
226            records: records
227                .into_iter()
228                .map(|(tag, value)| CaaRecord {
229                    flags: 0,
230                    tag: tag.to_string(),
231                    value: value.to_string(),
232                })
233                .collect(),
234            effective_domain: Some("example.com".to_string()),
235            has_policy: true,
236            issuer_match: None,
237            note: ISSUANCE_TIME_NOTE.to_string(),
238        }
239    }
240
241    #[test]
242    fn classify_no_policy() {
243        assert_eq!(
244            classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
245            IssuerCaaMatch::NoPolicy
246        );
247    }
248
249    #[test]
250    fn classify_indeterminate_when_only_iodef() {
251        let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
252        assert_eq!(
253            classify_issuer("Let's Encrypt R3", &policy),
254            IssuerCaaMatch::Indeterminate
255        );
256    }
257
258    #[test]
259    fn classify_permitted_letsencrypt() {
260        let policy = policy_with(vec![("issue", "letsencrypt.org")]);
261        assert_eq!(
262            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
263            IssuerCaaMatch::Permitted
264        );
265    }
266
267    #[test]
268    fn classify_permitted_via_alias() {
269        // Issuer CN/O does not literally contain "pki.goog"; alias table
270        // maps it to "Google Trust Services".
271        let policy = policy_with(vec![("issue", "pki.goog")]);
272        assert_eq!(
273            classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
274            IssuerCaaMatch::Permitted
275        );
276    }
277
278    #[test]
279    fn classify_mismatch_when_only_other_ca_allowed() {
280        let policy = policy_with(vec![("issue", "digicert.com")]);
281        assert_eq!(
282            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
283            IssuerCaaMatch::Mismatch
284        );
285    }
286
287    #[test]
288    fn classify_mismatch_when_issuance_forbidden() {
289        // A bare `issue ";"` forbids all issuance, yet a cert exists.
290        let policy = policy_with(vec![("issue", ";")]);
291        assert_eq!(
292            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
293            IssuerCaaMatch::Mismatch
294        );
295    }
296
297    #[test]
298    fn classify_issuewild_treated_like_issue() {
299        let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
300        assert_eq!(
301            classify_issuer("CN=R3, O=Let's Encrypt", &policy),
302            IssuerCaaMatch::Permitted
303        );
304    }
305
306    #[test]
307    fn empty_policy_has_no_issuer_match_set() {
308        let p = CaaPolicy::empty();
309        assert!(p.records.is_empty());
310        assert!(!p.has_policy);
311        assert!(p.issuer_match.is_none());
312        assert_eq!(p.note, ISSUANCE_TIME_NOTE);
313    }
314}