浏览器能打开,Python 却报证书错误:一次 HTTPS 证书链排障
整理博客资料时,抓取 Matrix67 的脚本报了一个证书错误:
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1010)
浏览器却能打开这个站点。请求没有走到返回文章正文的阶段,问题出在 HTTPS 握手时的身份验证,继续调整正文选择器或重试间隔都帮不上忙。
最后发现,服务器只发送了站点证书,没有附带构建可信路径需要的中间证书。在这台 Windows 机器上,改用系统原生证书链验证后,请求恢复正常。但写文章前再复测,默认 Python ssl 居然也能连接了,独立的 OpenSSL 命令仍然失败。
这段变化比「安装一个库就好了」更值得留下。证书验证依赖客户端当时能找到哪些证书,排查本身也可能改变后续实验的条件。
服务器到底发了哪些证书
这次环境是 Windows、Python 3.12.14、Python 链接的 OpenSSL 3.5.8,以及 truststore 0.10.5。以下结果采集于 2026 年 10 月 23 日,只描述当时的站点和本机状态;服务端续签证书或补齐配置后,结果可能变化。
先看服务器发来的证书列表。下面的命令适用于已经安装 OpenSSL、且 openssl 在 PATH 中的终端;Windows 可以使用 Git for Windows 附带的 OpenSSL,但要确认调用的是哪个程序。
openssl s_client -connect matrix67.com:443 -servername matrix67.com -showcerts -verify_return_error -verify_hostname matrix67.com
-servername 指定握手中的目标域名,避免同一个 IP 上的其他虚拟主机影响结果;-verify_hostname 检查证书是否匹配该域名。-verify_return_error 让证书验证失败时终止握手,不能只看到工具打印出 TLS 版本或密码套件,就把它当成连接已经通过验证。
这里最重要的是 -showcerts。OpenSSL 文档明确说明,它展示的是服务器实际发送的证书列表,不是客户端验证后构建出的完整证书链。下面是本次输出的节选,省略了公钥信息、PEM 正文和其他握手字段:
Certificate chain
0 s:CN=matrix67.com
i:C=US, O=Let's Encrypt, CN=YR1
...
Verify return code: 20 (unable to get local issuer certificate)
列表里只有编号 0 这一张站点证书,签发者是 YR1,后面没有编号 1 的签发者证书。这比一句 CERTIFICATE_VERIFY_FAILED 更具体:至少在这次握手里,服务器没有发送后续中间证书。它不能单独证明本机信任库没有其他问题,但已经找到了一个会影响客户端兼容性的服务端缺口。
完整复测输出、证书 SHA-256 和两个 Python 上下文的结果保存在这份原始记录中。记录区分了首次排查的错误节选和写作时重新执行的实验,没有用复测结果替换最初的失败。
少了中间证书,会卡在哪里
站点证书里有域名、公钥和签发者,但拿到它并不意味着客户端已经信任它。客户端需要验证签名,找到签发它的 CA 证书,再继续向上构建到本地信任的根证书。
这里的 CA 是证书颁发机构。根证书作为信任锚,由客户端的信任配置决定是否接受;中间证书连接根和站点证书,不能仅凭「从服务器下载到了」就被当成新的可信根。
按照 Let’s Encrypt 公布的证书链,YR1 的默认兼容路径会经过 Root YR,再连接到 ISRG Root X1。其中使用的是由 X1 交叉签名的 Root YR 证书,而不是把 Root YR 的自签名版本直接假定为所有客户端都已信任。
图中箭头指向签发者,表示构建路径时的查找关系。服务器通常应发送站点证书和必要的中间证书,根证书一般由客户端预先信任,无须随握手发送。基础的签名验证与 TLS 握手过程,可以接着看这篇 HTTPS 机制文章。
服务器漏发中间证书后,客户端得从其他地方补齐。如果本地已经有相应证书,或验证实现能够获取缺失的签发者证书,这条连接仍可能成功;找不到可用路径的客户端就会失败。
浏览器能打开,也不能直接证明服务器的证书链配置完整。浏览器可能有中间证书缓存,也可能通过 AIA 获取签发者证书。这次没有单独记录浏览器的证书获取过程,因此不能断言它具体走了哪一条路径。
AIA 是证书里的 Authority Information Access 扩展,可以提供签发者证书的下载地址。能下载到一张证书,只是多了一份构链材料;签名、用途和信任路径仍然需要验证。Windows 的证书链接口就提供了 AIA 获取相关控制。
读取系统证书与调用系统验证
一个很容易说顺口的解释是「浏览器使用系统证书,Python 不使用」。这句话不适合描述这次问题。
Windows 上,默认 ssl.create_default_context() 会通过 load_default_certs() 从系统 CA 和 ROOT 存储加载证书。因此,不能把默认 Python 的失败直接归结为它没有使用系统证书。Python 3.12 文档也说明了这项平台行为。
但把证书加载进 OpenSSL,与委托 Windows 构建和验证证书链,是不同的处理方式。前者取决于当前上下文已经加载、能够查找到的证书;后者还涉及系统链引擎的证书查找、缓存与获取策略。
truststore 提供了类似 ssl.SSLContext 的接口,把证书验证接到平台能力上。它的 0.10.5 版 Windows 实现会调用 CertGetCertificateChain 构建链,再调用 CertVerifyCertificateChainPolicy 检查 SSL 策略。验证由平台链引擎完成,缺失证书的查找和获取也受系统策略控制,证书错误仍然会让请求失败。
我在采集器里采用的是独立上下文,没有把标准库的 ssl 全局替换。下面是用于比较连接结果的最小示例,不是完整的 HTTP 采集工具。先在自己的 Python 3.10+ 虚拟环境中安装依赖:
python -m pip install truststore==0.10.5
再执行:
import socket
import ssl
import truststore
host = "matrix67.com"
context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
with socket.create_connection((host, 443), timeout=20) as raw:
with context.wrap_socket(raw, server_hostname=host) as tls:
print(tls.version(), context.verify_mode.name, context.check_hostname)
本机复测输出是:
TLSv1.3 CERT_REQUIRED True
这表示该上下文完成了握手,而且仍要求证书验证、检查主机名。把上下文替换为 ssl.create_default_context(),可以比较默认实现的结果;比较时要保留域名、端口和 SNI,也要记录执行时间,而不是把两次结果都假定成完全相同的初始环境。
truststore 的平台说明分别列出 Windows CryptoAPI、macOS Security framework 和 Linux OpenSSL。平台实现不同,所以本次 Windows 上验证成功,不足以证明相同代码在所有 Linux 容器里都能自动补齐缺失证书。集成到其他 HTTP 库时,还需要按该库支持的入口传入上下文;仅安装包不会自动改变现有请求。
复测时,默认 Python 也成功了
写作前又执行了一轮实验,结果与首次排查并不完全相同:
| 验证方式 | 首次排查 | 后续复测 |
|---|---|---|
Python 默认 ssl |
缺少签发者证书,失败 | TLS 1.3 握手通过 |
Python truststore |
握手通过 | TLS 1.3 握手通过 |
| 独立 OpenSSL 命令 | 校验失败 | 仍失败,服务器列表仍只有一张证书 |
复测中,两种 Python 上下文得到的站点证书 SHA-256 相同。默认上下文当前加载的 CA 证书里,也已经能看到 YR1 和 Root YR。这些信息都保留在前面的原始记录中。
这些观察与本机证书材料、补链状态发生变化相符,但实验没有隔离证明是哪次请求写入了哪些缓存。因此,不能把默认 Python 后来成功直接归因于某一次 truststore 调用,更不能据此宣称原站已经补齐证书链。独立 OpenSSL 仍能观察到服务器只发送一张证书。
我没有为了重现最初的错误去清空日常机器的系统证书存储。那会影响其他应用,也不是这篇排障记录需要做的事。真要做严格的前后对照,应该使用干净的虚拟机或隔离环境,分别记录执行前后的信任库、缓存、后端与服务端证书。
排障时多保留一份首次失败的现场很有必要。否则,等工具安装好、浏览器访问过、系统补过链,再执行相同的命令,原来的失败条件可能已经不存在了。
服务端补全链,客户端保留验证
对于站点维护者,直接的修复是让 HTTPS 服务发送正确的证书链,使没有缓存、也不主动获取中间证书的客户端能够验证。
以 Nginx 为例,ssl_certificate 的配置文档要求在同一个 PEM 文件里先放站点证书,再放中间证书。配置完成后,用没有相关缓存、也不主动补链的客户端复测,同时检查服务器发送列表与验证结果;只在日常浏览器里刷新一次,可能看不出链是否补全。
这是通用配置建议,不是对 Matrix67 服务端配置文件的检查结果;这次没有服务端管理权限,也没有修改它的 HTTPS 配置。
对于读取第三方网站的程序,只能在客户端可控范围里处理兼容性。选择系统证书链验证是一种办法,但仍应保留域名、有效期、用途和信任路径检查。verify=False、CERT_NONE、关闭主机名检查,或把刚下载的站点证书直接导入可信根,都不属于这里的修复。
也不用看到这个错误就先更新 CA 包。缺少可信根和缺少中间证书,都是构链失败的可能原因,但需要补的东西不同;根证书包更新了,服务器漏发的中间证书仍可能找不到。
总结
这次 Python 的证书错误,查到的是服务器没有发送必要的中间证书。客户端要完成验证,就得从本地存储、缓存或其他获取渠道找到缺失的构链材料。因此,浏览器能打开与脚本验证失败并不矛盾,浏览器成功也不能证明服务端已经配好了完整证书链。
客户端怎样使用这些材料,同样影响结果。Windows 上的默认 Python 会加载系统证书,但仍由 OpenSSL 验证;truststore 则调用系统原生链引擎。本机采用后一种方式后连接成功,后续默认 Python 也成功了,而独立 OpenSSL 仍失败。这说明比较结果时必须记录当时的证书材料与验证方式,不能仅凭先后顺序,就认定某次调用更新了缓存,或服务器已经修复。首次失败的记录因此也需要保留。
修复要对应各自可控的部分:站点维护者补齐服务端证书链,第三方读取程序选择合适的验证实现,并保留域名、有效期、用途和信任路径检查。关闭验证只是绕过错误,不能补上缺失的信任路径;更新根证书包也不一定能解决漏发中间证书的问题。
下次遇到类似的问题,我们的排查思路可以是:先确认失败发生在哪一层,再检查对应条件。
| 观察到的失败 | 接下来核对什么 |
|---|---|
| DNS 解析失败、TCP 超时 | 解析、目标端口与网络路径 |
| TLS 证书验证失败 | SNI、服务器发送列表、有效期、签发者与客户端信任来源 |
| 已收到 HTTP 401、403、429 | 认证、拒绝原因与限流要求;不靠换浏览器或身份继续抓取 |
| HTTP 成功,但提取不到正文 | 最终 URL、响应类型、页面结构和渲染方式 |
它们可能先后出现,但不能混成一个「网站打不开」。这次部分 Matrix67 旧图还有 HTTP 地址断连、同路径 HTTPS 可读取的现象,那是另外的请求问题,不能拿来解释证书链为什么缺失。