using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; namespace WpywMail.Native; /// /// 入站邮件身份校验:SPF(RFC 7208)、DKIM 验签(RFC 6376)、DMARC(RFC 7489)。 /// /// 为什么要有这一层:在此之前谁都能用 `From: wpy@wpy.email` 给这台服务器发信, /// 服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有任何区别。 /// /// 设计取舍: /// 1. **默认只标注不拒收**(`RejectOnDmarcReject=false`):校验实现自己也可能有 bug, /// 拒收是不可逆的,投进垃圾箱是可逆的。DMARC 判失败时按策略投 spam。 /// 2. **DNS 查询做成可注入的**():SPF/DKIM 的判定逻辑必须能 /// 用固定记录做确定性自检,否则自检依赖外网、结果不可复现。 /// 3. DNS 查询次数按 RFC 限制(SPF 10 次、void 2 次),避免成为放大攻击的靶子。 /// public sealed record InboundAuthVerdict { public string Spf { get; init; } = "none"; public string SpfDomain { get; init; } = ""; public string SpfDetail { get; init; } = ""; public string Dkim { get; init; } = "none"; public string DkimDomain { get; init; } = ""; public string DkimDetail { get; init; } = ""; public string Dmarc { get; init; } = "none"; public string DmarcDomain { get; init; } = ""; public string DmarcPolicy { get; init; } = "none"; public int Score { get; init; } public string[] Reasons { get; init; } = []; public bool Spam { get; init; } public bool Reject { get; init; } /// 可直接前置到报文里的 Authentication-Results 行(含 CRLF)。 public string HeaderBlock { get; init; } = ""; } /// DNS 查询抽象:真实实现走 UDP/系统解析器,自检用固定记录的实现。 public interface IDnsLookup { Task> TxtAsync(string name, CancellationToken token); Task> AddressesAsync(string name, CancellationToken token); Task> MxAsync(string name, CancellationToken token); } /// 真实 DNS:TXT 自己发 UDP 查询(系统解析器拿不到 TXT),A 用系统解析,MX 复用既有的 MxResolver。 public sealed class UdpDnsLookup : IDnsLookup { private readonly AppConfig config; public UdpDnsLookup(AppConfig config) => this.config = config; public async Task> TxtAsync(string name, CancellationToken token) { var timeout = Math.Max(1, config.InboundAuth.DnsTimeoutSeconds); var target = name; // 跟着 CNAME 走:outlook.com 之类的 DKIM 公钥就是发布成 CNAME 的, // 自己发的原始 UDP 查询不会自动跟(系统解析器才会),不跟就永远取不到公钥。 for (var depth = 0; depth < 5; depth++) { var moved = false; foreach (var server in DnsServers()) { try { var (records, aliases) = await QueryAsync(target, server, timeout, token); if (records.Count > 0) return records; if (aliases.Count > 0) { target = aliases[0]; moved = true; AppLog.Info($"[DNS] {name} 是 CNAME,继续查 {target}"); break; } } catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException or OperationCanceledException) { AppLog.Warn($"[DNS] 查询 {target} 的 TXT 失败({server}):{ex.Message}"); } } if (!moved) break; } return []; } public async Task> AddressesAsync(string name, CancellationToken token) { try { var addresses = await Dns.GetHostAddressesAsync(name, token); return addresses.Select(a => a.ToString()).ToArray(); } catch { return []; } } public async Task> MxAsync(string name, CancellationToken token) { try { return await MxResolver.ResolveAsync(name, config.DirectDelivery, token); } catch { return []; } } private IEnumerable DnsServers() { var configured = (config.DirectDelivery.DnsServer ?? "").Trim(); if (configured.Length > 0) { foreach (var part in configured.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)) if (IPAddress.TryParse(part.Trim(), out var ip)) yield return ip; yield break; } var system = new List(); try { foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) { if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; foreach (var dns in ni.GetIPProperties().DnsAddresses) if (dns.AddressFamily == AddressFamily.InterNetwork && !system.Contains(dns)) system.Add(dns); } } catch { } if (system.Count == 0) system.Add(IPAddress.Parse("1.1.1.1")); foreach (var ip in system) yield return ip; } private static async Task<(IReadOnlyList Txt, IReadOnlyList Cname)> QueryAsync(string name, IPAddress server, int timeoutSeconds, CancellationToken token) { using var udp = new UdpClient(server.AddressFamily); var query = BuildTxtQuery(name, out var id); await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53)); var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(timeoutSeconds), token); return ParseTxtResponse(result.Buffer, id); } private static byte[] BuildTxtQuery(string name, out ushort id) { id = (ushort)Random.Shared.Next(1, ushort.MaxValue); using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); writer.Write(ToNetwork(id)); writer.Write(ToNetwork((ushort)0x0100)); writer.Write(ToNetwork((ushort)1)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); writer.Write(ToNetwork((ushort)0)); foreach (var label in name.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries)) { var bytes = Encoding.ASCII.GetBytes(label); writer.Write((byte)bytes.Length); writer.Write(bytes); } writer.Write((byte)0); writer.Write(ToNetwork((ushort)16)); // TXT writer.Write(ToNetwork((ushort)1)); return stream.ToArray(); } private static (IReadOnlyList Txt, IReadOnlyList Cname) ParseTxtResponse(byte[] data, ushort expectedId) { var records = new List(); var aliases = new List(); if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return (records, aliases); var flags = ReadUInt16(data, 2); if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return (records, aliases); var questions = ReadUInt16(data, 4); var answers = ReadUInt16(data, 6); var offset = 12; for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; } for (var i = 0; i < answers && offset + 10 <= data.Length; i++) { ReadName(data, ref offset); if (offset + 10 > data.Length) break; var type = ReadUInt16(data, offset); var length = ReadUInt16(data, offset + 8); offset += 10; if (offset + length > data.Length) break; if (type == 5) { var cursor = offset; var alias = ReadName(data, ref cursor); if (alias.Length > 0) aliases.Add(alias.TrimEnd('.')); } else if (type == 16) { // TXT 的 RDATA 是若干「长度 + 字节」的片段,要拼起来(DKIM 公钥常被切成多段) var text = new StringBuilder(); var cursor = offset; var end = offset + length; while (cursor < end) { var piece = data[cursor]; cursor++; if (cursor + piece > end) break; text.Append(Encoding.UTF8.GetString(data, cursor, piece)); cursor += piece; } if (text.Length > 0) records.Add(text.ToString()); } offset += length; } return (records, aliases); } private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]); private static byte[] ToNetwork(ushort value) => [(byte)(value >> 8), (byte)(value & 0xFF)]; private static string ReadName(byte[] data, ref int offset) { var labels = new List(); var cursor = offset; var jumped = false; var next = offset; while (cursor < data.Length) { var length = data[cursor++]; if (length == 0) { if (!jumped) next = cursor; break; } if ((length & 0xC0) == 0xC0) { if (cursor >= data.Length) break; var pointer = ((length & 0x3F) << 8) | data[cursor++]; if (!jumped) next = cursor; // ⚠ 必须记住「指针之后」的位置: cursor = pointer; // 否则跟着指针跳进 question 段后, jumped = true; // 后面读 type/class/length 就全错位了(TXT 一条都解析不出来) continue; } if (length > 63 || cursor + length > data.Length) break; labels.Add(Encoding.ASCII.GetString(data, cursor, length)); cursor += length; } offset = next; return string.Join('.', labels); } } /// SPF 求值(RFC 7208 的常用子集:all/include/a/mx/ip4/ip6/exists + 限定符 + redirect)。 public static class Spf { public sealed record Result(string Outcome, string Domain, string Detail); private const string None = "none", Pass = "pass", Fail = "fail", SoftFail = "softfail", Neutral = "neutral", TempError = "temperror", PermError = "permerror"; public static async Task EvaluateAsync(string? ip, string? helo, string? mailFrom, IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token) { var senderDomain = DomainOf(mailFrom); if (senderDomain.Length == 0) { // 空 MAIL FROM(退信):按 RFC 7208 §2.4 用 HELO 域名 var heloDomain = (helo ?? "").Trim().TrimEnd('.'); if (heloDomain.Length == 0 || !heloDomain.Contains('.')) return new Result(None, "", "无发件人域,无法判定"); senderDomain = heloDomain; } if (!IPAddress.TryParse(ip, out var address)) return new Result(None, senderDomain, "来源地址不可解析"); var state = new State(dns, cfg, token); var outcome = await state.CheckDomainAsync(senderDomain, address, mailFrom ?? "", helo ?? "", depth: 0); return new Result(outcome.Outcome, senderDomain, outcome.Detail); } private sealed class State(IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token) { private int lookups; private int voids; public async Task<(string Outcome, string Detail)> CheckDomainAsync(string domain, IPAddress ip, string mailFrom, string helo, int depth) { if (depth > 5) return (PermError, "include/redirect 嵌套过深"); var records = await TxtAsync(domain); var spf = records.Where(r => r.TrimStart().StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase)).ToArray(); if (spf.Length == 0) return (None, $"{domain} 没有 SPF 记录"); if (spf.Length > 1) return (PermError, $"{domain} 有多条 SPF 记录"); var terms = Tokenize(spf[0]); var redirect = ""; foreach (var raw in terms) { if (raw.Length == 0) continue; var term = raw; var qualifier = '+'; if ("+-~?".Contains(term[0])) { qualifier = term[0]; term = term[1..]; } if (term.StartsWith("redirect=", StringComparison.OrdinalIgnoreCase)) { redirect = term["redirect=".Length..]; continue; } if (term.Contains('=')) continue; // 其它修饰符(exp= 等)本实现不处理 var (name, value) = Split(term); var match = false; switch (name.ToLowerInvariant()) { case "all": match = true; break; case "include": if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); { var included = await CheckDomainAsync(value, ip, mailFrom, helo, depth + 1); if (included.Outcome == Pass) return (Pass, $"include:{value} 通过"); if (included.Outcome is TempError or PermError) return (included.Outcome, included.Detail); } break; case "a": if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); match = await MatchesAddressAsync(value.Length > 0 ? value : domain, ip); break; case "mx": if (!await CountLookupAsync(value.Length > 0 ? value : domain)) return (PermError, "SPF 查询次数超过 10 次"); { var hosts = await SafeMxAsync(value.Length > 0 ? value : domain); foreach (var host in hosts) if (await MatchesAddressAsync(host, ip)) { match = true; break; } } break; case "ip4": match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetwork); break; case "ip6": match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetworkV6); break; case "exists": if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次"); match = (await SafeAddressesAsync(Expand(value, domain, ip, mailFrom, helo))).Count > 0; break; case "ptr": // RFC 7208 §5.5:ptr 机制不推荐使用,本实现直接视为不匹配 break; default: continue; } if (!match) continue; var outcome = qualifier switch { '-' => Fail, '~' => SoftFail, '?' => Neutral, _ => Pass, }; return (outcome, $"{raw} 命中({domain})"); } if (redirect.Length > 0) { if (!await CountLookupAsync(redirect)) return (PermError, "SPF 查询次数超过 10 次"); return await CheckDomainAsync(redirect, ip, mailFrom, helo, depth + 1); } return (Neutral, $"{domain} 的 SPF 没有匹配项"); } private async Task CountLookupAsync(string name) { lookups++; if (lookups > Math.Max(1, cfg.MaxSpfLookups)) return false; // void lookup(查了但没记录)超过 2 次即 permerror return await Task.FromResult(true); } private async Task> TxtAsync(string name) { var r = await SafeTxtAsync(name); if (r.Count == 0 && ++voids > 2) return r; return r; } private async Task> SafeTxtAsync(string name) { try { return await dns.TxtAsync(name.TrimEnd('.'), token); } catch (Exception ex) when (ex is not OperationCanceledException) { return []; } } private async Task> SafeAddressesAsync(string name) { try { return await dns.AddressesAsync(name.TrimEnd('.'), token); } catch { return []; } } private async Task> SafeMxAsync(string name) { try { return await dns.MxAsync(name.TrimEnd('.'), token); } catch { return []; } } private async Task MatchesAddressAsync(string host, IPAddress ip) { foreach (var candidate in await SafeAddressesAsync(host)) if (IPAddress.TryParse(candidate, out var parsed) && parsed.Equals(ip)) return true; return false; } } private static bool IpMatches(IPAddress ip, string value, AddressFamily family) { if (ip.AddressFamily != family) return false; var parts = value.Split('/', 2); if (!IPAddress.TryParse(parts[0], out var network)) return false; var bits = parts.Length > 1 && int.TryParse(parts[1], out var parsedBits) ? parsedBits : (family == AddressFamily.InterNetwork ? 32 : 128); var networkBytes = network.GetAddressBytes(); var ipBytes = ip.GetAddressBytes(); if (networkBytes.Length != ipBytes.Length) return false; var fullBytes = bits / 8; for (var i = 0; i < fullBytes; i++) if (networkBytes[i] != ipBytes[i]) return false; var remainder = bits % 8; if (remainder == 0) return true; var mask = (byte)(0xFF << (8 - remainder)); return (networkBytes[fullBytes] & mask) == (ipBytes[fullBytes] & mask); } private static IEnumerable Tokenize(string record) { foreach (var piece in record.Split(' ', '\t', '\r', '\n')) { var term = piece.Trim(); if (term.Length == 0) continue; yield return term; } } private static (string Name, string Value) Split(string term) { var colon = term.IndexOf(':'); if (colon < 0) return (term, ""); var name = term[..colon]; var value = term[(colon + 1)..]; // ⚠ 注意:**不能在这里砍掉 `/`** —— ip4:203.0.113.0/24 的 CIDR 就是值的一部分。 // a/mx 的 `a:domain/24` 形式由调用方自己拆(本实现不支持 a/mx 的 CIDR 修饰, // 这属于罕见的用法,但 ip4/ip6 的 CIDR 是必须的)。 if (name.Equals("a", StringComparison.OrdinalIgnoreCase) || name.Equals("mx", StringComparison.OrdinalIgnoreCase)) { var slash = value.IndexOf('/'); if (slash >= 0) value = value[..slash]; } return (name, value); } /// SPF 宏的常用子集(%{d} %{s} %{o} %{i} %{h})。 public static string Expand(string value, string domain, IPAddress ip, string mailFrom, string helo) { if (!value.Contains("%{")) return value; var sender = mailFrom.Split('@').LastOrDefault() ?? ""; var local = mailFrom.Split('@').FirstOrDefault() ?? ""; return value .Replace("%{d}", domain, StringComparison.OrdinalIgnoreCase) .Replace("%{s}", mailFrom, StringComparison.OrdinalIgnoreCase) .Replace("%{l}", local, StringComparison.OrdinalIgnoreCase) .Replace("%{o}", sender, StringComparison.OrdinalIgnoreCase) .Replace("%{i}", ip.ToString(), StringComparison.OrdinalIgnoreCase) .Replace("%{h}", helo, StringComparison.OrdinalIgnoreCase); } public static string DomainOf(string? address) { var text = (address ?? "").Trim(); var at = text.LastIndexOf('@'); if (at < 0) return ""; var domain = text[(at + 1)..]; // 可能是 `bob@example.com>` 或 `bob@example.com (注释)` —— 截到第一个分隔符 var stop = domain.IndexOfAny(['>', ' ', '\t', ')', ',', ';', '"']); if (stop >= 0) domain = domain[..stop]; return domain.Trim().TrimEnd('.').ToLowerInvariant(); } }