浏览器走 HTTPS 提交 order=demo-001;quantity=1,如果服务改用 HTTP/3,连接依旧需要验证服务身份,也依旧需要协商密钥。常见的一句概括是“QUIC 跑在 UDP 上,内置 TLS 1.3”。这句话若被理解成“把 TLS 的一条记录装进一个 UDP 包”,就会把后续的包号、密钥更新和加密边界都画错。问题应具体到字节:TLS 交给 QUIC 的是什么?QUIC 自己对哪些字节做了什么?

从订单到数据包:谁执行哪一步

RFC 9001 将两件事分给不同组件。TLS 负责握手消息的语义:双方交换临时 key share,服务用证书及握手签名绑定它的身份,Finished 检查到目前为止的握手转录,随后得到不同方向、不同阶段的流量秘密。QUIC 承载握手字节,并用这些秘密派生自己的包保护密钥。TLS 的记录层不会为 QUIC 的订单创建 TLSCiphertext 记录。QUIC 用自己的帧、包号、头字段和 AEAD,把订单所在的流数据放进受保护的数据包中。

1
2
3
4
5
6
7
8
浏览器                                         orders.test
Initial: CRYPTO(ClientHello + key_share) ---> 根据连接 ID 派生公开可算的 Initial keys
<--- Initial: CRYPTO(ServerHello)
<--- Handshake: CRYPTO(Certificate, CertificateVerify, Finished)
验证服务名、可信 CA、握手绑定、Finished
Handshake: CRYPTO(Finished) ---> 双方各得本方向 1-RTT 流量秘密
1-RTT: 包头 + 包号 + [STREAM: 合成订单] ---> 服务器用 QUIC 包保护密钥验证并解密
<--- 1-RTT: [STREAM: 合成订单响应]

这张图省略了重传、确认和加密等级切换的边界细节,不把 CRYPTO 帧当成已经获得最终认证的应用数据。最初的 Initial 层尤其容易误读:QUIC v1 使用固定公开 salt 与客户端首个 Initial 的目标连接 ID 派生 Initial secrets。旁观者能看见连接 ID,也知道 salt,所以能算出相同的 Initial keys。Initial 使用 AEAD 的包格式不意味着 ClientHello 对旁观者保密;要判断身份与后续流量保护,须看完整握手是否通过。

从流量秘密到包密钥

QUIC 不是把一个共享秘密直接复用到所有消息。初始包先按 RFC 9001 §5.2 派生 client in 和 server in;双方据此各算本方向的 quic key、quic iv、quic hp。握手后,TLS 提供 Handshake/1-RTT 各方向的流量秘密;QUIC 同样用有区分的标签生成包保护所需的 key、iv 和头部保护 key。双方持有能解密对应方向流量的秘密,网络旁观者不能仅凭连接 ID 算出经过认证握手后的 1-RTT 流量秘密。

1
2
3
4
5
6
7
8
9
10
11
公开的 QUIC v1 salt + 首个 Initial 的目标连接 ID
│ HKDF-Extract
└─> initial_secret(可由旁观者复算)
├─ "client in" ─> client_initial_secret
│ ├─ "quic key" ─> AEAD key
│ ├─ "quic iv" ─> IV
│ └─ "quic hp" ─> header protection key
└─ "server in" ─> 服务器方向的另一组三把密钥

TLS 握手输出的 Handshake / 1-RTT 流量秘密
└─ QUIC 标签派生各方向包保护密钥;不复用公开 Initial secret

发送者给包分配对应包号,按规范将包号参与 AEAD nonce 的构造,受保护载荷里装的是 QUIC 帧;头部中的一些字段参与认证,有的字段另做头部保护。这里的 hp 不是第二层“把所有头字段再加密一次”:它掩蔽规定的标志位和包号字节,接收方按规定顺序先恢复这些字段,再做载荷验证。删除 AEAD 认证,篡改帧或包头就可能不再被检测;删除不同方向或不同用途的派生标签,又会破坏协议依赖的密钥分离。具体包格式与顺序必须按 RFC 9001 §5.3–§5.4,而不是照搬 TLS 记录层的序号与 nonce 公式。

