浏览器连接 orders.test,握手里既出现两端的临时 key share,又出现服务器证书、CertificateVerify 和 Finished。如果证书已经写了公钥和名称,为什么还要后两步?如果 CertificateVerify 是签名,为什么还要 Finished?把这些消息统称为“TLS 验证一下”会掩盖三种攻击入口:服务名冒充、这次 key share 被替换、双方对握手与密钥的理解不一致。本文沿一次证书认证的 TLS 1.3 握手,给每个对象写清谁持有秘密、输入输出和删掉之后的反例。

规范基线是 RFC 9846:它在 2026 年取代 RFC 8446,并重排了部分章节。真实实验的 OpenSSL 3.0.13 发布早于它,所以本机协商到 TLS 1.3、出现规范要求的消息,不等于宣称已通过 RFC 9846 每项修订的实现符合性测试。

第一步:协商需要什么而不是发一把密钥

客户端自己预期访问 orders.test,并持有一次性测试 CA 的可信证书;服务器有叶子证书及对应私钥。两端为本次会话准备临时交换秘密与相应公开值,ClientHello、ServerHello 交换公开 key share 与算法协商数据。客户端将服务端 share 与自己未外发的临时私有输入结合;服务端做对称的一步。双方由此拥有可进一步派生握手密钥的共享材料。

1
2
3
4
5
6
7
8
9
10
客户端(预期名称与可信 CA)                         TLS 服务端
| ClientHello:算法与临时 key_share --------------> |
| <----------- ServerHello:选择与临时 key_share |
| 用各自私有输入 + 对端公开值派生握手秘密 |
| <======= EncryptedExtensions(握手密钥保护)===== |
| <======= Certificate:服务证书与链 ============= |
| <======= CertificateVerify:签握手摘要 ========== |
| <======= Finished:服务确认握手 ================= |
| ======= Finished:客户端确认握手 ===============> |
| <======= 应用数据:新方向密钥保护每条记录 =======> |

网络上的中间人也可以发自己的 key share:10 的正常 X25519 对照证明了双方能算出相同结果,中间人对照则得到两条各自成功的秘密。因此“DH 算成了”不能代替“这次临时交换确实与预期服务绑定”。服务端证书中的公钥通常用来验证这次握手里的身份证明,并不直接把所有订单做 RSA 公钥加密;测试证书即便含 RSA 公钥,实验的临时交换仍显示 X25519。TLS 1.2 旧式 RSA 密钥传输是另一条历史路径,E01 再讨论。

第二步:证书、当次签名与握手确认各有输入

Certificate 带来服务的公钥、SAN 名称、有效期与链材料。客户端须从自己信任的 CA 根和预期 orders.test 出发检查证书,而不是把服务端送来的任何根都装进系统。CA 对证书结构的签发签名,是对名称、公钥与限制的声明;CA 私钥不参加浏览器此次的临时 DH,也不给订单正文逐条签名。14 的本地实验已跑过:客户端没有该 CA 或使用错误服务名,证书验证都拒绝。

CertificateVerify 是证书对应私钥持有人针对这一次握手发出的数字签名。RFC 9846 §4.5.2 定义的签名输入不是“随便取订单 JSON”:先用 64 个 0x20,再用角色上下文字符串、单字节 0x00 与到 Certificate 为止的规定握手转录摘要。服务器签名的上下文是 TLS 1.3, server CertificateVerify,与客户端角色区分。客户端用经过证书路径/服务名核查的服务公钥验证。它证明私钥参与当前特定转录,不能用历史签名冒充这次的 key share;它也不自动审核 HTTP 用户能否下单。

Finished 并非同一私钥再签一遍。按 RFC 9846 §4.5.3,各端从握手流量秘密得到自己的 finished_key,用 HMAC 校验相应握手转录摘要。服务器和客户端的 Finished 处于不同方向与不同转录位置;验证者借此确认对方已持有并认可这次协商的派生秘密及握手内容。CertificateVerify、Finished 与之后使用的应用流量密钥不能写成“证书签名、证书签名、证书签名”。

1
2
3
4
CA 签发私钥 -------------> 证书的名称/公钥/约束与签发链
服务器证书对应私钥 ------> CertificateVerify 的角色化握手摘要
握手派生 finished_key ----> Finished 的 HMAC 校验
双方后续应用流量密钥 -----> 认证加密 HTTP 订单记录

