TLS 1.3 握手负责建立流量密钥,并按照应用指定的信任规则认证对端。普通 HTTPS 通常认证服务器,客户端证书需要另外配置,并不是连接一加密就自动双向认证。TCP 建连成功只说明传输连接建立了,既不证明证书身份正确,也不证明 HTTP 服务能够完成请求。
一次往返究竟计算什么
常见的证书认证握手由 ClientHello 开始,其中包含客户端支持的版本、算法和密钥份额。服务器通过 ServerHello 选择参数并提交自己的份额,随后发送加密扩展、证书、CertificateVerify 和 Finished。客户端验证证书与握手证明后,才发送自己的 Finished 和普通应用数据,而不是看到任意证书就立即信任对端。
没有 HelloRetryRequest 时,客户端可以在已建立的传输连接上经过一次 TLS 往返后发送这些数据。这一数字不包含 DNS 查询、TCP 建连,以及 HTTP 请求发出后的响应路程。如果客户端首次没有提供服务器需要的密钥份额,服务器可能要求 HelloRetryRequest,增加一次交换;如果根本没有共同参数,协商则可能直接失败。因此,一次 RTT 描述的是特定协议路径,不是整个网页加载时间的承诺。
TLS 1.3 的密码套件名称表示记录加密算法与哈希,并不会独自告诉你证书使用了什么签名算法,也不完整表示协商出的密钥交换组。看到套件里有 AES,不等于已经确认身份验证成功,更不能据此推断它使用了哪种临时交换。
身份认证与前向保密
在证书认证的临时 Diffie-Hellman 交换中,只要临时机密正确销毁、通信期间端点没有被攻破,攻击者日后仅取得证书私钥,不能据此还原此前记录的会话。这是有前提的前向保密,不是对任何形式的端点入侵都免疫。RSA 证书仍可以给临时交换做认证;证书是 RSA,不代表正在使用旧式 RSA 密钥传输。
但不能把这个结论扩展为“所有 TLS 1.3 模式都有同样的前向保密”。协议还定义了仅使用 PSK,以及 PSK 配合 DHE 的模式,前者没有后者的这项性质。可选 early data 的保证也更弱:它不具有前向保密,跨连接重放保护也不同于普通数据。服务器发过会话票据,不代表客户端自动启用 early data,更不代表以后的每次握手都变成零往返。
坏掉的样子
排障时要拆开版本协商、证书信任链、主机名验证和业务响应。两端版本范围没有交集,会在 HTTP 之前失败;未知签发者或错误的证书 SAN 也必须被拒绝,即使双方原本可以建立某种加密通信。证书过期和机器时钟异常需要分别核查,下方实验没有模拟这两种情况。
SNI 用于帮助服务器选择虚拟主机,主机名验证则检查返回的证书是否符合预期身份,两者不是同一个动作。工具打印了证书或密码套件,不意味着它已经执行并通过完整验证。应保留原始错误和预期访问名称,不能为了让检查通过就关闭验证、随意降低版本下限,或者不断重试掩盖确定性的配置错误。
下方把版本范围精确限定为 TLS 1.3,是为了观察清楚协商结果,不是未经评估就应采用的生产策略。版本上下限也不能修复证书信任问题。如果反向代理终止 TLS,必须分别检查客户端到代理、代理到上游的连接;它们是独立的握手,可能协商不同版本,也可能认证不同身份,不能用其中一段的成功代替另一段的证据。
快速验证
把下列代码保存为 handshake_probe.go,使用 Go 1.23 或更新版本执行 go run handshake_probe.go。程序只依赖标准库,在本机回环地址启动临时 HTTPS 服务。测试客户端仅信任该测试证书,不向系统证书库写入信任项;每个请求限制为两秒,并创建新的 HTTP transport,避免连接复用掩盖握手。
package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"time"
)
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func fetch(server *httptest.Server, config *tls.Config) (tls.ConnectionState, error) {
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)
if err != nil {
return tls.ConnectionState{}, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return tls.ConnectionState{}, err
}
check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
return *response.TLS, nil
}
func main() {
var handled atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
fmt.Fprintln(w, "ok")
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
trusted := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
trusted.MinVersion, trusted.MaxVersion = tls.VersionTLS13, tls.VersionTLS13
check(!trusted.InsecureSkipVerify, "certificate verification must stay enabled")
state, err := fetch(server, trusted)
check(err == nil, fmt.Sprint(err))
check(state.HandshakeComplete && state.Version == tls.VersionTLS13, "TLS 1.3 required")
check(!state.DidResume && len(state.VerifiedChains) > 0, "a verified full handshake is required")
fmt.Println(tls.VersionName(state.Version), tls.CipherSuiteName(state.CipherSuite), "verified")
wrongName := trusted.Clone()
wrongName.ServerName = "wrong.invalid"
_, err = fetch(server, wrongName)
var nameError x509.HostnameError
check(errors.As(err, &nameError), "wrong name must fail hostname verification")
untrusted := trusted.Clone()
untrusted.RootCAs = x509.NewCertPool()
_, err = fetch(server, untrusted)
var trustError x509.UnknownAuthorityError
check(errors.As(err, &trustError), "empty roots must reject the test certificate")
oldVersion := trusted.Clone()
oldVersion.MinVersion, oldVersion.MaxVersion = tls.VersionTLS12, tls.VersionTLS12
_, err = fetch(server, oldVersion)
check(err != nil, "disjoint protocol versions must fail")
check(handled.Load() == 1, "failed TLS attempts must not reach the HTTP handler")
_, err = fetch(server, trusted)
check(err == nil && handled.Load() == 2, "trusted TLS 1.3 must still work")
fmt.Println("wrong name, untrusted certificate, and TLS 1.2-only client rejected before HTTP")
fmt.Println(runtime.Version())
}
第一次请求输出 TLS 1.3 和协商得到的密码套件,具体套件可能随环境变化。错误名称和空信任根分别断言具体的 X.509 错误类型;随后只支持 TLS 1.2 的客户端无法连接只接受 TLS 1.3 的服务器。三个失败请求都没有进入 HTTP handler,计数始终为一;恢复正确配置后,请求再次成功,计数变成二。这说明拒绝发生在握手边界,而不是应用返回了一个错误页面。
已核验的 macOS、Go 1.27.0 运行通过全部断言,证书验证始终开启。该实验真实执行了 TLS 握手,但没有抓包测量 RTT,也没有验证双向 TLS、零往返数据、公共 DNS 或生产证书发布。调整线上协议范围之前,仍应单独测试实际客户端群体及其信任根;本地测试客户端信任测试证书,不能替代真实用户环境的兼容性检查。
延伸阅读
来源
- RFC 8446:TLS 1.3,第 1.2、2–2.3 节:完整握手、PSK 模式、重试交换与 early data 限制,已核验 2026-09-09。
- Go crypto/tls,版本范围、证书验证与 ConnectionState,已核验 2026-09-09。
- Go net/http/httptest,回环 TLS 测试服务与测试客户端信任配置,已核验 2026-09-09。