Featured image of post TLS 1.3 握手与证书链校验:原理、流程与工程排查
Web

TLS 1.3 握手与证书链校验:原理、流程与工程排查

TLS 1.3 握手与证书链校验:原理、流程与工程排查

线上偶发 “证书不受信任”“握手失败”“只能 HTTP 能开、HTTPS 间歇 525”,根因往往不在业务代码,而在 TLS 握手协商X.509 证书路径校验。浏览器地址栏的小锁背后,是一次版本/密码套件协商、密钥交换、身份证明和密钥确认;服务端 PEM 链顺序错一环,客户端就会在完全不同的报错路径上失败。

本文基于 RFC 8446(TLS 1.3)RFC 5280(PKIX 证书与路径校验),并对照 MDN 的 TLS/HTTPS 说明、OpenSSL s_client 与 Nginx ngx_http_ssl_module 官方文档,把机制、可复现排查命令与工程配置落到同一条链路上。

问题背景:TLS 到底在保护什么

MDN 对 Transport Layer Security(TLS)的定位很直接:让客户端在不可信网络上与服务器 安全通信。在 Web 上,用 TLS 保护 HTTP 的结果就是 HTTPS。TLS 从三方面加固连接:

目标含义
机密性(Encryption)传输中数据被加密,窃听者难以直接读内容
完整性(Integrity)中间人无法在不被发现的情况下篡改数据
身份认证(Authentication)一端能向另一端证明“我是谁”

在公开 Web 上,服务端向客户端认证 是常态;客户端向服务端出示证书 相对少见(多见于 mTLS / 专用 API)。HTTPS 也是对抗 中间人(MITM) 的关键防线:攻击者插在浏览器与站点之间时,若无法通过证书路径与握手完整性校验,就难以“静默”读写业务流量。

因此工程排错要分清两层:

  1. 握手层:版本、密码套件、密钥交换、Finished 校验是否完成。
  2. 信任层:证书链能否锚定到本地信任锚、名字是否匹配、是否在有效期内、是否被吊销策略拒绝。

核心原理:TLS 1.3 握手的三个阶段

RFC 8446 说明:安全通道的密码参数由 握手协议 产生。握手用于协商协议版本、选择密码算法、可选地互相认证,并建立共享密钥材料;完成后双方用该密钥保护应用层流量。握手失败或其它协议错误会终止连接(可选先发 alert)。

TLS 1.3 支持三种基本密钥交换模式:

  • (EC)DHE(有限域或椭圆曲线上的 Diffie-Hellman)
  • 仅 PSK
  • PSK 与 (EC)DHE 组合

完整握手消息流(1-RTT)

规范 Figure 1 给出了基本完整握手(* 表示可选/场景相关;{} 表示用握手流量密钥保护;[] 表示用应用流量密钥保护):

Client                                           Server

Key  ^ ClientHello
Exch | + key_share*
     | + signature_algorithms*
     | + psk_key_exchange_modes*
     v + pre_shared_key*       -------->
                                                  ServerHello  ^ Key
                                                 + key_share*  | Exch
                                            + pre_shared_key*  v
                                        {EncryptedExtensions}  ^  Server
                                        {CertificateRequest*}  v  Params
                                               {Certificate*}  ^
                                         {CertificateVerify*}  | Auth
                                                   {Finished}  v
                               <--------  [Application Data*]
     ^ {Certificate*}
Auth | {CertificateVerify*}
     v {Finished}              -------->
       [Application Data]      <------->  [Application Data]

可按三个阶段理解:

  1. Key Exchange(密钥交换)
    客户端发送 ClientHello(含随机数、可接受版本、对称密码/HKDF 哈希对列表,以及 key_share / pre_shared_key 等扩展)。服务端回 ServerHello 选定连接参数;若用 (EC)DHE,服务端 key_share 必须 落在客户端给出的某一组上。ClientHello 与 ServerHello 共同决定共享密钥。此阶段之后的消息均加密。

  2. Server Parameters(服务端参数)

    • EncryptedExtensions:对那些不决定密码参数、也不绑定单张证书的 ClientHello 扩展作应答。
    • CertificateRequest:若需要基于证书的客户端认证则出现;否则省略。
  3. Authentication(认证)
    证书类认证使用同一套消息:CertificateCertificateVerifyFinished(PSK 认证则作为密钥交换的副作用发生)。

    • Certificate:端点证书及链上支撑证书。约定使用证书认证时服务端 必须 发送;客户端仅当收到 CertificateRequest 时发送。
    • CertificateVerify:证明持有与证书对应的私钥,并对握手到该点的完整性提供签名保护;服务器基于证书认证时 必须 发送,且紧挨在 Certificate 之后、Finished 之前。
    • Finished:认证块的最后一条消息,用派生自 Base Key 的 MAC 对握手 transcript 做确认;收方必须校验,错误则 decrypt_error 终止。

双方都发送并成功校验对端 Finished 后,才可在连接上收发应用数据(0-RTT 与服务端首飞后抢发等例外见规范;后者在完成握手前 无法 确认对端身份与活性)。

