TLS session resumption

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

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

    • 1、Client 发送 ClientHello;Server 回复 ServerHello
    • 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 是最早的 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,尽管在实际情况中这么做不太现实。

    不过你可能已经不需要额外折腾 的代码。因为 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 集群,可以看下 这个模块,它可以实现集群层面上的密钥轮换(且无需重启 Worker 进程)。 尽管 lua-ssl-nginx-module 只提供了跟 memcache 配套使用的接口,但是参照 ,实现自己的一套密钥存储/同步方案不过是照葫芦画瓢。 关键在于 lualib/ngx/ssl/session/ticket.lua 其中的这两个函数:

    • 2、update_last_ticket_decryption_key(key):替换当前的解密密钥。

    两种方案的对比

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

    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,或者实现额外的时序标记。

    下表是一个完整 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 对比来自以下文章: