有人给浏览器发来一把公钥,说“我是 orders.test”。浏览器用它验证同一人送来的签名,结果当然可能成功。13 已经说明:**签名能证明私钥持有人参与了那次操作,却不能只靠自报的公钥确定持有人叫什么。**网站身份校验必须从浏览器自身预期访问的名称和事先配置的可信根出发,而不能让连接对面临时决定信任起点。

本篇用临时合成 CA 给 orders.test 签发一张测试证书,只在本机回环地址启动 TLS 1.3 服务端。客户端明确加载这一张 CA,匹配 DNS 名称后提交一笔合成订单;另试“名称不符”和“客户端没有这张 CA”两个失败路径。这里跑到了真实 OpenSSL/Python TLS 端点,但不是浏览器实验,更不把 CA 装进系统信任库。

验证者有一个先于网络对话的预期

TLS 连接发往哪个 IP 与客户端要认证哪个服务是两个不同输入。测试里套接字连的是 127.0.0.1 的随机端口,但客户端指定的服务名称是 orders.test。证书的 subjectAltName(SAN)包含 DNS 名称 orders.test;用于验证这张证书的测试 CA 只交给本次客户端的 SSLContext。客户端先按证书链校验签发方,再按自己指定的名称检查证书身份,二者缺一不可。

1
2
3
4
5
6
7
8
客户端可信配置:预期服务名 orders.test + 指定测试 CA 证书
|
127.0.0.1:随机端口 -- TLS 握手 --> 服务端返回测试叶子证书
| 公钥 / DNS:orders.test / 签发者
验签 CA -> 校验链和用途 -> 校验 SAN 名称 -> 握手完成
↑ 真正证书验证还有有效期等规则

客户端的 CA 配置不来自待校验的网络对端;CA 的私钥只用于签发。

证书是一份带签发签名的结构化声明:它把公钥、主体标识、有效期、扩展限制与签发方联系起来。客户端已信任的根公钥帮助它检查“这条证书链是否按规则到达可信根”;服务名比较再回答“这是我本来准备访问的那个名字吗”。通过两项校验,只能得到按这套信任策略接受了对端的身份声明,不代表站点业务绝对无害,也不代表订单必定授权、执行或交付。

RFC 9525 已取代旧的 RFC 6125,对于服务器域名的校验强调 SAN 中的 DNS-ID,而不拿 Subject Common Name(CN)作身份匹配的退路。实验把客户端 hostname_checks_common_name 明确设为 false,证书既有 SAN 也有 CN,但正例靠 SAN 匹配。这里观察的是 Python ssl 与其 OpenSSL 后端的结果,不据此推断所有浏览器的根证书政策、吊销和证书透明度行为。

证书公钥不是每条订单的加密密钥

这张合成服务器证书含公钥,服务端另持有相应私钥;CA 有自己的签发私钥。客户端持有受信 CA 公钥,而不是拿到 CA 私钥。典型证书认证的 TLS 1.3 握手还会用临时密钥交换形成共享输入并派生方向明确的流量密钥,再由记录层的认证加密保护订单。因此不要画成“CA 直接加密会话”或“浏览器拿服务器证书公钥直接 RSA 加密整个 HTTP 请求”。

1
2
3
4
CA 私钥 -> 签发证书(绑定名称、公钥与限制)
服务私钥 -> 在握手中证明与服务器证书公钥的关系
客户端 + 服务端临时 key share -> 得到可派生的会话秘密
派生的记录密钥 -> 保护 POST /orders 的应用字节

证书签发签名、TLS CertificateVerify 对握手内容的签名、Finished 对握手的认证确认以及流量记录的加密认证,是四个对象,不是“一张证书同时完成四件事”。本文的 Python API 只报告握手结果、TLS 版本和已认证的 SAN,没有打印完整握手消息,也未逐字节验证上述消息与派生公式;15–16 要对 RFC 9846 另作协议层核对。测试使用 RSA 签发证书不意味着 TLS 1.3 采用了历史 TLS 1.2 的 RSA 密钥传输。

正例:只把测试 CA 给需要的客户端

