跳到主要内容
ecdsa.com
← 全部文章

DKIM 为何验证失败:正文哈希、签名错误、密钥与 DNS

邮件在签名后被改动,或者验证者拿不到可用的密钥,DKIM 就会失效。下文的每种失败情形都用 dkimpy 和 mailauth 复现过,并附上各自产生的确切判定结果,方便你与 Authentication-Results 中看到的内容对照。

简短回答:DKIM 签名覆盖两部分内容:邮件正文的哈希(bh=),以及对选定的头字段连同这个哈希所做的签名(b=)。body hash did not verify 表示正文在签名后被改动,例如添加了页脚、免责声明或杀毒软件横幅,或者被重新编码。bad signature / signature verification failed 表示某个已签名的头字段被改动(例如 Subject 中加了标签、From 被改写),或者密钥不匹配。no key / key not found 和 permerror 指向 DNS:选择器错误,或者记录已吊销、格式不正确。relaxed 规范化(c=relaxed/relaxed)只容忍空白字符的变化和头字段名大小写的变化,除此之外一概不容忍。诊断时,把 Authentication-Results 头与 DKIM-Signature 的各个标签对照,然后修复在签名后修改邮件的那个环节,或者把签名放到流水线中更靠后的位置。

DKIM 实际签名的内容

DKIM(RFC 6376)会在邮件中添加一个 DKIM-Signature 头字段。下面这个是 dkimpy 1.1.8 为一封测试邮件生成的:

DKIM-Signature 头字段(dkimpy 1.1.8,保留生成时的折行)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
 i=@example.com; q=dns/txt; s=sel; t=1792500885; h=from : to : subject
 : date : message-id; bh=eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=;
 b=FVDVEZSc7Whs7waF1W5ImDSxpa/czkhdpeUBefQ3/daFclzCdP0x9EQh6NOxBCa6oWph4
 WtntuKENSuqQGtjzcVlLdT+itgDCj5PaKMHI6S6JmQPBo2yy2EzPpdcDPeuiORtNbJMRHdZ
 0MhMgg7k11sPD9ts0pE6i+nzWmUXyJ2jEKWw68oG59kSO3B9jns

这里的签名值做了截短。调试时需要关注的标签如下:

标签含义典型失败原因
d=、s=签名域名和选择器。密钥从 s._domainkey.d 获取选择器错误,记录缺失或已吊销
c=头部/正文的规范化方式,simple 或 relaxed。默认为 simple/simple空白字符的变化会破坏 simple 下的签名
h=已签名的头字段,按顺序排列其中任何一个被改动
bh=规范化后正文的 Base64 哈希值正文被改动
b=对已签名的头字段和 DKIM-Signature 本身(b= 置空)所做的签名头字段被改动,或者密钥错误
l=可选:只对正文的前 l 个字节计算哈希任何人都能追加内容(见下文)
a=算法:rsa-sha256 或 ed25519-sha256rsa-sha1 已不再有效(RFC 8301)

验证者按固定的顺序检查。首先获取密钥(s._domainkey.d,TXT 记录)。然后规范化正文,把它的哈希与 bh= 比较。只有在这之后,才针对头字段验证 b=。这个顺序解释了各种判定结果:只要正文被改动,即使头字段也被改动,报告的仍是正文哈希问题。

规范化:“relaxed”能容忍什么

规范化在计算哈希之前把邮件转换成标准形式,这样传输途中无害的改动就不会破坏签名。RFC 6376 §3.4 定义了两种形式:

  • simple 头部:不允许任何改动,连头字段名的大小写都不能变。
  • relaxed 头部:字段名转为小写,展开续行,把连续的空白压缩为一个空格,并删除值末尾以及冒号前后的空白。
  • simple 正文:只忽略末尾的空行。
  • relaxed 正文:在此基础上,还会忽略行尾的空白,并把一行之内连续的空格和制表符压缩为一个空格。

两种形式都不容忍改动过的词语、新增的行、重新编码的 MIME 部分或不同的换行符。relaxed 只容忍空白字符和头字段名大小写的变化。正因如此,c=relaxed/relaxed 是常用的选择。RFC 的默认值是 simple/simple,因此没有 c= 的 DKIM-Signature 使用的是严格形式。

失败情形复现

测试用一个 2048 位 RSA 密钥对一封短邮件签名(c=relaxed/relaxed,h=from:to:subject:date:message-id),按真实中间方的做法修改邮件,再用 dkimpy 1.1.8 验证。DNS 查询被替换为进程内的函数,因此可以在不同用例之间更改密钥记录:

