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 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 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 IssuerCaaMatch::Mismatch
185 }
186}
187
188fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
195 if issuer_lc.contains(caa_value) {
196 return true;
197 }
198 let base = caa_value
201 .rsplit_once('.')
202 .map(|(b, _)| b)
203 .unwrap_or(caa_value);
204 if !base.is_empty() && issuer_lc.contains(base) {
205 return true;
206 }
207 for (cv, aliases) in CA_ALIASES {
209 if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
210 return true;
211 }
212 }
213 false
214}
215
216const CA_ALIASES: &[(&str, &[&str])] = &[
219 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
220 ("pki.goog", &["google trust services", "gts "]),
221 ("digicert.com", &["digicert"]),
222 ("sectigo.com", &["sectigo", "comodo"]),
223 ("globalsign.com", &["globalsign"]),
224 ("amazon.com", &["amazon"]),
225 ("amazontrust.com", &["amazon"]),
226 ("zerossl.com", &["zerossl"]),
227 ("buypass.com", &["buypass"]),
228 ("entrust.net", &["entrust"]),
229 ("ssl.com", &["ssl.com"]),
230 ("certum.pl", &["certum"]),
231 ("identrust.com", &["identrust"]),
232];
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
239 CaaPolicy {
240 records: records
241 .into_iter()
242 .map(|(tag, value)| CaaRecord {
243 flags: 0,
244 tag: tag.to_string(),
245 value: value.to_string(),
246 })
247 .collect(),
248 effective_domain: Some("example.com".to_string()),
249 has_policy: true,
250 issuer_match: None,
251 note: ISSUANCE_TIME_NOTE.to_string(),
252 }
253 }
254
255 #[test]
256 fn classify_no_policy() {
257 assert_eq!(
258 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
259 IssuerCaaMatch::NoPolicy
260 );
261 }
262
263 #[test]
264 fn classify_indeterminate_when_only_iodef() {
265 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
266 assert_eq!(
267 classify_issuer("Let's Encrypt R3", &policy),
268 IssuerCaaMatch::Indeterminate
269 );
270 }
271
272 #[test]
273 fn classify_permitted_letsencrypt() {
274 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
275 assert_eq!(
276 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
277 IssuerCaaMatch::Permitted
278 );
279 }
280
281 #[test]
282 fn classify_permitted_via_alias() {
283 let policy = policy_with(vec![("issue", "pki.goog")]);
286 assert_eq!(
287 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
288 IssuerCaaMatch::Permitted
289 );
290 }
291
292 #[test]
293 fn classify_mismatch_when_only_other_ca_allowed() {
294 let policy = policy_with(vec![("issue", "digicert.com")]);
295 assert_eq!(
296 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
297 IssuerCaaMatch::Mismatch
298 );
299 }
300
301 #[test]
302 fn classify_mismatch_when_issuance_forbidden() {
303 let policy = policy_with(vec![("issue", ";")]);
305 assert_eq!(
306 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
307 IssuerCaaMatch::Mismatch
308 );
309 }
310
311 #[test]
312 fn classify_issuewild_treated_like_issue() {
313 let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
314 assert_eq!(
315 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
316 IssuerCaaMatch::Permitted
317 );
318 }
319
320 #[test]
321 fn empty_policy_has_no_issuer_match_set() {
322 let p = CaaPolicy::empty();
323 assert!(p.records.is_empty());
324 assert!(!p.has_policy);
325 assert!(p.issuer_match.is_none());
326 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
327 }
328
329 #[test]
333 fn classify_unknown_critical_tag_forces_mismatch() {
334 let policy = CaaPolicy {
335 records: vec![
336 CaaRecord {
338 flags: 0,
339 tag: "issue".to_string(),
340 value: "letsencrypt.org".to_string(),
341 },
342 CaaRecord {
344 flags: 0x80,
345 tag: "auth".to_string(),
346 value: "future-extension".to_string(),
347 },
348 ],
349 effective_domain: Some("example.com".to_string()),
350 has_policy: true,
351 issuer_match: None,
352 note: ISSUANCE_TIME_NOTE.to_string(),
353 };
354 assert_eq!(
355 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
356 IssuerCaaMatch::Mismatch,
357 "critical unknown tag must veto otherwise-matching issue"
358 );
359 }
360
361 #[test]
362 fn classify_unknown_non_critical_tag_does_not_veto() {
363 let policy = CaaPolicy {
366 records: vec![
367 CaaRecord {
368 flags: 0,
369 tag: "issue".to_string(),
370 value: "letsencrypt.org".to_string(),
371 },
372 CaaRecord {
373 flags: 0,
374 tag: "auth".to_string(),
375 value: "future-extension".to_string(),
376 },
377 ],
378 effective_domain: Some("example.com".to_string()),
379 has_policy: true,
380 issuer_match: None,
381 note: ISSUANCE_TIME_NOTE.to_string(),
382 };
383 assert_eq!(
384 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
385 IssuerCaaMatch::Permitted
386 );
387 }
388
389 #[test]
390 fn classify_critical_known_tag_does_not_veto() {
391 let policy = CaaPolicy {
394 records: vec![CaaRecord {
395 flags: 0x80,
396 tag: "issue".to_string(),
397 value: "letsencrypt.org".to_string(),
398 }],
399 effective_domain: Some("example.com".to_string()),
400 has_policy: true,
401 issuer_match: None,
402 note: ISSUANCE_TIME_NOTE.to_string(),
403 };
404 assert_eq!(
405 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
406 IssuerCaaMatch::Permitted
407 );
408 }
409}