1use serde::{Deserialize, Serialize};
12
13use crate::dns::{DnsResolver, RecordData, RecordType};
14use crate::error::Result;
15use crate::validation::normalize_host;
16
17pub const ISSUANCE_TIME_NOTE: &str = "CAA is checked by CAs at issuance time, not by \
21clients at validation time. A cert whose issuer is not in the current CAA policy is \
22not invalid — it may have been issued before the policy was set, or under a parent \
23zone. Treat mismatches as informational.";
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CaaRecord {
28 pub flags: u8,
30 pub tag: String,
32 pub value: String,
34}
35
36impl CaaRecord {
37 fn ca_domain(&self) -> String {
41 self.value
42 .split(';')
43 .next()
44 .unwrap_or(&self.value)
45 .trim()
46 .to_ascii_lowercase()
47 }
48}
49
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "lowercase")]
53pub enum IssuerCaaMatch {
54 NoPolicy,
56 Permitted,
60 Mismatch,
63 Indeterminate,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CaaPolicy {
73 pub records: Vec<CaaRecord>,
75 pub effective_domain: Option<String>,
79 pub has_policy: bool,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub issuer_match: Option<IssuerCaaMatch>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub iodef: Vec<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub wildcard_note: Option<String>,
96 pub note: String,
98}
99
100impl CaaPolicy {
101 pub fn empty() -> Self {
103 Self {
104 records: Vec::new(),
105 effective_domain: None,
106 has_policy: false,
107 issuer_match: None,
108 iodef: Vec::new(),
109 wildcard_note: None,
110 note: ISSUANCE_TIME_NOTE.to_string(),
111 }
112 }
113
114 fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
117 let iodef = records
118 .iter()
119 .filter(|r| r.tag == "iodef")
120 .map(|r| r.value.clone())
121 .filter(|v| !v.is_empty())
122 .collect();
123 let wildcard_note = analyze_wildcard(&records);
124 Self {
125 has_policy: true,
126 records,
127 effective_domain: Some(effective_domain),
128 issuer_match: None,
129 iodef,
130 wildcard_note,
131 note: ISSUANCE_TIME_NOTE.to_string(),
132 }
133 }
134}
135
136fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
140 records
141 .iter()
142 .filter(|r| r.tag == tag)
143 .map(CaaRecord::ca_domain)
144 .filter(|v| !v.is_empty())
145 .collect()
146}
147
148fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
156 let has_issue = records.iter().any(|r| r.tag == "issue");
157 let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
158 if !has_issue || !has_issuewild {
159 return None;
160 }
161 let issue = permitted_cas(records, "issue");
162 let issuewild = permitted_cas(records, "issuewild");
163 let looser: Vec<String> = issuewild
164 .iter()
165 .filter(|w| !issue.iter().any(|i| i == *w))
166 .cloned()
167 .collect();
168 if looser.is_empty() {
169 None
170 } else {
171 Some(format!(
172 "issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
173 looser.join(", ")
174 ))
175 }
176}
177
178pub async fn lookup_caa(
189 resolver: &DnsResolver,
190 host: &str,
191 nameserver: Option<&str>,
192) -> Result<CaaPolicy> {
193 let mut current = normalize_host(host)?;
194
195 loop {
196 match resolver
197 .resolve(¤t, RecordType::CAA, nameserver)
198 .await
199 {
200 Ok(records) if !records.is_empty() => {
201 let caa: Vec<CaaRecord> = records
202 .into_iter()
203 .filter_map(|r| match r.data {
204 RecordData::CAA { flags, tag, value } => Some(CaaRecord {
205 flags,
206 tag: tag.to_ascii_lowercase(),
207 value,
208 }),
209 _ => None,
210 })
211 .collect();
212
213 if !caa.is_empty() {
214 return Ok(CaaPolicy::from_records(caa, current));
215 }
216 }
217 Ok(_) | Err(_) => {}
218 }
219
220 match current.split_once('.') {
222 Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
223 _ => return Ok(CaaPolicy::empty()),
224 }
225 }
226}
227
228pub fn classify_issuer(issuer: &str, policy: &CaaPolicy, wildcard: bool) -> IssuerCaaMatch {
236 if !policy.has_policy {
237 return IssuerCaaMatch::NoPolicy;
238 }
239
240 const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
246 let critical_unknown = policy
247 .records
248 .iter()
249 .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
250 if critical_unknown {
251 return IssuerCaaMatch::Mismatch;
252 }
253
254 let has_issuewild = policy.records.iter().any(|r| r.tag == "issuewild");
255 let governing = if wildcard && has_issuewild {
256 "issuewild"
257 } else {
258 "issue"
259 };
260 let issue_values: Vec<String> = policy
261 .records
262 .iter()
263 .filter(|r| r.tag == governing)
264 .map(CaaRecord::ca_domain)
265 .collect();
266
267 if issue_values.is_empty() {
270 return IssuerCaaMatch::Indeterminate;
271 }
272
273 let issuer_lc = issuer.to_ascii_lowercase();
274 if issue_values
275 .iter()
276 .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc))
277 {
278 IssuerCaaMatch::Permitted
279 } else {
280 IssuerCaaMatch::Mismatch
283 }
284}
285
286fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
293 if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
300 return true;
301 }
302 for (cv, aliases) in CA_ALIASES {
307 if caa_value == *cv && aliases.iter().any(|a| contains_word(issuer_lc, a)) {
308 return true;
309 }
310 }
311 let base = caa_value
317 .rsplit_once('.')
318 .map(|(b, _)| b)
319 .unwrap_or(caa_value);
320 base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
321}
322
323const MIN_FALLBACK_BASE_LEN: usize = 6;
328
329fn contains_word(haystack: &str, needle: &str) -> bool {
338 if needle.is_empty() {
339 return false;
340 }
341 let bytes = haystack.as_bytes();
342 let mut from = 0;
343 while let Some(rel) = haystack[from..].find(needle) {
344 let start = from + rel;
345 let end = start + needle.len();
346 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
347 let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
348 if before_ok && after_ok {
349 return true;
350 }
351 let first_len = haystack[start..].chars().next().map_or(1, char::len_utf8);
355 from = start + first_len;
356 }
357 false
358}
359
360const CA_ALIASES: &[(&str, &[&str])] = &[
363 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
364 ("pki.goog", &["google trust services", "gts"]),
365 ("digicert.com", &["digicert"]),
366 ("sectigo.com", &["sectigo", "comodo"]),
367 ("globalsign.com", &["globalsign"]),
368 ("amazon.com", &["amazon"]),
369 ("amazontrust.com", &["amazon"]),
370 ("zerossl.com", &["zerossl"]),
371 ("buypass.com", &["buypass"]),
372 ("entrust.net", &["entrust"]),
373 ("ssl.com", &["ssl.com"]),
374 ("certum.pl", &["certum"]),
375 ("identrust.com", &["identrust"]),
376];
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[tokio::test]
385 async fn lookup_keeps_www_and_rejects_invalid_input() {
386 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
387 use std::sync::{Arc, Mutex};
388
389 let asked = Arc::new(Mutex::new(Vec::new()));
390 let log = asked.clone();
391 let port = spawn_mock_dns_fn(move |name, _| {
392 log.lock().unwrap().push(name.to_string());
393 MockReply::NoData
394 })
395 .await;
396 let resolver = mock_dns_resolver_default(port);
397
398 let policy = lookup_caa(&resolver, "HTTPS://WWW.Seer.Test/path", None)
399 .await
400 .unwrap();
401 assert!(!policy.has_policy);
402 let asked = asked.lock().unwrap().clone();
403 assert_eq!(
404 asked.first().map(|n| n.trim_end_matches('.')),
405 Some("www.seer.test"),
406 "{asked:?}"
407 );
408
409 assert!(lookup_caa(&resolver, "bad..name", None).await.is_err());
410 }
411
412 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
413 let records = records
416 .into_iter()
417 .map(|(tag, value)| CaaRecord {
418 flags: 0,
419 tag: tag.to_string(),
420 value: value.to_string(),
421 })
422 .collect();
423 CaaPolicy::from_records(records, "example.com".to_string())
424 }
425
426 #[test]
427 fn iodef_contacts_are_extracted() {
428 let policy = policy_with(vec![
429 ("issue", "letsencrypt.org"),
430 ("iodef", "mailto:security@example.com"),
431 ]);
432 assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
433 }
434
435 #[test]
436 fn wildcard_note_flags_broader_wildcard_policy() {
437 let policy = policy_with(vec![
440 ("issue", "letsencrypt.org"),
441 ("issuewild", "digicert.com"),
442 ]);
443 let note = policy
444 .wildcard_note
445 .expect("looser wildcard policy flagged");
446 assert!(
447 note.contains("digicert.com"),
448 "note names the extra CA: {note}"
449 );
450 }
451
452 #[test]
453 fn wildcard_note_absent_when_issuewild_missing() {
454 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
456 assert!(policy.wildcard_note.is_none());
457 }
458
459 #[test]
460 fn wildcard_note_absent_when_policies_consistent() {
461 let policy = policy_with(vec![
462 ("issue", "letsencrypt.org"),
463 ("issuewild", "letsencrypt.org"),
464 ]);
465 assert!(policy.wildcard_note.is_none());
466 }
467
468 #[test]
469 fn classify_no_policy() {
470 assert_eq!(
471 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty(), false),
472 IssuerCaaMatch::NoPolicy
473 );
474 }
475
476 #[test]
477 fn classify_indeterminate_when_only_iodef() {
478 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
479 assert_eq!(
480 classify_issuer("Let's Encrypt R3", &policy, false),
481 IssuerCaaMatch::Indeterminate
482 );
483 }
484
485 #[test]
486 fn classify_permitted_letsencrypt() {
487 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
488 assert_eq!(
489 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
490 IssuerCaaMatch::Permitted
491 );
492 }
493
494 #[test]
495 fn classify_permitted_via_alias() {
496 let policy = policy_with(vec![("issue", "pki.goog")]);
499 assert_eq!(
500 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy, false),
501 IssuerCaaMatch::Permitted
502 );
503 }
504
505 #[test]
506 fn classify_mismatch_when_only_other_ca_allowed() {
507 let policy = policy_with(vec![("issue", "digicert.com")]);
508 assert_eq!(
509 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
510 IssuerCaaMatch::Mismatch
511 );
512 }
513
514 #[test]
515 fn classify_verbatim_match_is_length_guarded() {
516 let policy = policy_with(vec![("issue", "ca")]);
521 assert_eq!(
522 classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy, false),
523 IssuerCaaMatch::Mismatch,
524 "two-letter verbatim CAA value must not over-match unrelated issuers"
525 );
526 }
527
528 #[test]
529 fn classify_full_domain_verbatim_still_matches() {
530 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
533 assert_eq!(
534 classify_issuer("CN=R3, O=letsencrypt.org", &policy, false),
535 IssuerCaaMatch::Permitted,
536 "full-domain verbatim value must still match"
537 );
538 }
539
540 #[test]
541 fn classify_does_not_overmatch_short_base_substring() {
542 let policy = policy_with(vec![("issue", "ssl.com")]);
546 assert_eq!(
547 classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy, false),
548 IssuerCaaMatch::Mismatch,
549 "bare 'ssl' substring must not over-match"
550 );
551 assert_eq!(
553 classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy, false),
554 IssuerCaaMatch::Permitted
555 );
556 }
557
558 #[test]
559 fn classify_unknown_ca_base_matches_only_on_word_boundary() {
560 let policy = policy_with(vec![("issue", "examplecorp.test")]);
563 assert_eq!(
564 classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy, false),
565 IssuerCaaMatch::Permitted
566 );
567 assert_eq!(
568 classify_issuer("CN=NotExamplecorporated CA", &policy, false),
569 IssuerCaaMatch::Mismatch,
570 "base inside a larger word must not match"
571 );
572 }
573
574 #[test]
580 fn classify_does_not_panic_on_multibyte_rejected_match() {
581 let policy = policy_with(vec![("issue", "éabcdef.com")]);
582 assert_eq!(
583 classify_issuer("CN=xéabcdef.com", &policy, false),
584 IssuerCaaMatch::Mismatch
585 );
586 assert!(!contains_word("xéabc", "éabc"));
588 assert!(contains_word("xéabc éabc", "éabc"));
589 }
590
591 #[test]
594 fn classify_alias_requires_whole_word() {
595 let policy = policy_with(vec![("issue", "entrust.net")]);
596 assert_eq!(
597 classify_issuer("CN=TrustID Server CA O1, O=IdenTrust", &policy, false),
598 IssuerCaaMatch::Mismatch,
599 "entrust must not match inside identrust"
600 );
601 assert_eq!(
602 classify_issuer(
603 "CN=Entrust Certification Authority - L1K, O=Entrust, Inc.",
604 &policy,
605 false
606 ),
607 IssuerCaaMatch::Permitted
608 );
609 let google = policy_with(vec![("issue", "pki.goog")]);
611 assert_eq!(
612 classify_issuer("CN=WR2, O=GTS", &google, false),
613 IssuerCaaMatch::Permitted
614 );
615 }
616
617 #[test]
618 fn classify_mismatch_when_issuance_forbidden() {
619 let policy = policy_with(vec![("issue", ";")]);
621 assert_eq!(
622 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
623 IssuerCaaMatch::Mismatch
624 );
625 }
626
627 #[test]
630 fn issuewild_governs_wildcards_and_only_wildcards() {
631 const LE: &str = "CN=R3, O=Let's Encrypt";
632 let split = policy_with(vec![
633 ("issue", "digicert.com"),
634 ("issuewild", "letsencrypt.org"),
635 ]);
636 assert_eq!(classify_issuer(LE, &split, true), IssuerCaaMatch::Permitted);
637 assert_eq!(classify_issuer(LE, &split, false), IssuerCaaMatch::Mismatch);
638
639 let wild_only = policy_with(vec![("issuewild", "letsencrypt.org")]);
641 assert_eq!(
642 classify_issuer(LE, &wild_only, true),
643 IssuerCaaMatch::Permitted
644 );
645 assert_eq!(
646 classify_issuer(LE, &wild_only, false),
647 IssuerCaaMatch::Indeterminate
648 );
649
650 let issue_only = policy_with(vec![("issue", "letsencrypt.org")]);
652 assert_eq!(
653 classify_issuer(LE, &issue_only, true),
654 IssuerCaaMatch::Permitted
655 );
656
657 let no_wild = policy_with(vec![("issue", "letsencrypt.org"), ("issuewild", ";")]);
659 assert_eq!(
660 classify_issuer(LE, &no_wild, true),
661 IssuerCaaMatch::Mismatch
662 );
663 assert_eq!(
664 classify_issuer(LE, &no_wild, false),
665 IssuerCaaMatch::Permitted
666 );
667 }
668
669 #[test]
670 fn empty_policy_has_no_issuer_match_set() {
671 let p = CaaPolicy::empty();
672 assert!(p.records.is_empty());
673 assert!(!p.has_policy);
674 assert!(p.issuer_match.is_none());
675 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
676 }
677
678 #[test]
682 fn classify_unknown_critical_tag_forces_mismatch() {
683 let policy = CaaPolicy {
684 records: vec![
685 CaaRecord {
687 flags: 0,
688 tag: "issue".to_string(),
689 value: "letsencrypt.org".to_string(),
690 },
691 CaaRecord {
693 flags: 0x80,
694 tag: "auth".to_string(),
695 value: "future-extension".to_string(),
696 },
697 ],
698 effective_domain: Some("example.com".to_string()),
699 has_policy: true,
700 issuer_match: None,
701 iodef: Vec::new(),
702 wildcard_note: None,
703 note: ISSUANCE_TIME_NOTE.to_string(),
704 };
705 assert_eq!(
706 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
707 IssuerCaaMatch::Mismatch,
708 "critical unknown tag must veto otherwise-matching issue"
709 );
710 }
711
712 #[test]
713 fn classify_unknown_non_critical_tag_does_not_veto() {
714 let policy = CaaPolicy {
717 records: vec![
718 CaaRecord {
719 flags: 0,
720 tag: "issue".to_string(),
721 value: "letsencrypt.org".to_string(),
722 },
723 CaaRecord {
724 flags: 0,
725 tag: "auth".to_string(),
726 value: "future-extension".to_string(),
727 },
728 ],
729 effective_domain: Some("example.com".to_string()),
730 has_policy: true,
731 issuer_match: None,
732 iodef: Vec::new(),
733 wildcard_note: None,
734 note: ISSUANCE_TIME_NOTE.to_string(),
735 };
736 assert_eq!(
737 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
738 IssuerCaaMatch::Permitted
739 );
740 }
741
742 #[test]
743 fn classify_critical_known_tag_does_not_veto() {
744 let policy = CaaPolicy {
747 records: vec![CaaRecord {
748 flags: 0x80,
749 tag: "issue".to_string(),
750 value: "letsencrypt.org".to_string(),
751 }],
752 effective_domain: Some("example.com".to_string()),
753 has_policy: true,
754 issuer_match: None,
755 iodef: Vec::new(),
756 wildcard_note: None,
757 note: ISSUANCE_TIME_NOTE.to_string(),
758 };
759 assert_eq!(
760 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
761 IssuerCaaMatch::Permitted
762 );
763 }
764}