// DMARC 对齐与策略(RFC 7489)。
//
// 对齐分 strict / relaxed 两种;relaxed 需要「组织域」,这里实现可替换的
// 公共后缀表:内置常见多级后缀,调用方也可以只依赖精确比较。
///|
pub enum DmarcAlign {
AlignRelaxed
AlignStrict
}
///|
pub struct DmarcPolicy {
policy : String
subdomain_policy : String?
pct : Int
adkim : DmarcAlign
aspf : DmarcAlign
rua : Array[String]
ruf : Array[String]
}
///|
/// 常见多级公共后缀。完整 PSL 很大,这里覆盖中文互联网最常见的组合,
/// 其余域名按「最后两级」计算组织域。
let multi_part_suffixes : Array[String] = [
"co.uk", "org.uk", "ac.uk", "gov.uk", "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.au", "net.au", "org.au", "co.jp", "ne.jp", "or.jp", "co.kr", "or.kr", "com.hk",
"com.tw", "org.tw", "co.in", "com.br", "com.mx", "co.za",
]
///|
/// 计算组织域:`a.b.example.co.uk` → `example.co.uk`。
pub fn organizational_domain(domain : String) -> String {
let normalized = normalize_name(domain)
let labels : Array[String] = []
for part in normalized.split(".") {
labels.push(part.to_owned())
}
if labels.length() <= 2 {
return normalized
}
let last_two = labels[labels.length() - 2] + "." + labels[labels.length() - 1]
let mut is_multi = false
for suffix in multi_part_suffixes {
if suffix == last_two {
is_multi = true
}
}
if is_multi && labels.length() >= 3 {
return labels[labels.length() - 3] + "." + last_two
}
last_two
}
///|
fn parse_align(value : String?) -> DmarcAlign {
match value {
Some(text) => if trim_wsp(text) == "s" { AlignStrict } else { AlignRelaxed }
None => AlignRelaxed
}
}
///|
/// 解析 DMARC 记录。返回 None 表示不是 `v=DMARC1`。
pub fn parse_dmarc_record(text : String) -> DmarcPolicy? {
let trimmed = trim_wsp(strip_txt_quotes(text))
if !starts_with_ignore_case(trimmed, "v=DMARC1") {
return None
}
let tags = parse_tags(trimmed)
let policy = tag_get(tags, "p").unwrap_or("none")
let subdomain_policy = tag_get(tags, "sp")
let pct = match tag_get(tags, "pct") {
Some(value) =>
match parse_int_tag(Some(value)) {
Some(number) =>
if number < 0 {
0
} else if number > 100 {
100
} else {
number
}
None => 100
}
None => 100
}
let sub = match subdomain_policy {
Some(value) => Some(ascii_lower(trim_wsp(value)))
None => None
}
Some({
policy: ascii_lower(trim_wsp(policy)),
subdomain_policy: sub,
pct,
adkim: parse_align(tag_get(tags, "adkim")),
aspf: parse_align(tag_get(tags, "aspf")),
rua: split_uri_list(tag_get(tags, "rua")),
ruf: split_uri_list(tag_get(tags, "ruf")),
})
}
///|
fn split_uri_list(value : String?) -> Array[String] {
let out : Array[String] = []
match value {
None => ()
Some(text) =>
for part in text.split(",") {
let uri = trim_wsp(part)
if uri.length() > 0 {
out.push(uri)
}
}
}
out
}
///|
fn domains_aligned(a : String, b : String, mode : DmarcAlign) -> Bool {
let left = normalize_name(a)
let right = normalize_name(b)
match mode {
AlignStrict => left == right
AlignRelaxed => organizational_domain(left) == organizational_domain(right)
}
}
///|
/// DMARC 求值。`sample` 用于 `pct` 采样:0 <= sample < pct 时才执行策略。
pub fn[R : Resolver] evaluate_dmarc(
resolver : R,
from_domain : String,
dkim : Array[DkimResult],
spf : SpfResult?,
sample? : Int,
) -> DmarcResult {
let domain = normalize_name(from_domain)
let mut records = resolver.txt("_dmarc." + domain)
// RFC 7489 6.6.3:子域没有记录时回退到组织域
if records.length() == 0 {
let org = organizational_domain(domain)
if org != domain {
records = resolver.txt("_dmarc." + org)
}
}
let mut policy : DmarcPolicy? = None
for record in records {
match parse_dmarc_record(record) {
Some(parsed) => policy = Some(parsed)
None => ()
}
}
let policy = match policy {
Some(p) => p
None =>
return {
domain,
policy: "",
disposition: "none",
dkim_aligned: false,
spf_aligned: false,
status: None_,
reason: "no DMARC record at _dmarc.\{domain}",
}
}
let mut dkim_aligned = false
for result in dkim {
if result.status == Pass &&
domains_aligned(result.domain, domain, policy.adkim) {
dkim_aligned = true
}
}
let mut spf_aligned = false
match spf {
Some(result) =>
if result.status == Pass &&
domains_aligned(result.domain, domain, policy.aspf) {
spf_aligned = true
}
None => ()
}
if dkim_aligned || spf_aligned {
let dkim_state = if dkim_aligned { "pass" } else { "fail" }
let spf_state = if spf_aligned { "pass" } else { "fail" }
return {
domain,
policy: policy.policy,
disposition: "none",
dkim_aligned,
spf_aligned,
status: Pass,
reason: "aligned identifier passed (dkim=" +
dkim_state +
", spf=" +
spf_state +
")",
}
}
// 子域策略优先
let effective = if domain != organizational_domain(domain) {
match policy.subdomain_policy {
Some(sp) => sp
None => policy.policy
}
} else {
policy.policy
}
let within_sample = match sample {
Some(value) => value < policy.pct
None => policy.pct >= 100
}
let disposition = if within_sample { effective } else { "none" }
{
domain,
policy: policy.policy,
disposition,
dkim_aligned,
spf_aligned,
status: Fail,
reason: "no aligned identifier passed, policy=\{effective}, pct=\{policy.pct}, disposition=\{disposition}",
}
}
///|
/// 生成 DMARC 聚合报告(最小可用 XML)。
pub fn dmarc_aggregate_report(
report : AuthReport,
org_name : String,
email : String,
begin : Int,
end : Int,
domain : String,
) -> String {
let mut dkim_pass = 0
let mut dkim_fail = 0
for result in report.dkim {
if result.status == Pass {
dkim_pass = dkim_pass + 1
} else {
dkim_fail = dkim_fail + 1
}
}
let mut spf_pass = 0
let mut spf_fail = 0
match report.spf {
Some(result) =>
if result.status == Pass {
spf_pass = 1
} else {
spf_fail = 1
}
None => ()
}
let disposition = match report.dmarc {
Some(result) => result.disposition
None => "none"
}
let policy = match report.dmarc {
Some(result) => result.policy
None => "none"
}
let xml = StringBuilder()
xml.write_string("\n")
xml.write_string("\n")
xml.write_string(" \n")
xml.write_string(" \{xml_escape(org_name)} \n")
xml.write_string(" \{xml_escape(email)} \n")
xml.write_string(
" \{begin} \{end} \n",
)
xml.write_string(" \n")
xml.write_string(" \n")
xml.write_string(" \{xml_escape(domain)} \n")
xml.write_string(" \{xml_escape(policy)}
\n")
xml.write_string(" \n")
xml.write_string(" \n")
xml.write_string(" \n")
xml.write_string(
" \{dkim_pass} \{dkim_fail} \n",
)
xml.write_string(
" \{spf_pass} \{spf_fail} \n",
)
xml.write_string(" \n")
xml.write_string(
" \{xml_escape(disposition)} \n",
)
xml.write_string(" \n")
xml.write_string(" \n")
xml.to_string()
}
///|
/// XML 最小转义。
pub fn xml_escape(text : StringView) -> String {
let out = StringBuilder()
for c in text {
match c {
'<' => out.write_string("<")
'>' => out.write_string(">")
'&' => out.write_string("&")
'"' => out.write_string(""")
_ => out.write_char(c)
}
}
out.to_string()
}