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
34impl CaaRecord {
35 fn ca_domain(&self) -> String {
39 self.value
40 .split(';')
41 .next()
42 .unwrap_or(&self.value)
43 .trim()
44 .to_ascii_lowercase()
45 }
46}
47
48#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum IssuerCaaMatch {
52 NoPolicy,
54 Permitted,
57 Mismatch,
60 Indeterminate,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CaaPolicy {
69 pub records: Vec<CaaRecord>,
71 pub effective_domain: Option<String>,
75 pub has_policy: bool,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub issuer_match: Option<IssuerCaaMatch>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub iodef: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub wildcard_note: Option<String>,
92 pub note: String,
94}
95
96impl CaaPolicy {
97 pub fn empty() -> Self {
99 Self {
100 records: Vec::new(),
101 effective_domain: None,
102 has_policy: false,
103 issuer_match: None,
104 iodef: Vec::new(),
105 wildcard_note: None,
106 note: ISSUANCE_TIME_NOTE.to_string(),
107 }
108 }
109
110 fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
113 let iodef = records
114 .iter()
115 .filter(|r| r.tag == "iodef")
116 .map(|r| r.value.clone())
117 .filter(|v| !v.is_empty())
118 .collect();
119 let wildcard_note = analyze_wildcard(&records);
120 Self {
121 has_policy: true,
122 records,
123 effective_domain: Some(effective_domain),
124 issuer_match: None,
125 iodef,
126 wildcard_note,
127 note: ISSUANCE_TIME_NOTE.to_string(),
128 }
129 }
130}
131
132fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
136 records
137 .iter()
138 .filter(|r| r.tag == tag)
139 .map(CaaRecord::ca_domain)
140 .filter(|v| !v.is_empty())
141 .collect()
142}
143
144fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
152 let has_issue = records.iter().any(|r| r.tag == "issue");
153 let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
154 if !has_issue || !has_issuewild {
155 return None;
156 }
157 let issue = permitted_cas(records, "issue");
158 let issuewild = permitted_cas(records, "issuewild");
159 let looser: Vec<String> = issuewild
160 .iter()
161 .filter(|w| !issue.iter().any(|i| i == *w))
162 .cloned()
163 .collect();
164 if looser.is_empty() {
165 None
166 } else {
167 Some(format!(
168 "issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
169 looser.join(", ")
170 ))
171 }
172}
173
174pub async fn lookup_caa(resolver: &DnsResolver, domain: &str) -> CaaPolicy {
181 let mut current = domain.trim_end_matches('.').to_ascii_lowercase();
182
183 loop {
184 match resolver.resolve(¤t, RecordType::CAA, None).await {
185 Ok(records) if !records.is_empty() => {
186 let caa: Vec<CaaRecord> = records
187 .into_iter()
188 .filter_map(|r| match r.data {
189 RecordData::CAA { flags, tag, value } => Some(CaaRecord {
190 flags,
191 tag: tag.to_ascii_lowercase(),
192 value,
193 }),
194 _ => None,
195 })
196 .collect();
197
198 if !caa.is_empty() {
199 return CaaPolicy::from_records(caa, current);
200 }
201 }
202 Ok(_) | Err(_) => {}
203 }
204
205 match current.split_once('.') {
207 Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
208 _ => return CaaPolicy::empty(),
209 }
210 }
211}
212
213pub fn classify_issuer(issuer: &str, policy: &CaaPolicy) -> IssuerCaaMatch {
216 if !policy.has_policy {
217 return IssuerCaaMatch::NoPolicy;
218 }
219
220 const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
226 let critical_unknown = policy
227 .records
228 .iter()
229 .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
230 if critical_unknown {
231 return IssuerCaaMatch::Mismatch;
232 }
233
234 let issue_values: Vec<String> = policy
235 .records
236 .iter()
237 .filter(|r| r.tag == "issue" || r.tag == "issuewild")
238 .map(CaaRecord::ca_domain)
239 .collect();
240
241 if issue_values.is_empty() {
244 return IssuerCaaMatch::Indeterminate;
245 }
246
247 let issuer_lc = issuer.to_ascii_lowercase();
248 if issue_values
249 .iter()
250 .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc))
251 {
252 IssuerCaaMatch::Permitted
253 } else {
254 IssuerCaaMatch::Mismatch
257 }
258}
259
260fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
267 if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
274 return true;
275 }
276 for (cv, aliases) in CA_ALIASES {
281 if caa_value == *cv && aliases.iter().any(|a| contains_word(issuer_lc, a)) {
282 return true;
283 }
284 }
285 let base = caa_value
291 .rsplit_once('.')
292 .map(|(b, _)| b)
293 .unwrap_or(caa_value);
294 base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
295}
296
297const MIN_FALLBACK_BASE_LEN: usize = 6;
302
303fn contains_word(haystack: &str, needle: &str) -> bool {
312 if needle.is_empty() {
313 return false;
314 }
315 let bytes = haystack.as_bytes();
316 let mut from = 0;
317 while let Some(rel) = haystack[from..].find(needle) {
318 let start = from + rel;
319 let end = start + needle.len();
320 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
321 let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
322 if before_ok && after_ok {
323 return true;
324 }
325 let first_len = haystack[start..].chars().next().map_or(1, char::len_utf8);
329 from = start + first_len;
330 }
331 false
332}
333
334const CA_ALIASES: &[(&str, &[&str])] = &[
337 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
338 ("pki.goog", &["google trust services", "gts"]),
339 ("digicert.com", &["digicert"]),
340 ("sectigo.com", &["sectigo", "comodo"]),
341 ("globalsign.com", &["globalsign"]),
342 ("amazon.com", &["amazon"]),
343 ("amazontrust.com", &["amazon"]),
344 ("zerossl.com", &["zerossl"]),
345 ("buypass.com", &["buypass"]),
346 ("entrust.net", &["entrust"]),
347 ("ssl.com", &["ssl.com"]),
348 ("certum.pl", &["certum"]),
349 ("identrust.com", &["identrust"]),
350];
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
357 let records = records
360 .into_iter()
361 .map(|(tag, value)| CaaRecord {
362 flags: 0,
363 tag: tag.to_string(),
364 value: value.to_string(),
365 })
366 .collect();
367 CaaPolicy::from_records(records, "example.com".to_string())
368 }
369
370 #[test]
371 fn iodef_contacts_are_extracted() {
372 let policy = policy_with(vec![
373 ("issue", "letsencrypt.org"),
374 ("iodef", "mailto:security@example.com"),
375 ]);
376 assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
377 }
378
379 #[test]
380 fn wildcard_note_flags_broader_wildcard_policy() {
381 let policy = policy_with(vec![
384 ("issue", "letsencrypt.org"),
385 ("issuewild", "digicert.com"),
386 ]);
387 let note = policy
388 .wildcard_note
389 .expect("looser wildcard policy flagged");
390 assert!(
391 note.contains("digicert.com"),
392 "note names the extra CA: {note}"
393 );
394 }
395
396 #[test]
397 fn wildcard_note_absent_when_issuewild_missing() {
398 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
400 assert!(policy.wildcard_note.is_none());
401 }
402
403 #[test]
404 fn wildcard_note_absent_when_policies_consistent() {
405 let policy = policy_with(vec![
406 ("issue", "letsencrypt.org"),
407 ("issuewild", "letsencrypt.org"),
408 ]);
409 assert!(policy.wildcard_note.is_none());
410 }
411
412 #[test]
413 fn classify_no_policy() {
414 assert_eq!(
415 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
416 IssuerCaaMatch::NoPolicy
417 );
418 }
419
420 #[test]
421 fn classify_indeterminate_when_only_iodef() {
422 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
423 assert_eq!(
424 classify_issuer("Let's Encrypt R3", &policy),
425 IssuerCaaMatch::Indeterminate
426 );
427 }
428
429 #[test]
430 fn classify_permitted_letsencrypt() {
431 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
432 assert_eq!(
433 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
434 IssuerCaaMatch::Permitted
435 );
436 }
437
438 #[test]
439 fn classify_permitted_via_alias() {
440 let policy = policy_with(vec![("issue", "pki.goog")]);
443 assert_eq!(
444 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
445 IssuerCaaMatch::Permitted
446 );
447 }
448
449 #[test]
450 fn classify_mismatch_when_only_other_ca_allowed() {
451 let policy = policy_with(vec![("issue", "digicert.com")]);
452 assert_eq!(
453 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
454 IssuerCaaMatch::Mismatch
455 );
456 }
457
458 #[test]
459 fn classify_verbatim_match_is_length_guarded() {
460 let policy = policy_with(vec![("issue", "ca")]);
465 assert_eq!(
466 classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy),
467 IssuerCaaMatch::Mismatch,
468 "two-letter verbatim CAA value must not over-match unrelated issuers"
469 );
470 }
471
472 #[test]
473 fn classify_full_domain_verbatim_still_matches() {
474 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
477 assert_eq!(
478 classify_issuer("CN=R3, O=letsencrypt.org", &policy),
479 IssuerCaaMatch::Permitted,
480 "full-domain verbatim value must still match"
481 );
482 }
483
484 #[test]
485 fn classify_does_not_overmatch_short_base_substring() {
486 let policy = policy_with(vec![("issue", "ssl.com")]);
490 assert_eq!(
491 classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy),
492 IssuerCaaMatch::Mismatch,
493 "bare 'ssl' substring must not over-match"
494 );
495 assert_eq!(
497 classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy),
498 IssuerCaaMatch::Permitted
499 );
500 }
501
502 #[test]
503 fn classify_unknown_ca_base_matches_only_on_word_boundary() {
504 let policy = policy_with(vec![("issue", "examplecorp.test")]);
507 assert_eq!(
508 classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy),
509 IssuerCaaMatch::Permitted
510 );
511 assert_eq!(
512 classify_issuer("CN=NotExamplecorporated CA", &policy),
513 IssuerCaaMatch::Mismatch,
514 "base inside a larger word must not match"
515 );
516 }
517
518 #[test]
524 fn classify_does_not_panic_on_multibyte_rejected_match() {
525 let policy = policy_with(vec![("issue", "éabcdef.com")]);
526 assert_eq!(
527 classify_issuer("CN=xéabcdef.com", &policy),
528 IssuerCaaMatch::Mismatch
529 );
530 assert!(!contains_word("xéabc", "éabc"));
532 assert!(contains_word("xéabc éabc", "éabc"));
533 }
534
535 #[test]
538 fn classify_alias_requires_whole_word() {
539 let policy = policy_with(vec![("issue", "entrust.net")]);
540 assert_eq!(
541 classify_issuer("CN=TrustID Server CA O1, O=IdenTrust", &policy),
542 IssuerCaaMatch::Mismatch,
543 "entrust must not match inside identrust"
544 );
545 assert_eq!(
546 classify_issuer(
547 "CN=Entrust Certification Authority - L1K, O=Entrust, Inc.",
548 &policy
549 ),
550 IssuerCaaMatch::Permitted
551 );
552 let google = policy_with(vec![("issue", "pki.goog")]);
554 assert_eq!(
555 classify_issuer("CN=WR2, O=GTS", &google),
556 IssuerCaaMatch::Permitted
557 );
558 }
559
560 #[test]
561 fn classify_mismatch_when_issuance_forbidden() {
562 let policy = policy_with(vec![("issue", ";")]);
564 assert_eq!(
565 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
566 IssuerCaaMatch::Mismatch
567 );
568 }
569
570 #[test]
571 fn classify_issuewild_treated_like_issue() {
572 let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
573 assert_eq!(
574 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
575 IssuerCaaMatch::Permitted
576 );
577 }
578
579 #[test]
580 fn empty_policy_has_no_issuer_match_set() {
581 let p = CaaPolicy::empty();
582 assert!(p.records.is_empty());
583 assert!(!p.has_policy);
584 assert!(p.issuer_match.is_none());
585 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
586 }
587
588 #[test]
592 fn classify_unknown_critical_tag_forces_mismatch() {
593 let policy = CaaPolicy {
594 records: vec![
595 CaaRecord {
597 flags: 0,
598 tag: "issue".to_string(),
599 value: "letsencrypt.org".to_string(),
600 },
601 CaaRecord {
603 flags: 0x80,
604 tag: "auth".to_string(),
605 value: "future-extension".to_string(),
606 },
607 ],
608 effective_domain: Some("example.com".to_string()),
609 has_policy: true,
610 issuer_match: None,
611 iodef: Vec::new(),
612 wildcard_note: None,
613 note: ISSUANCE_TIME_NOTE.to_string(),
614 };
615 assert_eq!(
616 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
617 IssuerCaaMatch::Mismatch,
618 "critical unknown tag must veto otherwise-matching issue"
619 );
620 }
621
622 #[test]
623 fn classify_unknown_non_critical_tag_does_not_veto() {
624 let policy = CaaPolicy {
627 records: vec![
628 CaaRecord {
629 flags: 0,
630 tag: "issue".to_string(),
631 value: "letsencrypt.org".to_string(),
632 },
633 CaaRecord {
634 flags: 0,
635 tag: "auth".to_string(),
636 value: "future-extension".to_string(),
637 },
638 ],
639 effective_domain: Some("example.com".to_string()),
640 has_policy: true,
641 issuer_match: None,
642 iodef: Vec::new(),
643 wildcard_note: None,
644 note: ISSUANCE_TIME_NOTE.to_string(),
645 };
646 assert_eq!(
647 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
648 IssuerCaaMatch::Permitted
649 );
650 }
651
652 #[test]
653 fn classify_critical_known_tag_does_not_veto() {
654 let policy = CaaPolicy {
657 records: vec![CaaRecord {
658 flags: 0x80,
659 tag: "issue".to_string(),
660 value: "letsencrypt.org".to_string(),
661 }],
662 effective_domain: Some("example.com".to_string()),
663 has_policy: true,
664 issuer_match: None,
665 iodef: Vec::new(),
666 wildcard_note: None,
667 note: ISSUANCE_TIME_NOTE.to_string(),
668 };
669 assert_eq!(
670 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
671 IssuerCaaMatch::Permitted
672 );
673 }
674}