相对 TLS 1.2 的关键变化(工程相关)

RFC 8446 §1.2 列出若干重大差异,与排障直接相关的包括:

变化工程含义
对称算法只保留 AEAD旧 CBC/非 AEAD 套件不再进入 1.3 协商
去掉静态 RSA / 静态 DH 套件基于公钥的密钥交换提供 前向保密(forward secrecy)
ServerHello 之后握手消息加密证书等不再明文暴露在握手中后期
密钥派生改用 HKDF密钥分离与分析模型更清晰
版本协商改为扩展中的版本列表兼容历史上错误实现版本协商的服务端
会话恢复 / 旧 PSK 套件统一为新 PSK 交换恢复与 0-RTT 都围绕 PSK 建模
可选 0-RTT early data省一轮 RTT,但牺牲部分安全属性

0-RTT:快,但默认不适合“写操作”

当客户端与服务端已共享 PSK(外部配置或先前握手得到)时,客户端可在 首飞 发送 early data。规范强调:0-RTT 在连接建立时节省往返,代价是某些安全属性。Nginx 文档进一步写明:early data 内的请求 可能遭受重放(replay);应用层可用 $ssl_early_data 识别,例如:

proxy_set_header Early-Data $ssl_early_data;

工程默认建议:

  • 保持 ssl_early_data off;(Nginx 默认即为 off),除非上层明确幂等且能处理重放。
  • 切勿把“首次提交订单/支付/改密”暴露给 0-RTT 路径。

证书链校验:从信任锚走到叶子

握手里的 Certificate 消息只是把 证书序列 交给对端;真正“信不信”取决于客户端的 认证路径校验

RFC 5280 §6 指出:Internet PKI 的路径处理验证 主体可分辨名和/或主体备用名(SAN) 与主体公钥之间的绑定;该绑定还受路径上证书约束与依赖方输入限制。基本路径校验中:

  • 有效路径从信任锚(trust anchor)签发的证书开始
  • 信任锚选择是策略问题(层级 PKI 顶端 CA、组织自建根、系统信任库中的根等),但路径校验过程本身与“选了哪颗根”无关。
  • 校验相对于 当前日期时间(实现也可支持对过去某时刻的验证,但不能对有效期之外的时间做校验)。
  • 客户端 必须拒绝 含有不支持 critical 扩展的证书。

叶子身份:SAN 才是名字的主战场

subjectAltName(SAN)把身份绑定到证书主体:可含邮箱、DNS 名、IP、URI 等。规范要求:只要要把这类身份绑进证书,就 必须 使用 SAN(或 issuer 对应扩展);DNS 名也可额外用 subject 的 domainComponent 表示,但现代浏览器与库几乎都以 SAN DNS 做主机名匹配。

线上“证书有效但浏览器仍报警”的常见原因:

  • 证书只写在 CN、未正确填 SAN。
  • SAN 有 example.com 却没有 www.example.com(或反过来)。
  • 多站点共用 IP,TLS 客户端未发 SNI,服务端回了默认虚拟主机证书。

CA 与路径长度:basicConstraints

basicConstraints 标明主体是否为 CA,以及可包含该证书的路径最大深度。若 v3 证书无此扩展,或存在但 cA 未断言,则该公钥 不得 用于验证证书签名——这也是“把服务器叶子证书误当成中间 CA 拼进链”会立刻失败的原因之一。

服务端交付的链 vs 客户端验证出的链

OpenSSL s_client -showcerts 显示的是 服务端发送的证书列表(发送顺序),文档明确:它不是已验证链。测试工具默认在证书校验错误后仍可能继续握手;生产客户端不应如此。需要“校验失败就中止”时使用 -verify_return_error

实践:用 OpenSSL 复现握手与链问题

1)看协议版本、套件与服务端发送的证书

# 强制 TLS 1.3,展示服务端 certificate_list,SNI 指定主机名
openssl s_client -connect example.com:443 \
  -servername example.com \
  -tls1_3 \
  -showcerts </dev/null

关注输出中的:

  • Protocol / Cipher(是否真是 TLSv1.3 与 AEAD 套件)
  • Certificate chain 段:叶子是否在前、中间证书是否齐全
  • Verify return code(在默认“测试继续”模式下仍会打印校验结果)

OpenSSL 说明:若未提供 -servername,会尽量用 -connect 里的 DNS 名填充 SNI;从 1.1.1 起默认行为如此。排查“默认证书错了”时,刻意省略 SNI显式指定 SNI 对比往往立刻见分晓:

# 对比:不发 SNI(排查默认 vhost 证书)
openssl s_client -connect 203.0.113.10:443 -noservername -showcerts </dev/null

2)让校验错误真正失败(贴近生产客户端)

openssl s_client -connect example.com:443 \
  -servername example.com \
  -verify_return_error \
  -CAfile /etc/ssl/certs/ca-certificates.crt </dev/null

-verify_return_error 会在服务端证书校验失败时返回错误并通常中止握手,而不是“打印 warning 仍连上”。

3)从 PEM 文件本地拆解链

