使用 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 或嵌入资源而非文件系统的情况的重载。

密钥处理规则:

  1. 永远不要将私钥提交到版本控制。 *.pem 属于 .gitignore.
  2. 在生产环境中,从密钥管理器(Azure Key Vault、AWS Secrets Manager、HashiCorp Vault)加载密钥,而不是从磁盘读取。
  3. 每 6–12 个月旋转一次密钥。首先发布新选择器,切换签名,然后在宽限期后撤回旧 DNS 记录。

从文件验证已签名的消息

验证是对称操作:给定已签名的 .eml, DkimVerifier 提取 DKIM-Signature 头部,对其执行 DNS 查询 <selector>._domainkey.<domain> 获取公钥并重新计算哈希。结果是一个 DkimResultIsValidErrorMessage.

// 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 签名。

如果更改任何已签名的头部值,或修改正文, bhb 不再匹配,导致验证失败。

在 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 检查工具 将告诉您发送的真实邮件是否能够端到端验证。在切换生产流量之前请先使用它们。