使用 DKIM 对电子邮件进行签名以实现身份验证和可投递性
DKIM(域密钥识别邮件, RFC 6376) 允许域所有者为外发邮件附加加密签名。接收服务器从 DNS 获取匹配的公钥,重新计算哈希,并一次确认两件事:邮件正文在传输途中未被修改,且邮件确实来自该域授权的发件人。没有 DKIM,现代提供商(Gmail、Microsoft 365、Yahoo)会将邮件标记为垃圾邮件、悄悄丢弃或拒绝投递——DKIM 对于生产邮件已不再可选。
本指南完整演示了 Aspose.Email的 .NET DKIM 命名空间。本文章中的每个代码块均取自可运行示例的 DKIM 示例 项目已完成并已对库进行端到端验证。
先决条件
DKIM 类型位于 Aspose.Email.DKIM 命名空间,仅随 .NET Framework 4.0/4.5 版本的库一起发布。它们有意从 .NET Standard 2.0 和 .NET 6/8 包中排除。要遵循下面的示例,您的项目必须引用针对该平台构建的 Aspose.Email 程序集 net45 (目标 net48 在使用的 .csproj).
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Aspose.Email">
<HintPath>..\path\to\net45\Aspose.Email.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
您还需要一对 RSA 密钥。私钥保存在您的应用程序中用于签名;公钥作为 DNS TXT 记录发布,以便接收方验证。2048 位密钥是当今的标准:
openssl genrsa -out sample-private-key.pem 2048
Aspose 的 PemReader 同时接受 PKCS#1(-----BEGIN RSA PRIVATE KEY-----) 和 PKCS#8(-----BEGIN PRIVATE KEY-----) 格式化的文件。
| 类别 | 角色 | |——-|——| | DKIMSignatureInfo | 描述签名:选择器、域、哈希算法、规范化以及覆盖的标题。 | | PemReader | 从 PEM 格式的文件或流加载 RSA 私钥到 RSACryptoServiceProvider. |
| MailMessage.DKIMSign(rsa, info) | 扩展/实例方法,返回包含 DKIM-Signature 标题已填充。 | | DkimVerifier | 对已签名的消息执行基于 DNS 的验证——文件、流、同步或异步。 |
完整流程始终为:加载密钥 → 描述签名 → 构建消息 → 签名 →(可选)验证。
最小签名示例
这是生成有效 DKIM 签名消息的最少代码量:
// Load private key from PEM file
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
// Create DKIM signature information
var signInfo = new DKIMSignatureInfo("dkim", "example.com")
{
// Default: Relaxed/Relaxed canonicalization
// Default: RSASha1 hash algorithm
};
// Add headers to sign
signInfo.Headers.Add("From");
signInfo.Headers.Add("To");
signInfo.Headers.Add("Subject");
// Create a test email
var mailMessage = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Test Subject",
"This is a test email body signed with DKIM.")
{
From = new MailAddress("sender@example.com", "Sender Name")
};
// Sign the message
var signedMsg = mailMessage.DKIMSign(rsa, signInfo);
// Save the signed message
string outputPath = Path.Combine(DataDir, "basic-signed.eml");
signedMsg.Save(outputPath);
Console.WriteLine($"DKIM-Signature header: {signedMsg.Headers["DKIM-Signature"]?.Substring(0, 50)}...");
两个参数为 DKIMSignatureInfo — "dkim" 和 "example.com" — 是选择器和域。它们一起告诉接收者在哪里查找您的公钥:在 DNS TXT 记录 dkim._domainkey.example.com。选择一个简短的选择器名称(通常是 default, mail,或类似的日期 2026jan);选择器允许您在不丢弃旧密钥的情况下轮换密钥。
一个关键细节: DKIMSign 返回一个新的 MailMessage 实例加载文件,使用 DKIM-Signature 标题已添加。原始消息未更改。保存或发送返回的对象,而不是输入。
选择要签名的标题
列出的标题在 signInfo.Headers 是唯一与签名绑定的值。未列出的任何内容都可以被中继修改而不破坏签名,这在某些情况下是可取的(例如 Received: 随后添加的头部)有时也会有风险(例如留下 From 未签名会使签名失去意义)。
经验法则:始终签名 From, To, Subject, Date. From 特别是 DMARC 对齐所必需的。
var signInfo = new DKIMSignatureInfo("dkim2", "example.com")
{
Headers =
{
"From",
"To",
"Subject",
"Date",
"MIME-Version"
}
};
// Create email with additional headers
var mailMessage = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Custom Headers Test",
"This message includes custom headers in the DKIM signature.")
{
From = new MailAddress("sender@example.com", "Sender"),
Date = DateTime.UtcNow
};
// Ensure headers listed in DKIM signature are present on the message
mailMessage.Headers.Add("MIME-Version", "1.0");
mailMessage.Headers.Add("X-Custom-Header", "CustomValue");
// Sign with custom headers
var signedMsg = mailMessage.DKIMSign(rsa, signInfo);
string outputPath = Path.Combine(DataDir, "custom-headers-signed.eml");
signedMsg.Save(outputPath);
如果您列出 Date 但永不设置 mailMessage.Date, DKIMSign 抛出 The following headers to be signed do not exist: Date。始终先填充头部,然后再签名。
散列算法 —— RSA‑SHA1 vs RSA‑SHA256
DKIM 支持两种签名算法。Aspose.Email 默认使用 RSASha1 为兼容旧版保留,但 每个新部署都应使用 RSASha256。许多提供商现在将 SHA-1 签名标记为无效,即使它们在加密上是可验证的。
// Example with RSASha1 (older, less secure but widely supported)
var signInfoSha1 = new DKIMSignatureInfo("dkim-sha1", "example.com")
{
HashAlgorithm = DKIMHashAlgorithm.RSASha1,
Headers = { "From", "To", "Subject" }
};
var mailMessage1 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"SHA1 Test",
"Signed with RSASha1 algorithm.");
mailMessage1.From = new MailAddress("sender@example.com");
var signedMsgSha1 = mailMessage1.DKIMSign(rsa, signInfoSha1);
string pathSha1 = Path.Combine(DataDir, "sha1-signed.eml");
signedMsgSha1.Save(pathSha1);
// Example with RSASha256 (newer, more secure)
var signInfoSha256 = new DKIMSignatureInfo("dkim-sha256", "example.com")
{
HashAlgorithm = DKIMHashAlgorithm.RSASha256,
Headers = { "From", "To", "Subject" }
};
var mailMessage2 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"SHA256 Test",
"Signed with RSASha256 algorithm.");
mailMessage2.From = new MailAddress("sender@example.com");
var signedMsgSha256 = mailMessage2.DKIMSign(rsa, signInfoSha256);
string pathSha256 = Path.Combine(DataDir, "sha256-signed.eml");
signedMsgSha256.Save(pathSha256);
生成的 DKIM-Signature 头部通过 a= 标签(a=rsa-sha1 对比 a=rsa-sha256)。接收方会查阅该标签以决定使用哪种哈希重新计算。
规范化 —— 简单 vs 放宽
规范化是 DKIM 在哈希之前就消息达成稳定、字节精确形式的方式。针对头部和正文分别定义了两种算法:
Simple—— 几乎不做规范化。删除正文末尾的空行,但不触碰其他空白。中继添加的单个空格将导致签名失效。Relaxed—— 在头部值内部折叠空白字符,小写头部名称,并在正文中折叠连续空白。能够容忍邮件传输代理常进行的琐碎重新格式化。
选择 Relaxed 除非您有特定原因,否则对头部和正文都使用。几乎所有生产部署都是如此。
// Simple canonicalization - almost no changes allowed
var signInfoSimple = new DKIMSignatureInfo("dkim-simple", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Simple,
BodyCanonicalization = CanonicalizationType.Simple,
Headers = { "From", "To", "Subject" }
};
var mailMessage1 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Simple Canonicalization",
"This message uses Simple canonicalization.");
mailMessage1.From = new MailAddress("sender@example.com");
var signedSimple = mailMessage1.DKIMSign(rsa, signInfoSimple);
string pathSimple = Path.Combine(DataDir, "simple-canonical.eml");
signedSimple.Save(pathSimple);
// Relaxed canonicalization - minor changes allowed (whitespace normalization)
var signInfoRelaxed = new DKIMSignatureInfo("dkim-relaxed", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Relaxed,
BodyCanonicalization = CanonicalizationType.Relaxed,
Headers = { "From", "To", "Subject" }
};
var mailMessage2 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Relaxed Canonicalization",
"This message uses Relaxed canonicalization.");
mailMessage2.From = new MailAddress("sender@example.com");
var signedRelaxed = mailMessage2.DKIMSign(rsa, signInfoRelaxed);
string pathRelaxed = Path.Combine(DataDir, "relaxed-canonical.eml");
signedRelaxed.Save(pathRelaxed);
头部和正文规范化是相互独立的—— relaxed/simple 是一种完全有效的组合,在 c= DKIM 头部的标签。
加载私钥
PemReader 透明处理两种 PEM 编码。没有单独的 Pkcs1Reader / Pkcs8Reader ——只需传递路径或流:
// Check if private key file exists
if (!File.Exists(PrivateKeyPath))
{
Console.WriteLine($"Private key file not found: {PrivateKeyPath}");
Console.WriteLine("Creating a sample private key for demonstration...");
// Create a sample RSA key (for testing only - in production, use real keys)
using var rsa = new System.Security.Cryptography.RSACryptoServiceProvider(2048);
var privateKeyParams = rsa.ExportParameters(true);
Console.WriteLine("Sample RSA key created (2048 bits).");
Console.WriteLine("In production, load your actual private key from a secure location.");
Console.WriteLine("PEM file format should contain either:");
Console.WriteLine(" - BEGIN RSA PRIVATE KEY (PKCS#1)");
Console.WriteLine(" - BEGIN PRIVATE KEY (PKCS#8)");
}
else
{
// Load the private key
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
Console.WriteLine($"Private key loaded successfully from: {PrivateKeyPath}");
Console.WriteLine("Key size: 2048 bits (typical for DKIM)");
}
Console.WriteLine("\nPEM File Format Examples:");
Console.WriteLine("--------------------------");
Console.WriteLine("PKCS#1 format:");
Console.WriteLine("-----BEGIN RSA PRIVATE KEY-----");
Console.WriteLine("MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy...");
Console.WriteLine("-----END RSA PRIVATE KEY-----");
Console.WriteLine();
Console.WriteLine("PKCS#8 format:");
Console.WriteLine("-----BEGIN PRIVATE KEY-----");
Console.WriteLine("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...");
Console.WriteLine("-----END PRIVATE KEY-----");
PemReader.GetPrivateKey 返回一个 RSACryptoServiceProvider 准备好交给 DKIMSign。还有一个 GetPrivateKey(Stream) 针对密钥来自密钥库、加密 Blob 或嵌入资源而非文件系统的情况的重载。
密钥处理规则:
- 永远不要将私钥提交到版本控制。
*.pem属于.gitignore. - 在生产环境中,从密钥管理器(Azure Key Vault、AWS Secrets Manager、HashiCorp Vault)加载密钥,而不是从磁盘读取。
- 每 6–12 个月旋转一次密钥。首先发布新选择器,切换签名,然后在宽限期后撤回旧 DNS 记录。
从文件验证已签名的消息
验证是对称操作:给定已签名的 .eml, DkimVerifier 提取 DKIM-Signature 头部,对其执行 DNS 查询 <selector>._domainkey.<domain> 获取公钥并重新计算哈希。结果是一个 DkimResult 与 IsValid 和 ErrorMessage.
// For this example, we need a signed message
if (!File.Exists(signedMessagePath))
{
Console.WriteLine($"Signed message file not found: {signedMessagePath}");
Console.WriteLine("Please provide a DKIM-signed .eml file for verification.");
return;
}
// Verify the signature
DkimResult result = DkimVerifier.Verify(signedMessagePath);
// Check the result
if (result.IsValid)
{
Console.WriteLine("[OK] DKIM signature verification successful!");
Console.WriteLine("The message was signed by the claimed domain.");
}
else
{
Console.WriteLine("[FAIL] DKIM signature verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
Console.WriteLine("\nVerification Process:");
Console.WriteLine("1. Extracts DKIM-Signature header from the message");
Console.WriteLine("2. Queries DNS for the public key (selector._domainkey.domain)");
Console.WriteLine("3. Verifies the signature using the public key");
Console.WriteLine("4. Compares computed body hash with the signed body hash");
验证使用
d=example.com总是会因 “字典中不存在密钥” 或类似 DNS 查询错误而失败,因为example.com不会发布 DKIM 记录。请使用您控制的域(或已知良好的测试邮件)进行真实验证测试。
从流验证
当消息不在磁盘上——它来自 IMAP 抓取、队列、HTTP 上传或 Blob 存储时——使用 Stream 重载。它接受任何可定位读取的流:
// Load the signed message into a stream
using (var stream = File.OpenRead(signedMessagePath))
{
DkimResult result = DkimVerifier.Verify(stream);
if (result.IsValid)
{
Console.WriteLine("[OK] DKIM signature verified successfully from stream!");
}
else
{
Console.WriteLine("[FAIL] Verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
}
Console.WriteLine("\nStream-based verification is useful when:");
Console.WriteLine(" - Working with email from IMAP/POP3 clients");
Console.WriteLine(" - Processing emails from databases or cloud storage");
Console.WriteLine(" - Building web applications that receive emails via HTTP");
Verify(Stream) 和 Verify(string) 在功能上是相同的——两者都会阻塞直到 DNS 查询完成。对于任何面向用户的场景,请使用下面的异步变体。
异步验证
DKIM 验证涉及 DNS 循环往返,即使在快速网络上也可能需要数十毫秒。在 ASP.NET 请求处理程序、消息处理循环或任何 UI 线程中,这会快速累加。 VerifyAsync 返回一个 Task<DkimResult> 您可以 await:
if (!File.Exists(signedMessagePath))
{
Console.WriteLine("Signed message file not found.");
return;
}
// Async verification
DkimResult result = await DkimVerifier.VerifyAsync(signedMessagePath);
if (result.IsValid)
{
Console.WriteLine("[OK] Async verification successful!");
}
else
{
Console.WriteLine("[FAIL] Async verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
Console.WriteLine("\nAsync verification is recommended for:");
Console.WriteLine(" - Web applications (ASP.NET, etc.)");
Console.WriteLine(" - Services that process multiple emails");
Console.WriteLine(" - Any application where you want to avoid blocking the main thread");
还有一个 VerifyAsync(Stream) 重载。与同步 API 一样,该方法声明 async Task (不是 async void)——调用点应 await 它,以及控制台应用入口点可以执行 VerifySignatureAsync().GetAwaiter().GetResult() 当一个 async Main 不可用。
端到端:加载、签名、保存、验证
上述各部分组合成一个工作流。这是您将在事务性电子邮件服务中嵌入的完整签名‑验证流水线:
// Step 1: Load private key
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
// Step 2: Create signature information
var signInfo = new DKIMSignatureInfo("dkim", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Relaxed,
BodyCanonicalization = CanonicalizationType.Relaxed,
HashAlgorithm = DKIMHashAlgorithm.RSASha256,
Headers =
{
"From",
"To",
"Subject",
"Date",
"MIME-Version"
}
};
// Step 3: Create email message
var mailMessage = new MailMessage(
"john.doe@example.com",
"jane.smith@example.org",
"Important Document - DKIM Signed",
"Please find the attached document.\n\nBest regards,\nJohn Doe");
mailMessage.From = new MailAddress("john.doe@example.com", "John Doe");
mailMessage.To.Add(new MailAddress("jane.smith@example.org", "Jane Smith"));
mailMessage.Date = DateTime.UtcNow;
mailMessage.Headers.Add("MIME-Version", "1.0");
// Step 4: Sign the message
var signedMessage = mailMessage.DKIMSign(rsa, signInfo);
// Step 5: Save the signed message
string signedPath = Path.Combine(DataDir, "end-to-end-signed.eml");
signedMessage.Save(signedPath);
// Step 6: Verify the signature
var result = DkimVerifier.Verify(signedPath);
if (result.IsValid)
{
Console.WriteLine(" [OK] Signature verification successful!");
Console.WriteLine(" [OK] The message integrity is confirmed.");
Console.WriteLine(" [OK] The sender domain is authenticated.");
}
else
{
Console.WriteLine($" [FAIL] Verification failed: {result.ErrorMessage}");
}
// Step 7: Display DKIM-Signature header
Console.WriteLine("\nStep 7: DKIM-Signature header:");
Console.WriteLine(" " + signedMessage.Headers["DKIM-Signature"]);
完整的 DKIM-Signature 此代码生成的头部如下所示:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
h=from:to:subject:date:mime-version; q=dns/txt; s=dkim; t=1780252880;
bh=e2RkwEm0k0yLcVmhi3KeXpt14Ppx3Xx49S33/+J8OGc=;
b=Zd3sfCGGD9YdOLw1o3YHLFs7DO6V9cc7anye5E3kQ/MEdkU14daTru0sXTZCtCryvJubCs...
有趣的标签:
v=1——DKIM 版本。a=rsa-sha256——算法;反映HashAlgorithm.c=relaxed/relaxed——规范化方式(头部/正文)。d=example.com/s=dkim——选择器和域名;它们共同形成 DNS 查询名称。h=from:to:subject:date:mime-version——按顺序列出的已签名头部。bh=——对规范化后的正文进行 base64 哈希。b=——对规范化后的头部进行实际的 RSA 签名。
如果更改任何已签名的头部值,或修改正文, bh 和 b 不再匹配,导致验证失败。
在 DNS 中发布公钥
本地签名只是完成了一半。接收方验证时,必须在 DNS TXT 记录中发布 RSA 密钥的公钥,位于 <selector>._domainkey.<domain>:
Record: dkim._domainkey.example.com
Type: TXT
Value: v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBC...
使用 OpenSSL 从 PEM 中提取公钥:
openssl rsa -in sample-private-key.pem -pubout -out sample-public-key.pem
剥离头部/尾部行并拼接 base64 内容以获取 p= 值。然后在依赖之前确认记录已生效:
dig dkim._domainkey.example.com TXT
在线工具,如 DKIM 验证器 和 MXToolbox DKIM 检查工具 将告诉您发送的真实邮件是否能够端到端验证。在切换生产流量之前请先使用它们。