简短回答: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: 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-sha256 | rsa-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 查询被替换为进程内的函数,因此可以在不同用例之间更改密钥记录:
--- 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
逐个来看:
- 邮件未改动:正文哈希匹配,签名验证通过。
- 追加了页脚:邮件列表添加了退订页脚。计算出的正文哈希(
got)与bh=(expected)不同,因此验证在检查签名之前就停止了。 - Subject 标签:邮件列表在 Subject 前加上
[team]。正文哈希仍然匹配,说明正文完好,但头部签名验证未通过。“正文哈希正确、签名不正确”意味着头字段被改动,或者密钥错误。 - 空白字符:测试把 Subject 中的一个空格改成两个,把正文中的两个连续空格合并为一个,并添加了一个行尾空格。使用
relaxed/relaxed时,签名仍然验证通过。使用simple/simple时,正文的改动导致正文哈希不匹配(4b),而仅头字段的改动就足以让签名验证失败(4c)。
接收方服务器会把结果写入 Authentication-Results。下面是用 mailauth 8.0.1(Node.js 24.22)验证同样用例的结果,格式与你在收到的邮件中看到的一致:
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 签名后,即使被追加了内容,签名仍然验证通过,无论追加的是页脚,还是别人写的一整段新内容:
--- 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:
--- 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 -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 字符串,因此要拆成多个字符串发布,验证者会把它们直接拼接起来,中间不加任何分隔符:
"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 记录生成器可以根据公钥生成正确拆分的记录。
逐步诊断
- 获取收到时的原始邮件,包括所有邮件头:Gmail 中的“显示原始邮件”、Outlook 中的“查看邮件源”,或者
.eml文件。把邮件转发给自己会改动邮件。 - 读取
Authentication-Results,要看的是你自己的接收服务器写入的那一条,也就是最上面的一条。记下 DKIM 判定结果及其原因:正文哈希、签名错误、无密钥、permerror。 - 读取
DKIM-Signature:d=和s=(哪个密钥)、c=(严格程度)、h=(哪些头字段)、l=(不应出现)。 - 检查密钥:
dig +short TXT s._domainkey.d或opendkim-testkey -d d -s s -vvv。密钥是否存在、未被吊销、至少 1024 位,并且完整? - 正文哈希不匹配:找出被添加或被重新编码的内容,比如发出的版本与收到的版本之间多出的页脚、横幅,或者
Content-Transfer-Encoding的变化。如果发送方由你控制,把“已发送”文件夹或 MTA 日志中的副本与收到的副本比较。 - 签名错误但正文哈希正确:逐个比较
h=中列出的头字段在发出副本和收到副本中是否一致。如果都没有变化,说明密钥与实际使用的私钥不匹配。用一封在本地签名并验证的邮件来测试。 - 间歇性失败:检查是否存在多条出站路径(两台 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日运行。