Deep dive
How it works
In: client IP, HELO, MAIL FROM, the message · Out: spf, dkim, dmarc results, policy to apply · State: none; DNS answers cached by TTL · Cost: at most 10 SPF term lookups, one key lookup and hash pass per DKIM signature, 1-2 DMARC lookups
- SPF record. Query TXT of the MAIL FROM domain (RFC 7208 §2.4; HELO may go first). Keep records starting with exactly
v=spf1: none givesnone, two givepermerror. - SPF terms. Left to right:
ip4,ip6,a,mx,include,all. The first match returns its qualifier:+pass (default),-fail,~softfail,?neutral. No match:neutral(or theredirect=result).includeuses only the inner result; a nested-allis just "no match". - SPF limits.
include,a,mx,ptr,existsandredirectcount towards 10; anmxqueries at most 10 addresses; over 2 void lookups (empty answers) ispermerror(§4.6.4, SHOULD). - DKIM sign. Canonicalize and hash the body (
bh=, cut tol=bytes), hash theh=headers plus theDKIM-Signaturefield with emptyb=, sign that hash (RFC 6376 §3.7). - DKIM verify. Query TXT
s._domainkey.d, recompute both hashes, checkb=againstp=. Emptyp=means revoked. - DMARC lookup. One From header only. Query TXT
_dmarc.<From domain>; if absent, find the organizational domain by DNS tree walk (RFC 9989 §4.10; RFC 7489 used the Public Suffix List). - Alignment. Identifiers: MAIL FROM domain (SPF),
d=(DKIM). Relaxed (default): same organizational domain; strict (aspf=s,adkim=s): identical. Pass if SPF passed and aligns, or any DKIM signature passed and aligns. - Policy. On failure apply
p=(sp=subdomains,np=non-existent ones);t=yasks for one level softer. Receivers send aggregate reports torua=.
DKIM-Signature tags: v=1, a= (rsa-sha256, ed25519-sha256), d= domain, s= selector, h= signed headers, bh= body hash, b= signature, c= canonicalization (default simple/simple; relaxed unfolds, lowercases names, collapses blanks), l= body length (§8.2: lets attackers append). Key record: v=DKIM1; k=rsa; p=<base64>; an Ed25519 key is 44 base64 characters (RFC 8463).
Code
use std::{collections::HashMap, net::Ipv4Addr};
// ---- DKIM relaxed canonicalization (RFC 6376 3.4.2 and 3.4.4) ----
fn squeeze(s: &str) -> String { // runs of space/tab become one space
let mut o = String::new();
for c in s.chars() {
let sp = c == ' ' || c == '\t';
if !(sp && o.ends_with(' ')) { o.push(if sp { ' ' } else { c }); }
}
o
}
pub fn relaxed_header(field: &str) -> String { // one raw field
let (name, value) = field.split_once(':').unwrap();
let value = squeeze(&value.replace("\r\n", "")); // unfold, then collapse WSP
format!("{}:{}\r\n", name.trim_matches([' ', '\t']).to_ascii_lowercase(), value.trim_matches(' '))
}
pub fn relaxed_body(body: &str) -> String {
let mut lines: Vec<String> = body.split("\r\n").map(|l| squeeze(l).trim_end().to_string()).collect();
while lines.last().map_or(false, |l| l.is_empty()) { lines.pop(); } // trailing empty lines
lines.iter().map(|l| format!("{l}\r\n")).collect() // empty body stays empty; missing CRLF added
}
// ---- SPF check_host() over an in-memory DNS (RFC 7208), IPv4 only ----
#[derive(Debug, PartialEq, Clone, Copy)] pub enum R { None, Neutral, Pass, Fail, SoftFail, PermError }
pub struct Dns(pub HashMap<String, Vec<String>>); // keys "TXT example.com", "MX domain", "A host"
#[derive(Default)] pub struct Budget { lookups: u32, voids: u32 }
fn get(d: &Dns, key: String) -> Vec<String> { d.0.get(&key).cloned().unwrap_or_default() }
fn in_net(a: Ipv4Addr, b: Ipv4Addr, len: u32) -> bool { u64::from(u32::from(a) ^ u32::from(b)) >> (32 - len) == 0 }
pub fn check_host(d: &Dns, ip: Ipv4Addr, domain: &str, b: &mut Budget) -> R {
let txt = get(d, format!("TXT {domain}"));
let spf: Vec<&String> = txt.iter().filter(|r| r.split(' ').next() == Some("v=spf1")).collect();
let rec = match spf.len() { 0 => return R::None, 1 => spf[0], _ => return R::PermError };
for term in rec.split_whitespace().skip(1) {
if term.starts_with("exp=") { continue; } // explanation modifier, no effect on the result
let (res, m) = match term.as_bytes()[0] {
b'+' => (R::Pass, &term[1..]), b'-' => (R::Fail, &term[1..]),
b'~' => (R::SoftFail, &term[1..]), b'?' => (R::Neutral, &term[1..]), _ => (R::Pass, term) };
let (mech, rest) = m.find([':', '/']).map_or((m, ""), |i| (&m[..i], &m[i..]));
let rest = rest.strip_prefix(':').unwrap_or(rest);
let (target, len) = rest.rsplit_once('/').map_or((rest, Ok(32)), |(t, l)| (t, l.parse::<u32>()));
let Ok(len @ 0..=32) = len else { return R::PermError };
let dom = if target.is_empty() { domain } else { target };
b.lookups += matches!(mech, "a" | "mx" | "include") as u32; // each DNS-causing term counts
if b.lookups > 10 { return R::PermError; }
let hit = match mech {
"all" => true,
"ip6" => false, // IPv4 sender: never matches
"ip4" => match target.parse() { Ok(n) => in_net(ip, n, len), Err(_) => return R::PermError },
"a" | "mx" => {
let hosts = if mech == "a" { vec![dom.to_string()] } else { get(d, format!("MX {dom}")) };
if hosts.len() > 10 { return R::PermError; } // at most 10 MX names per term
let addrs: Vec<Vec<String>> = hosts.iter().map(|h| get(d, format!("A {h}"))).collect();
b.voids += (hosts.is_empty() || addrs.iter().any(|v| v.is_empty())) as u32; // void lookups
if b.voids > 2 { return R::PermError; }
addrs.concat().iter().filter_map(|s| s.parse().ok()).any(|n| in_net(ip, n, len))
}
"include" => match check_host(d, ip, dom, b) { // only the inner result counts, not its -all
R::Pass => true, R::Fail | R::SoftFail | R::Neutral => false, _ => return R::PermError },
_ => return R::PermError, // redirect=, ptr, exists, macros: not implemented
};
if hit { return res; }
}
R::Neutral // default result
}
// ---- DMARC alignment, RFC 7489 style (RFC 9989: DNS tree walk instead of the list) ----
const SUFFIXES: [&str; 3] = ["com", "co.uk", "github.io"]; // tiny stand-in for the Public Suffix List
pub fn org_domain(host: &str) -> String {
let l = host.to_ascii_lowercase();
let v: Vec<&str> = l.split('.').collect();
// first hit = longest suffix; else the default rule: the last label is the suffix
let i = (1..v.len()).find(|&i| SUFFIXES.contains(&v[i..].join(".").as_str())).unwrap_or(v.len().max(1) - 1);
v[i.saturating_sub(1)..].join(".")
}
pub fn aligned(from: &str, auth: &str, strict: bool) -> bool {
if strict { from.eq_ignore_ascii_case(auth) } else { org_domain(from) == org_domain(auth) }
}
#[cfg(test)]
mod tests {
use super::*;
fn spf(rows: &[(&str, &str)], ip: &str, dom: &str) -> R {
let mut m: HashMap<String, Vec<String>> = HashMap::new();
for (k, v) in rows { m.entry(k.to_string()).or_default().push(v.to_string()); }
check_host(&Dns(m), ip.parse().unwrap(), dom, &mut Budget::default())
}
#[test]
fn rfc_examples() {
// RFC 6376 3.4.5 example 1
assert_eq!(relaxed_header("A: X \r\n"), "a:X\r\n");
assert_eq!(relaxed_header("B : Y \t\r\n\t Z \r\n"), "b:Y Z\r\n");
assert_eq!(relaxed_body(" C \r\nD \t E\r\n\r\n\r\n"), " C\r\nD E\r\n");
assert_eq!(relaxed_body(""), ""); // 3.4.4: null input
// RFC 7208 appendix A.1 zone
let zone = [("TXT example.com", "v=spf1 mx mx:example.org -all"), ("MX example.com", "mail-a.example.com"),
("MX example.com", "mail-b.example.com"), ("MX example.org", "mail-c.example.org"),
("A mail-a.example.com", "192.0.2.129"), ("A mail-b.example.com", "192.0.2.130"),
("A mail-c.example.org", "192.0.2.140")];
for (ip, want) in [("192.0.2.130", R::Pass), ("192.0.2.140", R::Pass), ("192.0.2.10", R::Fail)] {
assert_eq!(spf(&zone, ip, "example.com"), want);
}
// 11th DNS term and 3rd void lookup: permerror
let (ten, eleven) = (format!("v=spf1 {}-all", "a:h.x ".repeat(10)), format!("v=spf1 {}-all", "a:h.x ".repeat(11)));
let rows = [("A h.x", "10.0.0.1"), ("TXT ten", &ten), ("TXT eleven", &eleven), ("TXT void", "v=spf1 a:n1 a:n2 a:n3 -all")];
let r = |dom| spf(&rows, "1.2.3.4", dom);
assert_eq!([r("ten"), r("eleven"), r("void")], [R::Fail, R::PermError, R::PermError]);
// RFC 7208 5.2: inner -all is not final
let inc = [("TXT example.net", "v=spf1 include:example.com include:example.org -all"),
("TXT example.com", "v=spf1 a -all"), ("TXT example.org", "v=spf1 ip4:192.0.2.140 -all")];
assert_eq!((spf(&inc, "192.0.2.140", "example.net"), spf(&inc, "192.0.2.9", "example.net")), (R::Pass, R::Fail));
// RFC 9989 4.4 Table 1; github.io is a suffix, so b.github.io and c.github.io differ
let (n, a) = ("news.example.com", aligned);
assert!(a(n, "foo.example.com", false) && a(n, n, true) && !a(n, "foo.example.net", false) && !a(n, "foo.example.com", true));
assert!(a("shop.example.co.uk", "bounce.example.co.uk", false) && !a("b.github.io", "c.github.io", false));
}
}
The asserts are RFC examples. Signature verification is left out: hash relaxed_body and the relaxed h= fields with SHA-256, then call RSA or Ed25519 from a crypto library. redirect=, ptr, exists and macros are not implemented. The three-entry suffix list stands in for the Public Suffix List or the RFC 9989 tree walk.
Complexity and constants
- SPF: one TXT query, at most 10 term lookups, 10 address queries per
mx; records under 512 octets (§3.4). - DKIM: one pass over body and signed headers per signature; DMARC: one TXT query, tree walk at most 8. A 2048-bit RSA key is order of 390 base64 characters (two TXT strings); Ed25519 fits in one.
- Measured (Apple M4 Pro, Rust 1.99.1 release, in-memory DNS):
relaxed_body277 MB/s;relaxed_header334 ns;check_hoston themx mx:example.org -allrecord 802 ns;aligned311 ns.
Efficiency
- Cost: 50 KB message, one RSA-2048 signature, M4 Pro: SHA-256 about 16 µs (3.05 GB/s), RSA verify about 10 µs (
openssl speed, measured), my canonicalizer about 180 µs (computed from 277 MB/s). DNS dominates: key, SPF and DMARC records, each order of ms uncached. - Levers:
- cache DNS by TTL, negative answers too — memory read, not a round trip (order of ms to µs) — every receiver
- canonicalize straight into the hasher — my string version is 11x below SHA-256 speed (measured) — high volume
- sign once per message, reuse
bh=for all recipients — one hash, one signature — newsletters - Ed25519 signing — 62k vs 3k signatures/s per core, verify 25k vs about 100k/s (measured); dual-sign, not every verifier does — large senders
- flatten
includechains — fewer lookups, under 10 — many sending vendors
- Naive → standard → state of the art:
- uncached query per term, a signature per recipient · several body passes
- cached DNS, single-pass canonicalize and hash, one signature per message · one hash pass, one verify
- canonicalizer fused with the hash, dual signatures · bound by hash speed (order of 3 GB/s)
Implementation notes
- Canonicalize the received bytes, not a re-parse. Sign
From(required, §5.4), listed twice inh=so an added From breaks the signature. - DNS timeout is
temperror(retry), a malformed recordpermerror.
Pitfalls
- SPF authenticates the envelope sender, not From; only alignment ties them.
- Stacked vendor
includes pass 10 lookups silently, givingpermerror;checkdmarccounts them. - Forwarding changes the IP, so SPF fails;
p=rejectdomains must not rely on SPF alone (RFC 9989). - Mailing lists add footers, breaking
bh=. RFC 9989: receivers must not reject solely onp=reject; absent other analysis, treat it as quarantine. pctis gone in RFC 9989 (uset); RFC 7489 and 9989 receivers may find different organizational domains (App. C.3).
Variants and alternatives
quarantine or reject at 100 % plus a mark certificate.In production
- Gmail and Yahoo, since February 2024: SPF or DKIM for all senders; above 5,000 messages a day to Gmail also DMARC (
p=nonesuffices), aligned From, one-click unsubscribe, spam rate under 0.30 %. - Twilio SendGrid has customers add CNAME records for SPF and DKIM; Amazon SES Easy DKIM defaults to 2048-bit keys.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| mail-auth | Rust | stalwartlabs/mail-auth | cargo add mail-auth | — | — | — | — | DKIM, ARC, SPF, DMARC |
| Rspamd | C, Lua | rspamd/rspamd | apt install rspamd | — | — | — | — | spam filter with all four checks; also signs |
| OpenDMARC | C | trusteddomainproject/OpenDMARC | apt install opendmarc | — | — | — | — | milter; release 1.4.2 |
| OpenDKIM | C | trusteddomainproject/OpenDKIM | apt install opendkim | — | — | — | — | milter; last upstream commit 2018 |
| dkimpy | Python | launchpad.net/dkimpy | pip install dkimpy | — | — | — | — | DKIM, ARC; Ed25519 via PyNaCl |
| checkdmarc | Python | domainaware/checkdmarc | pip install checkdmarc | — | — | — | — | audits published records, counts lookups |
Sources
- Kitterman, Sender Policy Framework (SPF), RFC 7208, 2014. RFC
- Crocker et al. (eds.), DKIM Signatures, RFC 6376, 2011. RFC
- Kitterman, Cryptographic Algorithm and Key Usage Update to DKIM, RFC 8301, 2018. RFC
- Levine, A New Cryptographic Signature Method for DKIM, RFC 8463, 2018. RFC
- Herr, Levine (eds.), DMARC, RFC 9989, 2026. RFC
- Andersen et al., The Authenticated Received Chain (ARC) Protocol, RFC 8617, 2019. RFC · Historic draft
- Google, Email sender guidelines; Yahoo, Sender best practices; BIMI Group, Implementation guide