using System.Security.Cryptography; using System.Text; namespace WpywMail.Native; /// /// DKIM 验签(RFC 6376)。**独立按 RFC 实现,不复用签名端代码** —— /// 2026-09-13 那次 DKIM 顺序 bug 的教训就是「自己验自己」会一起错。 /// public static class DkimVerifier { public sealed record Result(string Outcome, string Domain, string Selector, string Detail); public static async Task> VerifyAllAsync(byte[] raw, IDnsLookup dns, CancellationToken token) { var text = Encoding.Latin1.GetString(raw); // 1 字节 ↔ 1 字符,索引即字节偏移 var headers = ParseHeaders(text); var results = new List(); foreach (var header in headers.Where(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase))) { try { results.Add(await VerifyOneAsync(text, headers, header, dns, token)); } catch (Exception ex) when (ex is not OperationCanceledException) { results.Add(new Result("temperror", "", "", ex.Message)); } } return results; } private sealed record Header(string Name, string Raw, string Value) { public bool Used { get; set; } } private static async Task VerifyOneAsync(string text, List
headers, Header signature, IDnsLookup dns, CancellationToken token) { var tags = ParseTags(signature.Value); var domain = tags.GetValueOrDefault("d", ""); var selector = tags.GetValueOrDefault("s", ""); if (tags.GetValueOrDefault("v", "1") != "1") return new Result("fail", domain, selector, "v= 不是 1"); if (domain.Length == 0 || selector.Length == 0) return new Result("fail", domain, selector, "缺 d= 或 s="); var algorithm = tags.GetValueOrDefault("a", "rsa-sha256").ToLowerInvariant(); var hashName = algorithm switch { "rsa-sha256" => HashAlgorithmName.SHA256, "rsa-sha1" => HashAlgorithmName.SHA1, "ed25519-sha256" => HashAlgorithmName.SHA256, _ => default, }; if (hashName == default) return new Result("fail", domain, selector, $"不支持的算法 a={algorithm}"); if (algorithm.StartsWith("ed25519", StringComparison.Ordinal)) return new Result("neutral", domain, selector, "ed25519 本实现不支持(极少见)"); var canon = tags.GetValueOrDefault("c", "simple/simple").ToLowerInvariant().Split('/'); var headerCanon = canon[0] is "relaxed" ? "relaxed" : "simple"; var bodyCanon = canon.Length > 1 && canon[1] == "relaxed" ? "relaxed" : "simple"; var body = ExtractBody(text); var canonicalBody = CanonicalizeBody(body, bodyCanon); if (tags.TryGetValue("l", out var lengthText) && int.TryParse(lengthText, out var limit) && limit >= 0 && limit < canonicalBody.Length) canonicalBody = canonicalBody[..limit]; var expectedBodyHash = tags.GetValueOrDefault("bh", ""); var actualBodyHash = Convert.ToBase64String(Hash(hashName, Encoding.Latin1.GetBytes(canonicalBody))); if (!string.Equals(expectedBodyHash, actualBodyHash, StringComparison.Ordinal)) return new Result("fail", domain, selector, $"正文哈希不符(bh={Short(expectedBodyHash)} 实际={Short(actualBodyHash)})"); // 按 h= 的先后顺序取头,重名时从**下往上**取(RFC 6376 §5.4.2) var signedNames = tags.GetValueOrDefault("h", "").Split(':', StringSplitOptions.RemoveEmptyEntries); if (signedNames.Length == 0) return new Result("fail", domain, selector, "h= 为空"); var builder = new StringBuilder(); foreach (var name in signedNames) { var picked = headers.LastOrDefault(h => !h.Used && !ReferenceEquals(h, signature) && h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)); if (picked is not null) picked.Used = true; builder.Append(CanonicalizeHeader(picked?.Name ?? name.Trim(), picked?.Raw ?? "", headerCanon)); } builder.Append(CanonicalizeHeader(signature.Name, StripSignatureValue(signature.Raw), headerCanon, trimCrLf: true)); var signatureBytes = Convert.FromBase64String(tags.GetValueOrDefault("b", "").Trim()); var data = Encoding.Latin1.GetBytes(builder.ToString()); var keyText = await LookupKeyAsync(domain, selector, dns, token); if (keyText is null) return new Result("temperror", domain, selector, $"取不到公钥 {selector}._domainkey.{domain}"); if (keyText.Length == 0) return new Result("fail", domain, selector, "公钥记录里 p= 为空(已吊销)"); try { using var rsa = RSA.Create(); rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(keyText), out _); var ok = rsa.VerifyData(data, signatureBytes, hashName, RSASignaturePadding.Pkcs1); return ok ? new Result("pass", domain, selector, $"a={algorithm} c={string.Join('/', canon)}") : new Result("fail", domain, selector, "签名不符(报文可能被改过)"); } catch (FormatException) { return new Result("fail", domain, selector, "公钥不是合法的 SPKI base64"); } catch (CryptographicException ex) { return new Result("fail", domain, selector, $"验签异常:{ex.Message}"); } } private static async Task LookupKeyAsync(string domain, string selector, IDnsLookup dns, CancellationToken token) { var name = $"{selector}._domainkey.{domain}"; var records = await dns.TxtAsync(name, token); foreach (var record in records) { var tags = ParseTags(record); if (!tags.ContainsKey("p")) continue; return tags["p"]; } return null; } private static string ExtractBody(string text) { var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); var lf = text.IndexOf("\n\n", StringComparison.Ordinal); var index = crlf >= 0 && (lf < 0 || crlf <= lf) ? crlf + 4 : lf >= 0 ? lf + 2 : -1; return index < 0 ? "" : text[index..]; } private static List
ParseHeaders(string text) { var list = new List
(); var block = text; var crlf = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); var lf = text.IndexOf("\n\n", StringComparison.Ordinal); if (crlf >= 0 && (lf < 0 || crlf <= lf)) block = text[..crlf]; else if (lf >= 0) block = text[..lf]; var lines = block.Split('\n'); StringBuilder? current = null; foreach (var rawLine in lines) { var line = rawLine.TrimEnd('\r'); if (line.Length == 0) continue; if ((line[0] == ' ' || line[0] == '\t') && current is not null) { current.Append("\r\n").Append(line); continue; } if (current is not null) list.Add(Finish(current.ToString())); current = new StringBuilder(line); } if (current is not null) list.Add(Finish(current.ToString())); return list; static Header Finish(string raw) { var colon = raw.IndexOf(':'); if (colon < 0) return new Header(raw.Trim(), raw, ""); var name = raw[..colon].Trim(); var value = raw[(colon + 1)..].Trim(); return new Header(name, raw, value); } } /// /// 把 b= 的值抹掉(验签输入里的 DKIM-Signature 头不能带签名本身)。 /// /// ⚠ 必须**按标签边界**找 b=:直接 IndexOf("b=") 会被前面的 `bh=` 的 base64 内容误伤 /// (base64 以 `b=` 结尾完全合法),一位之差就整封验不过 —— 自检里正是这一条抓出来的。 /// private static string StripSignatureValue(string raw) { var index = TagValueStart(raw, "b"); if (index < 0) return raw; var end = raw.IndexOf(';', index); var head = raw[..index]; var tail = end < 0 ? "" : raw[end..]; return head + tail; } /// 返回名为 name 的标签「值」的起始下标(-1 表示没有)。标签必须出现在 `;` 之后或开头。 private static int TagValueStart(string raw, string name) { var position = raw.IndexOf(':'); position = position < 0 ? 0 : position + 1; while (position < raw.Length) { while (position < raw.Length && (raw[position] is ' ' or '\t' or '\r' or '\n' or ';')) position++; if (position >= raw.Length) return -1; var equals = raw.IndexOf('=', position); if (equals < 0) return -1; var tag = raw[position..equals].Trim(); var semicolon = raw.IndexOf(';', equals); if (tag.Equals(name, StringComparison.OrdinalIgnoreCase)) return equals + 1; if (semicolon < 0) return -1; position = semicolon + 1; } return -1; } private static string CanonicalizeHeader(string name, string raw, string mode, bool trimCrLf = false) { string result; if (mode == "relaxed") { var colon = raw.IndexOf(':'); var value = colon < 0 ? "" : raw[(colon + 1)..]; var unfolded = value.Replace("\r\n", " "); var collapsed = Collapse(unfolded).TrimEnd(' ', '\t'); result = name.ToLowerInvariant().Trim() + ":" + collapsed + "\r\n"; } else { var text = raw.Length > 0 ? raw : name + ":"; result = text + "\r\n"; } return trimCrLf ? result[..^2] : result; } private static string CanonicalizeBody(string body, string mode) { if (body.Length == 0) return ""; var lines = new List(); var start = 0; while (start <= body.Length) { var end = body.IndexOf('\n', start); var hasTerminator = end >= 0; var line = hasTerminator ? body[start..end].TrimEnd('\r') : body[start..]; lines.Add(line); if (!hasTerminator) break; start = end + 1; } // 去掉末尾的空行(保留最后一行的行尾) while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0) return ""; var builder = new StringBuilder(); for (var i = 0; i < lines.Count; i++) { var line = lines[i]; if (mode == "relaxed") { line = Collapse(line).TrimEnd(' ', '\t'); } builder.Append(line).Append("\r\n"); } return builder.ToString(); } private static string Collapse(string value) { var builder = new StringBuilder(value.Length); var space = false; foreach (var c in value) { if (c is ' ' or '\t') { space = true; continue; } if (space && builder.Length > 0) builder.Append(' '); space = false; builder.Append(c); } return builder.ToString(); } private static byte[] Hash(HashAlgorithmName name, byte[] data) => name == HashAlgorithmName.SHA1 ? SHA1.HashData(data) : SHA256.HashData(data); private static string Short(string value) => value.Length <= 10 ? value : value[..10] + "…"; /// 解析 `k=v; k=v` 形式的标签(DKIM 签名头与 DNS 公钥记录共用)。 public static Dictionary ParseTags(string text) { var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var part in text.Split(';')) { var equals = part.IndexOf('='); if (equals <= 0) continue; var key = part[..equals].Trim().ToLowerInvariant(); var value = part[(equals + 1)..]; // 值里的换行/空白都要去掉(b=、p= 常被折成多行) value = new string(value.Where(c => c is not (' ' or '\t' or '\r' or '\n')).ToArray()); if (key.Length > 0) tags[key] = value; } return tags; } } /// DMARC 求值(RFC 7489 的常用子集)。 public static class Dmarc { public sealed record Result(string Outcome, string Policy, string Domain, string Detail); /// 公共后缀的常用子集(判断「组织域」用;没列到的按最后两段算)。 private static readonly HashSet MultiPartSuffixes = new(StringComparer.OrdinalIgnoreCase) { "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "com.hk", "com.tw", "com.au", "com.br", "com.sg", "co.kr", "com.mx", "co.in", "com.tr", }; /// 组织域(relaxed 对齐用):`mail.example.co.uk` → `example.co.uk`。 public static string OrganizationalDomain(string domain) { var parts = (domain ?? "").Trim().TrimEnd('.').ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries); if (parts.Length <= 2) return string.Join('.', parts); var lastTwo = string.Join('.', parts[^2..]); return MultiPartSuffixes.Contains(lastTwo) && parts.Length >= 3 ? string.Join('.', parts[^3..]) : lastTwo; } public static async Task EvaluateAsync(string fromDomain, (string Outcome, string Domain) spf, IReadOnlyList<(string Outcome, string Domain)> dkims, IDnsLookup dns, CancellationToken token) { fromDomain = (fromDomain ?? "").Trim().TrimEnd('.').ToLowerInvariant(); if (fromDomain.Length == 0) return new Result("none", "none", "", "报文没有可用的 From 域名"); var records = await dns.TxtAsync("_dmarc." + fromDomain, token); var record = records.FirstOrDefault(r => r.TrimStart().StartsWith("v=DMARC1", StringComparison.OrdinalIgnoreCase)); if (record is null) return new Result("none", "none", fromDomain, $"_dmarc.{fromDomain} 没有 DMARC 记录"); var tags = DkimVerifier.ParseTags(record); var policy = tags.GetValueOrDefault("p", "").ToLowerInvariant(); if (policy is not ("none" or "quarantine" or "reject")) return new Result("none", "none", fromDomain, $"DMARC 记录里的 p= 无效:{policy}"); var strictSpf = tags.GetValueOrDefault("aspf", "r").Equals("s", StringComparison.OrdinalIgnoreCase); var strictDkim = tags.GetValueOrDefault("adkim", "r").Equals("s", StringComparison.OrdinalIgnoreCase); bool Aligned(string candidate, bool strict) { if (candidate.Length == 0) return false; return strict ? candidate.Equals(fromDomain, StringComparison.OrdinalIgnoreCase) : OrganizationalDomain(candidate) == OrganizationalDomain(fromDomain); } if (spf.Outcome == "pass" && Aligned(spf.Domain, strictSpf)) return new Result("pass", policy, fromDomain, $"SPF 对齐通过({spf.Domain},aspf={(strictSpf ? "s" : "r")})"); foreach (var dkim in dkims.Where(d => d.Outcome == "pass")) if (Aligned(dkim.Domain, strictDkim)) return new Result("pass", policy, fromDomain, $"DKIM 对齐通过({dkim.Domain},adkim={(strictDkim ? "s" : "r")})"); // 没有任何对齐的通过项 → 失败(temperror 时按 RFC 也不该直接判失败,这里如实标注) var temperror = dkims.Any(d => d.Outcome == "temperror") || spf.Outcome == "temperror"; var detail = $"SPF={spf.Outcome}({spf.Domain}) DKIM=[{string.Join(",", dkims.Select(d => $"{d.Outcome}:{d.Domain}"))}] 与 From 域 {fromDomain} 无对齐"; return new Result(temperror ? "temperror" : "fail", policy, fromDomain, detail); } } /// 把三件事串起来:SPF → DKIM → DMARC,产出可写进报文的结论与是否判为垃圾。 public static class InboundAuth { public static async Task CheckAsync(byte[] raw, string clientIp, string helo, string mailFrom, AppConfig config, CancellationToken token, IDnsLookup? dns = null) { dns ??= new UdpDnsLookup(config); var cfg = config.InboundAuth; var fromDomain = ""; try { var parsed = Mime.Parse(raw); // 注意:服务端 Mime.Parse 的 From 是**字符串**(不是地址数组),要先用 Mime.Addresses 拆 fromDomain = Spf.DomainOf(Mime.Addresses(parsed.From).FirstOrDefault() ?? parsed.From); } catch { /* 报文解析失败就按无 From 处理 */ } var spf = await Spf.EvaluateAsync(clientIp, helo, mailFrom, dns, cfg, token); var dkimResults = new List<(string Outcome, string Domain)>(); var dkimDetail = ""; if (cfg.VerifyDkim) { var verified = await DkimVerifier.VerifyAllAsync(raw, dns, token); foreach (var v in verified) dkimResults.Add((v.Outcome, v.Domain)); var best = verified.OrderBy(v => v.Outcome == "pass" ? 0 : v.Outcome == "temperror" ? 1 : 2).FirstOrDefault(); if (best is not null) dkimDetail = $"d={best.Domain} s={best.Selector} {best.Detail}"; } var dmarc = await Dmarc.EvaluateAsync(fromDomain, (spf.Outcome, spf.Domain), dkimResults, dns, token); // ── 打分(0 = 干净;阈值默认 3) var score = 0; var reasons = new List(); switch (dmarc.Outcome) { case "fail": score += 4; reasons.Add($"DMARC 失败(p={dmarc.Policy}):{dmarc.Detail}"); if (dmarc.Policy == "reject") score += 2; break; case "none": if (spf.Outcome == "fail") { score += 2; reasons.Add("SPF 硬失败且该域没有 DMARC 记录"); } else if (spf.Outcome == "softfail") { score += 1; reasons.Add("SPF 软失败"); } else if (spf.Outcome == "permerror") { score += 1; reasons.Add("SPF 记录有错(permerror)"); } var anyDkim = dkimResults.Count > 0; if (anyDkim && !dkimResults.Any(d => d.Outcome == "pass")) { score += 1; reasons.Add("带了 DKIM 签名但验不过"); } break; } if (spf.Outcome is "none" && dkimResults.Count == 0 && dmarc.Outcome == "none") reasons.Add("既没有 SPF 也没有 DKIM(小发件人常见,仅作提示)"); var hardFail = dmarc.Outcome == "fail" || score >= Math.Max(1, cfg.SpamScoreThreshold); var spam = hardFail && cfg.SpamFolderOnFail; var reject = dmarc.Outcome == "fail" && dmarc.Policy == "reject" && cfg.RejectOnDmarcReject; var verdict = new InboundAuthVerdict { Spf = spf.Outcome, SpfDomain = spf.Domain, SpfDetail = spf.Detail, Dkim = dkimResults.Count == 0 ? "none" : dkimResults.Any(d => d.Outcome == "pass") ? "pass" : dkimResults.Any(d => d.Outcome == "temperror") ? "temperror" : "fail", DkimDomain = dkimResults.FirstOrDefault().Domain ?? "", DkimDetail = dkimDetail, Dmarc = dmarc.Outcome, DmarcDomain = dmarc.Domain, DmarcPolicy = dmarc.Policy, Score = score, Reasons = reasons.ToArray(), Spam = spam, Reject = reject, }; return cfg.AddAuthenticationResults ? verdict with { HeaderBlock = BuildHeader(verdict, config) } : verdict; } private static string BuildHeader(InboundAuthVerdict v, AppConfig config) { var builder = new StringBuilder(); builder.Append("Authentication-Results: ").Append(config.Hostname).Append(";\r\n"); builder.Append("\tspf=").Append(v.Spf); if (v.SpfDomain.Length > 0) builder.Append(" smtp.mailfrom=").Append(v.SpfDomain); builder.Append(";\r\n"); builder.Append("\tdkim=").Append(v.Dkim); if (v.DkimDomain.Length > 0) builder.Append(" header.d=").Append(v.DkimDomain); builder.Append(";\r\n"); builder.Append("\tdmarc=").Append(v.Dmarc); if (v.DmarcDomain.Length > 0) builder.Append(" header.from=").Append(v.DmarcDomain); if (v.DmarcPolicy != "none") builder.Append(" policy=").Append(v.DmarcPolicy); builder.Append("\r\n"); builder.Append("X-Spam-Score: ").Append(v.Score).Append("\r\n"); if (v.Reasons.Length > 0) builder.Append("X-Spam-Reason: ").Append(string.Join(" / ", v.Reasons)).Append("\r\n"); return builder.ToString(); } /// 把校验收到的头前置到报文最前面(不碰原有字节,避免破坏对方 DKIM 签名)。 public static byte[] PrependHeaders(byte[] raw, string headerBlock) { if (string.IsNullOrEmpty(headerBlock)) return raw; var prefix = Encoding.Latin1.GetBytes(headerBlock); var output = new byte[prefix.Length + raw.Length]; Buffer.BlockCopy(prefix, 0, output, 0, prefix.Length); Buffer.BlockCopy(raw, 0, output, prefix.Length, raw.Length); return output; } }