HTTPS 与 TLS:握手流程、证书链、密钥协商和会话复用(078)

IO、NIO与网络编程约 10 分钟阅读5,259 字符
知识库文档 79 / 251

一、HTTPS = HTTP + TLS

HTTPS 不是一个新的协议,而是在 HTTP 和 TCP 之间插入了一层 TLS(Transport Layer Security)。TLS 的前身是 SSL,目前主流版本是 TLS 1.2(2008)和 TLS 1.3(2018)。

TLS 的核心目标:在不安全的网络上建立安全的通信通道,提供三个保证:机密性(数据加密)、完整性(MAC 验证)、身份认证(证书链)。

二、TLS 1.2 握手流程:2-RTT

code
客户端                                    服务端
  |-- ClientHello ------------------------->|  (1) 支持的加密套件、随机数
  |<-- ServerHello -------------------------|  (2) 选择的加密套件、随机数
  |<-- Certificate -------------------------|  (3) 证书链
  |<-- ServerHelloDone ---------------------|  (4) 握手消息结束
  |-- ClientKeyExchange ------------------->|  (5) 加密的预主密钥
  |-- ChangeCipherSpec + Finished --------->|  (6) 开始加密通信
  |<-- ChangeCipherSpec + Finished ---------|  (7) 握手完成
  |<===== 加密通信开始 =====================>|

关键步骤:ClientHello 发送支持的加密套件和随机数。ServerHello 选择加密套件。Certificate 发送证书链(含公钥)。ClientKeyExchange 用服务端公钥加密预主密钥后发送。双方结合随机数计算出会话密钥。总耗时 2-RTT。

[图1:TLS 1.2握手时序图——ClientHello→ServerHello+Certificate→ClientKeyExchange→Finished]

文章配图
文章配图

三、TLS 1.3 握手:更快的 1-RTT

TLS 1.3 相比 TLS 1.2 的核心改进:握手从 2-RTT 降到 1-RTT,移除 RSA 密钥交换等不安全算法,加密更多握手消息(ServerHello 之后全部加密),支持 0-RTT 模式。

TLS 1.3 的 1-RTT 秘密:客户端在 ClientHello 中直接发送密钥协商参数(如 ECDHE 的公钥),服务端在 ServerHello 中回复自己的 ECDHE 公钥,双方在第一次往返后就可以计算出会话密钥。而在 TLS 1.2 中,密钥协商参数需要等到 ClientKeyExchange 才能发送,多了一次往返。

[图2:TLS 1.2 vs TLS 1.3握手对比——TLS 1.3省略的步骤和加密范围的扩大]

文章配图
文章配图

四、证书链:信任的传递

TLS 的身份认证依赖于证书链。服务器不只是发送自己的证书,而是发送从服务器证书到根证书的完整链:

根证书(Root CA,自签名,操作系统/浏览器预置)-> 中间证书(Intermediate CA,由根证书签发)-> 服务器证书(由中间证书签发,CN=example.com)。

验证过程:客户端用中间证书公钥验证服务器证书签名,用根证书公钥验证中间证书签名,根证书在信任库中直接信任。任何一级验证失败(过期、域名不匹配、签名无效),整个链不可信。

常见问题:缺少中间证书(客户端无法验证)、证书链顺序错误、证书过期、域名不匹配。

[图3:证书链验证示意图——根证书→中间证书→服务器证书的层级关系和签名验证]

文章配图
文章配图

五、密钥协商:ECDHE 与 Perfect Forward Secrecy

RSA 密钥协商(TLS 1.2 中已废弃):客户端生成预主密钥,用服务端 RSA 公钥加密后发送。如果服务端私钥在未来泄露,所有历史通信的数据都可以被解密。

ECDHE 密钥协商(TLS 1.3 默认):基于椭圆曲线 DH 密钥交换,双方各自生成临时公私钥对,交换公钥后各自计算会话密钥。服务端私钥只用于签名验证,不参与密钥协商。即使服务端私钥泄露,历史会话密钥也无法被推导出来——这就是前向保密(PFS)。

ECDHE 的数学原理:双方约定椭圆曲线参数和基点 G。客户端生成私钥 a,计算公钥 A = aG 发送。服务端生成私钥 b,计算公钥 B = bG 发送。双方计算共享密钥 = abG,中间人无法从 A 和 B 推导。

