作弊条:生成SSHFP和TLSA记录

• 本文约4284字,阅读大致需要9分钟 • Cheatsheets

上回书说到我最近在整理DNS的配置,其实当时这么做最早的诉求是把用来做ACME DNS-01验证(RFC 8555第8.4节)的域名单独拆出来,以改善其更新响应速度(因为大部分情况下权威DNS记录并不需要经常更新,我们更关注的是响应性能和可用性,而DNS-01验证则对更新时效性有比较高的要求)。

不过既然来都来了,索性把证书也顺手更新一下,从RSA换成ECC。由于我2015年就启用了DANE和TLSA,因此这意味着需要重新生成所有的TLSA记录;另外我也顺便给一些跑了SSH服务的域名补上了SSHFP记录。

这里记录一下所用的命令,供未来参考。

❗ 重要

这两种记录的可信度都来自DNSSEC签名,因此这个作弊条只对启用了DNSSEC的域名有用。

TLSA记录

DANE(基于DNS的命名实体认证,RFC 6698、RFC 7671)通过TLSA记录把服务所使用的TLS证书或公钥指纹直接发布在DNS上,并由DNSSEC签名保证其真实性,使客户端不必单纯依赖公共CA体系(关于它与Web PKI的取舍,参见前文)。

目前TLSA最主要的实用场景是邮件服务器之间的传输加密(RFC 7672)。SMTP的STARTTLS(RFC 3207)本身是机会性加密,中间人只要剥离 250-STARTTLS 就能把连接降级为明文;而支持DANE的发送方MTA(例如将smtp_tls_security_level设置为dane的Postfix)在查到目标MX主机的TLSA记录后,会强制要求TLS并校验对方证书。主流浏览器则基本不支持DANE,Web服务发布TLSA记录的实际意义不大,但对于不怕麻烦的管理员(有类似症状的病友)来说这么做有助于获得一些心理上的安慰(认识更多有类似症状的病友)。

记录语法格式

_<端口>._<协议>.<主机名FQDN>. IN TLSA <用途> <选择器> <匹配方式> <关联数据>

其中,端口是应用协议的端口编号,例如25、443等;协议指的是tcp或udp。主机名FQDN不言自明。接下来的三个参数(IANA TLSA参数表)大部分情况下选择3 1 1或3 1 2,具体说明如下:

  1. 用途(Certificate Usage):推荐使用 3(DANE-EE)。这表示由DNSSEC直接认证服务器的终端证书或公钥,不再依赖CA信任链。按RFC 7671第5.1节和RFC 7672第3.1.1节的规定,此时证书中的主机名和有效期都不再作为验证依据。
  2. 选择器(Selector):推荐选择 1(SPKI,即RFC 5280第4.1.2.7节定义的SubjectPublicKeyInfo,仅匹配公钥)。若选择0(整张证书),每次证书续期重签后指纹都会变化,需要同步更新DNS;而选择1时,只要续期时沿用相同的私钥,TLSA记录便依然有效。由于只有在私钥失窃,或是有意做轮换(例如用了很久,或是需要更换加密算法)时才会更换密钥,因此这样做可以减少推送DNS更新的需求。
  3. 匹配方式(Matching Type):0为完整数据,1为SHA-256,2为SHA-512。我目前的做法是同时发布 1 和 2。不特别讲究的话,按RFC 7671第5.1节的建议只发布3 1 1(SHA-256)也够用了:SHA-256的散列值较短,并且各家DANE实现都必须支持SHA-256,兼容性最好。

🚨 警告

「续期时沿用私钥」并不是所有ACME客户端的默认行为。例如acme.sh续期时默认复用私钥,而Certbot默认每次续期都会生成新私钥,需要加上--reuse-key;lego则需要在renew时指定--reuse-key。如果私钥在续期时悄悄换掉了,而TLSA记录没有同步更新,支持DANE的发信方会直接拒绝投递。

计算公钥的散列值

可以用OpenSSL的openssl-x509(1)、openssl-pkey(1)和openssl-dgst(1)提取DER编码的SPKI并计算散列值。

从证书生成

个人推荐使用无特权用户操作,从证书生成。由于不接触私钥,这种方法风险最小。

1
2
3
4
5
6
# 提取证书中的公钥并计算其SHA-256(用于3 1 1)
openssl x509 -in mx1.example.net.cer -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -r \
  | awk '{print "# SHA256: " $1}'
# SHA256: 71d5e809<SHA256值>
1
2
3
4
5
6
# 提取证书中的公钥并计算其SHA-512(用于3 1 2)
openssl x509 -in mx1.example.net.cer -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha512 -r \
  | awk '{print "# SHA512: " $1}'
# SHA512: ec35bff5<SHA512值>

从私钥生成

如果还没有获得签署好的证书(例如准备轮换密钥时,需要提前发布新公钥的记录),也可以从私钥生成。这需要能读取私钥文件的权限:

1
2
3
4
5
# 从私钥文件中提取公钥并计算其SHA-256
openssl pkey -in mx1.example.net.key -pubout -outform DER \
  | openssl dgst -sha256 -r \
  | awk '{print "# SHA256: " $1}'
# SHA256: 71d5e809<SHA256值>
1
2
3
4
5
# 从私钥文件中提取公钥并计算其SHA-512
openssl pkey -in mx1.example.net.key -pubout -outform DER \
  | openssl dgst -sha512 -r \
  | awk '{print "# SHA512: " $1}'
# SHA512: ec35bff5<SHA512值>

BIND格式记录模板

📓 注意

