use serde::{Deserialize, Serialize};
use crate::dns::{DnsResolver, RecordData, RecordType};
pub const ISSUANCE_TIME_NOTE: &str = "CAA is checked by CAs at issuance time, not by \
clients at validation time. A cert whose issuer is not in the current CAA policy is \
not invalid — it may have been issued before the policy was set, or under a parent \
zone. Treat mismatches as informational.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaaRecord {
pub flags: u8,
pub tag: String,
pub value: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IssuerCaaMatch {
NoPolicy,
Permitted,
Mismatch,
Indeterminate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaaPolicy {
pub records: Vec<CaaRecord>,
pub effective_domain: Option<String>,
pub has_policy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer_match: Option<IssuerCaaMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub iodef: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wildcard_note: Option<String>,
pub note: String,
}
impl CaaPolicy {
pub fn empty() -> Self {
Self {
records: Vec::new(),
effective_domain: None,
has_policy: false,
issuer_match: None,
iodef: Vec::new(),
wildcard_note: None,
note: ISSUANCE_TIME_NOTE.to_string(),
}
}
fn from_records(records: Vec<CaaRecord>, effective_domain: String) -> Self {
let iodef = records
.iter()
.filter(|r| r.tag == "iodef")
.map(|r| r.value.clone())
.filter(|v| !v.is_empty())
.collect();
let wildcard_note = analyze_wildcard(&records);
Self {
has_policy: true,
records,
effective_domain: Some(effective_domain),
issuer_match: None,
iodef,
wildcard_note,
note: ISSUANCE_TIME_NOTE.to_string(),
}
}
}
fn permitted_cas(records: &[CaaRecord], tag: &str) -> Vec<String> {
records
.iter()
.filter(|r| r.tag == tag)
.map(|r| {
r.value
.split(';')
.next()
.unwrap_or(&r.value)
.trim()
.to_ascii_lowercase()
})
.filter(|v| !v.is_empty())
.collect()
}
fn analyze_wildcard(records: &[CaaRecord]) -> Option<String> {
let has_issue = records.iter().any(|r| r.tag == "issue");
let has_issuewild = records.iter().any(|r| r.tag == "issuewild");
if !has_issue || !has_issuewild {
return None;
}
let issue = permitted_cas(records, "issue");
let issuewild = permitted_cas(records, "issuewild");
let looser: Vec<String> = issuewild
.iter()
.filter(|w| !issue.iter().any(|i| i == *w))
.cloned()
.collect();
if looser.is_empty() {
None
} else {
Some(format!(
"issuewild permits CA(s) not allowed by issue ({}) — wildcard issuance is broader than named issuance",
looser.join(", ")
))
}
}
pub async fn lookup_caa(resolver: &DnsResolver, domain: &str) -> CaaPolicy {
let mut current = domain.trim_end_matches('.').to_ascii_lowercase();
loop {
match resolver.resolve(¤t, RecordType::CAA, None).await {
Ok(records) if !records.is_empty() => {
let caa: Vec<CaaRecord> = records
.into_iter()
.filter_map(|r| match r.data {
RecordData::CAA { flags, tag, value } => Some(CaaRecord {
flags,
tag: tag.to_ascii_lowercase(),
value,
}),
_ => None,
})
.collect();
if !caa.is_empty() {
return CaaPolicy::from_records(caa, current);
}
}
Ok(_) | Err(_) => {}
}
match current.split_once('.') {
Some((_, rest)) if rest.contains('.') => current = rest.to_string(),
_ => return CaaPolicy::empty(),
}
}
}
pub fn classify_issuer(issuer: &str, policy: &CaaPolicy) -> IssuerCaaMatch {
if !policy.has_policy {
return IssuerCaaMatch::NoPolicy;
}
const KNOWN_TAGS: &[&str] = &["issue", "issuewild", "iodef"];
let critical_unknown = policy
.records
.iter()
.any(|r| (r.flags & 0x80) != 0 && !KNOWN_TAGS.contains(&r.tag.as_str()));
if critical_unknown {
return IssuerCaaMatch::Mismatch;
}
let issue_values: Vec<String> = policy
.records
.iter()
.filter(|r| r.tag == "issue" || r.tag == "issuewild")
.map(|r| {
r.value
.split(';')
.next()
.unwrap_or(&r.value)
.trim()
.to_ascii_lowercase()
})
.collect();
if issue_values.is_empty() {
return IssuerCaaMatch::Indeterminate;
}
let issuer_lc = issuer.to_ascii_lowercase();
let allowed_any = issue_values.iter().any(|v| !v.is_empty());
let matched = issue_values
.iter()
.any(|v| !v.is_empty() && ca_value_matches_issuer(v, &issuer_lc));
if matched {
IssuerCaaMatch::Permitted
} else if allowed_any {
IssuerCaaMatch::Mismatch
} else {
IssuerCaaMatch::Mismatch
}
}
fn ca_value_matches_issuer(caa_value: &str, issuer_lc: &str) -> bool {
if caa_value.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, caa_value) {
return true;
}
for (cv, aliases) in CA_ALIASES {
if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
return true;
}
}
let base = caa_value
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(caa_value);
base.len() >= MIN_FALLBACK_BASE_LEN && contains_word(issuer_lc, base)
}
const MIN_FALLBACK_BASE_LEN: usize = 6;
fn contains_word(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let bytes = haystack.as_bytes();
let mut from = 0;
while let Some(rel) = haystack[from..].find(needle) {
let start = from + rel;
let end = start + needle.len();
let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
const CA_ALIASES: &[(&str, &[&str])] = &[
("letsencrypt.org", &["let's encrypt", "letsencrypt"]),
("pki.goog", &["google trust services", "gts "]),
("digicert.com", &["digicert"]),
("sectigo.com", &["sectigo", "comodo"]),
("globalsign.com", &["globalsign"]),
("amazon.com", &["amazon"]),
("amazontrust.com", &["amazon"]),
("zerossl.com", &["zerossl"]),
("buypass.com", &["buypass"]),
("entrust.net", &["entrust"]),
("ssl.com", &["ssl.com"]),
("certum.pl", &["certum"]),
("identrust.com", &["identrust"]),
];
#[cfg(test)]
mod tests {
use super::*;
fn policy_with(records: Vec<(&str, &str)>) -> CaaPolicy {
let records = records
.into_iter()
.map(|(tag, value)| CaaRecord {
flags: 0,
tag: tag.to_string(),
value: value.to_string(),
})
.collect();
CaaPolicy::from_records(records, "example.com".to_string())
}
#[test]
fn iodef_contacts_are_extracted() {
let policy = policy_with(vec![
("issue", "letsencrypt.org"),
("iodef", "mailto:security@example.com"),
]);
assert_eq!(policy.iodef, vec!["mailto:security@example.com"]);
}
#[test]
fn wildcard_note_flags_broader_wildcard_policy() {
let policy = policy_with(vec![
("issue", "letsencrypt.org"),
("issuewild", "digicert.com"),
]);
let note = policy
.wildcard_note
.expect("looser wildcard policy flagged");
assert!(
note.contains("digicert.com"),
"note names the extra CA: {note}"
);
}
#[test]
fn wildcard_note_absent_when_issuewild_missing() {
let policy = policy_with(vec![("issue", "letsencrypt.org")]);
assert!(policy.wildcard_note.is_none());
}
#[test]
fn wildcard_note_absent_when_policies_consistent() {
let policy = policy_with(vec![
("issue", "letsencrypt.org"),
("issuewild", "letsencrypt.org"),
]);
assert!(policy.wildcard_note.is_none());
}
#[test]
fn classify_no_policy() {
assert_eq!(
classify_issuer("Let's Encrypt R3", &CaaPolicy::empty()),
IssuerCaaMatch::NoPolicy
);
}
#[test]
fn classify_indeterminate_when_only_iodef() {
let policy = policy_with(vec![("iodef", "mailto:sec@example.com")]);
assert_eq!(
classify_issuer("Let's Encrypt R3", &policy),
IssuerCaaMatch::Indeterminate
);
}
#[test]
fn classify_permitted_letsencrypt() {
let policy = policy_with(vec![("issue", "letsencrypt.org")]);
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Permitted
);
}
#[test]
fn classify_permitted_via_alias() {
let policy = policy_with(vec![("issue", "pki.goog")]);
assert_eq!(
classify_issuer("CN=GTS CA 1C3, O=Google Trust Services LLC", &policy),
IssuerCaaMatch::Permitted
);
}
#[test]
fn classify_mismatch_when_only_other_ca_allowed() {
let policy = policy_with(vec![("issue", "digicert.com")]);
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Mismatch
);
}
#[test]
fn classify_verbatim_match_is_length_guarded() {
let policy = policy_with(vec![("issue", "ca")]);
assert_eq!(
classify_issuer("CN=Verisign Class 3 CA, O=Symantec", &policy),
IssuerCaaMatch::Mismatch,
"two-letter verbatim CAA value must not over-match unrelated issuers"
);
}
#[test]
fn classify_full_domain_verbatim_still_matches() {
let policy = policy_with(vec![("issue", "letsencrypt.org")]);
assert_eq!(
classify_issuer("CN=R3, O=letsencrypt.org", &policy),
IssuerCaaMatch::Permitted,
"full-domain verbatim value must still match"
);
}
#[test]
fn classify_does_not_overmatch_short_base_substring() {
let policy = policy_with(vec![("issue", "ssl.com")]);
assert_eq!(
classify_issuer("CN=WoanWolf SSL Root CA, O=Other", &policy),
IssuerCaaMatch::Mismatch,
"bare 'ssl' substring must not over-match"
);
assert_eq!(
classify_issuer("CN=SSL.com RSA SSL subCA, O=SSL Corp", &policy),
IssuerCaaMatch::Permitted
);
}
#[test]
fn classify_unknown_ca_base_matches_only_on_word_boundary() {
let policy = policy_with(vec![("issue", "examplecorp.test")]);
assert_eq!(
classify_issuer("CN=ExampleCorp Root, O=ExampleCorp", &policy),
IssuerCaaMatch::Permitted
);
assert_eq!(
classify_issuer("CN=NotExamplecorporated CA", &policy),
IssuerCaaMatch::Mismatch,
"base inside a larger word must not match"
);
}
#[test]
fn classify_mismatch_when_issuance_forbidden() {
let policy = policy_with(vec![("issue", ";")]);
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Mismatch
);
}
#[test]
fn classify_issuewild_treated_like_issue() {
let policy = policy_with(vec![("issuewild", "letsencrypt.org")]);
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Permitted
);
}
#[test]
fn empty_policy_has_no_issuer_match_set() {
let p = CaaPolicy::empty();
assert!(p.records.is_empty());
assert!(!p.has_policy);
assert!(p.issuer_match.is_none());
assert_eq!(p.note, ISSUANCE_TIME_NOTE);
}
#[test]
fn classify_unknown_critical_tag_forces_mismatch() {
let policy = CaaPolicy {
records: vec![
CaaRecord {
flags: 0,
tag: "issue".to_string(),
value: "letsencrypt.org".to_string(),
},
CaaRecord {
flags: 0x80,
tag: "auth".to_string(),
value: "future-extension".to_string(),
},
],
effective_domain: Some("example.com".to_string()),
has_policy: true,
issuer_match: None,
iodef: Vec::new(),
wildcard_note: None,
note: ISSUANCE_TIME_NOTE.to_string(),
};
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Mismatch,
"critical unknown tag must veto otherwise-matching issue"
);
}
#[test]
fn classify_unknown_non_critical_tag_does_not_veto() {
let policy = CaaPolicy {
records: vec![
CaaRecord {
flags: 0,
tag: "issue".to_string(),
value: "letsencrypt.org".to_string(),
},
CaaRecord {
flags: 0,
tag: "auth".to_string(),
value: "future-extension".to_string(),
},
],
effective_domain: Some("example.com".to_string()),
has_policy: true,
issuer_match: None,
iodef: Vec::new(),
wildcard_note: None,
note: ISSUANCE_TIME_NOTE.to_string(),
};
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Permitted
);
}
#[test]
fn classify_critical_known_tag_does_not_veto() {
let policy = CaaPolicy {
records: vec![CaaRecord {
flags: 0x80,
tag: "issue".to_string(),
value: "letsencrypt.org".to_string(),
}],
effective_domain: Some("example.com".to_string()),
has_policy: true,
issuer_match: None,
iodef: Vec::new(),
wildcard_note: None,
note: ISSUANCE_TIME_NOTE.to_string(),
};
assert_eq!(
classify_issuer("CN=R3, O=Let's Encrypt", &policy),
IssuerCaaMatch::Permitted
);
}
}