[图5:ECDHE密钥协商原理图——椭圆曲线DH密钥交换,标注前向保密PFS]

文章配图
文章配图

六、会话复用:减少握手开销

TLS 握手 2-RTT 的延迟对短连接不可接受。三种会话复用机制:

  • Session ID(TLS 1.2):服务端缓存会话,客户端在 ClientHello 中带 Session ID,1-RTT。缺点:需要服务端缓存,分布式环境不共享。
  • Session Ticket(TLS 1.2):服务端加密会话密钥为 Ticket 发给客户端,客户端在 ClientHello 中带 Ticket,1-RTT。缺点:Ticket 密钥泄露则所有会话密钥被解密。
  • PSK 0-RTT(TLS 1.3):客户端在首次握手后获得 PSK,后续握手在 ClientHello 中直接带 PSK,可在第一个包中发送应用数据。缺点:重放攻击风险。

[图4:TLS会话复用机制对比——Session ID vs Session Ticket vs TLS 1.3 PSK 0-RTT]

文章配图
文章配图

七、加密套件解析

TLS 1.2 加密套件格式:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(密钥协商_身份认证_加密_哈希)。TLS 1.3 简化了加密套件:TLS_AES_128_GCM_SHA256,不再在套件中指定密钥协商和身份认证算法。

八、HTTPS 的性能优化

  • 启用 HTTP/2 多路复用减少连接数
  • 启用会话复用(Session Ticket/PSK)减少握手延迟
  • OCSP Stapling:服务端在握手中附带证书吊销状态
  • 使用 ECDSA 证书:比 RSA 更小更快
  • 选择合适加密套件:AES-GCM 或 ChaCha20-Poly1305

[图6:HTTPS性能优化策略全景图——五大优化方向]

文章配图
文章配图

九、面试常见误区

  • "HTTPS 就是 HTTP + SSL" —— SSL 已被 TLS 取代,现在说 HTTPS = HTTP + TLS 更准确。
  • "TLS 握手就是交换密钥" —— 还包括身份认证、协商加密算法、生成会话密钥。
  • "证书链中的根证书可以自己生成" —— 根证书需要被操作系统/浏览器预置才能被信任。
  • "TLS 1.3 的 0-RTT 很安全" —— 0-RTT 存在重放攻击风险,非幂等操作不应在 0-RTT 中发送。

十、总结

TLS 通过握手协商加密算法和密钥、证书链验证身份、ECDHE 提供前向保密、会话复用减少握手开销。TLS 1.3 是当前推荐版本——更快(1-RTT/0-RTT)、更安全(移除不安全算法、强制 PFS)、更简洁。

延伸阅读

  • RFC 8446(TLS 1.3)、RFC 5246(TLS 1.2)
  • 《HTTPS 权威指南》
  • 本系列相关篇目:077(HTTP/1.1-3)

面试官视角:提问与回答

以下问题从面试官视角出发,考察你对 TLS 的深度理解和实战经验。

Q1:TLS 1.3 的握手从 2-RTT 降到 1-RTT,但如果客户端发送的 ClientHello 中的 ECDHE 密钥协商参数服务端不支持(比如客户端只支持 x25519,服务端只支持 P-256),会发生什么?还是 1-RTT 吗?

如果客户端发送的密钥协商参数服务端不支持,TLS 1.3 会回退到 HelloRetryRequest 机制:服务端发送 HelloRetryRequest 消息,告诉客户端"请使用我支持的密钥协商算法重新发送 ClientHello"。客户端重新发送 ClientHello 后,服务端正常响应。这种情况下握手变成 2-RTT(重新发送 ClientHello 多一次往返)。为了避免这种情况,服务端应该配置与主流客户端兼容的密钥协商算法(如同时支持 x25519 和 P-256),客户端也应该在 ClientHello 中发送多个密钥协商参数(TLS 1.3 允许客户端在 ClientHello 中发送多个 KeyShareEntry)。

Q2:你配置了 Nginx 的 HTTPS,但用户反馈浏览器提示"证书不受信任"。你检查证书没有过期,域名也匹配。还可能是什么原因?

