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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub iodef: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub wildcard_note: Option<String>,
78 pub note: String,
80}
81
82impl CaaPolicy {
83 pub fn empty() -> Self {
85 Self {
86 records: Vec::new(),
87 effective_domain: None,
88 has_policy: false,
89 issuer_match: None,
90 iodef: Vec::new(),
91 wildcard_note: None,
92 note: ISSUANCE_TIME_NOTE.to_string(),
93 }
94 }
95
96 fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
99 let iodef = records
100 .iter()
101 .filter(|r| r.tag == "iodef")
102 .map(|r| r.value.clone())
103 .filter(|v| !v.is_empty())
104 .collect();
105 let wildcard_note = analyze_wildcard(&records);
106 Self {
107 has_policy: true,
108 records,
109 effective_domain: Some(effective_domain),
110 issuer_match: None,
111 iodef,
112 wildcard_note,
113 note: ISSUANCE_TIME_NOTE.to_string(),
114 }
115 }
116}
117
118fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
122 records
123 .iter()
124 .filter(|r| r.tag == tag)
125 .map(|r| {
126 r.value
127 .split(';')
128 .next()
129 .unwrap_or(&r.value)
130 .trim()
131 .to_ascii_lowercase()
132 })
133 .filter(|v| !v.is_empty())
134 .collect()
135}
136
137fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
145 let has_issue = records.iter().any(|r| r.tag == "issue");
146 let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
147 if !has_issue || !has_issuewild {
148 return None;
149 }
150 let issue = permitted_cas(records, "issue");
151 let issuewild = permitted_cas(records, "issuewild");
152 let looser: Vec<String> = issuewild
153 .iter()
154 .filter(|w| !issue.iter().any(|i| i == *w))
155 .cloned()
156 .collect();
157 if looser.is_empty() {
158 None
159 } else {
160 Some(format!(
161 "issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
162 looser.join(", ")
163 ))
164 }
165}
166
167pub async fn lookup_caa(resolver: &DnsResolver, domain: &str) -> CaaPolicy {
174 let mut current = domain.trim_end_matches('.').to_ascii_lowercase();
175
176 loop {
177 match resolver.resolve(¤t, RecordType::CAA, None).await {
178 Ok(records) if !records.is_empty() => {
179 let caa: Vec<CaaRecord> = records
180 .into_iter()
181 .filter_map(|r| match r.data {
182 RecordData::CAA { flags, tag, value } => Some(CaaRecord {
183 flags,
184 tag: tag.to_ascii_lowercase(),
185 value,
186 }),
187 _ => None,
188 })
189 .collect();
190
191 if !caa.is_empty() {
192 return CaaPolicy::from_records(caa, current);
193 }
194 }
195 Ok(_) | Err(_) => {}
196 }
197
198 match current.split_once('.') {
200 Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
201 _ => return CaaPolicy::empty(),
202 }
203 }
204}
205
206pub fn classify_issuer(issuer: &str, policy: &CaaPolicy) -> IssuerCaaMatch {
209 if !policy.has_policy {
210 return IssuerCaaMatch::NoPolicy;
211 }
212
213 const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
219 let critical_unknown = policy
220 .records
221 .iter()
222 .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
223 if critical_unknown {
224 return IssuerCaaMatch::Mismatch;
225 }
226
227 let issue_values: Vec<String> = policy
228 .records
229 .iter()
230 .filter(|r| r.tag == "issue" || r.tag == "issuewild")
231 .map(|r| {
232 r.value
235 .split(';')
236 .next()
237 .unwrap_or(&r.value)
238 .trim()
239 .to_ascii_lowercase()
240 })
241 .collect();
242
243 if issue_values.is_empty() {
244 return IssuerCaaMatch::Indeterminate;
245 }
246
247 let issuer_lc = issuer.to_ascii_lowercase();
248 let allowed_any = issue_values.iter().any(|v| !v.is_empty());
249
250 let matched = issue_values
251 .iter()
252 .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc));
253
254 if matched {
255 IssuerCaaMatch::Permitted
256 } else if allowed_any {
257 IssuerCaaMatch::Mismatch
258 } else {
259 IssuerCaaMatch::Mismatch
262 }
263}
264
265fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
272 if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
279 return true;
280 }
281 for (cv, aliases) in CA_ALIASES {
286 if caa_value == *cv && aliases.iter().any(|a| contains_word(issuer_lc, a)) {
287 return true;
288 }
289 }
290 let base = caa_value
296 .rsplit_once('.')
297 .map(|(b, _)| b)
298 .unwrap_or(caa_value);
299 base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
300}
301
302const MIN_FALLBACK_BASE_LEN: usize = 6;
307
308fn contains_word(haystack: &str, needle: &str) -> bool {
317 if needle.is_empty() {
318 return false;
319 }
320 let bytes = haystack.as_bytes();
321 let mut from = 0;
322 while let Some(rel) = haystack[from..].find(needle) {
323 let start = from + rel;
324 let end = start + needle.len();
325 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
326 let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
327 if before_ok && after_ok {
328 return true;
329 }
330 let first_len = haystack[start..].chars().next().map_or(1, char::len_utf8);
334 from = start + first_len;
335 }
336 false
337}
338
339const CA_ALIASES: &[(&str, &[&str])] = &[
342 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
343 ("pki.goog", &["google trust services", "gts"]),
344 ("digicert.com", &["digicert"]),
345 ("sectigo.com", &["sectigo", "comodo"]),
346 ("globalsign.com", &["globalsign"]),
347 ("amazon.com", &["amazon"]),
348 ("amazontrust.com", &["amazon"]),
349 ("zerossl.com", &["zerossl"]),
350 ("buypass.com", &["buypass"]),
351 ("entrust.net", &["entrust"]),
352 ("ssl.com", &["ssl.com"]),
353 ("certum.pl", &["certum"]),
354 ("identrust.com", &["identrust"]),
355];
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
362 let records = records
365 .into_iter()
366 .map(|(tag, value)| CaaRecord {
367 flags: 0,
368 tag: tag.to_string(),
369 value: value.to_string(),
370 })
371 .collect();
372 CaaPolicy::from_records(records, "example.com".to_string())
373 }
374
375 #[test]
376 fn iodef_contacts_are_extracted() {
377 let policy = policy_with(vec![
378 ("issue", "letsencrypt.org"),
379 ("iodef", "mailto:security@example.com"),
380 ]);
381 assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
382 }
383
384 #[test]
385 fn wildcard_note_flags_broader_wildcard_policy() {
386 let policy = policy_with(vec![
389 ("issue", "letsencrypt.org"),
390 ("issuewild", "digicert.com"),
391 ]);
392 let note = policy
393 .wildcard_note
394 .expect("looser wildcard policy flagged");
395 assert!(
396 note.contains("digicert.com"),
397 "note names the extra CA: {note}"
398 );
399 }
400
401 #[test]
402 fn wildcard_note_absent_when_issuewild_missing() {
403 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
405 assert!(policy.wildcard_note.is_none());
406 }
407
408 #[test]
409 fn wildcard_note_absent_when_policies_consistent() {
410 let policy = policy_with(vec![
411 ("issue", "letsencrypt.org"),
412 ("issuewild", "letsencrypt.org"),
413 ]);
414 assert!(policy.wildcard_note.is_none());
415 }
416
417 #[test]
418 fn classify_no_policy() {
419 assert_eq!(
420 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
421 IssuerCaaMatch::NoPolicy
422 );
423 }
424
425 #[test]
426 fn classify_indeterminate_when_only_iodef() {
427 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
428 assert_eq!(
429 classify_issuer("Let's Encrypt R3", &policy),
430 IssuerCaaMatch::Indeterminate
431 );
432 }
433
434 #[test]
435 fn classify_permitted_letsencrypt() {
436 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
437 assert_eq!(
438 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
439 IssuerCaaMatch::Permitted
440 );
441 }
442
443 #[test]
444 fn classify_permitted_via_alias() {
445 let policy = policy_with(vec![("issue", "pki.goog")]);
448 assert_eq!(
449 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
450 IssuerCaaMatch::Permitted
451 );
452 }
453
454 #[test]
455 fn classify_mismatch_when_only_other_ca_allowed() {
456 let policy = policy_with(vec![("issue", "digicert.com")]);
457 assert_eq!(
458 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
459 IssuerCaaMatch::Mismatch
460 );
461 }
462
463 #[test]
464 fn classify_verbatim_match_is_length_guarded() {
465 let policy = policy_with(vec![("issue", "ca")]);
470 assert_eq!(
471 classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy),
472 IssuerCaaMatch::Mismatch,
473 "two-letter verbatim CAA value must not over-match unrelated issuers"
474 );
475 }
476
477 #[test]
478 fn classify_full_domain_verbatim_still_matches() {
479 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
482 assert_eq!(
483 classify_issuer("CN=R3, O=letsencrypt.org", &policy),
484 IssuerCaaMatch::Permitted,
485 "full-domain verbatim value must still match"
486 );
487 }
488
489 #[test]
490 fn classify_does_not_overmatch_short_base_substring() {
491 let policy = policy_with(vec![("issue", "ssl.com")]);
495 assert_eq!(
496 classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy),
497 IssuerCaaMatch::Mismatch,
498 "bare 'ssl' substring must not over-match"
499 );
500 assert_eq!(
502 classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy),
503 IssuerCaaMatch::Permitted
504 );
505 }
506
507 #[test]
508 fn classify_unknown_ca_base_matches_only_on_word_boundary() {
509 let policy = policy_with(vec![("issue", "examplecorp.test")]);
512 assert_eq!(
513 classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy),
514 IssuerCaaMatch::Permitted
515 );
516 assert_eq!(
517 classify_issuer("CN=NotExamplecorporated CA", &policy),
518 IssuerCaaMatch::Mismatch,
519 "base inside a larger word must not match"
520 );
521 }
522
523 #[test]
529 fn classify_does_not_panic_on_multibyte_rejected_match() {
530 let policy = policy_with(vec![("issue", "éabcdef.com")]);
531 assert_eq!(
532 classify_issuer("CN=xéabcdef.com", &policy),
533 IssuerCaaMatch::Mismatch
534 );
535 assert!(!contains_word("xéabc", "éabc"));
537 assert!(contains_word("xéabc éabc", "éabc"));
538 }
539
540 #[test]
543 fn classify_alias_requires_whole_word() {
544 let policy = policy_with(vec![("issue", "entrust.net")]);
545 assert_eq!(
546 classify_issuer("CN=TrustID Server CA O1, O=IdenTrust", &policy),
547 IssuerCaaMatch::Mismatch,
548 "entrust must not match inside identrust"
549 );
550 assert_eq!(
551 classify_issuer(
552 "CN=Entrust Certification Authority - L1K, O=Entrust, Inc.",
553 &policy
554 ),
555 IssuerCaaMatch::Permitted
556 );
557 let google = policy_with(vec![("issue", "pki.goog")]);
559 assert_eq!(
560 classify_issuer("CN=WR2, O=GTS", &google),
561 IssuerCaaMatch::Permitted
562 );
563 }
564
565 #[test]
566 fn classify_mismatch_when_issuance_forbidden() {
567 let policy = policy_with(vec![("issue", ";")]);
569 assert_eq!(
570 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
571 IssuerCaaMatch::Mismatch
572 );
573 }
574
575 #[test]
576 fn classify_issuewild_treated_like_issue() {
577 let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
578 assert_eq!(
579 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
580 IssuerCaaMatch::Permitted
581 );
582 }
583
584 #[test]
585 fn empty_policy_has_no_issuer_match_set() {
586 let p = CaaPolicy::empty();
587 assert!(p.records.is_empty());
588 assert!(!p.has_policy);
589 assert!(p.issuer_match.is_none());
590 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
591 }
592
593 #[test]
597 fn classify_unknown_critical_tag_forces_mismatch() {
598 let policy = CaaPolicy {
599 records: vec![
600 CaaRecord {
602 flags: 0,
603 tag: "issue".to_string(),
604 value: "letsencrypt.org".to_string(),
605 },
606 CaaRecord {
608 flags: 0x80,
609 tag: "auth".to_string(),
610 value: "future-extension".to_string(),
611 },
612 ],
613 effective_domain: Some("example.com".to_string()),
614 has_policy: true,
615 issuer_match: None,
616 iodef: Vec::new(),
617 wildcard_note: None,
618 note: ISSUANCE_TIME_NOTE.to_string(),
619 };
620 assert_eq!(
621 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
622 IssuerCaaMatch::Mismatch,
623 "critical unknown tag must veto otherwise-matching issue"
624 );
625 }
626
627 #[test]
628 fn classify_unknown_non_critical_tag_does_not_veto() {
629 let policy = CaaPolicy {
632 records: vec![
633 CaaRecord {
634 flags: 0,
635 tag: "issue".to_string(),
636 value: "letsencrypt.org".to_string(),
637 },
638 CaaRecord {
639 flags: 0,
640 tag: "auth".to_string(),
641 value: "future-extension".to_string(),
642 },
643 ],
644 effective_domain: Some("example.com".to_string()),
645 has_policy: true,
646 issuer_match: None,
647 iodef: Vec::new(),
648 wildcard_note: None,
649 note: ISSUANCE_TIME_NOTE.to_string(),
650 };
651 assert_eq!(
652 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
653 IssuerCaaMatch::Permitted
654 );
655 }
656
657 #[test]
658 fn classify_critical_known_tag_does_not_veto() {
659 let policy = CaaPolicy {
662 records: vec![CaaRecord {
663 flags: 0x80,
664 tag: "issue".to_string(),
665 value: "letsencrypt.org".to_string(),
666 }],
667 effective_domain: Some("example.com".to_string()),
668 has_policy: true,
669 issuer_match: None,
670 iodef: Vec::new(),
671 wildcard_note: None,
672 note: ISSUANCE_TIME_NOTE.to_string(),
673 };
674 assert_eq!(
675 classify_issuer("CN=R3, O=Let's Encrypt", &policy),
676 IssuerCaaMatch::Permitted
677 );
678 }
679}