TLSA记录应发布在客户端实际连接的主机名下。对于SMTP,这是MX记录指向的主机名(例如mx1.example.net),而不是邮件地址中的域名example.net。如果主机名是CNAME,RFC 7671第7节规定在CNAME链整体通过DNSSEC验证时,客户端优先在CNAME展开后的最终主机名下查询TLSA,因此建议把记录放在最终A/AAAA记录所在的主机名下。(话说回来,如果对为什么MX记录不应该指向CNAME还有疑问,请您远离邮件服务器和DNS,不接受反驳)

邮件服务器

用于监听25端口的邮件接入主机(例如mx1.example.net),以及供邮件客户端使用的端口(RFC 8314):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
; mx1.example.net的SMTP端口(STARTTLS,MTA之间投递)
_25._tcp.mx1.example.net.    IN TLSA 3 1 1 71d5e809<SHA256值>
_25._tcp.mx1.example.net.    IN TLSA 3 1 2 ec35bff5<SHA512值>

; submission端口(STARTTLS)
_587._tcp.mx1.example.net.   IN TLSA 3 1 1 71d5e809<SHA256值>
_587._tcp.mx1.example.net.   IN TLSA 3 1 2 ec35bff5<SHA512值>

; submissions端口(隐式TLS,RFC 8314推荐优先使用)
_465._tcp.mx1.example.net.   IN TLSA 3 1 1 71d5e809<SHA256值>
_465._tcp.mx1.example.net.   IN TLSA 3 1 2 ec35bff5<SHA512值>

; IMAPS端口
_993._tcp.mx1.example.net.   IN TLSA 3 1 1 71d5e809<SHA256值>
_993._tcp.mx1.example.net.   IN TLSA 3 1 2 ec35bff5<SHA512值>

其中真正起作用的是25端口的记录。其余端口面向的是邮件客户端(MUA),目前支持DANE的MUA很少,发布这些记录主要是为了完整性,没有坏处,但也不要对它们能提供保护有什么预期就是了。

Web服务

; Web站点(HTTPS)
_443._tcp.www.example.net.   IN TLSA 3 1 1 71d5e809<SHA256值>

密钥轮换

更换私钥时,按RFC 7671第8.1节的做法,应当在新证书正式启用之前,先在DNS上同时发布新旧两个公钥的记录,等待至少一个TLSA记录的TTL(准确地说是原始TTL与剩余缓存时间中的较大者)之后再切换证书,最后删除旧的记录。例如:

_25._tcp.mx1.example.net.    IN TLSA 3 1 1 <当前线上公钥的散列值>
_25._tcp.mx1.example.net.    IN TLSA 3 1 1 <即将上线公钥的散列值>

这和DNSSEC密钥轮转时使用的预发布(pre-publish,RFC 6781第4.1.1.1节)做法是类似的。如果在新记录还未在各处缓存中生效之前就直接上线新证书,一些客户端可能会因为拿到的仍是旧的TLSA记录而无法正常连接。

SSHFP记录

使用SSHFP(SSH指纹记录,RFC 4255,后续由RFC 6594和RFC 7479增加了ECDSA、Ed25519及SHA-256的支持)可以将主机SSH密钥(Host Key)的指纹发布到DNS上。

SSH首次连接(TOFU)时,客户端通常只能让用户手工比对指纹,而实际上很少有人会认真去比对,何况除了FreeBSD和少数机构之外,极少有人会在网页等公开位置发布SSH主机密钥的指纹。有了经过DNSSEC签名的SSHFP记录,客户端可以自动完成这一步核对,从而避免首次连接时就已经存在中间人的风险。

使用ssh-keygen生成

OpenSSH的ssh-keygen(1)内建了从主机公钥(RSA、ECDSA和Ed25519)生成BIND兼容格式记录的能力。不指定-f时,ssh-keygen -r会读取/etc/ssh下所有sshd的主机公钥,因此在服务器上可以直接:

ssh-keygen -r $(hostname).

在一些系统上hostname返回的不是FQDN,这时可以直接写明本机的FQDN:

ssh-keygen -r beastie.example.net.

输出中每个密钥都会有两条记录,第一个数字表示算法(例如4为Ed25519),第二个数字表示散列类型(1为SHA-1,2为SHA-256,完整编号见IANA SSHFP参数表):

beastie.example.net. IN SSHFP 4 1 89e1635f<SHA1值>
beastie.example.net. IN SSHFP 4 2 0577eb2b<SHA256值>

SHA-1已经不推荐使用(RFC 6594与RFC 7479对于ECDSA和Ed25519甚至只定义了SHA-256),可以只保留SHA-256的记录:

ssh-keygen -r beastie.example.net. | awk '$5 == 2'

ssh-keyscan(1)加上-D(例如ssh-keyscan -D beastie.example.net)也能远程生成同样格式的记录,但它取得主机密钥的过程本身没有任何验证,因此只能在可信的网络上使用。

客户端配置

OpenSSH客户端默认不查询SSHFP记录,需要在~/.ssh/config或/etc/ssh/ssh_config(见ssh_config(5))中启用VerifyHostKeyDNS:

Host *.example.net
    VerifyHostKeyDNS yes

此外,客户端只信任DNSSEC验证通过(响应带有AD标志,见RFC 4035第3.2.3节)的SSHFP记录,因此需要本机使用能做DNSSEC验证的解析器,例如FreeBSD的local_unbound(8)。对于glibc 2.31及以后的系统,还需要在/etc/resolv.conf中加上options trust-ad,否则AD标志会被stub resolver丢弃,ssh只会提示Matching host key fingerprint found in DNS但仍要求手工确认是否信任。此处省去吐槽Linux安全性的一万字。