后记:独立生活

后记:独立生活转自delphij的博客的文章采用 Ed25519 的 DKIM 签名关于 Google Workspace 的 SPF / DMARC 设置SMTP SmugglingSMTP协议、流水线扩展和信体终结标志问题postfix 的解决方法postfix 的 SNI 支持与 gmail 的兼容问题用 rspamd 来实现反垃圾邮件后记的后记

大型邮件提供商随时都可能压垮你。运行自己的电子邮件的最佳方式是慢慢开始,遵守他们的规则,并发送人们想要接收的消息。

如果你建立了一个新的邮件系统,并立即开始在互联网上发送信息,你会看起来很可疑。成功启动新邮件系统的关键是缓慢启动。首先,给你认识的人发几条信息。让电子邮件帝国适应你的存在。如果你已经阅读了这本书,那么你已经从新服务器向你控制的大型提供商帐户发送了几封电子邮件,以证明你拥有DMARC、DKIM和SPF。这是一个开始。开始通过你的新服务器进行日常通信。如果你有任何疑问,可以向大型供应商发送电子邮件。如果您要从一个IP地址迁移到另一个,请让旧的MTA通过新服务器中继其部分邮件负载。一旦大型运营商和您的同行接受您正在遵循最佳实践,请证明您将继续遵循这些实践。

