Playwright Python 客户端证书(mTLS)测试资产解析:从证书生成到自动化验证
Playwright Python 客户端证书(mTLS)测试资产解析:从证书生成到自动化验证
本篇技术指南围绕 Playwright Python 仓库中 tests/assets/client-certificates/README.md 所管理的客户端证书测试资产展开,完整讲解如何通过一行命令重新生成整套 TLS 双向认证(mTLS)测试证书,并深入剖析证书在 client_certificates 配置中的使用方式、底层协议序列化实现以及对应的端到端测试验证逻辑。读完本文,你将掌握在 Playwright Python 项目中搭建客户端证书测试环境的完整方案,包括服务端签名证书、自签名证书、PFX 等多种形态的生成与选用场景。
一、这套测试资产在项目中的定位
Playwright Python 支持 TLS Client Authentication(TLS 客户端认证):当服务端在 TLS 握手阶段要求客户端出示证书时,浏览器需要携带指定的客户端证书才能完成认证。仓库通过 tests/async/test_browsercontext_client_certificates.py 与 tests/sync/test_browsercontext_client_certificates.py 两套对称的测试,专门验证 client_certificates 参数在普通上下文、持久化上下文以及 API Request 上下文中的行为。
而所有测试所需的证书材料,全部集中存放在 tests/assets/client-certificates/ 目录下。该目录内的 README 是一份极其精简的“证书再生成说明”:核心命令只有一条 bash generate.sh,用于重新生成全部证书。真正的内容承载在 generate.sh 脚本中,它完整刻画了每一类证书的 OpenSSL 生成与签发流程,是理解这套测试资产的一手资料。
二、目录结构与证书类型总览
tests/assets/client-certificates/
├── README.md # 再生成说明(bash generate.sh)
├── generate.sh # 全套证书生成脚本
├── server/
│ ├── server_cert.pem # 服务端证书(同时充当测试 CA)
│ └── server_key.pem # 服务端私钥
└── client/
├── trusted/ # 受信任客户端证书(服务端签名,有效)
│ ├── cert.pem / cert.pfx / cert-legacy.pfx
│ ├── csr.pem / key.pem
├── localhost/ # localhost 专用受信任证书
│ ├── localhost.pem / localhost.key / localhost.csr / localhost.ext
└── self-signed/ # 自签名客户端证书(服务端不可信,无效)
├── cert.pem / csr.pem / key.pem
从证书的信任关系看,可以划分为三类:
| 证书 | 签发方式 | 信任状态 | 用途 |
|---|---|---|---|
client/trusted/* |
由 server/server_cert.pem 作为 CA 签发 |
受信任(有效) | 验证正向通过场景,服务端返回 200 |
client/localhost/* |
由 server/server_cert.pem 签发,CN=localhost |
受信任(有效) | 验证 origin 匹配(https://127.0.0.1:8000)场景 |
client/self-signed/* |
自身私钥自签 | 不受信任(无效) | 验证拒绝场景,浏览器抛出 alert unknown ca |
这里的“受信任”是相对于测试服务器而言的:测试服务器把 server_cert.pem 作为信任根(CA)来校验客户端证书链(见下文测试服务器实现),因此由它签发的客户端证书可通过校验,而自签名的 Bob 证书则会被拒绝。
三、重新生成全部证书:一行命令
原 README 给出的核心操作只有一条命令:
bash generate.sh
该命令会在 tests/assets/client-certificates/ 目录下原地重建全部证书。由于脚本开头使用了 cd "$(dirname "$0")"(generate.sh),因此无论从仓库根目录还是从 tests/assets/client-certificates/ 目录执行,它都会自动切换到脚本所在目录,保证输出路径相对稳定。
执行前请确保系统已安装 OpenSSL 命令行工具(openssl version 可查看版本),脚本依赖 openssl req、openssl x509、openssl pkcs12 三个子命令,无需其他第三方依赖。
四、逐段拆解 generate.sh
4.1 服务端证书(Server Certificate)
openssl req \
-x509 \
-newkey rsa:4096 \
-keyout server/server_key.pem \
-out server/server_cert.pem \
-nodes \
-days 3650 \
-subj "/CN=localhost/O=Client\ Certificate\ Demo" \
-addext "subjectAltName=DNS:localhost,DNS:local.playwright"
这一步直接以 -x509 模式自签生成一张服务端证书,关键参数含义:
-newkey rsa:4096:同时生成 4096 位 RSA 新私钥并写入server_key.pem;-nodes:私钥不加密(no DES),便于测试环境直接读取;-days 3650:证书有效期 10 年,避免测试资产频繁过期;-subj "/CN=localhost/...":证书主体 CN 为localhost,组织为Client Certificate Demo;-addext "subjectAltName=DNS:localhost,DNS:local.playwright":写入 SAN 扩展,覆盖localhost与local.playwright两个主机名。
这张证书在后续环节中扮演双重角色:既作为测试服务器的 TLS 服务端证书,又作为签发客户端证书的测试 CA(-CA server/server_cert.pem -CAkey server/server_key.pem)。
4.2 受信任客户端证书(Trusted Client Certificate)
这是测试中最核心的一份客户端证书,分三步生成:
第一步:生成 Alice 的私钥与 CSR
openssl req \
-newkey rsa:4096 \
-keyout client/trusted/key.pem \
-out client/trusted/csr.pem \
-nodes \
-days 3650 \
-subj "/CN=Alice"
CN=Alice 会被测试服务器读取并拼进响应文案,例如 Hello Alice, your certificate was issued by localhost!。
第二步:用服务器证书作为 CA 签发
openssl x509 \
-req \
-in client/trusted/csr.pem \
-CA server/server_cert.pem \
-CAkey server/server_key.pem \
-out client/trusted/cert.pem \
-set_serial 01 \
-days 3650
-CA / -CAkey 使服务端证书充当 CA 对 CSR 签名,-set_serial 01 固定序列号,保证重建后的证书内容可复现。
第三步:生成 PFX 容器
openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure
PFX(PKCS#12)把私钥与证书打包成单个文件,口令为 secure。目录中还保留了 cert-legacy.pfx,用于覆盖旧版 PFX 格式的兼容性场景。当 API 侧不方便直接提供 PEM 路径时,可使用 pfxPath / pfx 字段携带 PFX 与 passphrase(见第五节)。
4.3 localhost 专用受信任证书
openssl req \
-newkey rsa:4096 \
-keyout client/localhost/localhost.key \
-out client/localhost/localhost.csr \
-nodes \
-days 3650 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,DNS:127.0.0.1"
随后脚本把 SAN 扩展写入 localhost.ext 文件,并在签发时通过 -extfile client/localhost/localhost.ext 一并注入:
echo "subjectAltName=DNS:localhost,DNS:127.0.0.1" > client/localhost/localhost.ext
openssl x509 \
-req \
-in client/localhost/localhost.csr \
-CA server/server_cert.pem \
-CAkey server/server_key.pem \
-set_serial 01 \
-out client/localhost/localhost.pem \
-days 3650 \
-extfile client/localhost/localhost.ext
CN=localhost 的客户端证书用于测试“证书已配置、但 origin 不匹配”的行为:测试中为 https://127.0.0.1:8000 配置证书,访问 https://localhost:8000 时由于 origin 不同不会携带证书,从而得到 401;访问 https://127.0.0.1:8000 时携带证书得到 200。
4.4 自签名证书(Self-signed,无效)
openssl req \
-newkey rsa:4096 \
-keyout client/self-signed/key.pem \
-out client/self-signed/csr.pem \
-nodes \
-days 3650 \
-subj "/CN=Bob"
openssl x509 \
-req \
-in client/self-signed/csr.pem \
-signkey client/self-signed/key.pem \
-out client/self-signed/cert.pem \
-days 3650
这里用 Bob 自己的私钥自签(-signkey),没有经过测试 CA 签发,因此服务端校验其证书链时必然失败。这正是测试 test_should_throw_with_untrusted_client_certs 所依赖的素材:浏览器在握手阶段直接抛出 alert unknown ca 错误(见第七节)。
五、client_certificates 配置结构与字段语义
测试中通过浏览器上下文与 API Request 上下文传入客户端证书,配置项的 Python 类型定义位于 playwright/_impl/_api_structures.py:
class ClientCertificate(TypedDict, total=False):
origin: str
certPath: Optional[Union[str, Path]]
cert: Optional[bytes]
keyPath: Optional[Union[str, Path]]
key: Optional[bytes]
结合 playwright/sync_api/_generated.py 中生成的 API 签名,完整字段还包括 pfxPath、pfx、passphrase,全部可选。各字段语义如下:
| 字段 | 类型 | 说明 |
|---|---|---|
origin |
str |
必填。证书适用的来源,如 https://127.0.0.1:8000,仅当请求 origin 匹配时浏览器才会携带该证书 |
certPath / cert |
Path / bytes |
证书文件路径或直接提供 PEM 证书字节内容,二者二选一 |
keyPath / key |
Path / bytes |
对应私钥路径或字节内容,二者二选一 |
pfxPath / pfx |
Path / bytes |
PFX 容器路径或字节内容,用于 PKCS#12 形态 |
passphrase |
str |
PFX 口令,对应 generate.sh 中的 -passout pass:secure |
使用路径形态(certPath + keyPath)是测试中的主流方式,例如:
context = await browser.new_context(
ignore_https_errors=True,
client_certificates=[
{
"origin": "https://127.0.0.1:8000",
"certPath": assetdir / "client-certificates/client/trusted/cert.pem",
"keyPath": assetdir / "client-certificates/client/trusted/key.pem",
}
],
)
使用字节内容形态(cert + key)的等价写法是把 PEM 文件读成 bytes 后直接传入,这在证书内容需要动态下发、不便依赖文件路径的场景中更实用。注意 origin 必须精确匹配请求 URL 的协议、主机与端口,测试中 https://127.0.0.1:8000 与 https://localhost:8000 被视作不同 origin,这正是多张证书素材存在的意义。
六、证书如何进入浏览器协议:底层序列化实现
client_certificates 列表并不会被直接透传给浏览器驱动,而是先在 playwright/_impl/_network.py 的 to_client_certificates_protocol 中做一次“路径 → 内容”的转换:
async def to_client_certificates_protocol(
clientCertificates: Optional[List[ClientCertificate]],
) -> Optional[List[Dict[str, str]]]:
...
out_record = {"origin": clientCertificate["origin"]}
if passphrase := clientCertificate.get("passphrase"):
out_record["passphrase"] = passphrase
if pfx := clientCertificate.get("pfx"):
out_record["pfx"] = base64.b64encode(pfx).decode()
if pfx_path := clientCertificate.get("pfxPath"):
out_record["pfx"] = base64.b64encode(
await async_readfile(pfx_path)
).decode()
if cert := clientCertificate.get("cert"):
out_record["cert"] = base64.b64encode(cert).decode()
if cert_path := clientCertificate.get("certPath"):
out_record["cert"] = base64.b64encode(
await async_readfile(cert_path)
).decode()
...
由此可以看出三个实现事实:
- 路径会被读取:所有
*Path字段都通过async_readfile在 Python 侧读入,证书与私钥最终都以 Base64 编码字符串传给浏览器端协议; - PFX 与 PEM 二选一:
pfxPath/pfx与certPath/cert分别映射到协议中的pfx与cert字段,二者互斥使用; origin原样透传:作为匹配键,最终由浏览器端按 origin 决定是否发送证书。
这一转换逻辑在两类入口被复用:浏览器上下文路径(playwright/_impl/_browser_type.py 中的 new_context/launch_persistent_context 参数处理)与 API Request 路径(playwright/_impl/_fetch.py 中的 new_context 参数处理),这也解释了为什么第七节的测试能在页面导航与纯 API 请求两条链路上得到一致结果。
七、端到端测试验证:证书如何被“使用”
7.1 测试服务器行为
两套测试文件(async 版、sync 版)共用同一套由 Twisted 搭建的 HTTPS 测试服务器(监听 8000 端口,reactor.listenSSL(8000, ...))。服务器读取 server/server_cert.pem 与 server/server_key.pem 启动 TLS,并在 HttpsResource.render_GET 中校验客户端证书:
- 校验通过(证书链以
server_cert.pem为信任根):返回 HTTP 200,文案Hello {CN}, your certificate was issued by {issuer}!",即Hello Alice, your certificate was issued by localhost!; - 有证书但校验失败:返回 HTTP 403,文案
Sorry {CN}, certificates from {issuer} are not welcome here.; - 无证书:返回 HTTP 401,文案
Sorry, but you need to provide a client certificate to continue.。
校验逻辑基于 OpenSSL 的 X509Store:把服务端证书加入信任库后,用 X509StoreContext.verify_certificate() 验证客户端证书链(_verify_cert_chain)。因此“受信任”的本质就是“由 server_cert 这把测试 CA 签发”。
7.2 用例与结果矩阵
async 与 sync 两套文件用例一一对应,覆盖以下场景:
| 测试用例 | 配置素材 | 预期行为 |
|---|---|---|
test_should_throw_with_untrusted_client_certs |
self-signed/cert.pem + key.pem,origin 为 https://localhost:8000/ |
请求抛出异常,匹配 alert unknown ca |
test_should_work_with_new_context |
trusted/cert.pem + key.pem,origin 为 https://127.0.0.1:8000 |
访问 localhost 得 401,访问 127.0.0.1 得 Hello Alice, ...;API Request 同样验证 |
test_should_work_with_new_context_passing_as_content |
以 cert/key 字节内容传参 |
与路径传参行为完全一致 |
test_should_work_with_new_persistent_context |
trusted 证书经 launch_persistent_context 传入 |
持久化上下文同样生效 |
test_should_work_with_global_api_request_context |
trusted 证书经 playwright.request.new_context 传入 |
纯 API 链路同样生效 |
测试文件还包含一个平台相关约束:_skip_webkit_darwin fixture 表明 WebKit 在 macOS 上不代理 localhost,因此该测试在 macOS + WebKit 组合下直接跳过。
八、在自己的项目中复用这套模式
如果要在自己的 Playwright Python 项目中建立客户端证书测试环境,可直接照搬本仓库的资产与脚本:
- 复制资产:把 tests/assets/client-certificates 整个目录拷贝到项目测试资产目录下;
- 重建证书:运行
bash generate.sh,按需修改-subj中的 CN(对应测试文案中的主体名称)、-days有效期以及-passout pass:secure中的 PFX 口令; - 配置传入:在
browser.new_context()/browser_type.launch_persistent_context()/playwright.request.new_context()中填写client_certificates列表,并确保origin与测试 URL 的 scheme + host + port 完全一致; - 编写服务端校验:参照测试中的
HttpsResource,用 OpenSSLX509Store校验客户端证书链,并在响应中区分 200 / 403 / 401 三种情况,即可断言证书是否真正被浏览器携带。
需要注意两点前提:一是受信任证书必须由你信任的 CA(脚本中即 server_cert.pem)签发,自签名证书只能用于负向测试;二是若测试机尚未配置该 CA 为系统信任,通常需与 ignore_https_errors=True 搭配使用(测试源码中以 TODO 注释标注“移除后即可传入自定义 CA”)。
九、注意事项与边界说明
- 命令环境:
generate.sh依赖 OpenSSL 命令行,需在安装了 OpenSSL 的环境(Linux/macOS/WSL)中执行; - 证书有效期:所有证书均为 10 年有效期,且序列号固定(
-set_serial 01),重建结果可复现; - 私钥形态:
-nodes生成的私钥未加密,仅适用于本地测试资产,不应直接用于生产环境; - PFX 口令:仓库中
cert.pfx的口令固定为secure(-passout pass:secure),使用pfxPath/pfx时必须同步传入passphrase="secure"; - origin 匹配粒度:
client_certificates的origin按完整来源匹配,127.0.0.1与localhost是不同 origin,配置时需按实际访问地址逐一覆盖。
通过 README 指引、generate.sh 的完整命令语义以及测试用例与底层序列化代码的相互印证,你可以完整掌握 Playwright Python 客户端证书测试环境的构建与验证全流程——这套资产既是仓库自测的基础设施,也是读者在自己项目中搭建 mTLS 测试的现成模板。