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 caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
202 return true;
203 }
204 for (cv, aliases) in CA_ALIASES {
208 if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
209 return true;
210 }
211 }
212 let base = caa_value
218 .rsplit_once('.')
219 .map(|(b, _)| b)
220 .unwrap_or(caa_value);
221 base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
222}
223
224const MIN_FALLBACK_BASE_LEN: usize = 6;
229
230fn contains_word(haystack: &str, needle: &str) -> bool {
235 if needle.is_empty() {
236 return false;
237 }
238 let bytes = haystack.as_bytes();
239 let mut from = 0;
240 while let Some(rel) = haystack[from..].find(needle) {
241 let start = from + rel;
242 let end = start + needle.len();
243 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
244 let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
245 if before_ok && after_ok {
246 return true;
247 }
248 from = start + 1;
249 }
250 false
251}
252
253const CA_ALIASES: &[(&str, &[&str])] = &[
256 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
257 ("pki.goog", &["google trust services", "gts "]),
258 ("digicert.com", &["digicert"]),
259 ("sectigo.com", &["sectigo", "comodo"]),
260 ("globalsign.com", &["globalsign"]),
261 ("amazon.com", &["amazon"]),
262 ("amazontrust.com", &["amazon"]),
263 ("zerossl.com", &["zerossl"]),
264 ("buypass.com", &["buypass"]),
265 ("entrust.net", &["entrust"]),
266 ("ssl.com", &["ssl.com"]),
267 ("certum.pl", &["certum"]),
268 ("identrust.com", &["identrust"]),
269];
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
276 CaaPolicy {
277 records: records
278 .into_iter()
279 .map(|(tag, value)| CaaRecord {
280 flags: 0,
281 tag: tag.to_string(),
282 value: value.to_string(),
283 })
284 .collect(),
285 effective_domain: Some("example.com".to_string()),
286 has_policy: true,
287 issuer_match: None,
288 note: ISSUANCE_TIME_NOTE.to_string(),
289 }
290 }
291
292 #[test]
293 fn classify_no_policy() {
294 assert_eq!(
295 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
296 IssuerCaaMatch::NoPolicy
297 );
298 }
299
300 #[test]
301 fn classify_indeterminate_when_only_iodef() {
302 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
303 assert_eq!(
304 classify_issuer("Let's Encrypt R3", &policy),
305 IssuerCaaMatch::Indeterminate
306 );
307 }
308
309 #[test]
310 fn classify_permitted_letsencrypt() {
311 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
312 assert_eq!(
313 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
314 IssuerCaaMatch::Permitted
315 );
316 }
317
318 #[test]
319 fn classify_permitted_via_alias() {
320 let policy = policy_with(vec![("issue", "pki.goog")]);
323 assert_eq!(
324 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
325 IssuerCaaMatch::Permitted
326 );
327 }
328
329 #[test]
330 fn classify_mismatch_when_only_other_ca_allowed() {
331 let policy = policy_with(vec![("issue", "digicert.com")]);
332 assert_eq!(
333 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
334 IssuerCaaMatch::Mismatch
335 );
336 }
337
338 #[test]
339 fn classify_verbatim_match_is_length_guarded() {
340 let policy = policy_with(vec![("issue", "ca")]);
345 assert_eq!(
346 classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy),
347 IssuerCaaMatch::Mismatch,
348 "two-letter verbatim CAA value must not over-match unrelated issuers"
349 );
350 }
351
352 #[test]
353 fn classify_full_domain_verbatim_still_matches() {
354 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
357 assert_eq!(
358 classify_issuer("CN=R3, O=letsencrypt.org", &policy),
359 IssuerCaaMatch::Permitted,
360 "full-domain verbatim value must still match"
361 );
362 }
363
364 #[test]
365 fn classify_does_not_overmatch_short_base_substring() {
366 let policy = policy_with(vec![("issue", "ssl.com")]);
370 assert_eq!(
371 classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy),
372 IssuerCaaMatch::Mismatch,
373 "bare 'ssl' substring must not over-match"
374 );
375 assert_eq!(
377 classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy),
378 IssuerCaaMatch::Permitted
379 );
380 }
381
382 #[test]
383 fn classify_unknown_ca_base_matches_only_on_word_boundary() {
384 let policy = policy_with(vec![("issue", "examplecorp.test")]);
387 assert_eq!(
388 classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy),
389 IssuerCaaMatch::Permitted
390 );
391 assert_eq!(
392 classify_issuer("CN=NotExamplecorporated CA", &policy),
393 IssuerCaaMatch::Mismatch,
394 "base inside a larger word must not match"
395 );
396 }
397
398 #[test]
399 fn classify_mismatch_when_issuance_forbidden() {
400 let policy = policy_with(vec![("issue", ";")]);
402 assert_eq!(
403 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
404 IssuerCaaMatch::Mismatch
405 );
406 }
407
408 #[test]
409 fn classify_issuewild_treated_like_issue() {
410 let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
411 assert_eq!(
412 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
413 IssuerCaaMatch::Permitted
414 );
415 }
416
417 #[test]
418 fn empty_policy_has_no_issuer_match_set() {
419 let p = CaaPolicy::empty();
420 assert!(p.records.is_empty());
421 assert!(!p.has_policy);
422 assert!(p.issuer_match.is_none());
423 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
424 }
425
426 #[test]
430 fn classify_unknown_critical_tag_forces_mismatch() {
431 let policy = CaaPolicy {
432 records: vec![
433 CaaRecord {
435 flags: 0,
436 tag: "issue".to_string(),
437 value: "letsencrypt.org".to_string(),
438 },
439 CaaRecord {
441 flags: 0x80,
442 tag: "auth".to_string(),
443 value: "future-extension".to_string(),
444 },
445 ],
446 effective_domain: Some("example.com".to_string()),
447 has_policy: true,
448 issuer_match: None,
449 note: ISSUANCE_TIME_NOTE.to_string(),
450 };
451 assert_eq!(
452 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
453 IssuerCaaMatch::Mismatch,
454 "critical unknown tag must veto otherwise-matching issue"
455 );
456 }
457
458 #[test]
459 fn classify_unknown_non_critical_tag_does_not_veto() {
460 let policy = CaaPolicy {
463 records: vec![
464 CaaRecord {
465 flags: 0,
466 tag: "issue".to_string(),
467 value: "letsencrypt.org".to_string(),
468 },
469 CaaRecord {
470 flags: 0,
471 tag: "auth".to_string(),
472 value: "future-extension".to_string(),
473 },
474 ],
475 effective_domain: Some("example.com".to_string()),
476 has_policy: true,
477 issuer_match: None,
478 note: ISSUANCE_TIME_NOTE.to_string(),
479 };
480 assert_eq!(
481 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
482 IssuerCaaMatch::Permitted
483 );
484 }
485
486 #[test]
487 fn classify_critical_known_tag_does_not_veto() {
488 let policy = CaaPolicy {
491 records: vec![CaaRecord {
492 flags: 0x80,
493 tag: "issue".to_string(),
494 value: "letsencrypt.org".to_string(),
495 }],
496 effective_domain: Some("example.com".to_string()),
497 has_policy: true,
498 issuer_match: None,
499 note: ISSUANCE_TIME_NOTE.to_string(),
500 };
501 assert_eq!(
502 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
503 IssuerCaaMatch::Permitted
504 );
505 }
506}