dkimpy 1.1.8,Python 3.13.5(dkimpy 的日志行,base64 值完整显示)
--- 1. unchanged message, relaxed/relaxed
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: True
    verify: True
--- 2. list appends a footer to the body
    log: bh: b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8='
    exception: ValidationError: body hash mismatch (got b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8=', expected b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=')
--- 3. list adds a [team] tag to the Subject
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: False
    verify: False
--- 4a. whitespace changed in transit, relaxed/relaxed
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: True
    verify: True
--- 4b. same whitespace change, simple/simple
    log: bh: b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk='
    exception: ValidationError: body hash mismatch (got b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk=', expected b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY=')
--- 4c. simple/simple, only the Subject whitespace changed
    log: bh: b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY='
    log: b'DKIM-Signature' valid: False
    verify: False

逐个来看:

  1. 邮件未改动:正文哈希匹配,签名验证通过。
  2. 追加了页脚:邮件列表添加了退订页脚。计算出的正文哈希(got)与 bh=(expected)不同,因此验证在检查签名之前就停止了。
  3. Subject 标签:邮件列表在 Subject 前加上 [team]。正文哈希仍然匹配,说明正文完好,但头部签名验证未通过。“正文哈希正确、签名不正确”意味着头字段被改动,或者密钥错误。
  4. 空白字符:测试把 Subject 中的一个空格改成两个,把正文中的两个连续空格合并为一个,并添加了一个行尾空格。使用 relaxed/relaxed 时,签名仍然验证通过。使用 simple/simple 时,正文的改动导致正文哈希不匹配(4b),而仅头字段的改动就足以让签名验证失败(4c)。

接收方服务器会把结果写入 Authentication-Results。下面是用 mailauth 8.0.1(Node.js 24.22)验证同样用例的结果,格式与你在收到的邮件中看到的一致:

