阅读建议:每个Section先看核心问题,再看设计解答,最后看实现要点
目录
- DTLS要解决的根本问题:UDP ≠ TCP
- 为什么DTLS需要Connection ID?(核心设计)
- 记录层重新设计:可变长头部与序列号加密
- ACK机制:DTLS 1.3最大的协议创新
- 握手流程与DoS防护(Cookie交换)
- Epoch与密钥管理
- 分片与重组
- 重传状态机:实现层面的核心
- Key Update与Connection ID更新
- AEAD安全边界与实现约束
- 实现陷阱与踩坑指南
1. DTLS要解决的根本问题:UDP ≠ TCP
1.1 核心问题:TLS为什么不能直接跑在UDP上?
原文:
"TLS cannot be used directly over datagram transports for the following four reasons"
| # | 问题 | TLS的假设 | UDP的现实 | DTLS的解法 |
|---|---|---|---|---|
| 1 | 隐式序列号 | 包不会丢,序号 implicit 递增 | 丢包是常态 | 显式序列号 + Epoch |
| 2 | 握手必须保序 | 握手消息严格按序到达 | 乱序/丢包 | message_seq + 重传定时器 |
| 3 | 握手消息可能超大 | TCP自动分片重组 | UDP包<1500B | 应用层分片 (fragment_offset) |
| 4 | DoS攻击面 | TCP三次握手已有源地址验证 | 源IP可伪造 | Cookie交换验证可达性 |
开发理解:TLS是建立在"可靠传输"假设上的。DTLS不是去模拟TCP(早期的SSL over UDP方案试过,延迟巨大),而是在TLS之上做最少必要的修改来适配UDP的不可靠语义。
1.2 DTLS 1.3 vs DTLS 1.2:承上启下的关系
TLS 1.1 ──delta──> DTLS 1.0 (RFC 4347)
TLS 1.2 ──delta──> DTLS 1.2 (RFC 6347) ← 被废弃
TLS 1.3 ──delta──> DTLS 1.3 (RFC 9147) ← 本文档
DTLS 1.3直接从TLS 1.3派生,没有DTLS 1.1这个版本(版本号对齐TLS)。这意味着:
- TLS 1.3的所有握手优化(1-RTT、0-RTT)DTLS 1.3都继承
- TLS 1.3废弃的(CBC模式、压缩、重协商)DTLS 1.3也废弃
2. 为什么DTLS需要Connection ID?(核心设计)
2.1 问题的根源:NAT + UDP = 地址会变的连接
这是DTLS独有的核心需求,TLS完全不需要这东西。为什么?
TCP的场景:
Client:192.168.1.10:54321 ──TCP──> Server:10.0.0.1:443
↑
这个五元组(5-tuple)在整个连接生命周期不变
NAT表项保持,TCP连接维持
UDP的场景:
阶段1:
Client:192.168.1.10:54321 ──UDP──> Server:10.0.0.1:443
(NAT映射为 203.0.113.5:60001)
阶段2(NAT超时后,或者客户端切WiFi到4G):
Client:192.168.1.10:40000 ──UDP──> Server:10.0.0.1:443 ← 源端口变了!
(NAT映射为 203.0.113.5:60002)
问题:Server收到阶段2的包,5-tuple变了,怎么知道这是同一个DTLS连接?
原文:
"DTLS records without CIDs do not contain any association identifiers, and applications must arrange to multiplex between associations. With UDP, the host/port number is used to look up the appropriate security association for incoming records without CIDs."
没有CID时的查找方式:
收到UDP包(src_ip, src_port, dst_ip, dst_port)
↓
查哈希表(5-tuple → DTLS Association)
↓
问题:NAT重新映射后5-tuple变了,找不到关联!
2.2 Connection ID的设计:在记录层嵌入连接标识
CID的本质:DTLS协议在每个记录头部嵌入一个连接标识符,接收方用CID(而不是IP+端口)来查找对应的DTLS连接。
DTLS 1.3记录头部(含CID):
0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|0|0|1|1|1|1|E E| ← C bit = 1 (CID Present)
+-+-+-+-+-+-+-+-+
| Connection | ← CID (协商好的长度)
| ID |
/ (negotiated) /
| (e.g. 4B) |
+-+-+-+-+-+-+-+-+
| 16-bit |
| Seq Number |
+-+-+-+-+-+-+-+-+
| Length |
+-+-+-+-+-+-+-+-+
查找方式变了:
收到UDP包 → 解析DTLS头部 → 提取CID → 查哈希表(CID → Association)
↑
CID不变,即使IP和端口全变了!
2.3 CID的生命周期
握手协商CID 数据传输 CID更新
Client ─────────────> Server Client ──────> Server Client <──────> Server
| | | | | |
| ClientHello | | CID=5 | | [KeyUpdate] |
| + connection_id=5 | |─────────────>| | [NewCID] |
| ("我用CID=5标识 | | | | |
| 发给你们的包") | | CID=100 | | |
| | |<─────────────| | |
|<──────── ServerHello | | ("Server用 | | |
| + connection_id=100 | | CID=100" ) | | |
| ("你们发给我们的 | | | | |
| 包请带CID=100") | | | | |
每个方向独立:Client→Server的CID 和 Server→Client的CID 是不同的,各自独立协商。
这是DTLS独有的核心机制——TLS运行在TCP上,TCP连接就是标识,不需要额外的东西。DTLS运行在UDP上,UDP没有"连接"概念,所以必须自己造一个标识嵌入协议里。
2.4 CID更新的实现要点
// RFC中的数据结构
struct {
ConnectionId cids<0..2^16-1>; // 一批新的CID
ConnectionIdUsage usage; // cid_immediate(0) 或 cid_spare(1)
} NewConnectionId;
// 两种模式:
// cid_immediate: 收到后立即切换使用新CID
// cid_spare: 先留着备用(比如多路径场景,每条路径用一个CID)
约束:
- 同一时刻只能有一个未确认的NewConnectionId请求(MUST NOT have more than one outstanding)
- 收到RequestConnectionId后应尽快响应
- 换路径时应该使用新CID(防追踪)
2.5 CID的隐私保护意义
"The mechanism for encrypting sequence numbers prevents trivial tracking by on-path adversaries that attempt to correlate the pattern of sequence numbers received on different paths"
如果没有CID:
- 客户端从WiFi切换到4G,IP变了,必须重新握手建立新连接
- 攻击者看到两个不同IP上出现了类似的DTLS流量模式 → 关联为同一客户端
有了CID+序列号加密:
- 客户端可以无缝切换网络(连接不断)
- 不同路径用不同CID + 序列号加密 → 攻击者无法关联
3. 记录层重新设计:可变长头部与序列号加密
3.1 头部格式:为什么DTLS 1.3要重新设计?
DTLS 1.2的头部(固定13字节):
+--------+--------+--------+--------+--------
| Type(1)|Version(2)|Epoch(2)|Seq(6) |Len(2)|
+--------+--------+--------+--------+--------
21/22/23 FEFD 0000 000000000001 xxxx
DTLS 1.3的头部(可变,最小2字节!):
明文记录 (DTLSPlaintext):
+--------+--------+--------+--------+--------
| Type(1)|Version(2)|Epoch(2)|Seq(6) |Len(2)|
+--------+--------+--------+--------+--------
密文记录 (DTLSCiphertext) - 最小头部:
+--------+
|001000EE| ← 仅2字节:固定3位 + 标志位 + Epoch低2位
+--------+ ← 后面直接就是加密内容(无CID/无长度/8位序列号)
为什么能省这么多? TLS 1.3在设计时就意识到头部开销对物联网/低带宽场景是致命的。DTLS 1.3进一步激进优化。
3.2 Unified Header的位级解析
字节0: 0 0 1 C S L E1 E0
───── ─ ─ ─ ────
固定 CID 序列号 长度 Epoch低位
标志 长度标志 标志
// C=0,S=0,L=0: 最小头部,2字节,无CID,8位序列号,无长度
// C=1,S=1,L=1: 最大头部,含CID,16位序列号,有长度
最小变体 (2字节):
+--------+--------+
|001000EE|8位Seq |
+--------+--------+
头部 加密数据(占满UDP包剩余空间)
完整变体 (最大):
+--------+--------+--------+--------+--------+--------+
|0011SLEE| CID | CID | CID | CID |16位Seq |
+--------+--------+--------+--------+--------+--------+
| Seq | Length | 加密数据...
+--------+----------+
3.3 序列号加密:为什么这很重要?
攻击场景(没有序列号加密时):
攻击者 Eve 在路径上监听:
包1: Seq=100, len=100
包2: Seq=101, len=100
包3: Seq=102, len=1500 ← 大流量包
包4: Seq=103, len=100
Eve 分析序列号模式 → 推断出通信模式
即使CID换了,如果序列号是连续的,仍可跨路径追踪
DTLS 1.3的序列号加密:
加密序列号 = 原始序列号 XOR Mask
其中 Mask 的生成方式:
AES-based: Mask = AES-ECB(sn_key, Ciphertext[0:16])
ChaCha20: Mask = ChaCha20(sn_key, Ciphertext[0:4], Ciphertext[4:16])
关键:Mask 依赖于密文内容,每次都不一样!
→ 线上看不到原始序列号
→ 攻击者无法进行序列号模式分析
实现约束:
- 密文必须至少16字节(否则无法生成Mask)
- 如果原始数据太短,需要padding(TLS_AES_128_CCM_8_SHA256的tag只有8字节,这种情况需要特别注意)
3.4 解复用逻辑(Demultiplexing):收到UDP包怎么判断格式?
这是实现时的第一道关口,必须正确:
收到UDP数据包,看第一个字节:
+-------------------+
| 第一个字节 (OCT) |
+-------------------+
|
+--------------+--------------+--------------+
| | | |
OCT==20 OCT==21/22 32<=OCT<64 其他值
| /26\ /001xxxx\ |
↓ ↓ ↓ ↓
ChangeCipherSpec 明文DTLS DTLSCiphertext 拒绝
(DTLS<1.3) (Plaintext) (加密记录, (deprotection
ACK属于此类 真正的内容在 failed)
解密后的内部)
解密后看内部ContentType:
DCT==21 → Alert
DCT==22 → Handshake
DCT==23 → Application Data
DCT==26 → ACK
关键区间:32-63 (0b0010 0000 to 0b0011 1111) 被DTLS 1.3加密记录占用。IANA不会在这个范围内分配新的Content Type。
4. ACK机制:DTLS 1.3最大的协议创新
4.1 为什么DTLS 1.2不需要ACK,而DTLS 1.3需要?
DTLS 1.2的重传策略:
DTLS 1.2: 定时器超时 → 重传整个Flight的所有消息
问题:
Client发送Flight(假设5条消息,1000字节)
↓
只有第3条丢了 → Server无法处理(缺中间消息)
↓
定时器超时 → Client重传全部5条消息(1000字节)
↓
浪费带宽!尤其在高丢包网络
DTLS 1.3的ACK策略:
Client发送Flight(5条消息)
↓
Server收到3条,缺第3条 → 发ACK("我收到0,1,2,4,缺3")
↓
Client收到ACK → 只重传第3条!
↓
大大节省带宽,在高丢包网络尤其重要
原文:
"ACK messages are used in two circumstances, namely: On sign of disruption, or lack of progress; and To indicate complete receipt of the last flight in a handshake."
ACK的两种用途:
- 加速恢复:检测到接收中断时,告诉对端"我收到了哪些",让对端选择性重传
- 协议正确性:确认最后一个Flight(Mandatory)——没有后续Flight来"隐式确认"它了
4.2 ACK的格式
struct {
RecordNumber record_numbers<0..2^16-1>;
} ACK;
RecordNumber结构:
struct {
uint64 epoch; // 8字节
uint64 sequence_number; // 8字节
} RecordNumber; // 共16字节
一个ACK消息包含一组已收到记录的(epoch, seq_num)列表,按序排列。
4.3 ACK的发送时机
必须发ACK的情况:
1. 收到乱序消息(不是期望的下一个消息/分片)
→ 立即发ACK告知"我收到了这些"
2. 收到部分Flight后,短时间内没收到剩余部分
→ 设置定时器(1/4重传定时器值),超时发ACK
3. 收到"最后一个Flight"
→ 必须ACK,否则对端会一直重传到上限
不需要发ACK的情况(隐式确认):
ClientHello → 不需要ACK(Server会回HelloRetryRequest/ServerHello)
ServerHello Flight → 不需要ACK(Client会回Certificate+Finished)
Client的Cert Flight → 不需要ACK(如果有的话...实际上这是最后一个,需要ACK)
等等,这里有个微妙点。看原文:
"When a handshake flight is sent without any expected response, as is the case with the client's final flight or with the NewSessionTicket message, the flight must be acknowledged with an ACK message."
理解:如果发了一个Flight,之后"没有下一个Flight要发",那这个Flight就必须被ACK。
- 完整握手中,Client的最后一个Flight(Certificate+Finished)→ 需要ACK
- NewSessionTicket → 需要ACK
- KeyUpdate → 需要ACK
4.4 ACK的Epoch约束
重要规则:
"During the handshake, ACK records MUST be sent with an epoch which is equal to or higher than the record which is being acknowledged."
举例:
场景:Server的Flight跨越多个epoch
Record 0: ServerHello (epoch 0) ← 你收到了
Record 1: EncryptedExtensions (epoch 2) ← 你也收到了
Record 2: Certificate (epoch 2)
你想ACK Record 0和Record 1:
→ ACK必须用epoch ≥ 2来发(不能用epoch 0)
→ 因为你已经进入了epoch 2,你应该用当前最高epoch发ACK
4.5 ACK在握手流程中的实际例子
Client Server
ClientHello (Record 0, epoch=0) ──────>
(丢了!)
[定时器超时]
ClientHello (Record 0, epoch=0) ──────>
<────── ServerHello (Record 0, epoch=0)
<────── EncryptedExtensions (Record 1, epoch=2)
<────── Certificate (Record 2, epoch=2)
<────── CertificateVerify (Record 3, epoch=2)
<────── Finished (Record 4, epoch=2)
ACK [] (Record 1, epoch=2) ──────>
↑
注意:这是空ACK!为什么?
因为Client在收到ServerHello后才能推导出epoch 2的密钥,
才能解密EncryptedExtensions等。收到这些后,它才能ACK。
但此时Server的Flight已经完整到达。
空ACK的作用是:告诉Server"我还在,继续",加速Server的响应。
Certificate (Record 2, epoch=2) ──────>
CertificateVerify (Record 3, epoch=2) ────>
Finished (Record 4, epoch=2) ──────>
<────── ACK [2,3,4] (Record 5, epoch=3)
↑
Server确认收到客户端最后Flight
4.6 ACK的接收处理
收到ACK时:
1. 遍历record_numbers列表
2. 对每个已确认的记录,标记对应消息为"已ACK"
3. 如果Flight中所有消息都已ACK → 取消重传定时器
4. 如果部分ACK → 只重传未被确认的消息
5. 握手流程与DoS防护(Cookie交换)
5.1 为什么DTLS必须做Cookie交换,TLS不用?
TLS场景(有TCP保护):
攻击者伪造ClientHello(源IP = 受害者IP)
↓
TCP三次握手 → 受害者不会回ACK/SYN-ACK → 连接建不起来
↓
攻击失败(TCP本身就是可达性验证)
DTLS场景(无连接保护):
攻击者伪造ClientHello(源IP = 受害者IP,比如8.8.8.8)
↓
Server → 分配状态 → 做密钥运算 → 发送大证书(可能几KB~几十KB)
↓
流量全部打到 8.8.8.8(受害者被洪水攻击!)
↓
攻击成功:Server成了攻击放大器
Cookie交换 = 在DTLS层面实现"可达性验证":
ClientHello ────> Server
↓
不分配任何状态!
只生成Cookie = HMAC(客户端IP, ClientHello哈希, 时间戳, 密钥)
↓
HelloRetryRequest + Cookie ────> Client
↓
Client必须带回Cookie
↓
ClientHello + Cookie ────> Server
↓
验证Cookie( Stateless,只需验HMAC )
验证通过 → 开始正式握手,分配状态
5.2 实现细节
原文:
"The HelloRetryRequest is designed to be small enough that it will not itself be fragmented, thus avoiding concerns about interleaving multiple HelloRetryRequests."
关键点:HelloRetryRequest本身不需要重传机制。为什么?
- 服务器发HelloRetryRequest后不创建任何状态
- 如果HelloRetryRequest丢了,客户端超时重传ClientHello
- 服务器收到重传的ClientHello后,再发一次HelloRetryRequest
- 不需要在服务器端维护"我发过HelloRetryRequest给这个客户端"的状态
Cookie的内容:
Cookie = HMAC-SHA256(ServerKey, ClientIP || ClientHelloHash || Timestamp)
推荐包含:
- 客户端IP地址(防止攻击者偷别人的Cookie来用)
- ClientHello的哈希值(握手日志需要)
- 时间戳(Cookie有效期控制)
- 服务器密钥标识符(支持密钥轮换)
5.3 完整握手流程图
Full DTLS Handshake (with Cookie Exchange)
Client Server
| |
| ClientHello |
| (no cookie) +--------+ |
|-----------------------------> | Flight | |
| +--------+ |
| |
| +--------+ |
|<------------------------ HelloRetryRequest | Flight | |
| + cookie +--------+ |
| |
| ClientHello |
| + cookie +--------+ |
|-----------------------------> | Flight | |
| +--------+ |
| |
| ServerHello |
| {EncryptedExtensions} |
| {CertificateRequest*} |
|<---------+ {Certificate*} |
| | {CertificateVerify*} |
| | {Finished} |
| | +--------+ |
| | | Flight | |
| | +--------+ |
| +-- 这里Server一口气把所有消息发过来 |
| |
| {Certificate*} |
| {CertificateVerify*} +--------+ |
| {Finished} + [AppData*] ----------------------> | Flight | |
| +--------+ |
| |
| +--------+ |
|<--------------------------- [ACK] | Flight | |
| [AppData*] +--------+ |
| |
| [AppData] <--------------------------------------------> [AppData]
图例说明:
{} = 用handshake_traffic_secret保护的记录 (epoch 2)
[] = 用application_traffic_secret保护的记录 (epoch 3)
() = 未加密的记录 (epoch 0)
* = 可选的消息
5.4 PSK/0-RTT握手(无Cookie交换)
PSK Handshake (without Cookie Exchange)
Client Server
| ClientHello |
| + pre_shared_key +--------+ |
| + psk_key_exchange_modes | Flight | |
| + key_share* -----------------------------> +--------+ |
| |
| ServerHello |
| + pre_shared_key |
|<----------------------------- + key_share* |
| {EncryptedExtensions} |
| {Finished} |
| [AppData*] |
| +--------+ |
| | Flight | |
| +--------+ |
| {Finished} +--------+ |
| [AppData*] ---------------------------------------> | Flight | |
| +--------+ |
| +--------+ |
|<-------------------------------- [ACK] | Flight | |
| [AppData*] +--------+ |
| |
| [AppData] <------------------------------------------> [AppData]
注意:0-RTT场景如果跳过Cookie交换,Server发送的数据量不能超过Client发送量的3倍(防放大)。
5.5 0-RTT握手
Zero-RTT Handshake
Client Server
| ClientHello |
| + early_data +--------+ |
| + psk_key_exchange_modes | Flight | |
| + key_share* +--------+ |
| + pre_shared_key |
| (Application Data*) ---------------------------------> |
| 这里客户端在第1个包就发了应用数据!|
| |
| ServerHello |
| + pre_shared_key |
|<----------------------------- + key_share* |
| {EncryptedExtensions} |
| {Finished} |
| [AppData*] |
| +--------+ |
| | Flight | |
| +--------+ |
| {Finished} +--------+ |
| [AppData*] ---------------------------------------> | Flight | |
| +--------+ |
| +--------+ |
|<-------------------------------- [ACK] | Flight | |
| [AppData*] +--------+ |
| |
| [AppData] <------------------------------------------> [AppData]
6. Epoch与密钥管理
6.1 Epoch的本质:密钥版本号
Epoch = 一个整数编号,每次密钥切换时递增
不同Epoch对应不同的密钥:
Epoch 0: 明文,无加密(ClientHello, ServerHello, HelloRetryRequest)
Epoch 1: client_early_traffic_secret(0-RTT early data)
Epoch 2: [sender]_handshake_traffic_secret(握手加密消息)
Epoch 3: [sender]_application_traffic_secret_0(初始应用数据)
Epoch 4+: [sender]_application_traffic_secret_N(Key Update后的数据)
为什么需要Epoch? UDP包可能乱序到达,你收到一个加密包时,必须知道用哪把钥匙解密。Epoch就是钥匙的编号。
6.2 Epoch在握手过程中的变化
Client Server
| |
| ClientHello (epoch=0) |
| ----------------------------> |
| |
| <-------- HelloRetryRequest (epoch=0)
| |
| ClientHello (epoch=0) |
| ----------------------------> |
| |
| <-------- ServerHello (epoch=0)
| {EncryptedExtensions} (epoch=2)
| {Certificate} (epoch=2)
| {CertificateVerify} (epoch=2)
| {Finished} (epoch=2)
| |
| {Certificate} (epoch=2) |
| {CertificateVerify} (epoch=2) |
| {Finished} (epoch=2) ──────────────> |
| |
| <──────── [ACK] (epoch=3) |
| |
| [Application Data] (epoch=3) |
| <───────────────────────────> [Application Data] (epoch=3)
| |
| Some time later ... |
| |
| <──────── [NewSessionTicket] (epoch=3)
| [ACK] (epoch=3) |
| ────────────────────────────> |
| |
| Some time later ... (Rekeying) |
| |
| <──────── [AppData] (epoch=4) |
| [AppData] (epoch=4) |
| ────────────────────────────> |
6.3 Epoch与记录号的关系
完整记录号 = (epoch, sequence_number) 共16字节
线上传输的 完整版本
─────────── ───────────
Epoch: 低2位 (EE位) → uint64 (8字节)
Seq Num: 低8/16位 → uint64 (8字节)
Epoch的重构:线上只有2位Epoch,怎么知道是哪个Epoch?
- 握手阶段:Epoch低2位就能唯一确定(只有0和2在用)
- 应用阶段:找最近的成功解密的epoch,低2位匹配即可
序列号的重构:
已知的:当前epoch最高成功解密序列号 = 500
收到的:线上8位序列号 = 0xF5 (245)
推导:完整序列号应该是让低8位=245,且最接近501的值
可能的值:245, 501, 757, 1013...
最接近501的是 501 本身 (0x1F5, 低8位=0xF5)
所以完整序列号 = 501
7. 分片与重组
7.1 为什么握手消息需要分片?
TLS握手消息最大: 2^24 - 1 = 16,777,215 bytes (~16MB)
UDP典型MTU: ~1200-1500 bytes(去掉IP/UDP头后)
证书链很容易达到几十KB → 必须分片!
7.2 分片机制
原始握手消息(假设 3000 bytes):
+--------+--------+----------------------------+
|msg_type| length | body (3000 bytes) |
| 22 | 3000 | |
+--------+--------+----------------------------+
分成3个DTLS分片:
Fragment 1:
+--------+--------+---------+----------+----------+-----------+
|msg_type| length | msg_seq | frag_off | frag_len | body[0:1000]|
| 22 | 3000 | 0 | 0 | 1000 | |
+--------+--------+---------+----------+----------+-----------+
Fragment 2:
+--------+--------+---------+----------+----------+-------------+
|msg_type| length | msg_seq | frag_off | frag_len |body[1000:2000]|
| 22 | 3000 | 0 | 1000 | 1000 | |
+--------+--------+---------+----------+----------+-------------+
Fragment 3:
+--------+--------+---------+----------+----------+-------------+
|msg_type| length | msg_seq | frag_off | frag_len |body[2000:3000]|
| 22 | 3000 | 0 | 2000 | 1000 | |
+--------+--------+---------+----------+----------+-------------+
关键:
- 所有分片的 msg_seq 相同(属于同一个消息)
- 所有分片的 length 相同(原始消息总长)
- frag_off 递增,frag_len 是当前片大小
7.3 重组实现要点
收到分片时的处理逻辑:
1. 检查 msg_seq == next_receive_seq?
- 是 → 进入重组流程
- 否 → 缓存,等待前面的消息
2. 检查分片是否已有?
- 用 (frag_off, frag_len) 标记已收到的区域
- 允许重叠!(重传时可能改变分片大小)
3. 收集完成后:
- 按 frag_off 排序拼接
- 去掉 DTLS 头(msg_seq/frag_off/frag_length)
- 还原成标准 TLS Handshake 结构
- 加入 transcript 哈希
4. 验证:
- 所有字节到齐?
- 长度 == 原始 length?
重要约束:
- 重传时不能修改消息内容的字节(MUST NOT change handshake message bytes upon retransmission)
- 但可以改变分片大小(允许重叠分片)
- 接收方应检查重传字节是否一致,不一致则abort(illegal_parameter alert)
8. 重传状态机:实现层面的核心
8.1 状态机
+-----------+
+------->| PREPARING |
| | |
| +-----------+
| |
| | Buffer next flight
| |
| v
| +-----------+
| | |
+-------| SENDING |<------------------+
| | | |
| +-----------+ |
Receive next | | |
flight | | Send flight or partial |
| | flight |
| | Set retransmit timer |
| v |
| +-----------+ |
| | | |
+-------| WAITING |-------------------+
| | | Timer expires |
| +-----------+ |
| | |
| +--------+---------+ |
| | | |
| | Receive record | Receive ACK |
| | (Maybe Send ACK) | for last |
| | | flight |
| v v |
| +-----------+ +-----------+ |
| | Send ACK | | FINISHED | |
| | maybe | | | |
| | retransmit| +-----------+ |
| +-----------+ ^ |
| | |
+-----------------------+----------------+
8.2 状态详解
| 状态 | 行为 |
|---|---|
| PREPARING | 计算/准备下一个Flight的消息,放入发送缓冲区,进入SENDING |
| SENDING | 发送缓冲区的所有消息(或部分消息),设置重传定时器,进入WAITING |
| WAITING | 等待对端响应。有4种退出方式(见下) |
| FINISHED | 握手完成。但Server仍需在2×MSL内响应Client最后Flight的重传 |
8.3 WAITING的4种退出方式
1. 定时器超时
→ 进入SENDING重传整个Flight
→ 定时器翻倍(指数退避)
→ 回到WAITING
2. 收到ACK(部分确认)
→ 进入SENDING,只重传未被确认的消息
→ 重置定时器
→ 回到WAITING
3. 收到对端的重传Flight
(说明你之前发的Flight对方没收到)
→ 进入SENDING重传你的Flight
→ 重置定时器
→ 回到WAITING
4. 收到下一个Flight
→ 如果是最后Flight → FINISHED
→ 否则 → PREPARING(准备你的下一个Flight)
8.4 定时器配置
默认配置:
- 初始超时 = 1000ms
- 每次重传翻倍
- 上限 = 60秒
有RTT信息时:
- 超时 = 1.5 × RTT
实测到RTT后:
- 超时 = 1.5 × 实测RTT
长时间空闲后(> 10 × 当前超时):
- 重置为初始值
特殊场景:
- DTLS-SRTP(实时语音):400ms
- 物联网低功耗mesh:可能更长
8.5 Post-Handshake消息的状态机
每种post-handshake消息类型有独立的状态机:
允许批量发(不等ACK)的:
- NewSessionTicket: 可以一次发多个
- CertificateRequest: 可以一次发多个
必须等ACK后才能发下一个的:
- KeyUpdate
- NewConnectionId
- RequestConnectionId
为什么KeyUpdate必须等ACK?
如果不等ACK:
Client发KeyUpdate (epoch=4)
↓
Client紧接着发另一个KeyUpdate (epoch=5)
↓
第一个KeyUpdate的ACK丢了
↓
Server收到epoch=5的包,但没见过epoch=4的KeyUpdate
↓
Server不知道怎么办 → 协议失败
所以约束:MUST NOT send records with the new keys until the previous KeyUpdate has been acknowledged
9. Key Update与Connection ID更新
9.1 Key Update流程
Client Server
| |
| [AppData] (epoch=3) |
| ────────────────────────────> |
| |
| <──────── [AppData] (epoch=3) |
| |
| [KeyUpdate] (+update_requested) |
| (epoch=3) ────────────────────────────> |
| |
| <──────── [AppData] (epoch=3) |
| |
| <──────── [ACK] (epoch=3) |
| ← 收到ACK后才能切换密钥 |
| |
| [AppData] (epoch=4) ← 用新密钥! |
| ────────────────────────────> |
| |
| <──────── [KeyUpdate] |
| (epoch=3) |
| [ACK] (epoch=4) |
| ────────────────────────────> |
| |
| <──────── [AppData] (epoch=4) |
9.2 Connection ID更新流程
Client Server
| |
| [NewSessionTicket] (epoch=3) |
| <──────────────────────────── |
| |
| [ACK] (epoch=3) |
| ────────────────────────────> |
| |
| 一段时间后... |
| |
| <──────── [NewConnectionId] |
| cids=[10,11,12] |
| usage=cid_spare |
| |
| [ACK] (epoch=3) |
| ────────────────────────────> |
| |
| Client切换到新路径(WiFi → 4G) |
| IP变了,开始用 CID=10 发数据 |
| [AppData] (cid=10, epoch=3) |
| ────────────────────────────> |
| |
| Server看到新IP但CID=10 → 查到是同一个连接 |
| <──────── [AppData] (cid=5) |
| (Server的CID没变) |
10. AEAD安全边界与实现约束
10.1 为什么DTLS的AEAD限制比TLS更严格?
TLS 1.3行为:
收到认证失败的包 → 立即断开连接
→ 攻击者只有一次尝试机会
DTLS行为:
收到认证失败的包 → 静默丢弃
→ 攻击者可以不断尝试伪造!
→ 必须计数丢弃次数,超过上限断开
10.2 各AEAD算法的安全边界
| AEAD算法 | 加密包上限 | 认证失败上限 | 备注 |
|---|---|---|---|
| AES-128-GCM | 见TLS 1.3 | 2^36 | |
| AES-256-GCM | 见TLS 1.3 | 2^36 | |
| ChaCha20-Poly1305 | 见TLS 1.3 | 2^36 | |
| AES-128-CCM | 2^23 | 2^23.5 | 更严格 |
| AES-128-CCM-8 | 自定义 | 自定义 | 禁止无额外防护使用 |
10.3 实现要求
// 每个epoch必须维护的计数器
struct EpochState {
uint64_t encrypt_count; // 成功加密的包数
uint64_t auth_failure_count; // 认证失败的包数
// 达到任一上限时必须:
// 1. 发起 Key Update
// 2. 或关闭连接
};
11. 实现陷阱与踩坑指南
11.1 Epoch管理
坑1:收到旧Epoch的包
场景:
Server发送Finished (epoch 2)后进入epoch 3
但网络乱序,Client的某个epoch 2的包晚到了
正确做法:
保留旧epoch的密钥至少2×MSL(~4分钟)
尝试用旧密钥解密
成功后处理,但不要用这个包更新滑动窗口
坑2:Epoch回绕
Epoch是uint64_t,理论上不可能回绕
但必须显式检查:达到 2^48-1 后禁止继续KeyUpdate
→ 必须终止连接建新连接
11.2 序列号重构
坑:收到乱序包时怎么重构完整序列号?
已收到的:epoch=3, seq=100, 101, 102, 105
现在收到:线上8位序列号 = 0x67 (103)
最近的完整seq = 105
期望的下一个 = 106
候选值:
低8位=0x67的可能值:...55, 311, 567, 823...
最接近106的是 103 (0x67)
所以完整序列号 = 103
但注意:如果网络大幅乱序,这个算法可能选错!
(不过选错的结果只是解密失败,不会有安全问题)
11.3 ACK处理的边界情况
坑1:空ACK
场景:收到EncryptedExtensions但还没收到ServerHello
问题:无法解密EncryptedExtensions,不敢确认它
解决:发空ACK(record_numbers为空)
作用:告诉对端"我还活着,继续发"
坑2:收到ACK时Flight已被部分确认
必须只重传未确认的消息
需要维护每条消息→所在记录的映射
坑3:握手阶段收到epoch比ACK目标低的记录
规则:ACK必须用 ≥ 被确认记录的epoch来发
实现:始终用当前最高发送epoch来发ACK
11.4 兼容性
坑1:DTLS 1.3不使用TLS 1.3的"compatibility mode"
- Server MUST NOT echo legacy_session_id
- 两端 MUST NOT 发送 ChangeCipherSpec
坑2:legacy_version字段
- 所有记录必须设为 {254, 253}(即DTLS 1.2的版本号)
- 仅在初始ClientHello中可为 {254, 255}(兼容性)
- 这个字段必须被接收方忽略!
坑3:DTLS 1.3的transcript不包含DTLS特有字段
- message_seq, fragment_offset, fragment_length 不计入哈希
- 这与DTLS 1.2不同!
11.5 多路径与CID
坑:收到不同源地址的包
DTLS 1.3允许地址变化(通过CID)
但实现 MUST NOT 因为收到新地址的包就改变发送目标
为什么?攻击者可以伪造源地址让你把流量发到第三方!
(反射攻击)
正确做法:
- 有CID的包:用CID查连接,不改变发送地址
- 没有CID的包:5-tuple查连接
- 地址更新需要专门的可达性验证(本规范未定义)
附录:核心数据结构与常量速查
记录层结构
// 明文记录(握手早期)
struct DTLSPlaintext {
uint8_t type; // ContentType
uint16_t legacy_record_version; // {254, 253}
uint16_t epoch; // 低2字节
uint8_t sequence_number[6]; // 48位序列号
uint16_t length;
uint8_t fragment[];
};
// 密文记录头部(可变长)
// 字节0: 0 0 1 C S L E1 E0
// 可选: Connection ID (协商长度)
// 可选: 8位或16位序列号
// 可选: 16位长度
// 解密后的内部结构
struct DTLSInnerPlaintext {
uint8_t content[];
uint8_t type; // 真正的ContentType
uint8_t zeros[]; // 填充
};
// 完整记录号(用于ACK和AEAD)
struct RecordNumber {
uint64_t epoch;
uint64_t sequence_number;
};
握手结构
struct DTLSHandshake {
uint8_t msg_type; // HandshakeType
uint24_t length; // 消息总长度
uint16_t message_seq; // DTLS特有
uint24_t fragment_offset; // DTLS特有
uint24_t fragment_length; // DTLS特有
// body...
};
// HandshakeType
enum {
client_hello(1),
server_hello(2),
new_session_ticket(4),
end_of_early_data(5),
encrypted_extensions(8),
request_connection_id(9), // DTLS 1.3新增
new_connection_id(10), // DTLS 1.3新增
certificate(11),
certificate_request(13),
certificate_verify(15),
finished(20),
key_update(24),
message_hash(254)
};
CID相关
enum {
cid_immediate(0),
cid_spare(1)
} ConnectionIdUsage;
struct NewConnectionId {
ConnectionId cids[]; // 提供的CID列表
ConnectionIdUsage usage;
};
struct RequestConnectionId {
uint8_t num_cids; // 请求的CID数量
};
关键常量
| 常量 | 值 | 说明 |
|---|---|---|
| Initial Timer | 1000 ms | 默认初始重传超时 |
| Max Timer | 60 s | 最大重传超时 |
| Max Epoch | 2^48 - 1 | Epoch上限 |
| AES-128-CCM Limit | 2^23 | 该算法的包上限 |
| Auth Failure Limit (GCM/ChaCha) | 2^36 | GCM/ChaCha认证失败上限 |
| ContentType ACK | 26 | ACK内容类型编号 |
| Alert too_many_cids_requested | 52 | 过多CID请求alert |
总结:DTLS 1.3设计的5个核心思想
- 最少修改原则:在TLS 1.3上做最小必要的UDP适配,不重复发明
- 头部极致压缩:可变长Unified Header,最小2字节,适应物联网
- 序列号加密: borrowed from QUIC,防流量分析和跨路径追踪
- ACK选择性重传:解决DTLS 1.2全Flight重传的带宽浪费
- Connection ID:解决NAT/网络切换问题,这是TLS完全没有的需求
推荐阅读顺序:
- 先通读RFC 9147的Section 3(设计原理)
- 重点研究Section 4(记录层格式)和Section 7(ACK)
- 结合本文档的实现要点进行代码设计
- 最后参考Appendix C(实现陷阱)