还有一处容易混淆:TLS 1.3 可通过 KeyUpdate 更新自己的记录密钥,但 QUIC 不发送 TLS KeyUpdate 消息。QUIC 的 1-RTT 包使用 Key Phase 和 RFC 9001 §6 规定的更新机制。看到 TLS 负责建钥,不等于 TLS 控制 UDP 上的每一次包更新。

可以复现的两个实验及其上限

第一个脚本只用 Python 标准库 hmac、hashlib 的 HMAC 接口重现 RFC 9001 附录 A.1 的公开 Initial 向量;它不是 TLS/QUIC 实现。输入固定为公开 CID 8394c8f03e515708;断言九项预期的 secret、key、iv、hp。把 CID 改成 changed! 后 key 必须不同;客户端方向的 key 与服务器方向的 key 也不能相等。若有人从这个输出推论“Initial 数据受端到端保密”,正好倒过来了:旁观者也能用同样输入计算它。

第二个脚本使用固定版本 aioquic==1.2.0 建立真实回环 UDP QUIC:临时签发只用于此客户端的测试 CA、orders.test 服务证书,连接建立后发送合成订单字节并读取响应。对照把客户端预期服务名换成 wrong.test,同一 CA 下证书名检查拒绝握手。这证实指定客户端确实检查服务器身份,而不只是“能加密”。

先按仓库内 examples/cryptography/README.md 完成环境准备;以下命令从仓库根目录执行。

1
2
3
4
python3 examples/cryptography/19_quic_initial.py
python3 -m unittest discover -s examples/cryptography -p 'test_19_quic_initial.py' -v
python examples/cryptography/19_quic_loopback.py
python -m unittest discover -s examples/cryptography -p 'test_19*.py' -v

临时 CA 仅供本次回环实验使用,不安装到系统信任库;Python 依赖按实验 README 在隔离环境中准备。2026-10-06 UTC 两层分别退出码 0:向量测试 2 项、包含真实端点的测试 3 项通过。回环端点没有测公网 QUIC、HTTP/3 浏览器、0-RTT 重放和 Key Phase 更新;不会由“回环订单收到响应”虚构这些结果。包号与密文的独立抓包核算也未做,应依 RFC 与后续专门实验,不把熟悉的 TLS 术语误当抓包证据。

两道有答案的练习

画图题: 画出四个框:“公开 Initial 输入”“TLS 握手消息”“TLS 产出的 1-RTT 流量秘密”“QUIC 保护的 1-RTT 包”。在箭头上标出谁能复算 Initial key、谁验证 orders.test、订单是否成为 TLS 记录。

可核对答案: 旁观者凭公开固定 salt 和首个 Initial 的目标连接 ID 能复算 Initial key;客户端依信任的 CA、证书服务名与握手认证验证服务身份;1-RTT 秘密来自双方握手,QUIC 根据它派生包保护密钥。订单成为 QUIC STREAM 数据,不成为 TLS record。删掉服务名验证,持同一 CA 签发的别名证书可能冒充目标;删掉握手身份绑定,攻击者可在 DH 两边各做一次交换。

变更题: 将 19_quic_initial.py 的 CLIENT_DESTINATION_ID 改一字节,或将 19_quic_loopback.py 中正常路径 client_config("orders.test") 改成 wrong.test,分别预测哪层断言失败。

可核对答案: 前者九项 RFC 9001 A.1 已知向量不再匹配,不能据此称 HKDF 坏了,只是输入不同;后者正确服务名的连接应在握手阶段失败,无法送达合成订单。失败前确保测试 CA 仍只在该实验客户端中使用。两种失败都不能说明钱包交易签名或账本状态。

资料与导航