mailauth 8.0.1:Authentication-Results 的 dkim 部分
unchanged                          dkim=pass header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
footer appended                    dkim=neutral (body hash did not verify) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
[team] added to Subject            dkim=fail (bad signature) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
key revoked (empty p=)             dkim=neutral (invalid public key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
no key in DNS                      dkim=neutral (no key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;

不同的验证者对结果的措辞各不相同。mailauth 默认把正文哈希不匹配报告为 neutral,在严格模式下则报告为 dkim=fail (body hash did not verify)。OpenDKIM 对正文和头字段的改动都写成 dkim=fail reason="signature verification failed"。背后的原因是一样的。body hash did not verify、signature verification failed、key not found in DNS 和 permerror 这几个错误页面列出了各种变体。

谁在传输途中改动了邮件

根据症状找出原因:

症状常见元凶
同一发件人的每封邮件都出现正文哈希不匹配在 DKIM 签名者之后运行的内容过滤器、免责声明工具或追加签名档的工具,往往就在发送方自己这一侧
只有经过邮件列表的邮件出现正文哈希不匹配邮件列表软件添加了页脚或改写了 MIME 部分
只有含非 ASCII 文本的邮件出现正文哈希不匹配中继把 8 位内容转换成了 quoted-printable。RFC 6376 §5.3 指出,签名者应当在签名之前转换为 7 位 MIME
脚本生成的邮件出现正文哈希不匹配签名时用的是单独的 LF 换行符,传输时却是 CRLF,或者反过来
签名错误,正文哈希正确Subject 标签、From 改写(邮件列表应对 DMARC 的缓解措施)、网关对头字段重新折行或重新编码、转发时改动了 Reply-To 或 To
每封邮件都签名错误私钥与 DNS 记录不匹配:选择器错误、密钥已轮换,或者粘贴密钥时出了错

通过普通的 .forward 或别名转发时,通常会保留正文和已签名的头字段,因此在 SPF 失效的场景下,DKIM 依然能通过。邮件列表是破坏签名的典型角色,因为它们本来就要改动邮件。现代邮件列表软件可以避免这一点:不修改正文和 Subject,或者把 From 改写为列表自己的域名,并用自己的密钥签名。支持 ARC(RFC 8617,实验性)的接收方还可以信任邮件列表的 ARC 链,ARC 链记录了修改之前原始的 DKIM 结果。

l= 标签:健壮性的代价是可被伪造

l= 让验证者只对规范化后正文的前 l 个字节计算哈希。它的设计初衷是让签名在邮件列表追加页脚之后依然有效。测试展示了它的弊端。同一封邮件用 l=35 签名后,即使被追加了内容,签名仍然验证通过,无论追加的是页脚,还是别人写的一整段新内容:

dkimpy 1.1.8:带 l= 的签名
--- 5. signed with l= (body length limit)
    l=35
--- 5a. footer appended to an l= signature
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: True
    verify: True
--- 5b. a whole new part appended to an l= signature
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: True
    verify: True

5b 中追加的文本是 Please pay invoice 4711 to IBAN XX00 0000 0000.,而签名结果仍然是 pass。RFC 6376 §8.2 警告说,l=“使心怀恶意的中间方能够修改邮件,从而实施攻击”,“签名者应当对使用这个标签极其谨慎”,“评估者可能希望忽略使用该标签的签名”。借助 MIME 和 HTML,追加的内容可以完全替换读者看到的内容。不要用 l= 签名。如果在你发出的邮件中发现了它,就从签名者的配置中把它删除。在 OpenDKIM 中,l= 来自 BodyLengthDB 和 MaximumSignedBytes 这两项设置。

密钥与 DNS

验证者以 TXT 记录的形式查询 selector._domainkey.domain。先看演示中的用例,再看真实的 DNS:

dkimpy 1.1.8:密钥问题
--- 6. key revoked in DNS (empty p=)
    log: could not parse RSA public key (b''): Unparsable public key; SubjectPublicKeyInfo: Data truncated at byte 0; RSAPublicKey: Data truncated at byte 0
    verify: False
--- 7. no key record in DNS
    log: missing public key: b'sel._domainkey.example.com.'
    verify: False
--- 8. 512-bit RSA key
    log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
    log: b'DKIM-Signature' valid: True
    exception: KeyFormatError: public key too small: 512

域名通过把 p= 置空来吊销选择器。用例 8 表明,512 位签名的 RSA 运算本身是成立的,但验证者仍然拒绝了这个密钥。RFC 8301 规定,验证者“不得(MUST NOT)把使用小于 1024 位 RSA 密钥的签名视为有效签名”。签名者“必须(MUST)使用至少 1024 位的 RSA 密钥”,并且“应当(SHOULD)使用至少 2048 位的 RSA 密钥”;rsa-sha1“不得(MUST NOT)用于签名或验证”。此外还定义了 Ed25519(a=ed25519-sha256,RFC 8463)。这份 RFC 自带的示例邮件同时带有 RSA 签名和 Ed25519 签名,实际部署也是这样做的:RSA 签名留给不支持 Ed25519 的验证者。

在真实 DNS 中,OpenDKIM 的 opendkim-testkey 会像验证者那样检查选择器:

opendkim-testkey(OpenDKIM 2.11.0~beta2,Debian 13),2026年10月20日
$ opendkim-testkey -d github.com -s google -vvv
opendkim-testkey: checking key 'google._domainkey.github.com'
opendkim-testkey: key not secure
opendkim-testkey: key OK
$ opendkim-testkey -d gmail.com -s 20230601 -vvv
opendkim-testkey: checking key '20230601._domainkey.gmail.com'
opendkim-testkey: Revoked key
$ opendkim-testkey -d gmail.com -s nosuchselector -vvv
opendkim-testkey: checking key 'nosuchselector._domainkey.gmail.com'
opendkim-testkey: 'nosuchselector._domainkey.gmail.com' record not found

“key not secure”只表示这个应答没有经过 DNSSEC 验证。gmail.com 的 20230601 选择器是一个真实的已停用密钥,它的 p= 为空。用它签名的邮件现在都会验证失败,这就是旧邮件在密钥轮换后可能“失去”DKIM 通过结果的原因。

2048 位密钥无法放进单个 255 字节的 TXT 字符串,因此要拆成多个字符串发布,验证者会把它们直接拼接起来,中间不加任何分隔符:

dig +short TXT google._domainkey.github.com(BIND 9.20.10,2026年10月20日)
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSqGoYWaWaFbS2UAeyPrEmd0gogocmRfS441qwR8/0KB81Hw89P0l4YiFRrXYk7NVIGfyCRHAYYZUzCkGeOysI2EjgzLFhd/NEsbRzOEc/kWkK/RO6JFq/5lOn6M9AZw/ap9tds4JG9ApgNNdSpPxp9DmvpsOSgNMVflRxQFrk3kdS4RNAPKu/" "OPoA7dlR/A/pECryjRoYgENtDXzdnK70HgCekems6UDzxDj61cjyoKoXtEMF/QsaHEQ1Gjfv014rDJBsubk/kT5VqHkWHa/ia68Z5r228Ety/wFfQNjXTx/J7KGZ9GkZlKED659eiJcLnWcKDSiQlhwIDAQAB"

典型的 DNS 错误:手动拆分密钥时,把引号或空格粘贴到了密钥内部;DNS 服务商把一个长字符串变成了两条记录,而不是一条包含两个字符串的记录;密钥发布在错误的名称下,例如区域自动追加 origin 时得到的 sel._domainkey.example.com.example.com;以及在记录完成传播之前就启用了新的选择器。DKIM 记录生成器可以根据公钥生成正确拆分的记录。

逐步诊断

  1. 获取收到时的原始邮件,包括所有邮件头:Gmail 中的“显示原始邮件”、Outlook 中的“查看邮件源”,或者 .eml 文件。把邮件转发给自己会改动邮件。
  2. 读取 Authentication-Results,要看的是你自己的接收服务器写入的那一条,也就是最上面的一条。记下 DKIM 判定结果及其原因:正文哈希、签名错误、无密钥、permerror。
  3. 读取 DKIM-Signature:d= 和 s=(哪个密钥)、c=(严格程度)、h=(哪些头字段)、l=(不应出现)。
  4. 检查密钥:dig +short TXT s._domainkey.d 或 opendkim-testkey -d d -s s -vvv。密钥是否存在、未被吊销、至少 1024 位,并且完整?
  5. 正文哈希不匹配:找出被添加或被重新编码的内容,比如发出的版本与收到的版本之间多出的页脚、横幅,或者 Content-Transfer-Encoding 的变化。如果发送方由你控制,把“已发送”文件夹或 MTA 日志中的副本与收到的副本比较。
  6. 签名错误但正文哈希正确:逐个比较 h= 中列出的头字段在发出副本和收到副本中是否一致。如果都没有变化,说明密钥与实际使用的私钥不匹配。用一封在本地签名并验证的邮件来测试。
  7. 间歇性失败:检查是否存在多条出站路径(两台 MTA、一个云中继),其中只有一条正确签名,或者其中一条仍在使用已轮换掉的选择器。

如果不想手动操作,可以把邮件粘贴到 DKIM 调试器中:它会展示规范化的每个步骤、计算出的 bh= 与头字段中的值的对比、DNS 中的密钥,以及最可能的改动。邮件只保留在你的浏览器中。要查看包括 SPF、DMARC 和 ARC 在内的完整情况,可以使用电子邮件身份验证分析器。

长期有效的修复方法

  • 最后签名:在你自己的出站路径上,让免责声明、页脚、链接改写和杀毒扫描都在 DKIM 签名者之前运行。在 Postfix 中,这取决于 smtpd_milters 或 content_filter 的顺序。
  • 使用 c=relaxed/relaxed:它没有任何代价,还能经受住邮件头重新折行和空白字符的变化。
  • 签名前转换为 7 位 MIME(quoted-printable 或 base64),这是 RFC 6376 §5.3 的建议,这样不支持 8BITMIME 的中继就不会重新编码正文。
  • 不要使用 l=,也不要把中间方会正当修改的头字段放进 h=,除非你确实需要对它们签名。
  • 轮换密钥时留出重叠期:先发布新的选择器,等 DNS 缓存过期后再切换签名,旧记录再保留几天,让传输途中的邮件仍能通过验证,然后用空的 p= 吊销它。
  • 使用 2048 位 RSA 密钥:1024 位是最低要求。如果你的签名者支持,再额外添加一个 Ed25519 签名。
  • 对于你运营的邮件列表:不要修改正文和 Subject,或者改写 From 并以列表的域名签名,同时添加 ARC 签名。

参考资料

  • RFC 6376:DKIM Signatures,§3.4(规范化)、§3.5(标签)、§5.3(传输转换)、§8.2(l=)。
  • RFC 8301:密码算法与密钥用法更新(不再使用 SHA-1,最低 1024 位)。
  • RFC 8463:在 DKIM 中使用 Ed25519。
  • RFC 8617:Authenticated Received Chain(经过身份验证的接收链,实验性)。
  • dkimpy 1.1.8、mailauth 8.0.1、OpenDKIM opendkim-testkey。

dkimpy 用例运行在 node:24.22.0-trixie 中(Debian 自带的 Python 3.13.5,使用 venv),mailauth 运行在 node:24.22.0-bookworm-slim 中,opendkim-testkey 运行在 debian:trixie-20261005-slim 中,dig 运行在 nicolaka/netshoot:v0.14 中,全部于2026年10月20日运行。