TLS session resumption

在上一节我们介绍了 OCSP stapling。本节我们介绍另一种 HTTPS 性能优化的技巧,TLS session resumption。

一个 完整 的 TLS 握手需要两次:

TLSv1.2 完整 TLS 握手

TLSv1.2 完整 TLS 握手(图片来自 浅谈 TLS 1.3

  • 1、Client 发送 ClientHello;Server 回复 ServerHello
  • 2、Client 回复最终确定的 Key,Finished;Server 回复 Finished
  • 3、握手完毕,Client 发送加密后的 HTTP 请求;Server 回复加密后的 HTTP 响应

这一过程的花费是 2RTT(Round-Trip-Time)。意味着仅仅部署了 HTTPS,就会让你的 Web 应用的响应都慢上 2RTT。 花在信息传递过程中的延迟在整个响应时间的占比不容小觑,看看国内有多少 CDN 厂商就知道了。

为什么强调是“完整的”呢?因为通过 TLS session resumption,我们可以复用未过期的会话,把 RTT 减低到 1,甚至更低。TLSv1.2 有两种会话恢复的方式:Session ID 和 Session Ticket。

Session ID

Session ID 是最早的 TLS session resumption 方案。除了某些上古浏览器,大部分客户端都支持它。 Client 发送的 ClientHello 当中,就包含了一个 Session ID。服务器接收到 Session ID 之后,会返回之前存储的 SSL 会话。这么一来,重建连接就只需一次 TLS 握手。

Session ID 方案

Session ID 方案(图片来自 完全吃透 TLS/SSL

  • 1、Client 发送 ClientHello(包含 Session ID);Server 回复 ServerHello 和 Finished
  • 2、握手完毕,Client 发送加密后的 HTTP 请求;Server 回复加密后的 HTTP 响应

Nginx 自身支持 Session ID,但有个问题,它的会话最多只能存储在共享内存里面。服务器和客户端上次握手建立的会话,只有某个服务器自己认得,换个服务器就忘光光了。当然你也可以要求负载均衡的时候只用 IP hash,尽管在实际情况中这么做不太现实。

OpenResty 提供了 ssl_session_fetch_by_lua*ssl_session_store_by_lua* 这两个支持协程的阶段,以及跟 Session id 相关的 ngx.ssl.session 模块,把存储的决定权交到开发者手中。 你可以把 session 放到独立的 Redis 或 Memcached 服务器上。

Session Tickets

不过你可能已经不需要额外折腾 ssl_session_* 的代码。因为 Session ID 已经过时了。 TLS 提供了名为 Session Tickets 的拓展,用来代替之前的 Session ID 方案。

Session ID 方案要求服务端记住会话状态,有违于 HTTP 服务无状态的特点。Session Tickets 方案旨在解决这个问题。

Session Tickets 跟 Session ID 差不多,只是有点关键上的不同:现在轮到 由客户端记住会话状态

  • 1、Client 发送 ClientHello(包含 Session Ticket);Server 回复 ServerHello 和 Finished
  • 2、握手完毕,Client 发送加密后的 HTTP 请求;Server 回复加密后的 HTTP 响应

服务端仅需记住当初用于加密返回给客户端的 Ticket 的密钥 ,以解密客户端握手时发送的 Session Ticket。

这么一来,只要在不同的服务器间共享同一个密钥,就能避免会话丢失的问题,不再需要独立的 Redis 或 Memcached 服务器来存储会话信息。

在高兴之余看下 两个坏消息

  • 1、Session Tickets 不具有前向安全性,所以你需要定期轮换服务端用于加密的密钥。
  • 2、只有现代浏览器才支持这一 TLS 拓展。比如 Win7 下的 IE 就不支持。

对于 Nginx,你需要关注两个指令:ssl_session_ticketsssl_session_ticket_file。 前者启用 Session Tickers 支持,后者决定具体用到的密钥。如果不配置后者,则使用随机生成的密钥。这意味着每台服务器返回的 session ticket 会不一样。

如果你需要管理一整套 OpenResty 集群,可以看下 lua-ssl-nginx-module 这个模块,它可以实现集群层面上的密钥轮换(且无需重启 Worker 进程)。 尽管 lua-ssl-nginx-module 只提供了跟 memcache 配套使用的接口,但是参照 lualib/ngx/ssl/session/ticket/key_rotation.lua,实现自己的一套密钥存储/同步方案不过是照葫芦画瓢。 关键在于 lualib/ngx/ssl/session/ticket.lua 其中的这两个函数:

  • 1、update_ticket_encryption_key(key, nkeys):插入新的密钥,之前的密钥会被轮转成解密密钥,最多保留 nkeys 个密钥。
  • 2、update_last_ticket_decryption_key(key):替换当前的解密密钥。

两种方案的对比

序号 Session ID Session Tickets
1 减少一个 RTT 减少一个 RTT
2 减少非对称加密 减少非对称加密
3 客户端支持率较高 支持分布式环境
4 服务端缓存,耗费内存 客户端支持率较低
5 较难支持分布式缓存 维护全局 session ticket 密钥

0 RTT!?

既然通过 Session ID/Tickets,我们已经把 RTT 减到了 1,能不能更进一步,减到 0? 初看像是天方夜谭,但最新的 TLSv1.3 确实允许做到这一点。(所谓 0 RTT 是指在会话复用的基础上做到的,在完整握手时没法做到 0 RTT。

TLSv1.3 完整 TLS 握手

TLSv1.3 完整 TLS 握手(图片来自 浅谈 TLS 1.3

在继续之前先看下 TLSv1.3 的支持情况:Nginx 需要 1.13+ 的版本,外加 OpenSSL 1.1.1。客户端方面,截止到写作本文的时间,Firefox 和 Chrome 的 nightly build 版本均支持。

0 RTT 是 TLSv1.3 的可选功能。客户端和服务器第一次建立会话时,会生成一个 PSK(pre-shared key)。服务器会用 ticket key 去加密 PSK,作为 Session Ticket 返回。 客户端再次和服务器建立会话时,会先用 PSK 去加密 HTTP 请求,然后把加密后的内容发给服务器。服务器解密 PSK,然后再用 PSK 去解密 HTTP 请求,并加密 HTTP 响应。

  • 1、Client 发送 ClientHello(包含 PSK)和加密后的 HTTP 请求;Server 回复 ServerHello 和 Finished 和加密后的 HTTP 响应。

这就完事了。

由于 HTTPS 握手已经跟 HTTP 请求合并到一起,确实是当之无愧的 0 RTT 呢。

在高兴之余看下 两个坏消息

  • 1、PSK 不具有前向安全性,所以你依然需要定期轮换服务端用于加密的 ticket key。
  • 2、0 RTT 不提供 non-replayable 的保障,所以需要更上层的 HTTP 协议提供防重放的保障。比如只在幂等的 HTTP 方法中启用 0 RTT,或者实现额外的时序标记。

RTT 对比

下表是一个完整 HTTP/2 请求从 TCP 开始所需的 RTT 对比:

序号 版本和连接类型 TCP 握手 TLS 握手 HTTP 请求 总计
1 TLSv1.2 首次连接 1-RTT 2-RTT 1-RTT 4-RTT
2 TLSv1.2 会话复用 1-RTT 1-RTT 1-RTT 3-RTT
3 TLSv1.3 首次连接 1-RTT 1-RTT 1-RTT 3-RTT
4 TLSv1.3 会话复用 1-RTT 0-RTT 1-RTT 2-RTT

从表中可以看出,不管是 TLSv1.2 还是 TLSv1.3、首次连接还是会话复用,TCP 握手和 HTTP 请求各 1 个 RTT 是不可避免的,能优化的就只有 TLS 握手了。 从上面的 TLSv1.2 和 TLSv1.3 的握手原理可以知道,在首次连接和会话复用情况下,TLSv1.3 都比 TLSv1.2 少一个 RTT。

参考

图片和 RTT 对比来自以下文章: