“省一次往返”需要结合协议版本和 early data 模式理解。TLS 1.3 的普通完整握手,以及不携带 early data 的恢复握手,通常都需要一次 TLS 往返。恢复仍然可以减少证书传输和身份认证开销,但开启票据并不意味着每个 HTTPS 请求都能提前一个 RTT 开始。
新连接仍要派生新的流量密钥
TLS 会话恢复使用先前握手派生的 PSK,为一条新连接建立安全上下文。NewSessionTicket 提供的是与恢复状态关联的不透明标识,不是让后续连接直接沿用原来的流量密钥。新的握手仍会根据自身上下文派生密钥。服务器可以用密码学方法保护票据状态,也可以在不透明标识后保存服务端状态;应用不应把票据解析成一种通用登录凭据。
服务器接受 PSK 认证时,不再发送自己的 Certificate 和 CertificateVerify 消息。恢复可以结合一次新的临时 Diffie-Hellman 交换,获得相应的前向保密;协议也允许仅使用 PSK 的模式,后者没有这项保护。因此,“拿到了票据”既不能证明实际选用了哪种密钥交换,也不能独自证明节省了多少网络等待。
可选的零往返模式允许符合条件的应用数据跟随 ClientHello 一起发送,这与普通会话恢复是两件事。early data 不具有前向保密,并可能跨连接被重放;某条连接后来完成握手,也不能消除另一条连接上已经发生或可能发生的重放。HTTP 服务需要按资源的实际副作用决定能否接受重放风险,并正确处理拒绝,包括规范要求的 425 Too Early 响应。
坏掉的样子
客户端可能根本没有启用票据缓存,可能在处理握手后票据之前就关闭连接,也可能每次请求都重建并丢弃缓存。服务器重启、密钥轮换或票据过期后,也可能不再接受原来的恢复状态。一次恢复尝试最终走完整握手,不自动等于 TLS 服务不可用;恢复比例和建连成功率应该分别观察。
HTTP 连接复用还会让测试混淆。多个请求若沿用同一条已建立连接,根本不会重新握手。验证会话恢复时,必须有意创建新的 TCP 连接,再检查双方报告的协商状态,不能只根据第二次请求耗时较短就下结论。总耗时里还混有调度、传输建连、服务端业务处理和其他缓存效果。
票据及客户端缓存属于敏感状态,应限制适用范围和存续时间,协调服务器密钥轮换,并在认证策略变化时重新审视缓存状态。Go 的 VerifyPeerCertificate 不会在恢复连接上调用,而 VerifyConnection 会覆盖包括恢复在内的所有连接。必须每次检查的自定义策略应放在后者路径上;这并不是关闭标准证书验证的理由,更不能让缓存绕过刚刚收紧的授权规则。
快速验证
将代码保存为 resumption_probe.go,用 Go 1.23 或更新版本运行 go run resumption_probe.go。服务端和客户端都限定 TLS 1.3,测试证书仅在本地测试客户端中受信任。实验禁用 HTTP/1.1 的持久连接复用,但让前两个独立 transport 共享容量为四的 TLS 会话缓存;第三个 transport 刻意不配置缓存。
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"sync/atomic"
"time"
)
type connectionKey struct{}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var serial, verified atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(connectionKey{}).(int64)
w.Header().Set("X-Connection-ID", strconv.FormatInt(id, 10))
w.Header().Set("X-Resumed", strconv.FormatBool(r.TLS.DidResume))
fmt.Fprintln(w, "ok")
}))
server.Config.ConnContext = func(ctx context.Context, connection net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, serial.Add(1))
}
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
base := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
base.MinVersion, base.MaxVersion = tls.VersionTLS13, tls.VersionTLS13
check(!base.InsecureSkipVerify, "certificate verification must stay enabled")
base.VerifyConnection = func(state tls.ConnectionState) error {
if len(state.VerifiedChains) == 0 {
return fmt.Errorf("verified certificate chain required")
}
verified.Add(1)
return nil
}
request := func(cache tls.ClientSessionCache) (int64, bool) {
config := base.Clone()
config.ClientSessionCache = cache
transport := &http.Transport{
TLSClientConfig: config,
DisableKeepAlives: true,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
response, err := client.Get(server.URL)
check(err == nil, fmt.Sprint(err))
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
check(err == nil, fmt.Sprint(err))
check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
check(response.ProtoMajor == 1 && response.ProtoMinor == 1, "HTTP/1.1 required")
check(response.TLS.HandshakeComplete && response.TLS.Version == tls.VersionTLS13, "TLS 1.3 required")
id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
check(err == nil, fmt.Sprint(err))
resumed := response.TLS.DidResume
check(response.Header.Get("X-Resumed") == strconv.FormatBool(resumed), "peers disagree")
return id, resumed
}
cache := tls.NewLRUClientSessionCache(4)
firstID, first := request(cache)
secondID, second := request(cache)
thirdID, third := request(nil)
check(firstID == 1 && secondID == 2 && thirdID == 3, "three new TCP connections required")
check(!first && second && !third, "expected full, resumed, full")
check(verified.Load() == 3, "VerifyConnection must run for all three handshakes")
fmt.Printf("new TCP connections: %d %d %d; DidResume: %t %t %t\n",
firstID, secondID, thirdID, first, second, third)
fmt.Println("both peers agree; VerifyConnection ran three times; no early data was sent")
fmt.Println(runtime.Version())
}
每次完整读取响应,让这个测试服务器发出的握手后票据在下一次连接前得到处理。服务端为每条实际接受的 TCP 连接分配独立编号,预期顺序是一、二、三,而 DidResume 依次为 false、true、false。响应头还携带服务器自己看到的恢复状态,必须与客户端一致,不能只检查一端或凭耗时猜测。
VerifyConnection 的计数必须为三,说明恢复连接也经过这个回调,同时标准证书验证始终开启。恢复时保留的已验证证书链状态,关联的是先前认证的会话,并不意味着本次握手重新传输了一份证书消息。会话状态仅保留在内存缓存中;程序不打印票据、私钥和流量机密,也不将它们写入磁盘,不把测试证书安装到机器的全局信任库。
已核验的 macOS、Go 1.27.0 运行通过全部断言。实验直接证明:缓存会话可以在新的传输连接上被接受,没有缓存时则进行完整协商。它没有发送 early data、统计报文往返、基准测试握手 CPU,也没有验证负载均衡集群之间的票据共享与轮换。预估生产提速前,必须分别验证这些条件;本机第二次请求较快,不能代替其中任何一项证据。
延伸阅读
来源
- RFC 8446:TLS 1.3,第 2.2、2.3、4.6.1、8 节:PSK、票据、early data 与重放,已核验 2026-09-09。
- RFC 8470:在 HTTP 中使用 Early Data,重放敏感操作与 425 响应,已核验 2026-09-09。
- Go crypto/tls,ClientSessionCache、DidResume 与验证回调,已核验 2026-09-09。
- Go net/http/httptest,隔离的回环 HTTPS 测试服务,已核验 2026-09-09。