DTLS 握手流程总览:从零开始建立安全通道

DTLS 握手,说白了就是客户端和服务器在 UDP 上互相确认身份、协商密钥的过程。它脱胎于 TLS 握手,但为了适应 UDP 的不可靠特性,做了不少改动。

我记得刚接触 DTLS 时,总觉得它比 TLS 复杂。后来发现,核心逻辑其实没变——就是“你好,我来了”、“好的,这是我的证书”、“这是密钥材料”、“好了,开始加密通信”。只不过 DTLS 多了个“万一丢包了怎么办”的机制。

握手流程的宏观视角

一个完整的 DTLS 1.2 握手,大致分这么几步:

  1. ClientHello:客户端发起连接,告诉服务器自己支持的版本、密码套件、随机数。
  2. ServerHello:服务器回应,选定版本和密码套件,也发一个随机数。
  3. Certificate(可选):服务器发送自己的证书链。
  4. ServerKeyExchange(可选):如果密码套件需要,服务器发送密钥交换参数。
  5. CertificateRequest(可选):服务器要求客户端也发证书。
  6. ServerHelloDone:服务器说“我发完了”。
  7. Certificate(客户端,可选):客户端发证书。
  8. ClientKeyExchange:客户端发送密钥交换参数。
  9. CertificateVerify(可选):客户端用私钥签名,证明自己持有证书对应的私钥。
  10. ChangeCipherSpec:双方各自通知对方“我要开始加密了”。
  11. 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 1.2 完整握手流程 客户端 服务器 ① ClientHello (cookie 为空) ② HelloVerifyRequest (发送 cookie) ③ ClientHello (携带 cookie) ④ ServerHello + Certificate + ServerKeyExchange ⑤ ServerHelloDone ⑥ ClientKeyExchange + CertificateVerify ⑦ ChangeCipherSpec (双方) ⑧ Finished (双方,加密后) 注意:步骤②③是 DTLS 特有的 cookie 交换,用于防止 UDP 反射攻击

握手状态机:设备怎么知道下一步该做什么

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 握手的基础框架就是这样。下一层我们就要深入每个消息的具体格式和字段含义了。不过那是后面的事,先把这些基础概念消化掉。