using System.Text;
namespace WpywMail.Native;
///
/// 入站校验(SPF / DKIM / DMARC)自检。
///
/// 关键设计:**DNS 查询走固定记录的桩实现**,所以断言是确定性的、不依赖外网。
/// 而 DKIM 部分特意包含「篡改必须失败」的反向用例 —— 2026-09-13 的 DKIM 事故就是
/// 「验签和签名犯了同一个错,于是一直假通过」,任何验签实现都必须能被打假才算数。
///
public static partial class SelfTest
{
private sealed class StubDns : IDnsLookup
{
public readonly Dictionary Txt = new(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary Addresses = new(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary Mx = new(StringComparer.OrdinalIgnoreCase);
public Task> TxtAsync(string name, CancellationToken token) =>
Task.FromResult>(Txt.TryGetValue(name, out var v) ? v : []);
public Task> AddressesAsync(string name, CancellationToken token) =>
Task.FromResult>(Addresses.TryGetValue(name, out var v) ? v : []);
public Task> MxAsync(string name, CancellationToken token) =>
Task.FromResult>(Mx.TryGetValue(name, out var v) ? v : []);
}
private static void TestInboundAuth()
{
var config = new AppConfig
{
Domain = "wpy.email",
Hostname = "mail.example.com",
AdminEmail = "wpy@wpy.email",
AdminPassword = "SelfTest-Password-12",
DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-auth-{Guid.NewGuid().ToString("N")[..8]}"),
Dkim = new DkimConfig { Enabled = true, Selector = "sel" },
InboundAuth = new InboundAuthConfig { DnsTimeoutSeconds = 2 },
};
Directory.CreateDirectory(config.DataDirectory);
try
{
var dns = new StubDns();
var token = CancellationToken.None;
var cfg = config.InboundAuth;
// ─────────────────────────── SPF
dns.Txt["strict.example.com"] = ["v=spf1 ip4:203.0.113.0/24 -all"];
var pass = Spf.EvaluateAsync("203.0.113.5", "mail.example.com", "bob@strict.example.com", dns, cfg, token).Result;
Check("SPF:ip4 CIDR 命中 → pass", pass.Outcome == "pass", $"{pass.Outcome} {pass.Detail}");
var fail = Spf.EvaluateAsync("198.51.100.7", "mail.example.com", "bob@strict.example.com", dns, cfg, token).Result;
Check("SPF:ip4 不命中且 -all → fail", fail.Outcome == "fail", $"{fail.Outcome} {fail.Detail}");
dns.Txt["soft.example.com"] = ["v=spf1 ~all"];
var soft = Spf.EvaluateAsync("198.51.100.7", "x", "bob@soft.example.com", dns, cfg, token).Result;
Check("SPF:~all → softfail", soft.Outcome == "softfail", soft.Outcome);
dns.Txt["neutral.example.com"] = ["v=spf1 ?all"];
var neutral = Spf.EvaluateAsync("198.51.100.7", "x", "bob@neutral.example.com", dns, cfg, token).Result;
Check("SPF:?all → neutral", neutral.Outcome == "neutral", neutral.Outcome);
dns.Txt["inc.example.com"] = ["v=spf1 include:_spf.relay.net -all"];
dns.Txt["_spf.relay.net"] = ["v=spf1 ip4:198.51.100.7 -all"];
var included = Spf.EvaluateAsync("198.51.100.7", "x", "bob@inc.example.com", dns, cfg, token).Result;
Check("SPF:include 命中 → pass", included.Outcome == "pass", included.Detail);
dns.Txt["inc2.example.com"] = ["v=spf1 include:_spf.other.net -all"];
dns.Txt["_spf.other.net"] = ["v=spf1 ip4:203.0.113.1 -all"];
var notIncluded = Spf.EvaluateAsync("198.51.100.7", "x", "bob@inc2.example.com", dns, cfg, token).Result;
Check("SPF:include 不命中 → 落到 -all 的 fail", notIncluded.Outcome == "fail", notIncluded.Outcome);
dns.Txt["a.example.com"] = ["v=spf1 a -all"];
dns.Addresses["a.example.com"] = ["198.51.100.7"];
var aMatch = Spf.EvaluateAsync("198.51.100.7", "x", "bob@a.example.com", dns, cfg, token).Result;
Check("SPF:a 机制按 A 记录命中", aMatch.Outcome == "pass", aMatch.Outcome);
dns.Txt["mx.example.com"] = ["v=spf1 mx -all"];
dns.Mx["mx.example.com"] = ["mx1.example.com"];
dns.Addresses["mx1.example.com"] = ["198.51.100.7"];
var mxMatch = Spf.EvaluateAsync("198.51.100.7", "x", "bob@mx.example.com", dns, cfg, token).Result;
Check("SPF:mx 机制按 MX 主机命中", mxMatch.Outcome == "pass", mxMatch.Outcome);
var missing = Spf.EvaluateAsync("198.51.100.7", "x", "bob@nospf.example.com", dns, cfg, token).Result;
Check("SPF:没有记录 → none", missing.Outcome == "none", missing.Outcome);
dns.Txt["dup.example.com"] = ["v=spf1 -all", "v=spf1 +all"];
var dup = Spf.EvaluateAsync("198.51.100.7", "x", "bob@dup.example.com", dns, cfg, token).Result;
Check("SPF:两条记录 → permerror", dup.Outcome == "permerror", dup.Outcome);
// 查询次数上限:链式 include 超过 10 次必须 permerror
for (var i = 0; i < 14; i++)
dns.Txt[$"chain{i}.example.com"] = [$"v=spf1 include:chain{i + 1}.example.com -all"];
dns.Txt["chain14.example.com"] = ["v=spf1 ip4:203.0.113.9 -all"];
var tooMany = Spf.EvaluateAsync("198.51.100.7", "x", "bob@chain0.example.com", dns, cfg, token).Result;
Check("SPF:include 链超过 10 次查询 → permerror", tooMany.Outcome == "permerror", tooMany.Outcome);
var heloFallback = Spf.EvaluateAsync("198.51.100.7", "helo.example.com", "", dns, cfg, token).Result;
Check("SPF:空 MAIL FROM 时回退用 HELO 域", heloFallback.Domain == "helo.example.com", heloFallback.Domain);
Check("SPF:组织域/宏展开",
Spf.DomainOf("Bob ") == "example.com"
&& Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "bob@ex.com", "h") == "ex.com/ex.com/bob",
Spf.Expand("%{d}/%{o}/%{l}", "ex.com", System.Net.IPAddress.Parse("1.2.3.4"), "bob@ex.com", "h"));
// ─────────────────────────── DKIM(用本机签名器造真实签名,再打假)
var signer = DkimSigner.Create(config);
Check("DKIM:签名器可用(自检用)", signer is not null, "未启用则跳过后面的验签");
if (signer is not null)
{
var raw = BuildMessage("from@dkim.example.com", "收件人", "DKIM self-test 主题", "line-one ASCII marker\r\n第二行。\r\n");
var signed = signer.Sign(raw);
dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
var okResult = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
Check("DKIM:自己签的报文验签通过", okResult.Count == 1 && okResult[0].Outcome == "pass",
okResult.Count == 0 ? "(没找到签名)" : $"{okResult[0].Outcome} {okResult[0].Detail}");
// 反向用例 1:改正文(用 ASCII 标记改,避免在 Latin1 视图里搜中文搜不到)
var bad1 = DkimVerifier.VerifyAllAsync(Tamper(signed, "line-one ASCII marker", "line-one TAMPERED"), dns, token).Result;
Check("DKIM:改正文后必须验签失败", bad1.Count > 0 && bad1[0].Outcome == "fail",
bad1.Count == 0 ? "(没找到签名)" : bad1[0].Detail);
// 反向用例 2:改被签名的头(同样只能用 ASCII 片段做替换 —— Latin1 视图里搜不到 UTF-8 中文)
var bad2 = DkimVerifier.VerifyAllAsync(Tamper(signed, "DKIM self-test", "DKIM tampered!!"), dns, token).Result;
Check("DKIM:改主题后必须验签失败", bad2.Count > 0 && bad2[0].Outcome == "fail",
bad2.Count == 0 ? "(没找到签名)" : bad2[0].Detail);
// 反向用例 2b:只翻转正文里的一个字节也必须失败(最严格的篡改用例)
var flipped = (byte[])signed.Clone();
var marker = Encoding.Latin1.GetBytes("line-one ASCII marker");
var at = IndexOf(flipped, marker);
if (at > 0) flipped[at + 3] ^= 0x01;
var bad2b = DkimVerifier.VerifyAllAsync(flipped, dns, token).Result;
Check("DKIM:正文翻转一个字节必须验签失败", at > 0 && bad2b.Count > 0 && bad2b[0].Outcome == "fail",
at > 0 ? bad2b[0].Detail : "测试标记没找到");
// 反向用例 3:DNS 里换成别人的公钥
var otherConfig = new AppConfig
{
Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail,
AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory,
Dkim = new DkimConfig { Enabled = true, Selector = "other" },
};
var other = DkimSigner.Create(otherConfig);
dns.Txt[signer.RecordName + "." + config.Domain] = [other!.RecordValue];
var bad3 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
Check("DKIM:公钥不匹配必须验签失败", bad3.Count > 0 && bad3[0].Outcome == "fail",
bad3.Count == 0 ? "(没找到签名)" : bad3[0].Detail);
// 反向用例 4:公钥被吊销(p= 为空)
dns.Txt[signer.RecordName + "." + config.Domain] = ["v=DKIM1; k=rsa; p="];
var bad4 = DkimVerifier.VerifyAllAsync(signed, dns, token).Result;
Check("DKIM:公钥 p= 为空(已吊销)→ fail", bad4.Count > 0 && bad4[0].Outcome == "fail",
bad4.Count == 0 ? "(没找到签名)" : bad4[0].Detail);
dns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
// 加前置头(我们入站校验要往报文前面插 Authentication-Results)不能破坏对方签名
var withHeaders = InboundAuth.PrependHeaders(signed, "Authentication-Results: mail.example.com; spf=pass\r\nX-Spam-Score: 0\r\n");
var stillOk = DkimVerifier.VerifyAllAsync(withHeaders, dns, token).Result;
Check("DKIM:前面插入我们自己的头之后,对方签名依然有效",
stillOk.Count == 1 && stillOk[0].Outcome == "pass", $"{stillOk.Count} 个签名");
var plain = DkimVerifier.VerifyAllAsync(BuildMessage("a@b.com", "x", "无签名", "正文"), dns, token).Result;
Check("DKIM:没有签名的报文返回空列表", plain.Count == 0, $"{plain.Count} 个");
}
// ─────────────────────────── DMARC
dns.Txt["_dmarc.strict2.example.com"] = ["v=DMARC1; p=reject; rua=mailto:dmarc@example.com"];
var dmarcPass = Dmarc.EvaluateAsync("strict2.example.com", ("pass", "strict2.example.com"), [], dns, token).Result;
Check("DMARC:SPF 对齐通过 + p=reject", dmarcPass.Outcome == "pass" && dmarcPass.Policy == "reject",
$"{dmarcPass.Outcome}/{dmarcPass.Policy}");
var dmarcFail = Dmarc.EvaluateAsync("strict2.example.com", ("fail", "evil.net"), [], dns, token).Result;
Check("DMARC:SPF 未对齐 → fail 且带策略", dmarcFail.Outcome == "fail" && dmarcFail.Policy == "reject",
$"{dmarcFail.Outcome}/{dmarcFail.Policy}");
dns.Txt["_dmarc.relaxed.example.com"] = ["v=DMARC1; p=quarantine"];
var relaxedAlign = Dmarc.EvaluateAsync("relaxed.example.com", ("pass", "mail.relaxed.example.com"), [], dns, token).Result;
Check("DMARC:relaxed 对齐(子域算对齐)→ pass", relaxedAlign.Outcome == "pass", relaxedAlign.Detail);
dns.Txt["_dmarc.strictdomain.example.com"] = ["v=DMARC1; p=none; aspf=s"];
var strictAlign = Dmarc.EvaluateAsync("strictdomain.example.com", ("pass", "mail.strictdomain.example.com"), [], dns, token).Result;
Check("DMARC:aspf=s 时子域不算对齐 → fail", strictAlign.Outcome == "fail", strictAlign.Detail);
var dmarcDkim = Dmarc.EvaluateAsync("dkimalign.example.com", ("fail", "x"), [("pass", "dkimalign.example.com")],
new StubDnsWithRecords(("_dmarc.dkimalign.example.com", "v=DMARC1; p=none")), token).Result;
Check("DMARC:SPF 挂了但 DKIM 对齐 → 依然 pass", dmarcDkim.Outcome == "pass", dmarcDkim.Detail);
var noRecord = Dmarc.EvaluateAsync("nodmarc.example.com", ("fail", "x"), [], dns, token).Result;
Check("DMARC:没有记录 → none", noRecord.Outcome == "none", noRecord.Outcome);
Check("DMARC:组织域判定(含 com.cn/co.uk 这类多段后缀)",
Dmarc.OrganizationalDomain("a.b.example.co.uk") == "example.co.uk"
&& Dmarc.OrganizationalDomain("mail.example.com") == "example.com"
&& Dmarc.OrganizationalDomain("news.sina.com.cn") == "sina.com.cn",
Dmarc.OrganizationalDomain("a.b.example.co.uk"));
// ─────────────────────────── 端到端:干净信 vs 冒名信
var cleanDns = new StubDns();
if (signer is not null)
{
var cleanRaw = signer.Sign(BuildMessage("boss@wpy.email", "我", "正常邮件", "正文内容\r\n"));
cleanDns.Txt[signer.RecordName + "." + config.Domain] = [signer.RecordValue];
cleanDns.Txt["_dmarc." + config.Domain] = ["v=DMARC1; p=reject"];
cleanDns.Txt[config.Domain] = ["v=spf1 ip4:203.0.113.0/24 -all"];
var clean = InboundAuth.CheckAsync(cleanRaw, "203.0.113.9", "mail.example.com", "boss@wpy.email", config, token, cleanDns).Result;
Check("端到端:SPF+DKIM+DMARC 全通过的邮件不判垃圾",
clean.Spf == "pass" && clean.Dkim == "pass" && clean.Dmarc == "pass" && clean.Score == 0 && !clean.Spam,
$"spf={clean.Spf} dkim={clean.Dkim} dmarc={clean.Dmarc} 分数={clean.Score}");
Check("端到端:Authentication-Results 头写全了",
clean.HeaderBlock.Contains("spf=pass") && clean.HeaderBlock.Contains("dkim=pass")
&& clean.HeaderBlock.Contains("dmarc=pass") && clean.HeaderBlock.Contains("X-Spam-Score: 0"),
clean.HeaderBlock.Replace("\r\n", " | ").Trim());
// 冒名信:外域 IP 假冒 wpy.email,无 DKIM,DMARC p=reject
var spoof = InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "我是老板", "把钱转过来"),
"198.51.100.7", "evil.example.net", "boss@wpy.email", config, token, cleanDns).Result;
Check("端到端:冒名邮件被判为垃圾(SPF fail + DMARC 失败)",
spoof.Spf == "fail" && spoof.Dmarc == "fail" && spoof.Spam && spoof.Score >= 4,
$"spf={spoof.Spf} dmarc={spoof.Dmarc} 分数={spoof.Score} 垃圾={spoof.Spam}");
Check("端到端:默认不拒收(只投垃圾箱,可逆)", !spoof.Reject, $"Reject={spoof.Reject}");
var strictConfig = new AppConfig
{
Domain = config.Domain, Hostname = config.Hostname, AdminEmail = config.AdminEmail,
AdminPassword = config.AdminPassword, DataDirectory = config.DataDirectory,
Dkim = config.Dkim,
InboundAuth = new InboundAuthConfig { RejectOnDmarcReject = true },
};
var strictSpoof = InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "我是老板", "把钱转过来"),
"198.51.100.7", "evil.example.net", "boss@wpy.email", strictConfig, token, cleanDns).Result;
Check("端到端:打开 RejectOnDmarcReject 后 p=reject 的冒名信会被拒收", strictSpoof.Reject, $"Reject={strictSpoof.Reject}");
// 插入头之后报文仍可正常解析(不能把收信搞坏)
var withAuth = InboundAuth.PrependHeaders(cleanRaw, clean.HeaderBlock);
var reparsed = Mime.Parse(withAuth);
Check("端到端:插入校验头后报文仍能正常解析",
reparsed.Subject == "正常邮件" && reparsed.Text.Contains("正文内容"), $"{reparsed.Subject} / {reparsed.Text.Trim()}");
}
Check("端到端:X-Spam-Reason 会说明判垃圾的理由",
InboundAuth.CheckAsync(BuildMessage("boss@wpy.email", "我", "x", "y"),
"198.51.100.7", "evil.example.net", "boss@wpy.email", config, token, cleanDns).Result
.HeaderBlock.Contains("X-Spam-Reason:"), "");
}
catch (Exception ex)
{
Fail("入站校验自检", $"抛出异常:{ex}");
}
finally
{
try { Directory.Delete(config.DataDirectory, true); } catch { }
}
}
/// 只带一条 DNS 记录的一次性桩(用于个别用例)。
private sealed class StubDnsWithRecords(params (string Name, string Value)[] records) : IDnsLookup
{
public Task> TxtAsync(string name, CancellationToken token) =>
Task.FromResult>(records.Where(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
.Select(r => r.Value).ToArray());
public Task> AddressesAsync(string name, CancellationToken token) => Task.FromResult>([]);
public Task> MxAsync(string name, CancellationToken token) => Task.FromResult>([]);
}
/// 在字节层面做替换(用 Latin1 视图,1 字符 = 1 字节,不会动到别的字节)。
private static byte[] Tamper(byte[] raw, string from, string to) =>
Encoding.Latin1.GetBytes(Encoding.Latin1.GetString(raw).Replace(from, to));
private static int IndexOf(byte[] haystack, byte[] needle)
{
for (var i = 0; i + needle.Length <= haystack.Length; i++)
{
var hit = true;
for (var j = 0; j < needle.Length; j++)
if (haystack[i + j] != needle[j]) { hit = false; break; }
if (hit) return i;
}
return -1;
}
private static byte[] BuildMessage(string from, string toName, string subject, string body)
{
var text = $"From: <{from}>\r\nTo: \r\nSubject: {subject}\r\n"
+ $"Date: {Mime.FormatDate(DateTimeOffset.Now)}\r\nMessage-ID: <{Guid.NewGuid():N}@example.com>\r\n"
+ "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n"
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" + body;
return Encoding.UTF8.GetBytes(text);
}
}