# 分拆 fullchain.pem 中的每张证书主题/颁发者/SAN/有效期
openssl crl2pkcs7 -nocrl -certfile /etc/nginx/ssl/fullchain.pem \
  | openssl pkcs7 -print_certs -noout -text \
  | egrep 'Subject:|Issuer:|DNS:|Not Before|Not After|CA:'

核对顺序:叶子 → 中间 →(一般 不要 把公共根塞进服务端发送链,根应由客户端信任库提供)。

4)Nginx 侧:链顺序与协议底线

官方 ssl_certificate同一 PEM 内先主证书(叶子),再中间证书;密钥用 ssl_certificate_key
ssl_protocols 默认已是 TLSv1.2 TLSv1.3——新站点通常不必再打开 TLS 1.0/1.1。

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # 叶子在前,中间在后(官方顺序)
    ssl_certificate     /etc/nginx/ssl/api.fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/api.key;

    # 与当前默认一致;显式写出便于审计
    ssl_protocols TLSv1.2 TLSv1.3;
    # TLS 1.3 套件由库侧管理;此指令主要影响 ≤1.2
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers off;

    # 多 worker 共享会话参数,降低重复握手成本
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    # OCSP stapling:需让 Nginx 知道签发者证书
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/nginx/ssl/api.chain-only.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    # 默认关闭 0-RTT;开启前先确认业务幂等
    ssl_early_data off;

    # 仅在 mTLS 场景打开
    # ssl_verify_client on;
    # ssl_client_certificate /etc/nginx/ssl/client-ca.pem;
    # ssl_verify_depth 2;
}

说明(均来自 Nginx 官方指令文档):

  • ssl_session_cache 默认 none;生产建议 shared:... 并配合合适超时。
  • ssl_stapling 默认 off;开启时签发者证书须可知(可在 fullchain 或 ssl_trusted_certificate 中提供)。
  • ssl_verify_client 默认 offssl_verify_depth 默认 1
  • ssl_early_data 默认 off;开启后请求可能被重放。

常见坑与排查清单

现象更可能的原因建议动作
浏览器报 NET::ERR_CERT_COMMON_NAME_INVALIDSAN 不匹配访问主机名openssl x509 -in leaf.pem -noout -ext subjectAltName
手机信任、某台服务器不信任服务端少发中间证书 / 系统信任库过旧-showcerts 数链长;补全 intermediate
同 IP 多站,偶发证书串站SNI 未传到正确 vhost对比 -servername 有无
仅部分客户端握手失败仍协商到 TLS 1.0/1.1 或过旧套件收紧 ssl_protocols;抓包看 ClientHello
s_client 显示 Verify error 但仍连上工具默认不因校验失败中止-verify_return_error
开启 early data 后偶发重复写0-RTT 可重放关闭 early data,或业务读 $ssl_early_data 拒绝非幂等
stapling 无效 / 日志 OCSP error缺签发者证书或 resolverssl_trusted_certificate + resolver
mTLS 客户端被拒深度/信任锚/optional 语义不符ssl_verify_client / ssl_verify_depth / 客户端链

补充运维习惯:

  1. 证书轮换演练:新 fullchain 应用后,立刻用外网 VPS 跑一遍 s_client -verify_return_error,不要只在本机 curl -k
  2. HTTP→HTTPS:MDN 建议对明文入口 301 到 HTTPS,并配合 HSTSStrict-Transport-Security)降低 SSL stripping 窗口;同时杜绝 mixed content(HTTPS 页加载 HTTP 子资源)。
  3. 密钥权限与进程身份:私钥仅 Nginx worker 可读;配置重载失败时注意旧进程仍握旧 fd。

总结

  • TLS 1.3 握手可粗分为 密钥交换 → 服务端参数 → 认证;ServerHello 之后的握手消息受握手流量密钥保护,CertificateVerify 证明私钥持有,Finished 做握手完整性确认。
  • 1.3 强制 AEAD、去掉静态 RSA 密钥交换、引入统一 PSK/0-RTT 模型——配置与排障要以 前向保密 + 加密握手 + 谨慎 early data 为默认心智。
  • 证书信任是 从信任锚出发的路径校验(RFC 5280),不是“PEM 里有几段文字”;SAN、basicConstraints、中间证书顺序与 SNI 是线上最高频的失败点。
  • openssl s_client 区分“服务端发送了什么”和“客户端验证是否通过”,用 Nginx 官方顺序拼 fullchain,并按业务风险决定是否打开会话缓存、stapling 与 0-RTT。

把“能连上”升级为“能证明连对了人、用对了算法、链完整且可复现验证”,HTTPS 才真正从开关变成可运维的安全能力。

参考资料

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3.txthttps://www.rfc-editor.org/rfc/rfc8446.txt
  2. RFC 5280: Internet X.509 Public Key Infrastructure Certificate and CRL Profile.txthttps://www.rfc-editor.org/rfc/rfc5280.txt
  3. MDN: Transport Layer Security (TLS)
  4. MDN: HTTPS
  5. OpenSSL: openssl-s_client
  6. Nginx: Module ngx_http_ssl_module
使用 Hugo 构建
主题 StackJimmy 设计