402 lines
18 KiB
C#
402 lines
18 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace WpywMail.Native;
|
||
|
||
/// <summary>
|
||
/// 自检:不依赖网络与真实邮箱,验证中文编解码、MIME 往返、附件、DKIM 签名可被验证。
|
||
/// 用法:WpywMail.Native.exe --selftest
|
||
/// </summary>
|
||
public static partial class SelfTest
|
||
{
|
||
private static int passed;
|
||
private static int failed;
|
||
|
||
public static int Run()
|
||
{
|
||
Console.OutputEncoding = Encoding.UTF8;
|
||
Console.WriteLine($"=== WpywMail.Native 自检 {BuildInfo.Version} ===");
|
||
|
||
var config = new AppConfig
|
||
{
|
||
Domain = "wpy.email",
|
||
Hostname = "mail.example.com",
|
||
AdminEmail = "[email protected]",
|
||
AdminPassword = "selftest-password-1234",
|
||
DataDirectory = Path.Combine(Path.GetTempPath(), "wpyw-selftest-" + Guid.NewGuid().ToString("N")[..8]),
|
||
Dkim = new DkimConfig { Enabled = true, Selector = "mail" },
|
||
};
|
||
Directory.CreateDirectory(config.DataDirectory);
|
||
|
||
try
|
||
{
|
||
TestChineseRoundTrip(config);
|
||
TestMessageIdDomain(config);
|
||
TestRfc2047Decoding();
|
||
TestRawUtf8Headers();
|
||
TestQuotedPrintable();
|
||
TestAttachmentRoundTrip(config);
|
||
TestCharsetFallback();
|
||
TestDkimSignatureVerifies(config);
|
||
TestDkimSurvivesTransport(config);
|
||
TestAddressParsing();
|
||
TestSmtpDataReader();
|
||
TestDkimCanonicalization();
|
||
TestStorageBackends();
|
||
TestAccounts();
|
||
TestInboundAuth();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Fail("自检整体", $"抛出异常:{ex}");
|
||
}
|
||
finally
|
||
{
|
||
try { Directory.Delete(config.DataDirectory, true); } catch { }
|
||
}
|
||
|
||
Console.WriteLine();
|
||
Console.WriteLine($"=== 结果:{passed} 项通过,{failed} 项失败 ===");
|
||
return failed == 0 ? 0 : 1;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 用例
|
||
|
||
private static void TestChineseRoundTrip(AppConfig config)
|
||
{
|
||
const string subject = "中文主题测试:你好,世界(含全角标点)";
|
||
const string body = "这是正文第一行。\n第二行包含 emoji 之外的符号:→ ★ ①\n第三行结束。";
|
||
var raw = Mime.Build(new ComposeRequest("[email protected]", "王朋友", ["[email protected]"], [], subject, body), config);
|
||
var parsed = Mime.Parse(raw);
|
||
|
||
Check("中文主题往返", parsed.Subject == subject, $"得到「{parsed.Subject}」");
|
||
Check("中文正文往返", parsed.Text == body, $"得到「{parsed.Text.Replace("\n", "\\n")}」");
|
||
Check("正文使用 base64", Encoding.ASCII.GetString(raw).Contains("Content-Transfer-Encoding: base64"), "");
|
||
Check("主题使用 RFC2047", Encoding.ASCII.GetString(raw).Contains("=?UTF-8?B?"), "");
|
||
Check("报文全为 ASCII(可安全传输)", raw.All(b => b < 0x80), "存在 8bit 字节");
|
||
Check("带显示名的中文发件人", RawHeader(raw, "From").Contains("=?UTF-8?B?"), RawHeader(raw, "From"));
|
||
}
|
||
|
||
private static void TestMessageIdDomain(AppConfig config)
|
||
{
|
||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [], "t", "b"), config);
|
||
var messageId = RawHeader(raw, "Message-ID");
|
||
Check("Message-ID 使用配置域名", messageId.Contains("@wpy.email>"), messageId);
|
||
Check("Message-ID 不再写死旧域名", !messageId.Contains("wpyw.site"), messageId);
|
||
}
|
||
|
||
private static void TestRfc2047Decoding()
|
||
{
|
||
var b = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("中文主题")) + "?=";
|
||
Check("解码 B 编码字", Mime.DecodeHeader(b) == "中文主题", Mime.DecodeHeader(b));
|
||
|
||
var q = "=?UTF-8?Q?=E4=B8=AD=E6=96=87?=";
|
||
Check("解码 Q 编码字", Mime.DecodeHeader(q) == "中文", Mime.DecodeHeader(q));
|
||
|
||
var adjacent = "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes("前半")) + "?= =?UTF-8?B?" +
|
||
Convert.ToBase64String(Encoding.UTF8.GetBytes("后半")) + "?=";
|
||
Check("相邻编码字拼接无多余空格", Mime.DecodeHeader(adjacent) == "前半后半", Mime.DecodeHeader(adjacent));
|
||
|
||
var mixed = "Hello " + b;
|
||
Check("编码字与纯文本混排", Mime.DecodeHeader(mixed) == "Hello 中文主题", Mime.DecodeHeader(mixed));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 裸 UTF-8 头(没有 RFC 2047 编码字)与 8bit 正文。
|
||
/// 现实中不少客户端这么发;早期版本会把它解成「[æµè¯]」这种乱码。
|
||
/// </summary>
|
||
private static void TestRawUtf8Headers()
|
||
{
|
||
var raw = Encoding.UTF8.GetBytes(
|
||
"From: 张三 <[email protected]>\r\n" +
|
||
"Subject: 中文裸 UTF-8 主题\r\n" +
|
||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||
"Content-Transfer-Encoding: 8bit\r\n" +
|
||
"\r\n" +
|
||
"中文正文内容\r\n");
|
||
var parsed = Mime.Parse(raw);
|
||
Check("裸 UTF-8 主题解码", parsed.Subject == "中文裸 UTF-8 主题", parsed.Subject);
|
||
Check("裸 UTF-8 发件人显示名", parsed.From.Contains("张三"), parsed.From);
|
||
Check("8bit 正文解码", parsed.Text == "中文正文内容", parsed.Text);
|
||
}
|
||
|
||
private static void TestQuotedPrintable() {
|
||
var raw = Encoding.ASCII.GetBytes("Subject: =?UTF-8?Q?=E4=B8=AD=E6=96=87?=\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n=E4=B8=AD=E6=96=87=20ok=\r\n=E7=BB=AD=E8=A1=8C\r\n");
|
||
var parsed = Mime.Parse(raw);
|
||
// =E7=BB=AD = U+7EED「续」,且 =CRLF 是软换行必须被合并
|
||
Check("QP 正文解码(含软换行合并)", parsed.Text == "中文 ok续行", $"得到「{parsed.Text}」");
|
||
Check("QP 主题解码", parsed.Subject == "中文", parsed.Subject);
|
||
}
|
||
|
||
private static void TestAttachmentRoundTrip(AppConfig config)
|
||
{
|
||
var data = Encoding.UTF8.GetBytes("附件内容:中文测试数据");
|
||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [],
|
||
"带附件", "见附件", null, [new OutgoingAttachment("中文报告.txt", "text/plain", data)]), config);
|
||
var parsed = Mime.Parse(raw);
|
||
|
||
Check("附件数量", parsed.Attachments.Count == 1, $"{parsed.Attachments.Count}");
|
||
if (parsed.Attachments.Count == 1)
|
||
{
|
||
var attachment = parsed.Attachments[0];
|
||
Check("附件名(中文)", attachment.FileName == "中文报告.txt", attachment.FileName);
|
||
Check("附件内容逐字节一致", attachment.Data.SequenceEqual(data), "");
|
||
Check("附件使用 RFC2231 文件名", Encoding.ASCII.GetString(raw).Contains("filename*=UTF-8''"), "");
|
||
}
|
||
Check("附件存在时正文仍可读", parsed.Text.Contains("见附件"), parsed.Text);
|
||
}
|
||
|
||
private static void TestCharsetFallback()
|
||
{
|
||
Check("UTF-8 解码", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "utf-8") == "中文", "");
|
||
Check("未知字符集回退不崩", Mime.DecodeCharset(Encoding.UTF8.GetBytes("中文"), "x-unknown-999") == "中文", "");
|
||
|
||
var provider = CodePagesShim.Provider;
|
||
if (provider is not null)
|
||
{
|
||
try
|
||
{
|
||
Encoding.RegisterProvider(provider);
|
||
var gbk = Encoding.GetEncoding(936).GetBytes("中文测试");
|
||
Check("GBK 解码", Mime.DecodeCharset(gbk, "gb2312") == "中文测试", Mime.DecodeCharset(gbk, "gb2312"));
|
||
}
|
||
catch (Exception ex) { Check("GBK 解码", false, ex.Message); }
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine(" [跳过] GBK 解码(未引用 System.Text.Encoding.CodePages)");
|
||
}
|
||
}
|
||
|
||
private static void TestDkimSignatureVerifies(AppConfig config)
|
||
{
|
||
var signer = DkimSigner.Create(config);
|
||
if (signer is null) { Check("DKIM 初始化", false, "返回 null"); return; }
|
||
|
||
var raw = Mime.Build(new ComposeRequest("[email protected]", null, ["[email protected]"], [],
|
||
"DKIM 测试主题", "DKIM 测试正文内容。"), config);
|
||
var signed = signer.Sign(raw);
|
||
|
||
Check("报文已带 DKIM-Signature", Encoding.ASCII.GetString(signed).Contains("DKIM-Signature:"), "");
|
||
Check("被签名报文仍是 ASCII", signed.All(b => b < 0x80), "");
|
||
Check("DKIM 签名可被本地验证(RFC 6376 §3.7 顺序)", VerifyDkim(signed, signer), "签名校验失败");
|
||
// 反向对照:如果验签器把「签名头放最前 + 结尾带 CRLF」也判为有效,说明它根本没在
|
||
// 按 RFC 校验(2026-09-13 的真实事故就是签名端与验签端一起犯错导致假通过)。
|
||
Check("(反向对照)非规范顺序必须验不过",
|
||
!VerifyDkim(signed, signer, rfcOrder: false), "两种顺序都能通过 → 验签器未按 RFC 6376 校验");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 最关键的一致性测试:签名覆盖的字节必须与传输写出的字节完全一致。
|
||
/// 用一个「裸 LF 行尾 + 以点开头的行」的恶劣报文走完整链路:
|
||
/// 规范化 → 签名 → DATA 编码(dot-stuffing)→ 收件端还原 → 验签。
|
||
/// 若签名后才改行尾,或 dot-stuffing 破坏了正文,这里必然失败。
|
||
/// </summary>
|
||
private static void TestDkimSurvivesTransport(AppConfig config)
|
||
{
|
||
var signer = DkimSigner.Create(config);
|
||
if (signer is null) { Check("DKIM 传输一致性", false, "签名器不可用"); return; }
|
||
|
||
var hostile = Encoding.UTF8.GetBytes(
|
||
"From: [email protected]\n" +
|
||
"To: [email protected]\n" +
|
||
"Subject: 传输一致性测试\n" +
|
||
"MIME-Version: 1.0\n" +
|
||
"Content-Type: text/plain; charset=UTF-8\n" +
|
||
"Content-Transfer-Encoding: 8bit\n" +
|
||
"\n" +
|
||
"第一行中文\n" +
|
||
". 这一行以点开头\n" +
|
||
"最后一行\n");
|
||
|
||
var normalized = SmtpDataEncoder.Normalize(hostile);
|
||
// 注意:报文是 UTF-8 字节,检查时必须按 UTF-8 还原(用 Latin-1 解会得到乱码而匹配不上)
|
||
Check("裸 LF 在签名前被规范化为 CRLF",
|
||
Encoding.UTF8.GetString(normalized).Contains("\r\n. 这一行以点开头\r\n"),
|
||
"");
|
||
|
||
var signed = signer.Sign(normalized);
|
||
var wire = SmtpDataEncoder.Encode(signed);
|
||
Check("线上报文对行首点做了 dot-stuffing",
|
||
Encoding.UTF8.GetString(wire).Contains("\r\n.. 这一行以点开头\r\n"),
|
||
"");
|
||
|
||
var received = SmtpDataEncoder.Decode(wire);
|
||
Check("接收端还原后与签名前字节一致", received.SequenceEqual(signed), "");
|
||
|
||
var verified = VerifyDkim(received, signer);
|
||
Check("DKIM 经过完整传输变换后仍可验证", verified, "签名校验失败");
|
||
}
|
||
|
||
private static void TestAddressParsing()
|
||
{
|
||
var result = Mime.Addresses("张三 <[email protected]>, [email protected]; \"李四, 五\" <[email protected]>");
|
||
Check("地址解析(含引号内逗号)", result.Length == 3 && result[0] == "[email protected]" && result[2] == "[email protected]",
|
||
string.Join(" | ", result));
|
||
Check("非法地址被过滤", Mime.Addresses("not-an-address, [email protected]").Length == 1, "");
|
||
}
|
||
|
||
private static void TestSmtpDataReader()
|
||
{
|
||
// 模拟客户端发送:正文里有中文 UTF-8、以点开头的行需要 dot-stuffing
|
||
var payload = "Subject: 测试\r\n\r\n.. 以点开头\r\n中文内容\r\n";
|
||
var wire = Encoding.UTF8.GetBytes(payload.Replace("\r\n", "\r\n"));
|
||
using var stream = new MemoryStream();
|
||
// 客户端在 DATA 后追加结束行
|
||
stream.Write(wire);
|
||
stream.Write(Encoding.ASCII.GetBytes(".\r\n"));
|
||
stream.Position = 0;
|
||
|
||
var reader = new SmtpReader(stream);
|
||
var data = reader.ReadDataAsync(1024 * 1024, CancellationToken.None).GetAwaiter().GetResult();
|
||
var text = Encoding.UTF8.GetString(data);
|
||
|
||
Check("DATA 读取保留 UTF-8 中文", text.Contains("中文内容"), text.Replace("\r\n", "\\n"));
|
||
Check("DATA 读取做 dot-unstuffing", text.Contains(". 以点开头"), text.Replace("\r\n", "\\n"));
|
||
Check("DATA 读取行尾统一 CRLF", text.EndsWith("\r\n"), "");
|
||
}
|
||
|
||
private static void TestDkimCanonicalization()
|
||
{
|
||
// 折行 + 多余空白应被规范化成单空格
|
||
var raw = Encoding.ASCII.GetBytes("From: [email protected]\r\nSubject: hello world \r\n\tcontinued\r\n\r\nbody\r\n");
|
||
var (headers, _) = SplitForTest(raw);
|
||
var subject = headers.First(h => h.Name == "Subject");
|
||
var canonical = CanonicalForTest(subject.Name, subject.Value);
|
||
Check("relaxed 头规范化(折行与空白)", canonical == "subject:hello world continued", canonical);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- DKIM 校验(独立于签名器的实现)
|
||
|
||
/// <summary>
|
||
/// 按 RFC 6376 §3.7 第 2 步重建签名输入并验签。
|
||
/// rfcOrder=false 时故意用「DKIM-Signature 放最前 + 结尾带 CRLF」的**非规范**顺序,
|
||
/// 仅用于反向对照:非规范顺序绝不能被判为有效。
|
||
/// </summary>
|
||
private static bool VerifyDkim(byte[] message, DkimSigner signer, bool rfcOrder = true)
|
||
{
|
||
var (headers, body) = SplitForTest(message);
|
||
var dkim = headers.FirstOrDefault(h => h.Name.Equals("DKIM-Signature", StringComparison.OrdinalIgnoreCase));
|
||
if (dkim.Name is null) return false;
|
||
|
||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var part in dkim.Value.Split(';'))
|
||
{
|
||
var equals = part.IndexOf('=');
|
||
if (equals > 0) tags[part[..equals].Trim()] = part[(equals + 1)..].Trim();
|
||
}
|
||
if (!tags.TryGetValue("b", out var signatureBase64) || !tags.TryGetValue("bh", out var bodyHash)) return false;
|
||
if (!tags.TryGetValue("h", out var headerList)) return false;
|
||
|
||
// 1) 正文哈希
|
||
var canonicalBody = CanonicalBodyForTest(body);
|
||
var computedBodyHash = Convert.ToBase64String(SHA256.HashData(canonicalBody));
|
||
if (computedBodyHash != bodyHash.Trim()) return false;
|
||
|
||
// 2) 重建签名输入
|
||
var withoutSignature = dkim.Value.Replace(tags["b"], "").TrimEnd();
|
||
var signatureLine = CanonicalForTest("DKIM-Signature", withoutSignature);
|
||
var builder = new StringBuilder();
|
||
|
||
if (!rfcOrder)
|
||
{
|
||
builder.Append(signatureLine).Append("\r\n");
|
||
}
|
||
|
||
foreach (var name in headerList.Split(':', StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
var header = headers.FirstOrDefault(h => h.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||
if (header == default) return false;
|
||
builder.Append(CanonicalForTest(header.Name, header.Value)).Append("\r\n");
|
||
}
|
||
|
||
if (rfcOrder)
|
||
{
|
||
// RFC 6376 §3.7:签名头放最后,且不带结尾 CRLF
|
||
builder.Append(signatureLine);
|
||
}
|
||
|
||
var publicKey = RSA.Create();
|
||
publicKey.ImportSubjectPublicKeyInfo(signer.PublicKeyBytes, out _);
|
||
// 与签名端一致用 Latin1(字节保真):ASCII 会把 8bit 头里的非 ASCII 字符替换成 '?'
|
||
return publicKey.VerifyData(Encoding.Latin1.GetBytes(builder.ToString()),
|
||
Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||
}
|
||
|
||
private static byte[] CanonicalBodyForTest(byte[] body)
|
||
{
|
||
var text = Encoding.Latin1.GetString(body).Replace("\r\n", "\n").Replace('\r', '\n');
|
||
var lines = text.Split('\n').Select(l => CollapseForTest(l).TrimEnd(' ', '\t'));
|
||
var joined = string.Join("\r\n", lines).TrimEnd('\r', '\n');
|
||
if (joined.Length > 0) joined += "\r\n";
|
||
return Encoding.Latin1.GetBytes(joined);
|
||
}
|
||
|
||
private static string CanonicalForTest(string name, string value)
|
||
{
|
||
var unfolded = value.Replace("\r\n", "").Replace("\n", "");
|
||
return name.Trim().ToLowerInvariant() + ":" + CollapseForTest(unfolded).Trim();
|
||
}
|
||
|
||
private static string CollapseForTest(string value)
|
||
{
|
||
var builder = new StringBuilder();
|
||
var inWhitespace = false;
|
||
foreach (var c in value)
|
||
{
|
||
if (c is ' ' or '\t') { if (!inWhitespace) builder.Append(' '); inWhitespace = true; }
|
||
else { builder.Append(c); inWhitespace = false; }
|
||
}
|
||
return builder.ToString();
|
||
}
|
||
|
||
private static (List<(string Name, string Value)> Headers, byte[] Body) SplitForTest(byte[] message)
|
||
{
|
||
var headers = new List<(string, string)>();
|
||
var index = 0;
|
||
string? name = null;
|
||
var value = new StringBuilder();
|
||
while (index < message.Length)
|
||
{
|
||
var lineEnd = Array.IndexOf(message, (byte)'\n', index);
|
||
if (lineEnd < 0) lineEnd = message.Length;
|
||
var line = Encoding.Latin1.GetString(message, index, lineEnd - index).TrimEnd('\r');
|
||
index = lineEnd + 1;
|
||
if (line.Length == 0) break;
|
||
if ((line[0] == ' ' || line[0] == '\t') && name is not null) { value.Append("\r\n").Append(line); continue; }
|
||
if (name is not null) headers.Add((name, value.ToString()));
|
||
var colon = line.IndexOf(':');
|
||
if (colon <= 0) { name = null; value.Clear(); continue; }
|
||
name = line[..colon].Trim();
|
||
value.Clear().Append(line[(colon + 1)..]);
|
||
}
|
||
if (name is not null) headers.Add((name, value.ToString()));
|
||
return (headers, message[Math.Min(index, message.Length)..]);
|
||
}
|
||
|
||
private static string RawHeader(byte[] raw, string name)
|
||
{
|
||
var (headers, _) = SplitForTest(raw);
|
||
var header = headers.FirstOrDefault(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||
return header == default ? "" : header.Value.Trim();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 断言
|
||
|
||
private static void Check(string title, bool ok, string detail)
|
||
{
|
||
if (ok)
|
||
{
|
||
passed++;
|
||
Console.WriteLine($" [通过] {title}");
|
||
}
|
||
else
|
||
{
|
||
failed++;
|
||
Console.WriteLine($" [失败] {title}" + (string.IsNullOrEmpty(detail) ? "" : $" —— {detail}"));
|
||
}
|
||
}
|
||
|
||
private static void Fail(string title, string detail) => Check(title, false, detail);
|
||
}
|