首页
/ requests 测试证书实战:用 OpenSSL 自制一张「已过期」TLS 证书来验证 SSL 校验行为

requests 测试证书实战:用 OpenSSL 自制一张「已过期」TLS 证书来验证 SSL 校验行为

2026-09-05 14:43:36作者:劳婵绚Shirley

在 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 ca and an invalid server certificate in server.

也就是两个子目录,各自承担一个角色:

子目录 角色 状态 产物
tests/certs/expired/ca/ 自签名根 CA 有效(20 年) ca-private.keyca.crt(及符号链接 cacert.pem)、序列号文件 ca.srl
tests/certs/expired/server/ 由该 CA 签发的服务器证书 已过期(有效期 0 天) server.keyserver.csrserver.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.crtca-private.key 作为签发输入;clean 则把两个子目录的生成物全部清掉(cacert.pemca.crtca-private.key*.csrserver.*)。整个构建只依赖 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

要点:

  1. openssl genrsa -out ca-private.key 2048:生成 2048 位 RSA 私钥,作为后续所有签名的根;
  2. openssl req -x509 ... -days 7300:直接签发一张自签名 CA 证书,7300 天 ≈ 20 年,保证 CA 在测试期间永远有效——这正是"CA 有效、叶子证书过期"对照实验成立的前提;
  3. 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:truekeyUsage = 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.*

逐条解读:

  1. openssl req -key $< -new -out $@ -config cert.cnf:基于服务器私钥生成 CSR(证书签名请求),主体信息来自配置而非交互输入;
  2. openssl x509 -req -CA ../ca/ca.crt -CAkey ../ca/ca-private.key ... -days 0:用 CA 的证书和私钥签发叶子证书,-days 0 是整套实验的"题眼"——签发完成的那一刻证书就已过期(notAfter 早于当前时间),无需等真实时间过去;-CAcreateserial 顺便创建 CA 序列号文件(即仓库中的 ca.srl);
  3. openssl x509 -in ../ca/ca.crt -outform PEM >> $@:把 CA 证书追加进 server.pem,使这份文件成为一个自带完整证书链的 bundle(服务器证书在前、CA 证书在后),这样测试用的 TLS 服务器只需指定一个文件即可通过 ssl 模块加载完整链。

另外 Makefile 用到了 $<(第一个前置条件)这类 GNU Make 变量,构建顺序由 server.pem: server.csrserver.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*.localhost127.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")

三个关键点:

  1. verify=False 能拿到 200:关闭校验时请求本身是通的,说明传输层没问题,错误只可能出在校验逻辑;
  2. verify="tests/certs/expired/ca/ca.crt" 抛出 requests.exceptions.SSLError:客户端把这张过期证书的签发者——那个完全有效的 CA——作为信任根传入,信任链每一环都"对",唯独时间校验失败,于是底层 sslCERTIFICATE_HAS_EXPIRED,requests 包装成 SSLError 上抛。这是整组证书设计的直接验证:"错误不是因为你选错了 CA,而是因为证书过期";
  3. 相邻用例(如 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 和含 keyCertSignkeyUsage
  • 服务器侧必须写 extendedKeyUsage = serverAuth 和覆盖实际测试地址的 subjectAltName
  • prompt = no 保证构建可重复、无人值守。

适用前提与限制:该方案依赖系统 openssl 与 GNU Make,用于测试环境完全足够;生成的证书 CN 为 localhost、组织信息为 Python Software Foundation / python-requests,仅用于本地回环测试,不应部署到任何真实服务端。若只需要"当前已过期"的静态证书,直接复用仓库里现成的 server.pemca.crt 即可,不必再生成;只有需要自定义域名或更长/更短的"剩余寿命"时,才需改动 -days 参数重新构建。

小结

tests/certs/expired/ 这套资产的核心思想可以概括为三点:

  • 单变量控制:CA 有效期 7300 天保证信任链恒有效,服务器证书 -days 0 保证即刻过期,"过期"成为唯一被测试的变量;
  • 构建即文档make clean && make all 两条命令可完整再生全部证书,三个 Makefile 与两份 openssl 配置就是可执行的说明书;
  • 测试闭环tests/test_requests.pyverify=False 得 200、verify=<有效CA> 却抛 SSLError 的对照断言,直接证明了这套证书精确地只制造"过期"这一种故障。

理解并复用这套模式后,你可以在自己的 TLS 相关测试中,用同样手段稳定复现证书过期、自签名、链不完整等边界条件,而不再依赖真实时钟或外部服务。

登录后查看全文
热门项目推荐
相关项目推荐