证书明明没过期,Python 为什么仍然报错:一次 Windows 信任库排障

Python 客户端访问 HTTPS 服务时提示 certificate has expired,但浏览器可以正常打开,服务端证书也仍在有效期内。问题究竟出在哪里?

这篇文章记录一次从服务端证书、客户端时间、CA 信任源到 Windows 证书库的完整排查过程。文中的域名、证书名称和输出均已脱敏,只保留可以复用的分析方法。

问题现象

一个使用 Python 标准库发起 HTTPS 请求的程序突然失败:

1
2
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
certificate has expired (_ssl.c:xxxx)

看到 certificate has expired,最容易得出的结论是“服务端忘记续签证书”。然而,这条错误只说明客户端最终选中的验证路径中存在过期证书,并不能直接证明网站的叶子证书已经过期。

一次 TLS 验证至少涉及以下几层:

  1. 服务端叶子证书是否有效,域名是否匹配;
  2. 服务端是否发送了正确、完整的中间证书;
  3. 客户端系统时间是否准确;
  4. 客户端从哪里加载受信任 CA,以及如何构建证书链。

因此,排查的关键不是反复重试,而是逐层缩小范围。

第一步:检查服务端实际返回的证书

先用 OpenSSL 查看目标服务返回的证书。以下域名仅为脱敏示例:

1
2
3
4
openssl s_client \
-connect packages.example.com:443 \
-servername packages.example.com \
-showcerts </dev/null

其中 -servername 用于发送 SNI。共享同一 IP 的站点可能根据 SNI 返回不同证书,漏掉它会让排查方向从一开始就发生偏差。

如果只想查看叶子证书的有效期、主题和签发者,可以继续交给 openssl x509

1
2
3
4
5
openssl s_client \
-connect packages.example.com:443 \
-servername packages.example.com \
2>/dev/null </dev/null \
| openssl x509 -noout -dates -subject -issuer

脱敏后的输出类似:

1
2
3
4
notBefore=<生效时间>
notAfter=<未来时间>
subject=CN=*.example.com
issuer=CN=<Intermediate CA>

这里主要观察三件事:

  • 当前时间是否位于 notBeforenotAfter 之间;
  • subject 或 SAN 是否覆盖访问的域名;
  • 服务端是否随叶子证书返回了所需的中间证书。

同时检查 Windows 系统时间与时间同步状态:

1
2
Get-Date
w32tm /query /status

本次排查中,服务端叶子证书仍然有效,系统时间也没有异常。问题范围由此收缩到客户端信任链。

openssl s_client 很适合观察服务端实际发送了什么,但它自己的验证结果还会受到 OpenSSL 所用 CA 文件影响,不能单凭一条输出断定 Windows 信任库一定正常。

第二步:用两套 CA 信任源做对照

Python 的 ssl.create_default_context() 会加载平台默认 CA;在 Windows 上,默认 CA 来自系统的 CAROOT 证书库。可以用 certifi 提供的 Mozilla CA bundle 做一次对照实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import socket
import ssl

import certifi

HOST = "packages.example.com"
PORT = 443


def check(label, context):
try:
with socket.create_connection((HOST, PORT), timeout=10) as connection:
with context.wrap_socket(connection, server_hostname=HOST) as tls_socket:
print(f"{label}: OK -> {tls_socket.version()}")
except ssl.SSLCertVerificationError as error:
print(f"{label}: FAIL -> {error.verify_message}")


system_context = ssl.create_default_context()
portable_context = ssl.create_default_context(cafile=certifi.where())

check("系统默认证书库", system_context)
check("certifi CA bundle", portable_context)

如果出现下面这种差异:

1
2
系统默认证书库: FAIL -> certificate has expired
certifi CA bundle: OK -> TLSv1.3

可以合理地把排查重点放到本机证书库、信任策略或证书链选择上。这仍然只是定位依据,不足以直接证明某一张证书就是根因,下一步还需要检查系统证书库。

需要注意,certifi 不一定包含企业内部 CA。公司代理、私有 PKI 或 HTTPS 检查设备签发的证书,可能只能被受管的系统信任库验证。因此,这个实验适合诊断,也可以作为明确管理 CA bundle 时的方案,但不应无条件替换企业环境的系统信任策略。

第三步:检查 Windows 证书库

PowerShell 的 Cert: 驱动器可以访问当前用户和本机证书库。先只读地枚举过期证书,不要直接批量删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$certificateStores = @(
'Cert:\CurrentUser\CA',
'Cert:\CurrentUser\Root',
'Cert:\CurrentUser\AuthRoot',
'Cert:\LocalMachine\CA',
'Cert:\LocalMachine\Root',
'Cert:\LocalMachine\AuthRoot'
)
$now = Get-Date

