计算机网络 24:TLS 如何确认通信对象,TLS 1.3、证书链、主机名、密钥与记录
TCP 已经连通,收到的证书也能解析,为什么 TLS 仍可能拒绝连接?证书内容可读、签名可验证、签发者可信、服务名字匹配,是不同条件。只证明其中一个,不能替代其他条件。
前置是第 16 篇连接状态。本篇把连接地址与预期服务身份分开,用专用测试 CA 验证三个场景:正常连接、主机名不匹配、不信任签发者。实验不向系统安装 CA,不访问公共 HTTPS 网站;会话恢复与早期数据留到第 25 篇。
先确定要连接谁,再检查它提供的身份
假设应用要连接 server.lab.test,测试中它的传输地址固定为 127.0.0.1。IP 地址告诉 socket 连接到哪里,预期名字告诉证书验证要匹配谁。连接到回环地址不要求证书必须含该 IP,前提是应用的参考身份确实是配置好的 DNS 名,并按该名字验证。
RFC 9525 §6.1要求客户端独立构造可接受的参考身份,不能拿服务端刚给出的证书名字作为预期值,再宣布它与自己匹配。否则任意证书都可能通过这项检查。
服务端证书的 subjectAltName(SAN)可以包含 DNS 名或 IP 地址。DNS 身份与 dNSName 匹配,直接 IP 身份与 iPAddress 匹配,不能把文本相同就当作类型相同。RFC 9525 §2不再使用 Common Name 作为服务身份来源。实验关闭 CN 回退,并让 CN 与正确的 SAN 名不同,以免把两条路径混为一谈。
这也说明,DNS 解析结果与身份不是同一层证据。应用经过可信配置得到预期名字后,仍需检查收到的证书;不能因为连接目标有一个可用 IP,就省去服务身份验证。
证书链的终点是本地信任决定
叶证书携带服务公钥与身份信息,签发者的签名把这些内容纳入证书认证。验证还涉及有效期、用途、CA 约束和可用的信任锚等条件。服务器给出的链材料有助于构造路径,但“对方发来了根证书”本身不能让该根变成可信。
RFC 8446 §4.4.2.4将详细证书验证指向 PKIX 规则。本篇不重写证书验证器,而由已安装 OpenSSL 经 Python ssl 执行。专用根 CA 只装入实验客户端的 SSLContext;另一个测试根用于构造不受信任的对照,两者都不改变系统信任库。
链可信仍不保证名字正确。某个可信 CA 签发给 server.lab.test 的证书,不因此也适用于 wrong.lab.test。反过来,名字正确但签发者不受客户端信任,也不能建立本篇要求的已认证连接。实验用这两个互相独立的错误条件检验失败分支。
直接由根签叶证书只覆盖短链。中间 CA 缺失、交叉签名、撤销信息、公开浏览器信任策略等情况没有在本篇执行,不能由短链成功推出它们都正常。
TLS 1.3 的证书密钥不负责加密一份会话密钥
在本篇讨论的证书认证握手中,临时密钥交换提供共享秘密,HKDF 结合握手上下文派生流量秘密及后续密钥。客户端与服务端的方向、握手阶段与应用阶段分别使用相应材料,不能把它简化为“双方一直共享一个相同 AES 密钥”。RFC 8446 §7.1–7.3给出派生关系。
证书中的公钥承担认证所需的验签职责。持有一份公开证书的人不一定持有相应私钥,因此还需要本次握手中的持钥证明。TLS 1.3 不采用旧式“客户端用证书 RSA 公钥加密预主秘密”的握手路径;不能根据证书里有 RSA 公钥就推断发生了 RSA 密钥传输。
下面画的是不含 HelloRetryRequest、客户端证书和 PSK 的证书认证握手骨架。它描述规范中的消息关系,不是本篇逐包捕获结果:
1 | |
RFC 8446 §4.4.3中的 CertificateVerify,对带规定上下文的握手摘要签名,提供对应私钥的持有证明并绑定此前握手。它不能由“证书文件能下载”替代。
§4.4.4中的 Finished 使用派生密钥计算 MAC,确认握手及密钥材料。签名与 Finished 承担不同检查;解析到了 Certificate 不表示二者都已完成。本实验在客户端握手成功返回后才发送挑战数据,不实现早期数据路径。
记录保护与应用完成仍需分别确认
握手建立的应用流量密钥用于记录层保护。RFC 8446 §5.2定义使用 AEAD 的记录保护,认证失败不能当作正常明文继续交付。应用看到的 ssl socket 则仍需按自己的消息长度读取数据,TLS 不替应用定义业务消息边界。
例如要回显 32 字节挑战,就应累计读取到 32 字节并比较,而不是认为一次 recv(32) 必然拿到全部内容。TLS 握手成功只支持连接的认证与协商判断;实际收到与发送内容完全相同的回显,才增加了本次应用交互完成的证据。
本篇记录 version() 与 cipher() 的实际返回值,但不把套件名字当作证书签名算法或密钥交换组的测量结果。也没有从密文长度推断业务内容、测量密码算法吞吐或声称完成了流量分析防护。
专用 CA 的本机实验
实际运行环境为 macOS 27.0 arm64、Python 3.14.4,Python 链接的 SSL 库与 /opt/homebrew/bin/openssl 均为 OpenSSL 3.6.2。脚本临时生成两个根 CA 和一个叶证书,根直接签叶;证书与私钥在退出时清理,日志只保留生成命令、公开证书文本和结果。
客户端调用 create_default_context(cafile=指定测试CA),保留证书必需、主机名检查及默认严格验证。三个上下文的证书库统计均为一张 CA、没有 CRL;verify_flags 实际为 557088,包含 STRICT 与 PARTIAL_CHAIN。两端最小与最大协议版本均限制为 TLS 1.3,并记录实际协商结果。
| 客户端条件 | 客户端观察 | 服务端观察 | 应用数据 |
|---|---|---|---|
| 正确 CA、server.lab.test | TLSv1.3,TLS_AES_256_GCM_SHA384 | 握手成功,收到挑战 | 32 字节逐字节回显一致 |
| 正确 CA、wrong.lab.test | 验证错误 62,Hostname mismatch | 握手阶段失败 | 没有进入应用交换 |
| 另一个 CA、server.lab.test | 验证错误 20,unable to get local issuer certificate | 握手阶段失败 | 没有进入应用交换 |
成功场景同时保存发送、接收、服务端收到和回显的十六进制内容,四者一致;对端证书 SAN 为 DNS:server.lab.test。另一次独立复跑得到同样三类结果,随机挑战、证书摘要和端口随运行改变。
服务端两个失败原因字符串分别包含 SSLV3_ALERT_BAD_CERTIFICATE 和 TLSV1_ALERT_UNKNOWN_CA。它们是库返回的错误标识,不能据这些词推断成功协商了 SSLv3 或 TLS 1.0;失败记录没有报告已建立的应用会话,程序的版本上下限均为 TLS 1.3。
客户端只捕获预期的证书验证异常;服务端独立记录失败所在阶段。错误场景没有通过关闭验证“修复”。线程停止、监听关闭和临时密钥清理都在最终日志中确认。
复现、反例与练习
附件提供TLS身份验证实验、三场景原始结果、环境和验证边界。
1 | |
脚本要求 Python 3.13 或更新版本,并使用已安装的 /opt/homebrew/bin/openssl;其他平台需先确认本机二进制路径和相关命令支持,不应直接假设相同版本行为。--self-check 仅检查 TLS 1.3 API 可用及摘要已知向量,不运行握手。默认运行才生成临时证书并执行三个真实连接。
每次生成的证书有效期为一天,复现时重新生成而非保留旧私钥。脚本在创建默认上下文前移除本进程的 SSLKEYLOGFILE 环境项,避免环境变量触发向外部路径写密钥;不修改父进程环境。它没有把测试 CA 导入操作系统,也不依赖修改 hosts 文件。
同一张有效证书在正确名字下成功、错误名字下失败,能否说明 CA 不可信?不能,失败条件是参考身份不匹配。排查时应先读错误类别,不能统一改为跳过证书验证。
如果客户端把预期名字改成证书里任意一个 SAN,名字检查是否还有原来的作用?没有。它改变了原始连接目标的身份要求,不能作为修复错误主机名的方法。
回显成功是否证明观察到了 CertificateVerify 的逐消息签名验证和全部记录解密过程?没有。这里观察的是库 API 的成功结果与应用数据,协议内部机制由一手规范说明;没有独立导出密钥并解密握手记录。
一手资料与未验证范围
本篇写作当次核查 TLS 1.3、服务身份规范和Python ssl 官方文档。证书生成参数对应OpenSSL req与X.509 v3 扩展配置。命令支持范围与运行版本均以本篇实验记录为准。
本机实验只覆盖指定客户端策略下的三种 TLS 连接结果及一次成功应用回显。没有验证公开 Web PKI、中间证书链、CRL/OCSP、客户端证书、握手降级、会话恢复、0-RTT 或公网安全边界。构建页面和下载附件也不构成这些机制的验证。



