第 4 章:连接建立与握手——从陌生人到老朋友
从咖啡馆的对话说起
想象你第一次去一家咖啡馆,想和吧台后面的咖啡师点单:
TCP + TLS 的方式(繁琐但按规矩来):
- 你走到吧台前,举手示意”我要点单”(SYN)
- 咖啡师看到你,点头说”好的,请说”(SYN+ACK)
- 你说”好,我准备说了”(ACK)——到这一步你还没说要喝什么!
- 然后你亮出会员卡,咖啡师验证身份(TLS 握手)
- 终于,你可以说”一杯拿铁”了
QUIC 的方式(高效自然):
- 你走到吧台前,直接说”我是会员XXX,一杯拿铁”——一句话包含了身份验证和点单(Initial + 0-RTT)
- 咖啡师确认”好的,正在做”——同时完成了身份确认和下单
如果你是老客户(之前来过),QUIC 甚至更快——你还没走到吧台,咖啡师看到你就开始做你常喝的那杯了(0-RTT 基于之前的会话票据)。
这就是 QUIC 握手的核心理念:把身份验证和数据传输合并到最少的往返次数中。
TLS 1.3 快速回顾
在深入 QUIC 的握手之前,需要先理解 TLS 1.3,因为 QUIC 的加密层就是 TLS 1.3。
TLS 做什么?
TLS(Transport Layer Security)解决三个问题:
- 认证:确认服务器是你要找的那个服务器(不是冒充的)
- 保密:通信内容加密,第三方看不懂
- 完整:数据不会被中间人篡改
TLS 1.3 的握手流程(独立运行时)
客户端 服务端
| |
|--- ClientHello ─────────────────────>|
| - 支持的加密套件 |
| - 客户端的 DH 公钥分享 |
| - 支持的签名算法 |
| |
|<── ServerHello ─────────────────────-|
| - 选择的加密套件 |
| - 服务端的 DH 公钥分享 |
| |
|<── {EncryptedExtensions} ───────────-| ← 从这里开始加密
|<── {Certificate} ───────────────────-|
|<── {CertificateVerify} ─────────────-|
|<── {Finished} ──────────────────────-|
| |
|--- {Finished} ─────────────────────->|
| |
| ← 1 RTT 后双方都有了加密密钥 → |
关键:TLS 1.3 用 Diffie-Hellman 密钥交换——客户端和服务端各出一个公钥,各自就能算出相同的共享密钥,而窃听者只看到两个公钥,算不出共享密钥。
TLS 1.3 的 0-RTT 机制
如果之前连接过,服务端会发送一个 NewSessionTicket(会话票据)。下次客户端用这个票据恢复会话:
客户端 服务端
| |
|--- ClientHello ─────────────────────>|
| + pre_shared_key(上次的票据) |
| + early_data(0-RTT应用数据) |
| |
| ↑ 第一个包就能带加密的应用数据! |
QUIC 如何集成 TLS 1.3
QUIC 不是简单地”在 UDP 上跑 TLS”——它把 TLS 1.3 嵌入到了传输层握手中。TLS 的消息被封装在 QUIC 的 CRYPTO 帧中,而不是像 TCP+TLS 那样作为独立的记录。
QUIC 的三种加密级别
QUIC 在连接建立过程中有三个加密级别,随着握手进展逐步升级:
加密级别 用途 保护强度
──────────────────────────────────────────────────────
Initial 连接建立初期 弱(密钥公开可计算)
Handshake 握手完成阶段 中(临时密钥保护)
1-RTT (Application) 正式数据传输 强(完全前向保密)
0-RTT (Early Data) 重连时的早期数据 中(无前向保密)
每个级别有自己的加密密钥,用于不同类型的包:
┌────────────────────────────────────────────────────┐
│ QUIC 包类型与加密级别 │
├─────────────┬──────────────┬───────────────────────┤
│ 包类型 │ 加密级别 │ 内容 │
├─────────────┼──────────────┼───────────────────────┤
│ Initial │ Initial │ CRYPTO帧(ClientHello) │
│ Handshake │ Handshake │ CRYPTO帧(Finished等) │
│ 0-RTT │ 0-RTT │ STREAM帧(早期数据) │
│ 1-RTT │ Application │ STREAM帧(正式数据) │
└─────────────┴──────────────┴───────────────────────┘
Initial 包的”弱加密”是什么意思?
Initial 包的加密密钥是从 Connection ID 派生的——而 Connection ID 在网络上是明文的。所以任何人都能解密 Initial 包。那为什么还要加密?
- 统一处理:所有包都用同样的加密/解密代码路径,简化实现
- 防止意外修改:中间设备即使能算出密钥,也需要主动去做(增加了阻力)
- 格式一致性:防止中间设备对”明文头部”形成依赖
QUIC 1-RTT 握手完整流程
让我们一步步走完 QUIC 首次连接的完整流程:
客户端 服务端
| |
| ─── Initial Packet ────────────────────────────> |
| Header: |
| Version: 0x00000001 (QUIC v1) |
| DCID: random (客户端随机选) |
| SCID: client_cid (客户端的CID) |
| Payload: |
| CRYPTO Frame: |
| TLS ClientHello |
| - supported_versions: [TLS 1.3] |
| - key_share: [客户端DH公钥] |
| - quic_transport_parameters: |
| initial_max_streams_bidi: 100 |
| initial_max_data: 1048576 |
| max_idle_timeout: 30000ms |
| PADDING Frame (填充到1200字节) |
| |
| <── Initial Packet ─────────────────────────────-|
| Payload: |
| CRYPTO Frame: |
| TLS ServerHello |
| - key_share: [服务端DH公钥] |
| ACK Frame (确认客户端的Initial) |
| |
| <── Handshake Packet ───────────────────────────-|
| Payload (Handshake密钥加密): |
| CRYPTO Frame: |
| EncryptedExtensions |
| - quic_transport_parameters |
| Certificate |
| CertificateVerify |
| Finished |
| |
| <── 1-RTT Packet ──────────────────────────────-|
| Payload (1-RTT密钥加密): |
| HANDSHAKE_DONE Frame |
| NEW_CONNECTION_ID Frame |
| |
| ─── Handshake Packet ──────────────────────────> |
| Payload: |
| CRYPTO Frame: |
| Finished (客户端确认) |
| ACK Frame |
| |
| ─── 1-RTT Packet (应用数据) ───────────────────> |
| Payload: |
| STREAM Frame (HTTP/3 请求) |
| |
| ← 1 RTT 后开始传应用数据 → |
为什么 Initial 包要填充到 1200 字节?
你注意到了那个 PADDING Frame——Initial 包必须至少 1200 字节。为什么?
防止放大攻击:
攻击场景(如果没有最小包大小限制):
攻击者 → 伪造源IP为受害者IP
攻击者发送:一个很小的 Initial 包 (50字节)
服务端回复:一大堆握手数据 (几千字节) → 发给受害者IP
放大倍率:几千/50 = 巨大的放大!
受害者被大量数据淹没 = DDoS 攻击
有了最小包大小限制:
攻击者必须发送:至少1200字节的 Initial 包
服务端回复:最多约3倍(3600字节)
放大倍率:3600/1200 = 3倍
放大效果大幅降低,攻击变得不划算
这是 QUIC 设计中安全考虑的一个典型例子——协议层面就防止了常见攻击。
QUIC 0-RTT 握手详解
0-RTT 的前提条件
要使用 0-RTT,需要满足:
- 客户端之前成功连接过这个服务端
- 服务端在上次连接结束时发送了 NewSessionTicket
- 客户端保存了这个票据和对应的传输参数
- 票据没有过期
0-RTT 流程
客户端 服务端
| |
| ─── Initial Packet ────────────────────────────> |
| CRYPTO Frame: |
| ClientHello |
| + pre_shared_key (上次的票据) |
| + early_data_indication |
| |
| ─── 0-RTT Packet ─────────────────────────────> |
| STREAM Frame: |
| HTTP/3 GET /index.html |
| ← 第一个包就发了请求! |
| |
| <── Initial + Handshake ────────────────────────-|
| 服务端确认接受 0-RTT |
| |
| <── 1-RTT Packet ──────────────────────────────-|
| STREAM Frame: |
| HTTP/3 Response (响应数据) |
| ← 客户端收到响应! |
| |
| 总计:在第一个 RTT 结束时就收到了响应 |
0-RTT 的安全风险与应对
重放攻击(Replay Attack):
正常 0-RTT:
客户端 ──[0-RTT: 转账100元]──> 服务端 ✓ 执行一次
攻击者截获并重放:
攻击者 ──[0-RTT: 转账100元]──> 服务端 ✗ 又执行一次!
结果:转了200元!
服务端的应对策略:
| 策略 | 实现方式 | 保护程度 |
|---|---|---|
| 限制幂等请求 | 只接受 GET/HEAD 的 0-RTT | 强 |
| 单用票据 | 每个 Ticket 只能用一次(维护已用列表) | 强但成本高 |
| 时间窗口 | 票据在短时间窗口内有效 | 中 |
| 应用层防重放 | 带请求 ID、幂等键 | 应用层保证 |
在实际实现中,各项目的处理方式:
quinn:应用层决定是否接受 0-RTT
- 提供 accept_0rtt() 回调
- 应用可以基于请求内容决定
quiche:配置级控制
- enable_early_data(true/false)
- 全有或全无
TQUIC:默认关闭,显式启用
- 安全保守的默认值
连接状态机:各实现的不同选择
连接从建立到关闭要经过多个状态。不同的实现对状态机的设计差异很大——这反映了它们的设计哲学。
ngtcp2:7 状态有限状态机(最清晰)
ngtcp2 连接状态机:
┌─────────────┐
│ INITIAL │ ← 刚创建,准备发/收 Initial 包
└──────┬──────┘
│ 收到对端的握手信息
▼
┌─────────────┐
│ HANDSHAKE │ ← TLS 握手进行中
└──────┬──────┘
│ Handshake 完成
▼
┌──────────────────┐
│ POST_HANDSHAKE │ ← 握手刚完成,处理收尾工作
└──────┬───────────┘
│ 确认完毕
▼
┌─────────────┐
│ CONFIRMED │ ← 正常数据传输状态
└──────┬──────┘
│ 收到/发送 CONNECTION_CLOSE
▼
┌─────────────┐
│ CLOSING │ ← 等待对端确认关闭
└──────┬──────┘
│ 超时或收到确认
▼
┌─────────────┐
│ DRAINING │ ← 排空剩余包(不再发新包)
└──────┬──────┘
│ 超时
▼
┌─────────────┐
│ CLOSED │ ← 连接完全结束
└─────────────┘
ngtcp2 的设计哲学:状态少、转换明确。每个状态的职责清晰,便于调试。作为 C 实现,它也不能像 Rust 那样用类型系统来保证状态转换的正确性,所以用简单的枚举 + switch-case 最安全。
msquic:30+ 位的 bitfield 状态(最灵活)
msquic 不用传统的枚举状态机,而是用一个 64 位的 bitfield,每一位表示一个状态标志:
// msquic 的连接状态(简化)
typedef struct QUIC_CONNECTION_STATE {
// 这些不是互斥的状态,而是可以组合的标志!
BOOLEAN HandshakeConfirmed : 1; // 握手已确认
BOOLEAN PeerTransportParamsReceived : 1; // 收到对端参数
BOOLEAN EncryptionEnabled : 1; // 加密已启用
BOOLEAN Connected : 1; // 已连接
BOOLEAN ShutdownInitiated : 1; // 已发起关闭
BOOLEAN ClosedLocally : 1; // 本地已关闭
BOOLEAN ClosedRemotely : 1; // 远端已关闭
// ... 还有 20+ 个标志位
} QUIC_CONNECTION_STATE;
为什么这样设计?
传统枚举状态机:
enum State { Initial, Handshake, Connected, Closing }
问题:如果某些状态可以并存呢?
例如:连接已建立(Connected),同时正在协商新路径(PathValidating)
用枚举表示:需要 Connected_PathValidating 这种组合状态
状态数爆炸:N个标志 → 2^N 种组合
bitfield 方案:
每个标志独立设置,可以任意组合
判断逻辑:if (state.Connected && !state.ShutdownInitiated)
灵活但复杂——需要大量测试保证不会出现非法组合
msquic 的设计哲学:极致灵活性。作为微软的生产级实现,需要处理 Windows 内核态的各种边界情况,bitfield 方案让它能精确控制每个状态转换。
quinn:Rust 类型系统保证(最安全)
quinn 用 Rust 的枚举和类型系统来建模连接状态:
// quinn 的连接状态(简化自 quinn-proto)
enum State {
Handshake(state::Handshake),
Established(state::Established),
Closed(state::Closed),
Draining,
Drained,
}
// 每个状态变体携带该状态特有的数据
struct Handshake {
// 只有握手阶段需要的字段
rem_cid_set: bool,
token: Bytes,
client_hello: Option<Bytes>,
}
struct Established {
// 只有已建立状态需要的字段
// 握手相关字段在这里不存在 → 编译器保证不会误访问
}
为什么这很好?
// 如果你试图在 Handshake 状态下访问 Established 的字段:
match connection.state {
State::Handshake(hs) => {
// hs.streams ← 编译错误!Handshake 没有 streams 字段
// 类型系统在编译时就防止了逻辑错误
}
State::Established(est) => {
est.streams // ← 只有这里才能访问
}
}
quinn 的设计哲学:让编译器帮你检查。非法的状态转换在编译时就被拒绝,而不是在运行时崩溃。
Transport Parameters:连接的”合同条款”
握手不只是加密——双方还要协商传输参数(Transport Parameters)。这些参数定义了连接的各种限制:
关键传输参数:
┌────────────────────────────────┬───────────────────────────┐
│ 参数 │ 含义 │
├────────────────────────────────┼───────────────────────────┤
│ initial_max_data │ 连接级流控窗口 │
│ initial_max_stream_data_bidi │ 双向流的流控窗口 │
│ initial_max_streams_bidi │ 最大并发双向流数 │
│ initial_max_streams_uni │ 最大并发单向流数 │
│ max_idle_timeout │ 多久没活动就关闭连接 │
│ max_udp_payload_size │ 最大 UDP 包大小 │
│ active_connection_id_limit │ 对端可提供多少个 CID │
│ initial_source_connection_id │ 初始 CID(防伪造) │
└────────────────────────────────┴───────────────────────────┘
这些参数在 ClientHello 和 EncryptedExtensions 中以 TLS 扩展的形式传递:
// quinn 中设置传输参数(简化)
let mut transport_config = TransportConfig::default();
transport_config
.max_concurrent_bidi_streams(100u32.into()) // ← 最多100个双向流
.max_concurrent_uni_streams(100u32.into())
.max_idle_timeout(Some(Duration::from_secs(30).try_into()?))
.initial_max_data(1_000_000); // ← 连接级流控1MB
流控(Flow Control)的双层设计
QUIC 的流控有两层:
层级1:连接级流控
整个连接所有流加起来不能超过 initial_max_data
层级2:流级流控
每个流不能超过 initial_max_stream_data
为什么要两层?
只有流级流控:恶意对端开1000个流,每个流发满 → 内存爆炸
只有连接级流控:无法对单个流限速
两层结合:既控制总量,又控制单流
示意图:
连接级窗口:10MB
├── Stream 0: 最多 1MB
├── Stream 4: 最多 1MB
├── Stream 8: 最多 1MB
├── ...
└── 所有流加起来 ≤ 10MB
如果 Stream 0 用了 1MB(到达流级上限),
其他流还能继续用剩下的 9MB
如果所有流加起来到了 10MB(到达连接级上限),
即使某个流还没到自己的限额,也不能继续发
地址验证与 Retry
为什么需要地址验证?
QUIC 在 UDP 上运行,UDP 没有三次握手——任何人都可以伪造源地址发包。服务端收到 Initial 包时,不能确定源地址是真实的还是伪造的。
如果不验证就分配资源,攻击者可以:
- 用伪造 IP 发大量 Initial → 服务端为每个”连接”分配内存 → 资源耗尽
- 用受害者 IP 发 Initial → 服务端响应发给受害者 → 反射攻击
Retry 机制
正常流程(服务端无压力时):
客户端 ── Initial ──> 服务端
服务端直接接受,开始握手
Retry 流程(服务端要验证地址时):
客户端 ── Initial ──────────────> 服务端
客户端 <── Retry(含 token) ─────- 服务端
客户端 ── Initial(含 token) ───-> 服务端 ← 证明了源地址是真的
服务端接受,开始握手
为什么这能验证地址?
- Retry token 是服务端用自己的密钥加密的
- 内容包含客户端IP和时间戳
- 只有真正在该IP的客户端才能收到 Retry 包
- 客户端把 token 原样带回,服务端解密验证IP匹配
各实现的 Retry 策略:
| 实现 | Retry 策略 |
|---|---|
| quiche | 应用层决定(提供 retry() API) |
| msquic | 自动化(基于负载触发) |
| quinn | 应用层决定(validate_address 配置) |
| TQUIC | 配置选项(地址验证开关) |
| ngtcp2 | 应用层回调 |
| s2n-quic | Provider trait(可定制验证逻辑) |
Version Negotiation:协议版本协商
QUIC 支持多个版本,客户端和服务端需要协商使用哪个版本:
客户端 ── Initial (Version=0x00000001) ──> 服务端
情况1:服务端支持这个版本
→ 正常继续握手
情况2:服务端不支持这个版本
服务端 ── Version Negotiation Packet ──> 客户端
支持的版本列表: [0x6b3343cf (QUIC v2), ...]
客户端选择一个双方都支持的版本,重新开始
当前版本:
0x00000001 = QUIC v1 (RFC 9000)
0x6b3343cf = QUIC v2 (RFC 9369)
QUIC v2 和 v1 的区别:只是换了加密算法的标识
(这是有意的——测试升级机制是否工作正常)
连接关闭的三种方式
1. 正常关闭(Immediate Close)
发起方 ── CONNECTION_CLOSE Frame ──> 对端
包含:错误码 + 原因字符串
收到 CONNECTION_CLOSE 后:
→ 进入 Draining 状态
→ 不再发送新数据
→ 等待一段时间后释放连接资源
2. 空闲超时(Idle Timeout)
双方协商:max_idle_timeout = 30000ms
如果 30 秒内没有任何包交换:
→ 双方独立判断连接已超时
→ 静默释放资源(不需要发任何包)
优点:不需要额外的网络交互
场景:用户关闭了 App、网络断了、电脑休眠了
3. 无状态重置(Stateless Reset)
场景:服务端重启了,丢失了所有连接状态
客户端 ── 1-RTT Packet ──> 服务端
服务端:"我不认识这个连接!"
服务端 ── Stateless Reset Token ──> 客户端
客户端收到 Reset Token:
→ 验证 token(之前握手时服务端给过)
→ 确认对端确实丢失了状态
→ 关闭连接,重新建立
Stateless Reset 的设计很精妙:服务端不需要记住任何东西就能告诉客户端”我不认识你了”,而客户端能验证这不是攻击者伪造的。
代码实例:ngtcp2 中的连接状态处理
// ngtcp2 中的连接状态枚举
typedef enum {
NGTCP2_CS_CLIENT_INITIAL, // 客户端初始状态
NGTCP2_CS_CLIENT_WAIT_HANDSHAKE, // 等待服务端握手
NGTCP2_CS_CLIENT_TLS_HANDSHAKE_FAILED,
NGTCP2_CS_SERVER_INITIAL, // 服务端初始状态
NGTCP2_CS_SERVER_WAIT_HANDSHAKE,
NGTCP2_CS_POST_HANDSHAKE, // 握手后处理
NGTCP2_CS_CLOSING, // 正在关闭
NGTCP2_CS_DRAINING, // 排空阶段
} ngtcp2_conn_state;
// 状态转换的核心逻辑(极度简化)
static int conn_recv_handshake_pkt(ngtcp2_conn *conn,
const ngtcp2_pkt *pkt) {
switch (conn->state) {
case NGTCP2_CS_CLIENT_WAIT_HANDSHAKE:
// 处理服务端的握手包
rv = conn_process_handshake(conn, pkt); // ← 核心处理
if (rv == 0 && conn->handshake_completed) {
conn->state = NGTCP2_CS_POST_HANDSHAKE; // ← 状态转换
}
break;
// ...
}
}
注意 ngtcp2 的实现是一个 14000+ 行的单文件(lib/ngtcp2_conn.c)。所有连接相关的逻辑都在这一个文件里——这是 C 语言实现的典型风格,和 Rust 项目(多文件模块化)形成鲜明对比。
代码实例:quinn 中的握手流程
// quinn-proto 中的连接建立(简化)
impl Connection {
pub fn handle_initial(
&mut self,
now: Instant,
remote: SocketAddr,
ecn: Option<EcnCodepoint>,
packet: Packet,
) -> Result<(), TransportError> {
// 1. 解密 Initial 包
let payload = self.crypto.decrypt_initial(&packet)?;
// 2. 处理 CRYPTO 帧(TLS 消息)
for frame in payload.frames() {
match frame {
Frame::Crypto(crypto) => {
// 把 TLS 数据喂给 TLS 引擎
self.tls.read_handshake(&crypto.data)?; // ← 关键
}
Frame::Ack(ack) => {
self.handle_ack(ack)?;
}
_ => {} // Initial 包里只允许这几种帧
}
}
// 3. TLS 引擎产出握手数据
if let Some(data) = self.tls.write_handshake()? {
self.queue_handshake_packet(data); // ← 准备 Handshake 包
}
// 4. 检查握手是否完成
if self.tls.is_handshake_complete() {
self.state = State::Established(Established::new());
self.events.push(Event::Connected); // ← 通知应用层
}
Ok(())
}
}
quinn 的设计让状态转换的逻辑非常清晰:TLS 引擎是一个黑盒,连接只负责把数据”喂给”TLS、从 TLS”取出”数据,然后根据 TLS 的状态决定连接的状态。
握手性能对比
不同网络条件下,握手延迟的实际影响:
假设 RTT = 100ms:
首次连接 重复连接
TCP + TLS 1.3: 200ms (2 RTT) 100ms (1 RTT, TLS resumption)
QUIC: 100ms (1 RTT) 0ms (0-RTT!)
差距:100ms
假设 RTT = 200ms(跨洋):
首次连接 重复连接
TCP + TLS 1.3: 400ms 200ms
QUIC: 200ms 0ms
差距:200ms
实际影响:
Google 数据:搜索延迟增加 100ms → 收入减少 1%
Amazon 数据:页面加载每慢 100ms → 销售额降低 1%
所以 QUIC 的握手优化不只是技术指标——是真金白银
连接迁移的握手补充
当网络变化触发连接迁移时,QUIC 需要验证新路径:
路径验证流程:
客户端(IP变了) 服务端
| |
| ── 1-RTT Packet (新源地址) ──────────>|
| 服务端发现源地址变了 |
| |
| <── PATH_CHALLENGE (随机8字节) ───────-| ← "证明你在新地址"
| |
| ── PATH_RESPONSE (同样的8字节) ──────->| ← "我确实在这里"
| |
| 服务端确认新路径有效 |
| 更新路径信息,继续传输 |
为什么需要路径验证?防止攻击者伪造源地址把流量引到第三方(放大攻击/反射攻击)。
本章小结
| 组件 | 关键设计 | 解决的问题 |
|---|---|---|
| 1-RTT 握手 | TLS 1.3 集成到传输层 | 减少首次连接延迟 |
| 0-RTT | 会话票据 + 早期数据 | 重连无延迟 |
| 三级加密 | Initial/Handshake/1-RTT | 渐进式安全 |
| Transport Parameters | 握手时协商 | 双方能力对齐 |
| Retry | 地址验证令牌 | 防放大/反射攻击 |
| 连接状态机 | 各实现设计不同 | 管理连接生命周期 |
| 路径验证 | PATH_CHALLENGE/RESPONSE | 安全的连接迁移 |
读完本章你能做什么
- 画出 QUIC 1-RTT 握手的完整时序图,标注每个包的加密级别
- 解释 0-RTT 的工作原理和安全风险
- 说出为什么 Initial 包要填充到 1200 字节
- 对比 ngtcp2(枚举)、msquic(bitfield)、quinn(类型系统)三种状态机设计
- 解释 QUIC 流控的双层设计
- 描述 Retry 机制如何防止反射攻击
常见误区
误区一:0-RTT 数据是不安全的,不应该使用
正确理解:0-RTT 数据的安全风险是重放攻击,不是被窃听或篡改。它仍然是加密的、完整性保护的。对于幂等操作(GET 请求、读取数据),0-RTT 完全安全。只有有副作用的操作(转账、下单)才需要避免 0-RTT 或实现应用层防重放。
误区二:QUIC 的 Initial 包是明文的,所以第一个包不安全
正确理解:Initial 包虽然”任何人都能解密”(因为密钥从 CID 派生),但这只是 ClientHello——里面没有敏感数据。它只包含加密协商参数和版本信息。真正的应用数据在 1-RTT(或 0-RTT)包中,用协商出的强密钥保护。
误区三:连接迁移时需要重新做 TLS 握手
正确理解:连接迁移只需要路径验证(PATH_CHALLENGE/RESPONSE),不需要重新做 TLS 握手。因为 TLS 的密钥绑定的是 Connection ID,不是 IP 地址。只要 CID 不变,加密通道就是安全的。路径验证只是确认”对端确实在新地址”,不涉及密钥更新。
导读目录:README.md 上一章:第 3 章 · QUIC vs TCP——一张图看懂本质区别 下一章:第 5 章 · 拥塞控制——在黑暗中找到最佳车速