foreach ($certificateStore in $certificateStores) {
if (-not (Test-Path -LiteralPath $certificateStore)) {
continue
}

Get-ChildItem -Path $certificateStore -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -lt $now } |
Select-Object `
@{Name = 'Store'; Expression = { $certificateStore }},
Subject,
Issuer,
NotAfter,
Thumbprint
}

证书库中存在过期证书并不必然意味着系统损坏。真正需要关注的是:它是否与当前失败请求的签发链有关、是否位于验证程序会加载的 store,以及移除后能否稳定复现“失败变成功”。

本次案例最终在 Cert:\CurrentUser\CA 中发现了与目标链相关的过期证书。系统默认信任源验证失败,而独立 CA bundle 验证成功;核对证书主题、签发者和有效期后,才把它确认为嫌疑对象。

根因:叶子证书有效,不代表验证路径有效

一条常见证书链可以简化为:

1
站点叶子证书 -> 中间 CA -> 根 CA

服务端通常发送叶子证书和必要的中间证书,客户端再结合本地证书库构建通往受信任根的路径。同一张叶子证书可能存在多条候选路径;当本地证书库残留旧中间证书、交叉签名证书,或受到企业策略影响时,某个客户端可能选中包含过期证书的路径。

这也解释了为什么“浏览器能打开”不能直接证明“Python 一定能访问”:不同程序可能使用不同 TLS 实现、信任源、路径构建策略和缓存。

在这次脱敏案例中,清理当前用户中间证书库里那张经过确认的过期证书后,Python 使用系统默认上下文即可重新完成验证。最终根因不是服务端叶子证书过期,而是客户端构建出的验证路径包含过期证书。

安全修复:先备份,再预演,再删除

不要因为看到过期日期,就批量清空 CARoot 证书库。应先确认目标证书与故障链相关,并记录它所在的准确 store。对于受公司策略管理的设备,优先联系管理员,因为被删除的证书可能会由组策略重新下发。

确认具体证书后,可以先导出备份,再用 -WhatIf 预演删除:

1
2
3
4
5
6
7
8
9
10
11
12
$thumbprint = '<经过核对的证书指纹>'
$certificatePath = "Cert:\CurrentUser\CA\$thumbprint"
$backupPath = Join-Path (Get-Location) 'certificate-backup.cer'

$certificate = Get-Item -LiteralPath $certificatePath
Export-Certificate -Cert $certificate -FilePath $backupPath

# 只显示将要执行的操作,不做修改
Remove-Item -LiteralPath $certificatePath -WhatIf

# 人工复核路径、指纹和备份后,再确认删除
Remove-Item -LiteralPath $certificatePath -Confirm

如果目标位于 LocalMachine,通常需要管理员权限。删除后应重启发生故障的程序,避免复用已经加载到内存中的 SSLContext,然后重新运行前面的对照测试。

也可以通过 certmgr.msc 查看当前用户证书,但无论使用图形界面还是 PowerShell,都只应操作已经明确确认的单张证书。

不要用“关闭证书验证”解决问题

以下做法可能暂时让请求成功,却会失去服务器身份校验,使中间人攻击变得可行:

1
2
# 不要把它当作生产环境修复方案
ssl._create_unverified_context()

同样,不应长期使用 verify=False。正确做法是修复服务端证书链、客户端时间或信任源;如果必须使用私有 CA,则应显式加载受控的 CA 文件,而不是跳过验证。

一套可复用的排查顺序

遇到 CERTIFICATE_VERIFY_FAILED 时,可以按下面的顺序处理:

  1. 确认请求的主机、端口和 SNI 没有写错;
  2. 检查服务端叶子证书的有效期、域名和返回链;
  3. 检查客户端时间与时间同步;
  4. 比较浏览器、OpenSSL 与 Python 的结果,但不要混淆它们的信任源;
  5. 使用系统默认 CA 和独立 CA bundle 做最小对照;
  6. 只读检查 Windows 的 CurrentUserLocalMachineCARoot store;
  7. 对可疑证书核对主题、签发者、有效期和指纹;
  8. 修复前先备份并使用 -WhatIf,修复后重复同一组测试。

小结

certificate has expired 描述的是验证失败的结果,不一定直接指向服务端证书。先检查服务端返回内容和系统时间,再通过不同 CA 信任源做对照,最后检查本地证书库,可以用较低成本把问题定位到具体一层。

更重要的是,证书库属于系统信任边界。排障过程应该保持“先观察、再确认、后修改”,并始终保留可回滚的备份。

参考资料