犀牛鸟 2026 研究笔记 estelledc.github.io

第 4 章:连接建立与握手——从陌生人到老朋友

从咖啡馆的对话说起

想象你第一次去一家咖啡馆,想和吧台后面的咖啡师点单:

TCP + TLS 的方式(繁琐但按规矩来):

  1. 你走到吧台前,举手示意”我要点单”(SYN)
  2. 咖啡师看到你,点头说”好的,请说”(SYN+ACK)
  3. 你说”好,我准备说了”(ACK)——到这一步你还没说要喝什么!
  4. 然后你亮出会员卡,咖啡师验证身份(TLS 握手)
  5. 终于,你可以说”一杯拿铁”了

QUIC 的方式(高效自然):

  1. 你走到吧台前,直接说”我是会员XXX,一杯拿铁”——一句话包含了身份验证和点单(Initial + 0-RTT)
  2. 咖啡师确认”好的,正在做”——同时完成了身份确认和下单

如果你是老客户(之前来过),QUIC 甚至更快——你还没走到吧台,咖啡师看到你就开始做你常喝的那杯了(0-RTT 基于之前的会话票据)。

这就是 QUIC 握手的核心理念:把身份验证和数据传输合并到最少的往返次数中


TLS 1.3 快速回顾

在深入 QUIC 的握手之前,需要先理解 TLS 1.3,因为 QUIC 的加密层就是 TLS 1.3。

TLS 做什么?

TLS(Transport Layer Security)解决三个问题:

  1. 认证:确认服务器是你要找的那个服务器(不是冒充的)
  2. 保密:通信内容加密,第三方看不懂
  3. 完整:数据不会被中间人篡改

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 包。那为什么还要加密?

  1. 统一处理:所有包都用同样的加密/解密代码路径,简化实现
  2. 防止意外修改:中间设备即使能算出密钥,也需要主动去做(增加了阻力)
  3. 格式一致性:防止中间设备对”明文头部”形成依赖

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,需要满足:

  1. 客户端之前成功连接过这个服务端
  2. 服务端在上次连接结束时发送了 NewSessionTicket
  3. 客户端保存了这个票据和对应的传输参数
  4. 票据没有过期

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 包时,不能确定源地址是真实的还是伪造的。

如果不验证就分配资源,攻击者可以:

  1. 用伪造 IP 发大量 Initial → 服务端为每个”连接”分配内存 → 资源耗尽
  2. 用受害者 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 安全的连接迁移

读完本章你能做什么

  1. 画出 QUIC 1-RTT 握手的完整时序图,标注每个包的加密级别
  2. 解释 0-RTT 的工作原理和安全风险
  3. 说出为什么 Initial 包要填充到 1200 字节
  4. 对比 ngtcp2(枚举)、msquic(bitfield)、quinn(类型系统)三种状态机设计
  5. 解释 QUIC 流控的双层设计
  6. 描述 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 章 · 拥塞控制——在黑暗中找到最佳车速