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。