requests 测试证书实战:用 OpenSSL 自制一张「已过期」TLS 证书来验证 SSL 校验行为
在 requests 的测试体系中,有一组专门用来复现"服务器证书已过期"场景的 TLS 证书(tests/certs/expired/)。它由一个完全有效的 CA 和一张有效期为 0 天的服务器证书组成,让测试代码无需等待真实时间流逝,就能稳定地触发客户端的证书过期错误。本文基于 过期证书说明文档 及其配套构建脚本,完整拆解这套证书目录的结构、make 再生流程、OpenSSL 配置细节,以及 requests 测试用例如何利用它断言 SSLError,帮你掌握"自制可控证书用于 TLS 行为测试"的通用方法论。
目录结构:一个有效 CA + 一张过期服务器证书
根据 tests/certs/expired/README.md 的说明,该目录的设计意图非常明确:
This has a valid certificate authority in
caand an invalid server certificate inserver.
也就是两个子目录,各自承担一个角色:
| 子目录 | 角色 | 状态 | 产物 |
|---|---|---|---|
| tests/certs/expired/ca/ | 自签名根 CA | 有效(20 年) | ca-private.key、ca.crt(及符号链接 cacert.pem)、序列号文件 ca.srl |
| tests/certs/expired/server/ | 由该 CA 签发的服务器证书 | 已过期(有效期 0 天) | server.key、server.csr、server.pem(含证书 + CA 证书链) |
它也是整个 tests/certs/ 测试证书集合的一部分,与 有效服务器证书(valid)和 mTLS 客户端证书(mtls)互为对照组——过期版用来验证"CA 信任没问题、唯独证书过期"这一单一变量下的行为,避免与其他 TLS 故障混在一起。
一键再生:make clean + make all
README 给出的再生方式只有两条命令:
make clean
make all
这两条命令的实际行为由顶层 Makefile 定义,它本身不含任何 OpenSSL 调用,只是分发到两个子目录:
.PHONY: all clean ca server
ca:
make -C $@ all
server:
make -C $@ all
all: ca server
clean:
make -C ca clean
make -C server clean
注意目标依赖顺序:all 先构建 ca 再构建 server,因为服务器证书必须以 CA 的 ca.crt 和 ca-private.key 作为签发输入;clean 则把两个子目录的生成物全部清掉(cacert.pem、ca.crt、ca-private.key、*.csr、server.*)。整个构建只依赖 OpenSSL 和 Make,没有 Python 参与,因此在任何干净的机器上都能复现。
CA 证书:20 年有效期的自签名根
ca/Makefile 完整流程:
root_files = ca-private.key
ca-private.key:
openssl genrsa -out ca-private.key 2048
all: ca-private.key
openssl req -x509 -sha256 -days 7300 -key ca-private.key -out ca.crt -config ca.cnf
ln -s ca.crt cacert.pem
clean:
rm -f cacert.pem ca.crt ca-private.key *.csr
要点:
openssl genrsa -out ca-private.key 2048:生成 2048 位 RSA 私钥,作为后续所有签名的根;openssl req -x509 ... -days 7300:直接签发一张自签名 CA 证书,7300天 ≈ 20 年,保证 CA 在测试期间永远有效——这正是"CA 有效、叶子证书过期"对照实验成立的前提;ln -s ca.crt cacert.pem:建一个符号链接,兼容习惯用cacert.pem命名的客户端/服务器代码。
CA 的身份与扩展配置在 ca.cnf 中:
[req]
default_bits = 2048
prompt = no
default_md = sha256
encrypt_key = no
distinguished_name = dn
x509_extensions = v3_ca
[dn]
C = US # country code
O = Python Software Foundation # organization
OU = python-requests # organization unit/department
CN = Self-Signed Root CA # common name / your cert name
[v3_ca]
basicConstraints = critical, CA:true
keyUsage = critical, cRLSign, digitalSignature, keyCertSign
prompt = no 表示完全按 [dn] 段读取,不会交互式询问;[v3_ca] 段中的 basicConstraints = critical, CA:true 和 keyUsage = critical, cRLSign, digitalSignature, keyCertSign 是 X.509 v3 里"这张证书只能当 CA 用"的标准声明,缺少它们客户端校验链时可能直接拒绝。
服务器证书:-days 0 是过期实验的核心技巧
server/Makefile 展示了完整的"私钥 → CSR → 证书"三步链:
server.key:
openssl genrsa -out $@ 2048
server.csr: server.key
openssl req -key $< -new -out $@ -config cert.cnf
server.pem: server.csr
openssl x509 -req -CA ../ca/ca.crt -CAkey ../ca/ca-private.key -in server.csr -outform PEM -out server.pem -days 0 -CAcreateserial
openssl x509 -in ../ca/ca.crt -outform PEM >> $@
all: server.pem
clean:
rm -f server.*
逐条解读:
openssl req -key $< -new -out $@ -config cert.cnf:基于服务器私钥生成 CSR(证书签名请求),主体信息来自配置而非交互输入;openssl x509 -req -CA ../ca/ca.crt -CAkey ../ca/ca-private.key ... -days 0:用 CA 的证书和私钥签发叶子证书,-days 0是整套实验的"题眼"——签发完成的那一刻证书就已过期(notAfter 早于当前时间),无需等真实时间过去;-CAcreateserial顺便创建 CA 序列号文件(即仓库中的ca.srl);openssl x509 -in ../ca/ca.crt -outform PEM >> $@:把 CA 证书追加进server.pem,使这份文件成为一个自带完整证书链的 bundle(服务器证书在前、CA 证书在后),这样测试用的 TLS 服务器只需指定一个文件即可通过ssl模块加载完整链。
另外 Makefile 用到了 $<(第一个前置条件)这类 GNU Make 变量,构建顺序由 server.pem: server.csr、server.csr: server.key 逐级推导,依赖关系清晰。
服务器身份:localhost / 127.0.0.1 / ::1 全打满
server/cert.cnf 定义了 CSR 的主体与关键扩展:
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt=no
[req_distinguished_name]
C = US
ST = DE
O = Python Software Foundation
OU = python-requests
CN = localhost
[v3_req]
# Extensions to add to a certificate request
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.localhost
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = ::1
[v3_req] 段是关键:
basicConstraints = CA:FALSE+keyUsage = digitalSignature, keyEncipherment:声明这是一张终端实体(服务器)证书;extendedKeyUsage = serverAuth:限定用途为 TLS 服务端认证;subjectAltName覆盖localhost、*.localhost、127.0.0.1和 IPv6 回环::1——现代客户端早已不校验 CN 而校验 SAN,把本地回环地址全部写进 SAN,才能保证无论测试服务器绑定127.0.0.1还是localhost,主机名校验这一变量不会污染"过期"这个唯一变量。
测试用例如何使用这套过期证书
这套证书的消费方在 tests/test_requests.py。典型用例 test_different_connection_pool_for_tls_settings_verify_bundle_expired_cert(约 L2959-L2990)展示了三层断言:
server = TLSServer(
handler=response_handler,
wait_to_close_event=close_server,
requests_to_handle=3,
cert_chain="tests/certs/expired/server/server.pem",
keyfile="tests/certs/expired/server/server.key",
)
with server as (host, port):
url = f"https://{host}:{port}"
r1 = s.get(url, verify=False)
assert r1.status_code == 200
# Has right trust bundle, but certificate expired
with pytest.raises(requests.exceptions.SSLError):
s.get(url, verify="tests/certs/expired/ca/ca.crt")
三个关键点:
verify=False能拿到 200:关闭校验时请求本身是通的,说明传输层没问题,错误只可能出在校验逻辑;verify="tests/certs/expired/ca/ca.crt"抛出requests.exceptions.SSLError:客户端把这张过期证书的签发者——那个完全有效的 CA——作为信任根传入,信任链每一环都"对",唯独时间校验失败,于是底层ssl抛CERTIFICATE_HAS_EXPIRED,requests 包装成SSLError上抛。这是整组证书设计的直接验证:"错误不是因为你选错了 CA,而是因为证书过期";- 相邻用例(如 L2992 起的
..._verify_bundle_unexpired_cert)换用 tests/certs/valid/ 下的有效证书做正对照;L3037 起的 mTLS 用例则把过期服务器证书与 mtls 客户端证书 组合,验证双向认证场景下过期同样触发SSLError。
这种"同一 TLS 服务器 + 不同 verify 入参 + 断言 200 / SSLError"的模式,正是过期证书目录存在的价值:它把一个依赖时间流逝、否则极难在 CI 中稳定复现的边界条件,固化成了随仓库分发的静态资产。
复用这套方法:如何给自己的项目造可控的过期证书
把 server/Makefile 的流程抽象出来,是一份可以直接照抄的最小配方:
# 1. 生成 CA(长期有效)
openssl genrsa -out ca-private.key 2048
openssl req -x509 -sha256 -days 7300 -key ca-private.key -out ca.crt -config ca.cnf
# 2. 生成服务器私钥与 CSR(CN/SAN 指向你的测试域名或回环地址)
openssl genrsa -out server.key 2048
openssl req -key server.key -new -out server.csr -config cert.cnf
# 3. 用 CA 签发,-days 0 使证书即刻过期;再把 CA 追加进 bundle 形成完整链
openssl x509 -req -CA ca.crt -CAkey ca-private.key -in server.csr \
-outform PEM -out server.pem -days 0 -CAcreateserial
openssl x509 -in ca.crt -outform PEM >> server.pem
配套 ca.cnf / cert.cnf 的最小要点与仓库完全一致:
- CA 侧必须写
basicConstraints = critical, CA:true和含keyCertSign的keyUsage; - 服务器侧必须写
extendedKeyUsage = serverAuth和覆盖实际测试地址的subjectAltName; - 用
prompt = no保证构建可重复、无人值守。
适用前提与限制:该方案依赖系统 openssl 与 GNU Make,用于测试环境完全足够;生成的证书 CN 为 localhost、组织信息为 Python Software Foundation / python-requests,仅用于本地回环测试,不应部署到任何真实服务端。若只需要"当前已过期"的静态证书,直接复用仓库里现成的 server.pem 与 ca.crt 即可,不必再生成;只有需要自定义域名或更长/更短的"剩余寿命"时,才需改动 -days 参数重新构建。
小结
tests/certs/expired/ 这套资产的核心思想可以概括为三点:
- 单变量控制:CA 有效期 7300 天保证信任链恒有效,服务器证书
-days 0保证即刻过期,"过期"成为唯一被测试的变量; - 构建即文档:
make clean && make all两条命令可完整再生全部证书,三个 Makefile 与两份 openssl 配置就是可执行的说明书; - 测试闭环:tests/test_requests.py 中
verify=False得 200、verify=<有效CA>却抛SSLError的对照断言,直接证明了这套证书精确地只制造"过期"这一种故障。
理解并复用这套模式后,你可以在自己的 TLS 相关测试中,用同样手段稳定复现证书过期、自签名、链不完整等边界条件,而不再依赖真实时钟或外部服务。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00