TOPNEC

邮件安全实践: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签名时间戳
bheml文件中,邮件正文部分的哈希值的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 验证失败( 邮件被修改)的情况会如何处理呢? 下一篇继续介绍。

文章合集

  1. 邮件安全实践:SPF、DKIM、DMARC(上) | TOPNEC
  2. 邮件安全实践:SPF、DKIM、DMARC(中) | TOPNEC
  3. 邮件安全实践:SPF、DKIM、DMARC(下) | TOPNEC

#Tech