1use serde::{Deserialize, Serialize};
12
13use crate::dns::{DnsResolver, RecordData, RecordType};
14
15pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CaaRecord {
26 pub flags: u8,
28 pub tag: String,
30 pub value: String,
32}
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "lowercase")]
37pub enum IssuerCaaMatch {
38 NoPolicy,
40 Permitted,
43 Mismatch,
46 Indeterminate,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CaaPolicy {
55 pub records: Vec<CaaRecord>,
57 pub effective_domain: Option<String>,
61 pub has_policy: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 pub issuer_match: Option<IssuerCaaMatch>,
67 pub note: String,
69}
70
71impl CaaPolicy {
72 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
84pub 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(¤t, 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 match current.split_once('.') {
123 Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
124 _ => return CaaPolicy::empty(),
125 }
126 }
127}
128
129pub 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 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 IssuerCaaMatch::Mismatch
171 }
172}
173
174fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
181 if issuer_lc.contains(caa_value) {
182 return true;
183 }
184 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 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
202const 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 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 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}