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>,
pub note: String,
}
impl CaaPolicy {
pub fn empty() -> Self {
Self {
records: Vec::new(),
effective_domain: None,
has_policy: false,
issuer_match: None,
note: ISSUANCE_TIME_NOTE.to_string(),
}
}
}
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 {
has_policy: true,
records: caa,
effective_domain: Some(current),
issuer_match: None,
note: ISSUANCE_TIME_NOTE.to_string(),
};
}
}
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;
}
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 issuer_lc.contains(caa_value) {
return true;
}
let base = caa_value
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(caa_value);
if !base.is_empty() && issuer_lc.contains(base) {
return true;
}
for (cv, aliases) in CA_ALIASES {
if caa_value == *cv && aliases.iter().any(|a| issuer_lc.contains(a)) {
return true;
}
}
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 {
CaaPolicy {
records: records
.into_iter()
.map(|(tag, value)| CaaRecord {
flags: 0,
tag: tag.to_string(),
value: value.to_string(),
})
.collect(),
effective_domain: Some("example.com".to_string()),
has_policy: true,
issuer_match: None,
note: ISSUANCE_TIME_NOTE.to_string(),
}
}
#[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_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);
}
}