Linux 上的 TLS 证书验证
摘要
深入解释 Linux 上的 TLS 证书验证,涵盖信任存储、链构建以及常见陷阱(如缺少中间证书)。文章阐明了为什么不同工具可能对证书有效性存在分歧。
<p><a href="https://lobste.rs/s/v6ukog/tls_certificate_validation_on_linux">评论</a></p>
查看缓存全文
缓存时间: 2026/07/16 01:50
# Linux 上的 TLS 证书验证 · Ivan Tomica
来源:https://www.tomica.net/blog/2026/07/tls-certificate-validation-on-linux/
我们都很熟悉这个流程。你在浏览器里输入 `https://`,出现一把小锁图标,然后大家都心照不宣地不再去想刚才发生了什么。但这一切在以下场景中就会出问题:你服务器上的某个应用程序开始抛出 `certificate verify failed` 错误,而用 `curl` 请求同一个端点却完全正常——或者反过来。这时候人们往往会发现,“系统信任这个证书”这句话其实远没有听起来那么明确。
那么,我们来拆解一下,当 Linux 系统上的某个程序决定是否信任一个 TLS 证书时,到底发生了什么,以及为什么同一台机器上的两个程序会对同一证书得出截然不同的结论。
## 服务器实际发送的内容
在 TLS 握手过程中,服务器不只发送它的证书。它还会发送(或者至少应该发送)一整条证书链:
- **叶证书**,为实际主机名签发
- 一个或多个**中间证书**,它们签发叶证书
但它不会发送根证书。因为发送根证书毫无意义。如果客户端没有根证书,那么它即使收到也无用,因为整个信任模型都建立在客户端本地拥有它认为可信的根证书副本之上。一个通过网络发送到你面前的根证书,其可信度大概和邮件里写着“我不是骗子”的签名差不多。
客户端的工作就是根据收到的叶证书,通过中间证书构建一条路径,最终连接到一个它已知且信任的根证书。链中的每个证书都由上一级证书签名,因此客户端会逐级验证签名,直到找到一个本地信任存储中的证书。如果无法完成路径构建(缺少中间证书、根未知等),连接就会被拒绝。
“缺少中间证书”这种情况值得特别提一下,因为它很经典。一个配置不当的服务器只发送叶证书,在浏览器中能正常工作(浏览器会缓存其他途径见过的中间证书,甚至可以按需获取),但在基于 OpenSSL 的工具中会彻底失败,因为它们不会做这种获取。如果你曾经遇到过“Chrome 能打开,但 curl 不行”的站点,那么很可能就是这个原因。
不过,链构建只是故事的一部分。在此过程中,客户端还会检查:
- **有效期**——证书是否已过期,或者尚未生效
- **主机名**——你正在连接的主机名是否出现在证书的主题备用名称(Subject Alternative Names)中
- **密钥用途约束**——中间证书是否真的有签发证书的权限
- **撤销状态**——理论上通过 CRL 或 OCSP 检查,但实际上,这一步骤被跳过或软处理的频率远远高于行业内任何人愿意承认的程度
所有这些都只是机械流程。有趣的问题在最后一步:那个链必须终止于的“本地信任存储”到底是什么?
## 系统信任存储
在 Linux 上,根证书就是磁盘上的文件。没有什么 TPM 魔法,没有注册表,没有什么特别的东西。就是一个装满 PEM 编码证书的目录,外加一些符号链接/串联技巧。
这些证书通常来自发行版的 `ca-certificates` 软件包,而该软件包又打包了 Mozilla 根证书计划的列表。所以当人们说“系统信任这个 CA”时,实际的意思就是“Mozilla 信任这个 CA,Debian 重新打包了那个决定,我的系统安装了它”。想想这条有趣的保管链,但它运行得非常好。
问题的不一致性在于这些文件**存放的位置**以及如何添加你自己的证书。每个发行版家族都有自己的想法:
- 基于 Debian 的系统将捆绑包放在 `/etc/ssl/certs/ca-certificates.crt` 中,你将本地 CA 放入 `/usr/local/share/ca-certificates/` 并运行 `update-ca-certificates`
- 基于 Red Hat 的系统使用 `/etc/pki/ca-trust/`,你将文件放入 `/etc/pki/ca-trust/source/anchors/` 并运行 `update-ca-trust`
- 还有其他发行版,每个都有略微不同的路径和更新命令
更新命令的目的是收集所有来源(发行版捆绑包加上你本地的添加项),然后重新生成应用程序期望的各种格式:一个单一的串联 PEM 文件,为 OpenSSL 查找准备的哈希目录,有时还会生成 Java 密钥库。现代发行版越来越多地通过 `p11-kit` 来整合这一切,它扮演一个中央代理的角色,使不同的加密库可以共享同一个信任源。**越来越多**,但并非普遍。请记住这个词,它马上就会变得重要。
## 这就是问题所在
一个不太光彩的秘密是:“系统信任存储”只是一个君子协定,并非强制机制。内核或其他任何地方都没有**强制**应用程序使用它。TLS 验证完全在用户空间进行,在应用程序链接到的任何 TLS 库内部,而该库会读取它被配置为读取的任何信任源。
而应用程序呢,它们偏偏喜欢自己带一个。
光是 TLS 库的划分就已经造成了碎片化。OpenSSL、GnuTLS 和 NSS 各自有各自的默认查找路径,发行版会通过打补丁让它们指向系统存储,但一致性参差不齐。但真正的麻烦还在更上一层:
- **Java** 自带自己的密钥库(`cacerts`),用自己的工具(`keytool`)管理,格式独特,完全无视 `/etc/ssl` 中的任何内容。有些发行版会把它连接到系统存储,有些则不会,而你作为 tarball 下载的 JVM 当然不会
- **Node.js** 将 Mozilla 的根证书列表**编译到二进制文件里**。你的 Node 应用所信任的证书是在构建这个 Node 版本时就决定好的
- **Python**——标准库使用 OpenSSL 的默认值,这通常意味着系统存储。但流行的 `requests` 库会拉取 `certifi`,这是以 pip 包形式分发的 Mozilla 捆绑包。所以,一个 Python 进程可以包含两个不同的信任存储,具体取决于某个代码路径使用了哪个 import
- **浏览器**众所周知完全自己搞一套:Firefox 用 NSS 和自己的根证书计划,Chrome 现在也用自己的一套根存储
- **Go** 会读取系统存储,但和其他几乎所有工具一样,它会优先考虑 `SSL_CERT_FILE` 和 `SSL_CERT_DIR` 环境变量,此外还有像 `NODE_EXTRA_CA_CERTS` 和 `REQUESTS_CA_BUNDLE` 这样的应用特定变量
从应用程序厂商的角度来看,这种做法完全合理。自带信任存储意味着在任何平台上的行为都一致,不依赖发行版把事情做对,也不会受到本地管理员对 `/etc/ssl` 做的任何操作的意外影响。Java 在 Linux、macOS、FreeBSD 以及那台谁都不想碰的 Windows 机器上行为一致,正是因为它对所有这些平台都一视同仁地忽略。
从系统管理员的角度来看,这意味着“这台机器信任我的 CA 吗?”这个问题没有单一的答案。机器本身不信任任何东西。信任的是应用程序,而且每个应用程序都有自己的方式。
## 私有 CA 的障碍赛
这个问题在内网基础设施中尤其严重。你运行自己的 CA(可能是为了内部服务、企业的 MitM 代理、家庭实验室等等),现在你需要你的系统信任它。天真的期望是只需一步:将 CA 添加到系统存储,运行更新命令,搞定。
实际体验往往更像是一场寻宝游戏:
1. 将 CA 添加到系统存储——`curl`、`wget` 以及大多数 C 链接的程序现在都满意了
2. 发现 Java 服务仍然失败,用 `keytool -importcert` 将其导入 `cacerts`
3. 发现 Node 服务仍然失败,设置 `NODE_EXTRA_CA_CERTS`
4. 发现 Python 服务仍然失败,因为 `certifi`,设置 `REQUESTS_CA_BUNDLE`
5. 发现某个容器镜像失败,因为它有自己的 `/etc/ssl`,与宿主机完全隔离
6. 对你未来部署的每一个运行时、工具和容器重复以上步骤
关于容器的最后一点值得额外提一句:容器中的应用程序自带文件系统,也就自带信任存储,这意味着以上所有步骤都嵌套了一层。宿主机信任 CA 没问题,但容器内部的进程正在根据镜像自带的 `/etc/ssl` 进行验证,对你在宿主机上做的任何事情一无所知。通常的解决方法是把宿主机的 CA 捆绑包挂载到容器的相应位置,可以是只读绑定挂载,在 Kubernetes 中则是挂载一个指向正确路径的卷。
严格来说,这些都不算“坏掉”。以上每个应用程序都按设计运行。只不过,“按设计运行”是由十几个不同项目、带着十几种不同理念决定下来的,而你需要把结果整合起来。
另外还有一个合理的反面:应用程序控制自己的信任链不仅是麻烦,也可能是一种特性。证书固定(pinning),即只信任你自己的特定 CA,甚至只信任一个特定证书,而不是那两百来个公共根证书,可以大幅减少某个连接的攻击面。一个只与你的内部 API 通信的后端服务,完全没有理由信任全球所有的证书颁发机构。正是同一个机制,既让私有 CA 的推广变得麻烦,也让这种加固成为可能。
信任模型本身没有问题。链构建、签名、本地根证书,所有这些都设计良好且经过实战检验。Linux 所缺乏的是单一的强制执行点,而且老实说,它从未声称过自己有。“系统信任存储”是一个默认、一个约定,大部分工具都会遵守,但没有工具必须遵守。
所以下次当某个应用程序拒绝一个箱子上其他所有工具都接受的证书时,请跳过怀疑自己智商的那个阶段,直接问一个有用的问题:**这个程序实际读的是哪个信任存储?**
相似文章
内部服务的TLS证书正确实践
介绍了如何使用分视域DNS、带DNS解析器的VPN以及ACME客户端(如acme.sh配合Let's Encrypt)来为内部服务设置TLS证书,为自签名证书提供了实用的替代方案。
Linux 与 Secure Boot 证书到期
本文讨论了即将到期的 Microsoft Secure Boot 证书(Linux 发行版依赖它通过 shim 进行引导),以及更新系统固件以适配替换密钥所涉及的复杂性。
欺骗Go的X.509证书验证
这篇博客文章探讨了Go的X.509证书验证中的一个细微错误,其中两个看似相同的CA证书由于PEM编码中空白字符处理的差异可能导致不同的结果。
安全启动和CA证书轮换——给发行版的提醒
本文提醒Linux发行版注意微软用于安全启动的UEFI CA证书即将到期,介绍了新证书以及在缺少旧证书的较新硬件上可能出现的启动问题。
imthenachoman/如何保障 Linux 服务器安全
这是一份全面的开源指南与工具集,旨在保障 Linux 服务器的安全,涵盖使用 Ansible、Fail2Ban 和 Lynis 等工具进行 SSH 加固、防火墙配置以及入侵检测。