examples/cryptography/14_local_tls.py 每次运行都在自动删除的临时目录中生成一张只供该次运行使用的 CA 和 SAN 为 orders.test 的服务器证书。服务端只绑定 127.0.0.1,启用 TLS 1.3;客户端用 ssl.create_default_context(cafile=那张临时CA证书) 明确选择可信根,TLS 握手完成后发送合成订单正文 order=demo-001;quantity=1。测试服务端收到正确正文返回 synthetic order accepted。没有导入系统根证书存储,也没有通过公网上的真实服务。

1
2
python3 examples/cryptography/14_local_tls.py
python3 -m unittest discover -s examples/cryptography -p 'test_14*.py' -v

2026-10-06 UTC,在 Python 3.12.3 / OpenSSL 3.0.13 上运行两条命令,退出码均为 0,1 项协议集成测试通过。公开输出为 tls_version=TLSv1.3、已认证 SAN=orders.test、合成订单收到正常响应;临时 CA 所在目录在函数结束后不存在。临时签发私钥、服务器私钥及会话秘密都不进入公开输出或 Git。关于证书有效期和业务逻辑,本地实验只覆盖 24 小时证书与固定正文的那个输入,没有完成吊销、CT、浏览器根策略、代理多跳或生产订单审核。

这是 A、B 原语层次之外的第一份真实协议端点证据:证明指定环境下的 Python/OpenSSL 能按实验配置的 CA、DNS 名和版本完成一次 TLS 通信,不能用来替代“浏览器在所有网络条件下安全”或“链上交易已确认”的验收。

两个失败对照分别删去哪一步

第一种对照保留刚才的 CA,却把客户端的预期名称改成 other.test。CA 对证书的签发仍是真实的,但证书 SAN 不是客户端想去的服务;Python ssl 以 SSLCertVerificationError 拒绝。这回答“为什么只看证书链不够”:同一个可信 CA 能为很多服务签证书,攻击者不能拿其他服务的证书去充当 orders.test。若删除名称检查,正确的 CA 不会自动补上身份匹配。

第二种对照保留 orders.test,但客户端不再加载指定测试 CA,只使用此环境默认的信任配置;同样以证书验证异常拒绝。证书自己写了“我属于这个名字”,在不可信签发者参与时没有客户端可信起点支撑。若让每次网络对端任意附一张 CA 并自动加入根存储,证书链看起来完整也不证明对端是预期服务。

TLS 成功不意味着客户端能替钱包签交易,也不意味着业务用户有权下单。脚本里只接受固定请求体,没有实际登录鉴权、订单去重或生产后端;攻击者如果本来就是有权用户,仍能发两次合法应用请求。服务器证书吊销、API 凭据撤销与链上权限变更也不是同一个操作,要分别在生命周期和业务规则章节解释。

两道带答案的练习

画图题。 在开头的图上把 CA 私钥、CA 公钥、服务端证书公钥、服务端私钥、客户端预期服务名分别放到持有人处。若网络攻击者带来一张自己签发、SAN 为 orders.test 的证书,用其公钥可以验证它的握手签名吗?客户端最终该怎么办?

可核对答案: CA 私钥仅签发者持有,受信 CA 公钥预配在客户端;服务端私钥留在服务端,叶子证书含服务端公钥和 SAN,客户端预期名来自它自己发起的连接。恶意证书自身的公钥可以用于验证恶意私钥生成的签名,但链无法到达客户端事先信任的根,必须拒绝;不能让该连接自动安装恶意根。

变更题。 删除 14_local_tls.py 中 context.hostname_checks_common_name = False,在现有证书 SAN 正确时可能仍通过,能否据此证明“CN 回退是安全的”?再把 server.ext 里 SAN 改为 DNS:other.test、保持服务端 CN orders.test,分别执行正例与错误主机名负例。

可核对答案: 不能。已有 SAN 时,是否开启 CN 回退都不该依赖 CN;变更 SAN 后、按脚本禁用 CN 回退的正例应在证书身份校验处失败,旧负例的预期条件也会改变。RFC 9525 已要求不以 CN 代替服务标识;注意确认失败是证书验证而非连接超时或文件生成失败,且不为了让实验“通过”去关闭所有证书校验。

资料与导航