邮件安全实践:SPF、DKIM、DMARC(中)
讲完了 SPF 我们接着讲 DKIM,如果说通过 SPF 可以验证发件方 IP 是否来自”白名单“ IP 地址列表,那么邮件在通过邮件服务器们的转发来到你信箱的时候,怎么验证信件内容是发件人本身撰写、且未发生篡改?这时候就需要:域名密钥标识邮件 (DKIM)
DKIM 签名生成
DKIM 就是给你的邮件盖上一个有你独有签名、且含有信件唯一指纹的「封印」,现在的主流的邮件服务商发出的邮件都设置了DKIM,你收到后点开查看原文就可以找到 DKIM 签名信息,大概是这样:
DKIM-Signature:
v=1;
a=rsa-sha256;
c=simple/simple;
d=discoursemail.com;
s=sea2;
t=1724601697;
bh=yz5sqlcu6gM3yCiZuDxM+gkFdFYfuYSqRdDLN07wRSQ=;
h=Date:From:Reply-To:To:Subject:List-Unsubscribe;
b=sU6fpk2yJm5cdPrOYudPdnh0wL3wYbqGE32T4g2D1IaxEcAsq8BYqLYYeI5lM39s8
qN95ERJ2vByVR0GbuP4bgn56wxRHN7YOo8411OrL9lH5pCHynunRCcVWEM5M48GGTa
NiYNKdJim9WlkrGaEKfcq+YKhOynTSMwywbe6ZInssAhR/3aU/F+6wjB/s2Fkng5sH
mZbnRWzegnMQks3uNIqGC2A3UnlNJUffOSGF0OJ3uCXY4lq64x3cQa8ESdqDpCJ0+s
fevu9iqDGp39AtC9nD9dCk5KAYYg6wD/yM+mW/FIgHsxE6c8gd2gYP4lKFu0ZX3C6q
4xYuxi9C+1YdA==
「封印」每一个字段都不是多余的,这里有详细介绍:DomainKeys Identified Mail - Wikipedia ,我用通俗一点的说法解析一下:
字段 | 意义 |
---|---|
v | 代表当前 DKIM 版本号 |
a | 签名过程中计算哈希值所使用的算法,公布出来方便收信方验证的时候使用 |
c | 在邮件正文和邮件头部分被签名时进行哈希值计算前,需要先完成格式化处理,比如处理多余的空格,大小写字母等,这里指定了标准化算法,simple 或 relexed |
d | 签名来自的域名,验证签名需要从这个域名的DNS解析记录中获取解密公钥 |
s | 签名选择器,一个域名可以存在多个DKIM签名密钥,当前信封用哪个加解密在这里指定 |
t | 签名时间戳 |
bh | eml文件中,邮件正文部分的哈希值的Base64编码,收件方可以自己计算并与此对比看看是否有篡改 |
h | 挑选哪些邮件头来被签名 |
b | 签名中最重要的部分,这里记录了信件的独一无二的指纹,经过签名者私钥加密后Base64编码存放在这里 |
下面看看我们看收到邮件里的DKIM签名是怎么形成的,这样更理解后续的验证过程,这里列出关键步骤:
1.对邮件的正文部分(body)按 c
指定算法的标准化处理、按 a
算法计算哈希值并 Base64 编码。这里我找了 Python 的一个第三方库 pydkim 的源代码,以 relaxed
为例,挑选了一部分代码,看看大概就明白了:
#标准化方法
class Relaxed:
"""Class that represents the "relaxed" canonicalization algorithm."""
name = b"relaxed"
@staticmethod
def canonicalize_headers(headers):
# Convert all header field names to lowercase.
# Unfold all header lines.
# Compress WSP to single space.
# Remove all WSP at the start or end of the field value (strip).
return [
(x[0].lower().rstrip(),
compress_whitespace(unfold_header_value(x[1])).strip() + b"\r\n")
for x in headers]
@staticmethod
def canonicalize_body(body):
# Remove all trailing WSP at end of lines.
# Compress non-line-ending WSP to single space.
# Ignore all empty lines at the end of the message body.
return correct_empty_body(strip_trailing_lines(
compress_whitespace(strip_trailing_whitespace(body))))
def compress_whitespace(content):
return re.sub(b"[\t ]+", b" ", content)
def strip_trailing_whitespace(content):
return re.sub(b"[\t ]+\r\n", b"\r\n", content)
#计算bh的关键代码
body = canon_policy.canonicalize_body(self.body)
self.hasher = HASH_ALGORITHMS[self.signature_algorithm]
h = self.hasher()
h.update(body)
bodyhash = base64.b64encode(h.digest())
2.然后加入h里挑选的参与签名的原始邮件头:
Subject: ddddd
From: [email protected]
To: [email protected]
3.然后准备好的标准化之后的待签名数据就是这样的 content:
subject:ddddd
from:[email protected]
to:[email protected]
DKIM-Signature: v=1;
a=rsa-sha256;
c=relaxed/relaxed;
s=selector1;
d=domain.com;
h=subject:from:to;
[email protected];
bh=rcr9nmkeqsjAGn29CUiUNJFRSmc=;
b=
4.最后计算哈希值、并用私钥完成加密、以Base64编码后,最终得到b值。填回到签名头的b=后面,随着邮件一起发出去。也就是我们文章上面那个打开邮件原文里看到的签名内容。
DKIM 签名验证
收到含有 DKIM-Signature 邮件后,验证过程逻辑很简单:用签名方提供的公钥解密被加密过的指纹(哈希值),然后自己根据上面指纹生成的算法和过程计算一次指纹(哈希值),然后对比,一致则通过。
公钥怎么获取呢?公钥存储在 邮件发送域 的 DNS 解析记录中的一条 txt记录中,具体就是 通过签名的 d 字段和 s 字段,按一个固定规则拼接出一个一个域名,去查询它的txt记录即可。
拼接的规则是:selector._domainkey.domain
回到我们最开始那个例子的签名 d=discoursemail.com; s=sea2,通过查询 公钥就是里面的p值:
dig sea2._domainkey.discoursemail.com txt
; <<>> DiG 9.18.28-0ubuntu0.22.04.1-Ubuntu <<>> sea2._domainkey.discoursemail.com txt
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 10726
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;sea2._domainkey.discoursemail.com. IN TXT
;; ANSWER SECTION:
sea2._domainkey.discoursemail.com. 3600 IN TXT "v=DKIM1; k=rsa; " "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwcT8FETF72zRePILjop3CONzVA8PWpgBA0rQjAvhKTX4bIAONhR6TVKABVyDMjpRevDZ+Gd3lBolCNRzq0cfEIyyswrGR+6Il09CrpnNgWh7CSuBm94Ec2sq6ZjBjnjohPz1ZVrKjEKXCpXNCwHmDP9maP9+OvYMk2O8LmRslbuYudNCnoIn7MZRasb1OtQbBjDJStwvES/RxA" "yYa+RMJyZhInvuf1/x6xg2jIb6fOrGf4OJEO6P+uwwx+dN/uJDPPCQnL/p42E2m/5t07mCDSyYuK8jdriina2r+JLi7m+7rSLAbYpVxm4bHdsUTzM1wYhBYdJJjTcPRGtYTe/hjQIDAQAB"
;; Query time: 40 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
;; WHEN: Mon Aug 26 01:52:25 UTC 2024
;; MSG SIZE rcvd: 487
当然,我们也可以用别人写好的第三方工具来查询:Free DKIM Record Checker
小结
现在你从主流的邮件服务商如GMAIL、OUTLOOK、QQ邮箱等发出的邮件、都会含有 DKIM 签名,而接收方基本上也都会进行 DKIM 的验证,从上面的原理中我们不难发现,任何对原始邮件头、时间戳、原始邮件正文的内容的篡改都会导致 DKIM 验证失败。一定程度上保证了邮件内容的安全性。
那么万一用户收到 SPF 验证失败(来自不可信发件源)或 DKIM 验证失败( 邮件被修改)的情况会如何处理呢? 下一篇继续介绍。