删去可信证书与名称绑定,攻击者用自己证书和自己私钥同样能算出一份通过的自签握手。保留证书却不让服务器证明对这一次转录持有私钥,就不能从“证书是真的”推断临时 share 必属持有人。又若跳过 Finished,双方缺少对应的握手密钥与完整转录确认。这里描述的是标准中这些步骤应阻止的攻击;本篇没有在真实 TLS 端点中故意剥掉 CertificateVerify 或篡改 Finished 做注入实验,不能把规范推理写作已观察到的注入结果。

第三步:真实握手到底观察到了什么

examples/cryptography/15_handshake.py 沿用 14 的回环地址服务器、每次新生成的短期 CA 与证书。它用 openssl s_client -tls1_3 -msg -brief -CAfile <该次临时CA> -servername orders.test -verify_hostname orders.test -verify_return_error 连接,只抽取进程输出中的握手消息名称及方向。未保存完整握手十六进制,更不使用会话 keylog 或持久化 CA 私钥。另一条客户端命令只改验证名称为 other.test,预期在证书验证处失败。

1
2
python3 examples/cryptography/15_handshake.py
python3 -m unittest discover -s examples/cryptography -p 'test_15*.py' -v

2026-10-06 UTC,Python 3.12.3 与 OpenSSL 3.0.13 上,两个脚本入口都退出 0,单元测试 1 项通过。内部 s_client 正例退出码 0,报告 TLSv1.3、Verification: OK、Server Temp Key: X25519;按顺序看到客户端 ClientHello,服务端 ServerHello、EncryptedExtensions、Certificate、CertificateVerify、Finished,最后客户端 Finished。错名客户端退出非 0 且错误是 hostname mismatch。实验用的证书与私钥目录在退出后消失。

这份证据只支持该环境中成功完成了这条证书认证的 TLS 消息路径,并在错误身份上失败。它不能独立逐字节重新计算 handshake transcript、CertificateVerify 签名或 Finished HMAC;这些算法对象仍以逐节核对的 RFC 为依据。握手完成后若还看到 NewSessionTicket,也不代表本篇已运行 PSK 恢复或 0-RTT;17 再分支检查。也没有实际浏览器、生产代理或内外网故障注入。

会话过后仍有其他验证工作

握手派生树继续产出每方向独立的应用流量秘密,16 要把密钥、序号与每条记录的 nonce 连接起来。在此之前,不能把“这笔订单通过正确 TLS 端点”写成“订单用户已经授权”“业务操作没有重复”“通过 HTTPS RPC 提交的交易已经确认”。一个边界负责一个问题;交易签名让账本按自己的规则核对支出权,和 TLS 对 RPC 端点的认证作用不同。

这也是区分“算法正确”和“协议正确”的方法:ECDH 提供共同输入却不认识服务名;证书链提供可追溯的身份声明但不保护每条记录;CertificateVerify 绑定具体握手与证书私钥;Finished 证明派生秘密与协商内容一致。网络攻击者只要找到其中一项被跳过、替换或误当作别项,就可能获得不该有的能力。

两道带答案的练习

画图题。 服务端的叶子证书采用 RSA 公钥,但 TLS 日志中临时密钥显示 X25519。分别标出 CA 签名、CertificateVerify、Finished、订单记录的输入与执行者,说明“RSA 证书公钥加密订单”错在哪里。

可核对答案: CA 用签发私钥签证书声明;服务端用证书私钥对带角色上下文的本次握手摘要签 CertificateVerify;双方用各自派生的 finished_key 对相应握手摘要做 HMAC;HTTP 记录用后续方向密钥认证加密。证书 RSA 公钥用于握手身份证明的验证,不把订单正文逐条拿来做 RSA 加密,也不意味着临时密钥交换是 RSA。

变更题。 把 15_handshake.py 正例的 -verify_hostname orders.test 改成 other.test,单独运行主脚本与测试。哪些断言应该失败?能否只看到非零退出码就判断是服务名不匹配?

可核对答案: openssl_success_exit == 0、Verification: OK 与完整消息顺序预期都会出问题;要进一步核对 hostname mismatch 错误类型,不能把网络超时、CA 丢失或进程内部错误冒充身份认证负例。别为得到漂亮输出把 -verify_return_error 去掉。

资料与导航