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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99 pub lookup_failed: bool,
100 pub note: String,
102}
103
104impl CaaPolicy {
105 pub fn empty() -> Self {
107 Self {
108 records: Vec::new(),
109 effective_domain: None,
110 has_policy: false,
111 issuer_match: None,
112 iodef: Vec::new(),
113 wildcard_note: None,
114 lookup_failed: false,
115 note: ISSUANCE_TIME_NOTE.to_string(),
116 }
117 }
118
119 fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
122 let iodef = records
123 .iter()
124 .filter(|r| r.tag == "iodef")
125 .map(|r| r.value.clone())
126 .filter(|v| !v.is_empty())
127 .collect();
128 let wildcard_note = analyze_wildcard(&records);
129 Self {
130 has_policy: true,
131 records,
132 effective_domain: Some(effective_domain),
133 issuer_match: None,
134 iodef,
135 wildcard_note,
136 lookup_failed: false,
137 note: ISSUANCE_TIME_NOTE.to_string(),
138 }
139 }
140}
141
142fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
146 records
147 .iter()
148 .filter(|r| r.tag == tag)
149 .map(CaaRecord::ca_domain)
150 .filter(|v| !v.is_empty())
151 .collect()
152}
153
154fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
162 let has_issue = records.iter().any(|r| r.tag == "issue");
163 let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
164 if !has_issue || !has_issuewild {
165 return None;
166 }
167 let issue = permitted_cas(records, "issue");
168 let issuewild = permitted_cas(records, "issuewild");
169 let looser: Vec<String> = issuewild
170 .iter()
171 .filter(|w| !issue.iter().any(|i| i == *w))
172 .cloned()
173 .collect();
174 if looser.is_empty() {
175 None
176 } else {
177 Some(format!(
178 "issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
179 looser.join(", ")
180 ))
181 }
182}
183
184pub async fn lookup_caa(
195 resolver: &DnsResolver,
196 host: &str,
197 nameserver: Option<&str>,
198) -> Result<CaaPolicy> {
199 let mut current = normalize_host(host)?;
200 let mut failed = false;
201
202 loop {
203 match resolver
204 .resolve(¤t, RecordType::CAA, nameserver)
205 .await
206 {
207 Ok(records) if !records.is_empty() => {
208 let caa: Vec<CaaRecord> = records
209 .into_iter()
210 .filter_map(|r| match r.data {
211 RecordData::CAA { flags, tag, value } => Some(CaaRecord {
212 flags,
213 tag: tag.to_ascii_lowercase(),
214 value,
215 }),
216 _ => None,
217 })
218 .collect();
219
220 if !caa.is_empty() {
221 return Ok(CaaPolicy::from_records(caa, current));
222 }
223 }
224 Ok(_) => {}
225 Err(_) => failed = true,
226 }
227
228 match current.split_once('.') {
230 Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
231 _ => {
232 return Ok(CaaPolicy {
233 lookup_failed: failed,
234 ..CaaPolicy::empty()
235 })
236 }
237 }
238 }
239}
240
241pub fn classify_issuer(issuer: &str, policy: &CaaPolicy, wildcard: bool) -> IssuerCaaMatch {
249 if !policy.has_policy {
250 return IssuerCaaMatch::NoPolicy;
251 }
252
253 const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
259 let critical_unknown = policy
260 .records
261 .iter()
262 .any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
263 if critical_unknown {
264 return IssuerCaaMatch::Mismatch;
265 }
266
267 let has_issuewild = policy.records.iter().any(|r| r.tag == "issuewild");
268 let governing = if wildcard && has_issuewild {
269 "issuewild"
270 } else {
271 "issue"
272 };
273 let issue_values: Vec<String> = policy
274 .records
275 .iter()
276 .filter(|r| r.tag == governing)
277 .map(CaaRecord::ca_domain)
278 .collect();
279
280 if issue_values.is_empty() {
283 return IssuerCaaMatch::Indeterminate;
284 }
285
286 let issuer_lc = issuer.to_ascii_lowercase();
287 if issue_values
288 .iter()
289 .any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc))
290 {
291 IssuerCaaMatch::Permitted
292 } else {
293 IssuerCaaMatch::Mismatch
296 }
297}
298
299fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
306 if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
313 return true;
314 }
315 for (cv, aliases) in CA_ALIASES {
320 if caa_value == *cv && aliases.iter().any(|a| contains_word(issuer_lc, a)) {
321 return true;
322 }
323 }
324 let base = caa_value
330 .rsplit_once('.')
331 .map(|(b, _)| b)
332 .unwrap_or(caa_value);
333 base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
334}
335
336const MIN_FALLBACK_BASE_LEN: usize = 6;
341
342fn contains_word(haystack: &str, needle: &str) -> bool {
351 if needle.is_empty() {
352 return false;
353 }
354 let bytes = haystack.as_bytes();
355 let mut from = 0;
356 while let Some(rel) = haystack[from..].find(needle) {
357 let start = from + rel;
358 let end = start + needle.len();
359 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
360 let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
361 if before_ok && after_ok {
362 return true;
363 }
364 let first_len = haystack[start..].chars().next().map_or(1, char::len_utf8);
368 from = start + first_len;
369 }
370 false
371}
372
373const CA_ALIASES: &[(&str, &[&str])] = &[
376 ("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
377 ("pki.goog", &["google trust services", "gts"]),
378 ("digicert.com", &["digicert"]),
379 ("sectigo.com", &["sectigo", "comodo"]),
380 ("globalsign.com", &["globalsign"]),
381 ("amazon.com", &["amazon"]),
382 ("amazontrust.com", &["amazon"]),
383 ("zerossl.com", &["zerossl"]),
384 ("buypass.com", &["buypass"]),
385 ("entrust.net", &["entrust"]),
386 ("ssl.com", &["ssl.com"]),
387 ("certum.pl", &["certum"]),
388 ("identrust.com", &["identrust"]),
389];
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[tokio::test]
398 async fn lookup_keeps_www_and_rejects_invalid_input() {
399 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
400 use std::sync::{Arc, Mutex};
401
402 let asked = Arc::new(Mutex::new(Vec::new()));
403 let log = asked.clone();
404 let port = spawn_mock_dns_fn(move |name, _| {
405 log.lock().unwrap().push(name.to_string());
406 MockReply::NoData
407 })
408 .await;
409 let resolver = mock_dns_resolver_default(port);
410
411 let policy = lookup_caa(&resolver, "HTTPS://WWW.Seer.Test/path", None)
412 .await
413 .unwrap();
414 assert!(!policy.has_policy);
415 let asked = asked.lock().unwrap().clone();
416 assert_eq!(
417 asked.first().map(|n| n.trim_end_matches('.')),
418 Some("www.seer.test"),
419 "{asked:?}"
420 );
421
422 assert!(lookup_caa(&resolver, "bad..name", None).await.is_err());
423 }
424
425 fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
426 let records = records
429 .into_iter()
430 .map(|(tag, value)| CaaRecord {
431 flags: 0,
432 tag: tag.to_string(),
433 value: value.to_string(),
434 })
435 .collect();
436 CaaPolicy::from_records(records, "example.com".to_string())
437 }
438
439 #[test]
440 fn iodef_contacts_are_extracted() {
441 let policy = policy_with(vec![
442 ("issue", "letsencrypt.org"),
443 ("iodef", "mailto:security@example.com"),
444 ]);
445 assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
446 }
447
448 #[test]
449 fn wildcard_note_flags_broader_wildcard_policy() {
450 let policy = policy_with(vec![
453 ("issue", "letsencrypt.org"),
454 ("issuewild", "digicert.com"),
455 ]);
456 let note = policy
457 .wildcard_note
458 .expect("looser wildcard policy flagged");
459 assert!(
460 note.contains("digicert.com"),
461 "note names the extra CA: {note}"
462 );
463 }
464
465 #[test]
466 fn wildcard_note_absent_when_issuewild_missing() {
467 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
469 assert!(policy.wildcard_note.is_none());
470 }
471
472 #[test]
473 fn wildcard_note_absent_when_policies_consistent() {
474 let policy = policy_with(vec![
475 ("issue", "letsencrypt.org"),
476 ("issuewild", "letsencrypt.org"),
477 ]);
478 assert!(policy.wildcard_note.is_none());
479 }
480
481 #[test]
482 fn classify_no_policy() {
483 assert_eq!(
484 classify_issuer("Let's Encrypt R3", &CaaPolicy::empty(), false),
485 IssuerCaaMatch::NoPolicy
486 );
487 }
488
489 #[test]
490 fn classify_indeterminate_when_only_iodef() {
491 let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
492 assert_eq!(
493 classify_issuer("Let's Encrypt R3", &policy, false),
494 IssuerCaaMatch::Indeterminate
495 );
496 }
497
498 #[test]
499 fn classify_permitted_letsencrypt() {
500 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
501 assert_eq!(
502 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
503 IssuerCaaMatch::Permitted
504 );
505 }
506
507 #[test]
508 fn classify_permitted_via_alias() {
509 let policy = policy_with(vec![("issue", "pki.goog")]);
512 assert_eq!(
513 classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy, false),
514 IssuerCaaMatch::Permitted
515 );
516 }
517
518 #[test]
519 fn classify_mismatch_when_only_other_ca_allowed() {
520 let policy = policy_with(vec![("issue", "digicert.com")]);
521 assert_eq!(
522 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
523 IssuerCaaMatch::Mismatch
524 );
525 }
526
527 #[test]
528 fn classify_verbatim_match_is_length_guarded() {
529 let policy = policy_with(vec![("issue", "ca")]);
534 assert_eq!(
535 classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy, false),
536 IssuerCaaMatch::Mismatch,
537 "two-letter verbatim CAA value must not over-match unrelated issuers"
538 );
539 }
540
541 #[test]
542 fn classify_full_domain_verbatim_still_matches() {
543 let policy = policy_with(vec![("issue", "letsencrypt.org")]);
546 assert_eq!(
547 classify_issuer("CN=R3, O=letsencrypt.org", &policy, false),
548 IssuerCaaMatch::Permitted,
549 "full-domain verbatim value must still match"
550 );
551 }
552
553 #[test]
554 fn classify_does_not_overmatch_short_base_substring() {
555 let policy = policy_with(vec![("issue", "ssl.com")]);
559 assert_eq!(
560 classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy, false),
561 IssuerCaaMatch::Mismatch,
562 "bare 'ssl' substring must not over-match"
563 );
564 assert_eq!(
566 classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy, false),
567 IssuerCaaMatch::Permitted
568 );
569 }
570
571 #[test]
572 fn classify_unknown_ca_base_matches_only_on_word_boundary() {
573 let policy = policy_with(vec![("issue", "examplecorp.test")]);
576 assert_eq!(
577 classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy, false),
578 IssuerCaaMatch::Permitted
579 );
580 assert_eq!(
581 classify_issuer("CN=NotExamplecorporated CA", &policy, false),
582 IssuerCaaMatch::Mismatch,
583 "base inside a larger word must not match"
584 );
585 }
586
587 #[test]
593 fn classify_does_not_panic_on_multibyte_rejected_match() {
594 let policy = policy_with(vec![("issue", "éabcdef.com")]);
595 assert_eq!(
596 classify_issuer("CN=xéabcdef.com", &policy, false),
597 IssuerCaaMatch::Mismatch
598 );
599 assert!(!contains_word("xéabc", "éabc"));
601 assert!(contains_word("xéabc éabc", "éabc"));
602 }
603
604 #[test]
607 fn classify_alias_requires_whole_word() {
608 let policy = policy_with(vec![("issue", "entrust.net")]);
609 assert_eq!(
610 classify_issuer("CN=TrustID Server CA O1, O=IdenTrust", &policy, false),
611 IssuerCaaMatch::Mismatch,
612 "entrust must not match inside identrust"
613 );
614 assert_eq!(
615 classify_issuer(
616 "CN=Entrust Certification Authority - L1K, O=Entrust, Inc.",
617 &policy,
618 false
619 ),
620 IssuerCaaMatch::Permitted
621 );
622 let google = policy_with(vec![("issue", "pki.goog")]);
624 assert_eq!(
625 classify_issuer("CN=WR2, O=GTS", &google, false),
626 IssuerCaaMatch::Permitted
627 );
628 }
629
630 #[test]
631 fn classify_mismatch_when_issuance_forbidden() {
632 let policy = policy_with(vec![("issue", ";")]);
634 assert_eq!(
635 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
636 IssuerCaaMatch::Mismatch
637 );
638 }
639
640 #[test]
643 fn issuewild_governs_wildcards_and_only_wildcards() {
644 const LE: &str = "CN=R3, O=Let's Encrypt";
645 let split = policy_with(vec![
646 ("issue", "digicert.com"),
647 ("issuewild", "letsencrypt.org"),
648 ]);
649 assert_eq!(classify_issuer(LE, &split, true), IssuerCaaMatch::Permitted);
650 assert_eq!(classify_issuer(LE, &split, false), IssuerCaaMatch::Mismatch);
651
652 let wild_only = policy_with(vec![("issuewild", "letsencrypt.org")]);
654 assert_eq!(
655 classify_issuer(LE, &wild_only, true),
656 IssuerCaaMatch::Permitted
657 );
658 assert_eq!(
659 classify_issuer(LE, &wild_only, false),
660 IssuerCaaMatch::Indeterminate
661 );
662
663 let issue_only = policy_with(vec![("issue", "letsencrypt.org")]);
665 assert_eq!(
666 classify_issuer(LE, &issue_only, true),
667 IssuerCaaMatch::Permitted
668 );
669
670 let no_wild = policy_with(vec![("issue", "letsencrypt.org"), ("issuewild", ";")]);
672 assert_eq!(
673 classify_issuer(LE, &no_wild, true),
674 IssuerCaaMatch::Mismatch
675 );
676 assert_eq!(
677 classify_issuer(LE, &no_wild, false),
678 IssuerCaaMatch::Permitted
679 );
680 }
681
682 #[test]
683 fn empty_policy_has_no_issuer_match_set() {
684 let p = CaaPolicy::empty();
685 assert!(p.records.is_empty());
686 assert!(!p.has_policy);
687 assert!(p.issuer_match.is_none());
688 assert_eq!(p.note, ISSUANCE_TIME_NOTE);
689 }
690
691 #[test]
695 fn classify_unknown_critical_tag_forces_mismatch() {
696 let policy = CaaPolicy {
697 records: vec![
698 CaaRecord {
700 flags: 0,
701 tag: "issue".to_string(),
702 value: "letsencrypt.org".to_string(),
703 },
704 CaaRecord {
706 flags: 0x80,
707 tag: "auth".to_string(),
708 value: "future-extension".to_string(),
709 },
710 ],
711 effective_domain: Some("example.com".to_string()),
712 has_policy: true,
713 issuer_match: None,
714 iodef: Vec::new(),
715 wildcard_note: None,
716 lookup_failed: false,
717 note: ISSUANCE_TIME_NOTE.to_string(),
718 };
719 assert_eq!(
720 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
721 IssuerCaaMatch::Mismatch,
722 "critical unknown tag must veto otherwise-matching issue"
723 );
724 }
725
726 #[test]
727 fn classify_unknown_non_critical_tag_does_not_veto() {
728 let policy = CaaPolicy {
731 records: vec![
732 CaaRecord {
733 flags: 0,
734 tag: "issue".to_string(),
735 value: "letsencrypt.org".to_string(),
736 },
737 CaaRecord {
738 flags: 0,
739 tag: "auth".to_string(),
740 value: "future-extension".to_string(),
741 },
742 ],
743 effective_domain: Some("example.com".to_string()),
744 has_policy: true,
745 issuer_match: None,
746 iodef: Vec::new(),
747 wildcard_note: None,
748 lookup_failed: false,
749 note: ISSUANCE_TIME_NOTE.to_string(),
750 };
751 assert_eq!(
752 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
753 IssuerCaaMatch::Permitted
754 );
755 }
756
757 #[test]
758 fn classify_critical_known_tag_does_not_veto() {
759 let policy = CaaPolicy {
762 records: vec![CaaRecord {
763 flags: 0x80,
764 tag: "issue".to_string(),
765 value: "letsencrypt.org".to_string(),
766 }],
767 effective_domain: Some("example.com".to_string()),
768 has_policy: true,
769 issuer_match: None,
770 iodef: Vec::new(),
771 wildcard_note: None,
772 lookup_failed: false,
773 note: ISSUANCE_TIME_NOTE.to_string(),
774 };
775 assert_eq!(
776 classify_issuer("CN=R3, O=Let's Encrypt", &policy, false),
777 IssuerCaaMatch::Permitted
778 );
779 }
780
781 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
782 use hickory_resolver::proto::op::ResponseCode;
783 use hickory_resolver::proto::rr::RecordType as WireType;
784
785 #[tokio::test]
786 async fn a_failed_level_with_no_policy_is_flagged_as_lookup_failed() {
787 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
788 ("www.caa.test", WireType::CAA) => MockReply::Rcode(ResponseCode::ServFail),
789 _ => MockReply::NoData,
790 })
791 .await;
792 let policy = lookup_caa(&mock_dns_resolver_default(port), "www.caa.test", None)
793 .await
794 .unwrap();
795 assert!(!policy.has_policy);
796 assert!(policy.lookup_failed, "{policy:?}");
797 }
798
799 #[tokio::test]
800 async fn a_clean_empty_walk_is_not_a_lookup_failure() {
801 let port = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
802 let policy = lookup_caa(&mock_dns_resolver_default(port), "www.caa.test", None)
803 .await
804 .unwrap();
805 assert!(!policy.has_policy);
806 assert!(!policy.lookup_failed);
807 }
808}