如果你想了解更多关于电子邮件的信息,我强烈推荐邮件操作员列表(https://www.mailop.org). 邮件列表档案是经验的宝库。

如果你想保持对大型提供商的可送达性,你必须发送人们想要阅读的消息。也许你正在给有趣的人写信,看看他们是否想成为你播客的客人,但如果收件人一直把你的消息扔进垃圾邮件桶,那么大型提供商最终会决定不接受你的电子邮件。你的信息可能是真诚的,在技术上可以验证为真实的,但如果人们一直说它们是垃圾邮件,大运营商就会这样对待你。

永远记住:在电子邮件中,公民身份就是一切。现代互联网是由公司建立的,但公司是由相信联系与合作的人经营的。人们一起工作,这样我们就可以在几分钟内廉价地在世界各地分享信息。我们建造了这个。我们把SMTP变成了一种将社区联系在一起的协议。没有理由把一切都交给大公司。

无论你做什么,都不要放弃协议。运行自己的邮件服务器。



 

转自delphij的博客的文章

采用 Ed25519 的 DKIM 签名

Ed25519 是一种高性能的公钥签名系统。和 RSA 相比, 同等强度的 Ed25519 的密钥长度要比 RSA 短很多,此外,软件实现的 Ed25519 的验证速度也比同等强度的 RSA 快很多。

尽管早在2018年 RFC 8463 Section 5 就已经将 RFC 6376 Section 3.3 修正为要求验证者必须支持 Ed25519 验证,然而时至今日主要的邮件服务提供商中, 包括 Google 的 Gmail 和 Microsoft 的 Outlook.com 以及 Apple 的 iCloud 都还不支持验证 Ed25519 DKIM 签名。ProtonMail 支持 Ed25519 的 DKIM 签名,但它并不使用 Ed25519 签名向外发出的邮件。

经测试,同时采用 RSA 和 Ed25519 签名发出的邮件可以让那些只支持 RSA 签名的邮件运营者正确验证邮件, 显然,这样做会增加邮件尺寸,也会增加 DNS 流量,这些都和采用 Ed25519 签名的希望背道而驰, 但我觉得总得做点什么来让互联网巨头开始面对这一现实,因此我打算从今天开始将我自己域名发出的邮件同时采用 RSA 和 Ed25519 签名,直到三大主要的邮件服务提供商(Google、 Microsoft 和 Apple)都支持它为止。

我目前采用的是 dkimpy-milter。 和之前使用的 OpenDKIM 相比,它的开发更为活跃(后者在2018年发布了一版beta之后就没有再发布新的版本了)。 其配置大致如下:

首先是生成签名用的密钥对:

这其中 ed25519-2023rsa-2023 是计划使用的 selector 名字。程序会生成 .key (私钥)和 .dns (公钥,用来添加到域名 zone 中)两个文件。

注意 dkimpy 并未考虑 TXT 记录只能有255个字符的限制,对于 2048-bit 的 RSA 公钥,必须将其切开。 对于 Ed25519 公钥,由于其长度很短,因此没有这个问题。

接下来要更新域名的 DNS zone 来添加新的记录。为了方便回退,暂时不要删除旧的公钥。

dkimpy-milter.conf 的配置主要是以下几方面:

  1. Domain。这是希望签名(自己的)域名。
  2. KeyFileSelector。这是 RSA key 的私钥(PEM格式)和 selector。除此之外也可以用 OpenDKIM 风格的 KeyTable
  3. KeyFileEd25519SelectorEd25519。这是 Ed25519 的私钥和 selector。除此之外也可以用 OpenDKIM 风格的 KeyTable25519
  4. Socket。我使用的是本地的 Unix socket (local:/var/spool/postfix/private/dkimpy-milter
  5. UserID。抛弃特权后的身份。注意 socket 必须可以被 Postfix 读写,可以设置为 postfix:dkimpy-milter

启动之后将 Postfix 的 master.cfsubmission 服务(或者其他希望接收往外发邮件的 smtpd 的服务马甲)上的 smtpd_milters 改为 -o smtpd_milters=unix:private/dkimpy-milter 就可以了。

同时使用两种签名算法时,邮件中会出现两个 DKIM-Signature: 信头。 对方邮件服务器收到之后通常会增加对应的信头说明验证情况。


关于 Google Workspace 的 SPF / DMARC 设置

这里简单记一笔。我有一个域名使用的是 Google Workspace 的邮件服务。 昨天,一位用户使用该域名向中国的 QQ 邮箱用户发送邮件时被退回了。

退信中给出的线索是:

腾讯给出的那个链接是一篇关于 DMARC 如何配置的文章。 从上下文来看,应该是由于腾讯的服务器认为 Google Workspace 的 IP 地址不在允许发信的范围内。

退信确实是 Google Workspace 的邮件服务生成的,数据来源是和对方(腾讯) MX 之间的 SMTP 会话。

我的这个域名确实启用了 DMARC。我的DMARC记录 (域名下的 _dmarc TXT 记录) 设置如下:

此配置要求对方检查 DKIM 和 SPF,并拒绝不匹配的邮件。

其 SPF 记录 (域名的 @ 的 TXT 记录) 设置如下。这是 Google 官方文档中推荐的设置

主要的区别是,我使用的是 -all,即拒绝 SPF 不匹配的邮件,而不是更为常见的、表示即使发现 SPF 不匹配,也仅仅做标记但允许接收的 ~all

这里需要说一下背景:在 2004 年左右,一些业内人士认为应该推荐采用 ~all 而不是 -all,因为当时许多大型邮件系统的架构中存在多层邮件服务器分别进行不同的过滤处理, 而在 SMTP 会话阶段直接回一个永久性拒绝 (5XX) 代码可能会让一些不适任的邮件系统管理员如此配置的邮件系统将一些伪造了来源的邮件退到不希望的地方。 我并不赞同这样的观点。事实上,在会话阶段进行退信要比在收件方服务器上生成退信并将退信退给发件人或对方域名的 postmaster 要更好: 对于垃圾邮件的发送者,这意味着他们的邮件服务器会立即知道自己的行为被直接地丑拒, 接收方明确告知他们吞下垃圾邮件,而不是继续发送。对于正常的邮件服务器, 这意味着发件人可以立即收到退信从而找到自己的邮件系统管理员,而不是告诉收件人去翻垃圾箱看看是否有自己的邮件。 另一方面,回应永久性拒绝并不妨碍收件服务器对邮件内容进行进一步分析:它完全可以在 DATA 阶段结束时再回拒绝,从而将邮件完整地接收下来但不递给收件人。

当然,历史无法假设,并已经无数次地证明人类根本不配拥有美好的事物。

DomainKey 记录 (域名下的 google._domainkey TXT 记录) 由于与此问题无关,故在此处略去。

该域名启用了 DNSSEC。

首先是检查 SPF 设置的是否有问题。 _spf.google.com 是 Google 各类邮件服务采用的公共 SPF 记录,其出现问题的可能性不大。 不过,我还是手工查询了记录。在本次 SMTP 会话中 Google 使用的发送邮件地址 2607:f8b0:4864:20::112b 在此 SPF 记录引用的 _netblocks2.google.com 中以 ip6:2607:f8b0:4000::/36 的形式列出:

用 Perl 的 Mail::SPF 验证得到的结论和我认为的一致:

尽管我的做法可能不太常见,但它完全符合 RFC 7208, 事实上,其 5.2 小节中描述的流程正是以 -all 作为例子的,按照 RFC 中的流程处理并没有二义性。

至于为什么腾讯的服务器认为 2607:f8b0:4864:20::112b 不是允许发信的 IP,我还是不完全理解。一个猜测是可能他们对于 -all 的处理和 ~all 有所不同。不过我离开邮件这行多年,一时也找不到腾讯的联络人(如果本blog的读者有哪位可以帮忙联系一下的话不甚感激),于是就采取了比较偷懒的做法, 也就是直接抄 gmail.com 的作业:

这一做法简单粗暴地告诉收件服务器:本域名直接以 _spf.google.com 的内容作为自己的 SPF 策略。

再测试时发现可以正常发信给 qq.com 了(作为副作用,这意味着最后的规则从 -all 变成了 ~all,测试显示,我的另一个域名使用 Google 推荐的 SPF 配置,即 ~all 时,其 Google Workspace 邮件可以正常发到 qq.com)。


SMTP Smuggling

最近没怎么关注安全方面的进展,结果错过了去年年底披露的 SMTP Smuggling。 这是 Timo Longin 发现的一个全新的针对 SMTP 协议的攻击手法, 现在的年轻人真是蛮厉害的。

SMTP协议、流水线扩展和信体终结标志

SMTP 协议是 RFC 5321 定义的邮件传输协议,通常采用 TCP 作为传输协议,在同一连接中传输指令和数据。传统上,SMTP 协议采取「一问一答」的形式,但目前正常的 SMTP 服务器和客户端普遍实现了流水线作业(RFC 2920) 来减少客户端与服务器之间的交互次数,从而减少送信延迟。采用流水线作业时,客户端必须继续遵守协议的状态机约束: 举例来说,在发出 EHLO / HELO 之后,客户端必须等待服务器的回应,而不能直接开始进入下一状态; 而在发出 DATA (信体开始)指令之后,在收到服务器的 354 回应之前,也不能开始传送信体。 更进一步,如果服务器没有回应自己支持 PIPELINING,则客户端必须采取旧式的「一问一答」形式, 而不是新式的流水线作业。

SMTP 协议与 D. J. Bernstein (“djb”) 设计的 QMTP 不同, 在传输信体时不会事先声明信体尺寸。RFC 5321 Section 4.1.1.4 规定,信体以单行的 . ,即 <CRLF>.<CRLF> 作为终结标志。RFC 3030 新增的 BDAT 指令是对此的一项补救措施。

问题

在电传打字机时代,「回车」(<CR>\r0x0d) 表示将打字头挪到一行开始,而「换行」(<LF>\n0x0a) 则表示将打印纸向上推一行。对电传打字机来说,由于一行的长度不固定,「回车」操作所需的时间也不固定, 而「换行」操作则是电机推一个固定的长度,两部分的驱动设备是独立的,并且前一操作通常需要消耗更多时间。 从设计角度,先开始「回车」再开始「换行」意味着两个动作可以同时进行并提高速度。

在上世纪六十年代,ASA (ANSI前身) 和 ISO 分别设计了与 ASCII 类似的编码标准, 在 ASA 草案中,采用「回车+换行」(<CRLF>) 作为新行的符号,而在 ISO 草案中,则同时接受「回车+换行」(<CRLF>) 和 「换行」(<LF>) 作为新行的符号。

这些差异影响了当时的操作系统设计者,他们采纳了不同的设计来表达文本中的新行。 DEC 采纳了「回车+换行」 (<CRLF>) 来配合电传打字机,这影响了后来的 CP/M 和 MS-DOS 以及 Windows。 而 Multics 则选择了采用 「换行」<LF> 来表示新行,并在设备驱动中将其翻译成回车加换行来支持电传打字机, 这影响了其精神继承者 Unix 和各类类 Unix 系统。

在早期的 Sendmail 版本中,除了标准的 <CRLF> 行末符, 也支持只用 <LF> 作为行末符,尽管 RFC 5321 Section 2.3.8 禁止客户端这样做,但我们都知道「历史无可替代的力量」究竟有多大, 时至今日,仍然有相当多的 MTA 服务器软件选择继续支持它来确保兼容性。

如此一来,对于 <LF>.<LF><LF>.<CRLF> 而不是 <CRLF>.<CRLF> 便可以有不同的解释。 对于不接受 <LF> 作为行末符的邮件服务器来说,前两种都只是再正常不过的文本,会原样发给下一个邮件服务器; 而对于接受 <LF> 作为行末符的的邮件服务器来说,它会被解读为不同的语义,即信体结束。 而当前一种邮件服务器把邮件发给后一种邮件服务器时, 后者的不同解读可能会导致灾难性的后果: 在 <LF>.<LF><LF>.<CRLF> 后面可以「夹带」(smuggling) 一组 SMTP 指令,如果这一层邮件服务器是拥有签名权的外发邮件服务器, 并且信任上一层的服务器的话,则可以通过这种方式绕过正常的访问控制来实现不正常的外发操作。

postfix 的解决方法

现时,正常的邮件客户端是不应该发出不带「回车」的裸「换行」的。postfix 的作者 建议 彻底禁止这样做, 这符合 RFC 5321 的规定。 Postfix 3.8.4、 3.7.9、 3.6.13 和 3.5.23 新增的 smtpd_forbid_bare_newline 在遇到裸「换行」时会直接断开连接,从而避免这种攻击。

更早版本的 Postfix 可以通过禁止绕过状态机的流水线操作 (reject_unauth_pipelining 或更早版本的 smtpd_forbid_unauth_pipelining,并禁止 CHUNKING 扩展) 来规避问题。


postfix 的 SNI 支持与 gmail 的兼容问题

今天在家里的票务系统上修改某个票的状态(该操作会出发点一封邮件)时, 我正好另一个窗口开着邮件服务器的日志,观察到一些奇怪的现象:

这里需要补充说明一下背景。由于 gmail 很早就实现了 TLS,因此我在 postfix 中配置了强制 TLS,具体做法是这样的:

然后我的 tls_policy_maps 配置大致是这样的:

这是因为主流的邮件服务提供商普遍没有启用 DANE,但他们的 TLS 证书配置通常是正确的。 FreeBSD.org 以及一些其他机构正确配置了 DANE,因此使用更强的 dane-only (要求对方必须使用 DANE 记录)策略。 对于其他域名,则使用上面的 dane 策略:有 DANE 记录则使用 DANE 进行验证,没有的话则尝试 TLS 并记录验证结果,如果失败则用明文传送。

总体上,上述策略的目标是避免发送邮件给无关的第三方邮件服务器,具体来说:

从现象上看,发生错误的原因是在给 gmail.com 发信时,邮件系统与 gmail.com 的 MX 进行 TLS 握手并要求对方提供证书,而出于某种原因该证书是自签名的。gmail.com 没有启用 DANE, 由于我指定了这些域名使用更强的 verify 而不是推荐的 may,因此系统拒绝向这些 MX 投递邮件。 直到最后, alt2.gmail-smtp-in.l.google.com 提供了一个可以验证的证书, 因此邮件最终投递成功。

提高 postfix 的 smtp TLS 日志级别:

然后再次尝试投递,发现:

具体来说,这个证书的内容是:

翻译成便于人类理解的内容:

Hmm… 所以问题出在我没有正确告知对方自己尝试连接的 SNI 名字。仔细读了一下 postfix 的 文档, 发现 postfix 出于兼容性考虑(坦率地讲,如果做 verify 的话,我其实希望尽量不兼容配置有问题的服务器), 在没有 DANE 的时候是不发出 SNI 名字的:

所以解法就是让 postfix 在 verify 的时候提供一个 SNI 名字,具体来说是把上面 tls_policy 中的 verify 替换为 verify servername=hostname (相当于指定 smtp_tls_servername=hostname, 但仅对这几个域名生效。 hostname 的意思是使用 DNS 解析得到的 MX 的主机名),并重建 tls_policy 的 hash db。

这之后发送邮件到 gmail.com 就正常了。

根据日志,似乎 gmail 是今天早上太平洋时间 06:00 到 09:17 部署的这个新的变动 (对于不认识或没有提供 SNI 域名的客户端,在 TLS 握手时送出一个 CN=invalid2.invalid 的自签名证书)。

我暂时还不太理解这么做的好处是什么。客户端发出 SNI 名字时, 服务器使用的是一个采用 P-256 的公钥的证书,而客户端没有这么做时(postfix 的默认配置如此, 大部分人恐怕也不会像我这样吃饱了撑的配置成 verify,因此很可能根本不会注意到这个问题), 服务器使用的是一个 2048-bit RSA 的公钥的自签名证书, 而通常的观点认为 P-256 大致相当于 3072-bit RSA。


用 rspamd 来实现反垃圾邮件

我搞邮件服务器有二十多年了,最开始是在学校做社团的邮件服务, 后来有几年和 老房东 在某领先网络媒体公司做了多年针对公众提供的邮件服务,因此前同事群的名字也是「老邮条」。 我个人的域名是2002年注册的,自从那时起我就一直在自己运行邮件服务。

在过去二十年中的大部分时间,我采用的是 amavisd-new, 与直接使用 SpamAssassin 相比, 它还增加了病毒扫描等一系列功能和 milter 接口,这让它与 MTA 更容易集成。

最近我发现 FreeBSD.org 把反垃圾系统替换成了 rspamd, 所以在11月初把我的邮件系统也换成了 rspamd,经过两个月的使用,总体的感觉是「我tm早干嘛去了」。

FreeBSD 的 rspamd port 是 Vsevolod Stakhov (vsevolod@) 维护的,和 amavisd-new 相比, 最肉眼可见的好处就是 CPU 开销的大幅下降,除了由于它是 C 写的之外,这也得益于其 事件驱动的异步架构设计

在 FreeBSD 上使用 rspamd 可以直接用 pkg 来安装(此处同时安装 redis 作为后端存储):

需要注意,redis 默认会绑到 127.0.0.1,如果在 jail 中运行 redis 的话,这可能会导致 redis 暴露给整个 Internet, 这很危险。解决方法是把 redis 绑到某个安全的内网,或是只使用 Unix domain socket。

例如,redis 可以如此配置:

注意上述权限配置中使用的权限是 660,因此需要把 rspamd 的角色用户加入 redis 用户组。

与之对应地,在 /usr/local/etc/rspamd/local.d/redis.conf 中如此配置:

与 amavisd-new 类似,rspamd 的 milter 服务也可以添加信头来方便其他 MUA 或是 sieve 来进行拣选。 很明显,其他系统添加的此类信头应该删掉,为了便于迁移,我采用了如下的配置 (/usr/local/etc/rspamd/local.d/milter_headers.conf):

其他配置方面我没有做特别多的改动。

postfix 集成部分,基本上只是把 smtpd_milter 换成 rspamd。

与 sieve 集成的部分,可以用 rspamc 去连接 rspamd 的 controller 来完成 learn_spamlearn_ham

我之前的系统中长期使用了 clamav,而该系统最后一次抓到病毒是 2006 年的事情。 clamav 本身依赖许多解压缩程序,尽管它是丢掉特权运行的,但考虑到现实情况,对于我这样的食古不化型 (邮件客户端关闭了全部附件预览等一系列功能) 的邮件用户来说,反病毒的价值确实不大,因此这次顺手暂时先拆掉了。

rspamd 的控制面板可以罩在 zero trust 代理 后面,方便访问。

后记的后记

特别鸣谢delphij同意将他的这几篇文章收录进来。