可能原因:1)缺少中间证书——Nginx 只配置了服务器证书,没有配置中间证书链(nginx 的 ssl_certificate 应该包含完整的证书链)。可以用 openssl s_client -connect example.com:443 -showcerts 检查服务器返回的证书链是否完整。2)证书链顺序错误——服务器证书在前,中间证书在后,顺序错误会导致验证失败。3)根证书不在浏览器的信任库中——如果是自建 CA 签发的证书,需要将根证书导入到客户端。4)证书被吊销——CA 吊销了证书,但服务器没有配置 OCSP Stapling。5)证书的 SAN(Subject Alternative Name)不包含访问的域名,CN 匹配但 SAN 不匹配。6)中间证书过期——服务器证书在有效期内,但中间证书过期了。

Q3:你在服务端配置了 TLS Session Ticket 来加速握手,但安全团队说"Session Ticket 破坏了 PFS"。为什么 Session Ticket 会破坏 PFS?如何在不破坏 PFS 的情况下使用它?

Session Ticket 的工作原理:服务端将会话密钥用只有自己知道的 Ticket Key 加密后发给客户端。问题在于:如果 Ticket Key 泄露,攻击者可以解密所有历史 Session Ticket,恢复所有历史会话密钥——这破坏了 PFS(PFS 要求即使服务器私钥泄露也不能解密历史会话)。缓解方案:1)定期轮换 Ticket Key(如每小时轮换一次),即使泄露也只影响最近一个小时的会话。2)使用多级 Ticket Key 机制——Nginx 支持配置多个 ssl_session_ticket_key,旧的 Ticket Key 可以解密但不能加密新的 Ticket,这样旧 Ticket 会自然过期。3)如果对 PFS 要求极高,不使用 Session Ticket,只使用 Session ID(但 Session ID 需要服务端缓存,分布式环境需要共享缓存)。4)升级到 TLS 1.3,使用 PSK 0-RTT 替代 Session Ticket(但 PSK 0-RTT 也有类似问题)。

Q4:你有一个 Java 应用需要调用第三方 HTTPS API。你在 JDK 的 cacerts 信任库中导入了第三方的自签名证书,但调用时仍然报**PKIX path building failed**。为什么?怎么排查?

可能原因:1)证书导入到了错误的 cacerts 文件——JDK 有多个 cacerts(如 JDK 安装目录下的和 JRE 下的),确认应用使用的是哪个 cacerts(System.getProperty("javax.net.ssl.trustStore"))。2)导入的证书格式不对——需要用 keytool -importcert 导入,而不是直接复制文件。3)中间证书缺失——你导入了服务器证书,但服务器证书是由中间证书签发的,当中间证书没有导入到 cacerts 时,验证链路不完整。需要导入中间证书或完整的证书链。4)证书别名冲突——导入时使用了已存在的别名,覆盖了原有证书。5)应用使用了自定义的 TrustManager,没有使用 cacerts。排查:在 JVM 参数中加上 -Djavax.net.debug=ssl:handshake:verbose,查看 TLS 握手日志,日志会明确显示证书验证失败的具体原因。

Q5:TLS 的加密套件中,为什么 AES-GCM 比 AES-CBC 更安全?为什么 Chrome 和 Firefox 都优先使用 AES-GCM?

AES-CBC(Cipher Block Chaining)模式先加密再 MAC(Message Authentication Code),这种"先加密后认证"的模式存在多个安全问题:1)Padding Oracle 攻击(如 POODLE 攻击)——攻击者通过观察服务端对填充错误的反应来推断明文。2)MAC-then-Encrypt 模式——先计算 MAC 再加密,解密时先解密再验证 MAC,时序攻击可能泄露信息。AES-GCM(Galois/Counter Mode)是 AEAD(Authenticated Encryption with Associated Data)模式,加密和认证同时进行,从根本上避免了 Padding Oracle 和时序攻击。GCM 还支持硬件加速(AES-NI 指令集),CPU 开销比 AES-CBC 更低。这就是为什么现代浏览器和 TLS 1.3 都优先选择 AES-GCM。在移动设备上,如果没有 AES-NI 硬件加速,ChaCha20-Poly1305 是更好的选择(纯软件实现性能更好)。