DTLS 握手流程总览:从零开始建立安全通道
DTLS 握手,说白了就是客户端和服务器在 UDP 上互相确认身份、协商密钥的过程。它脱胎于 TLS 握手,但为了适应 UDP 的不可靠特性,做了不少改动。
我记得刚接触 DTLS 时,总觉得它比 TLS 复杂。后来发现,核心逻辑其实没变——就是“你好,我来了”、“好的,这是我的证书”、“这是密钥材料”、“好了,开始加密通信”。只不过 DTLS 多了个“万一丢包了怎么办”的机制。
握手流程的宏观视角
一个完整的 DTLS 1.2 握手,大致分这么几步:
- ClientHello:客户端发起连接,告诉服务器自己支持的版本、密码套件、随机数。
- ServerHello:服务器回应,选定版本和密码套件,也发一个随机数。
- Certificate(可选):服务器发送自己的证书链。
- ServerKeyExchange(可选):如果密码套件需要,服务器发送密钥交换参数。
- CertificateRequest(可选):服务器要求客户端也发证书。
- ServerHelloDone:服务器说“我发完了”。
- Certificate(客户端,可选):客户端发证书。
- ClientKeyExchange:客户端发送密钥交换参数。
- CertificateVerify(可选):客户端用私钥签名,证明自己持有证书对应的私钥。
- ChangeCipherSpec:双方各自通知对方“我要开始加密了”。
- Finished:双方发送握手消息的校验值,确认握手没有被篡改。
嗯,这里要注意:DTLS 的 ChangeCipherSpec 和 Finished 之间,必须保证顺序。我在项目中遇到过因为消息乱序导致握手失败的情况,排查了半天才发现是缓冲区处理的问题。
关键点:DTLS 握手比 TLS 多了一个“无状态 cookie 交换”机制(HelloVerifyRequest),用来防止 UDP 反射攻击。这个我们后面会详细讲。
记录层协议:DTLS 的“信封”
记录层是 DTLS 的最底层封装。所有握手消息、应用数据、告警消息,都要先打包成记录层格式,再通过 UDP 发送。
记录层的结构很简单:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| Content Type | 1 | 消息类型:20=ChangeCipherSpec, 21=Alert, 22=Handshake, 23=Application Data |
| Version | 2 | 协议版本,DTLS 1.2 是 {254, 253} |
| Epoch | 2 | 加密状态编号,每次 ChangeCipherSpec 后递增 |
| Sequence Number | 6 | 记录序列号,用于防重放和乱序重组 |
| Length | 2 | 负载长度 |
| Fragment | 可变 | 实际消息内容 |
你想想看,为什么 DTLS 要加 Epoch 和 Sequence Number?因为 UDP 会丢包、会乱序。TLS 靠 TCP 保证顺序,DTLS 只能自己来。
我曾经在一个低功耗设备上调试 DTLS,发现设备偶尔会解密失败。后来抓包一看,原来是 Epoch 字段在 ChangeCipherSpec 之后没有正确递增。说白了,就是状态机没处理好。
个人经验:在实现 DTLS 记录层时,我建议把 Epoch 和 Sequence Number 的维护放在一个独立的状态机里。这样握手和应用数据切换时,不容易出错。
握手消息类型:DTLS 的“语言”
握手消息是记录层负载的一部分。DTLS 1.2 定义了 10 种握手消息类型,每种都有自己的格式和用途。
| 类型编号 | 消息名称 | 用途 |
|---|---|---|
| 0 | HelloRequest | 服务器要求客户端重新握手 |
| 1 | ClientHello | 客户端发起握手 |
| 2 | ServerHello | 服务器回应 |
| 3 | HelloVerifyRequest | DTLS 专用,发送 cookie 验证客户端 |
| 11 | Certificate | 发送证书链 |
| 12 | ServerKeyExchange | 服务器发送密钥交换参数 |
| 13 | CertificateRequest | 服务器请求客户端证书 |
| 14 | ServerHelloDone | 服务器表示握手消息发送完毕 |
| 15 | CertificateVerify | 客户端证明自己持有证书私钥 |
| 16 | ClientKeyExchange | 客户端发送密钥交换参数 |
| 20 | Finished | 握手完成校验 |
每个握手消息都有一个公共头部:
struct {
HandshakeType msg_type; // 1 字节
uint24 length; // 3 字节,消息体长度
uint16 message_seq; // 2 字节,消息序号
uint24 fragment_offset; // 3 字节,分片偏移
uint24 fragment_length; // 3 字节,分片长度
select (msg_type) {
case client_hello: ClientHello;
case server_hello: ServerHello;
// ... 其他消息类型
} body;
} Handshake;
注意这里的 message_seq、fragment_offset、fragment_length。这三个字段是 DTLS 特有的。为什么?因为 UDP 包大小有限制(通常 1500 字节 MTU),大的握手消息(比如证书链)需要分片发送。接收方根据这些字段重组消息。
我在一个项目中遇到过这样的坑:设备发送的证书链太大,超过了 UDP 的路径 MTU,结果被中间路由器分片了。DTLS 自己的分片机制和 IP 分片混在一起,导致接收方重组失败。后来我强制把证书链分片成 512 字节一块,问题就解决了。
避坑指南:我曾经在低功耗设备上实现 DTLS 时,忽略了 message_seq 的连续性。设备在重传时把 message_seq 重置了,导致服务器认为收到了重复消息。记住:message_seq 在整个握手过程中是单调递增的,重传时不能重置。
握手流程的 SVG 总览图
下面这张图展示了 DTLS 1.2 完整握手的消息交换过程。我特意把 DTLS 特有的 HelloVerifyRequest 和分片机制标了出来。
握手状态机:设备怎么知道下一步该做什么
DTLS 握手是一个典型的状态机。每个端点根据收到的消息类型,决定下一步动作。对于低功耗设备来说,状态机的实现直接影响内存占用和功耗。
我常用的状态机设计是这样的:
enum HandshakeState {
// 客户端状态
CLIENT_IDLE,
CLIENT_HELLO_SENT,
CLIENT_WAITING_COOKIE,
CLIENT_HELLO_RETRY_SENT,
CLIENT_WAITING_SERVER_DONE,
CLIENT_KEY_EXCHANGE_SENT,
CLIENT_FINISHED_SENT,
// 服务器状态
SERVER_IDLE,
SERVER_COOKIE_SENT,
SERVER_HELLO_SENT,
SERVER_WAITING_CLIENT_KEY,
SERVER_FINISHED_SENT,
// 公共状态
HANDSHAKE_DONE,
HANDSHAKE_FAILED
};
你想想看,为什么要把客户端和服务器的状态分开?因为低功耗设备可能只做客户端(比如传感器上报数据),也可能只做服务器(比如网关)。分开定义状态机,可以节省一半的状态存储空间。
优化建议:在低功耗设备上,我建议把状态机实现为枚举类型 + 函数指针表。这样每个状态只需要一个函数指针,而不是一堆 if-else。代码更紧凑,执行也更快。
握手消息的分片与重组
DTLS 的分片机制是它和 TLS 最大的区别之一。UDP 包大小有限,证书链可能很大,所以必须分片。
分片规则很简单:
- 每个握手消息有一个唯一的
message_seq - 如果消息太大,用
fragment_offset和fragment_length切分成多个记录 - 接收方根据
message_seq和fragment_offset重组 - 分片可以乱序到达,但必须等所有分片收齐才能处理
我曾经在一个项目中,设备的内存只有 32KB。证书链分片后,需要缓存所有分片才能重组。后来我改用流式处理:每收到一个分片,直接写入预分配的内存区域,而不是先存分片再拷贝。这样内存占用从 16KB 降到了 4KB。
避坑指南:我曾经遇到过一个问题:设备在重组分片时,没有检查 fragment_offset + fragment_length 是否超过消息总长度。结果一个恶意分片导致缓冲区溢出。记住:一定要做边界检查,这是安全底线。
超时与重传:UDP 的“确认”机制
DTLS 握手是可靠的吗?不是。UDP 本身不可靠,所以 DTLS 自己实现了超时重传机制。
基本策略:
- 发送握手消息后,启动一个定时器
- 如果超时未收到响应,重传最后一条消息
- 重传间隔指数退避(1s, 2s, 4s, 8s...)
- 达到最大重传次数后,握手失败
对于低功耗设备,这个机制很关键。你想想看,如果设备在睡眠模式下,定时器怎么处理?我一般用硬件定时器 + 唤醒机制:定时器到期时唤醒 CPU,检查是否需要重传。如果不需要,继续睡眠。
嗯,这里有个细节:DTLS 的重传是针对整个握手消息的,不是针对记录层分片。也就是说,如果某个分片丢失了,重传的是整个握手消息的所有分片。这看起来浪费,但简化了实现。
核心要点:DTLS 握手流程的核心是“三次握手”的变体:ClientHello → HelloVerifyRequest → ClientHello(带 cookie)→ ServerHello → ... → Finished。这个流程保证了在不可靠的 UDP 上也能安全建立连接。
好了,DTLS 握手的基础框架就是这样。下一层我们就要深入每个消息的具体格式和字段含义了。不过那是后面的事,先把这些基础概念消化掉。