上手 WebRTC DTLS 遇到很多 BUG?浅谈 DTLS Fragment
上一篇《详解 WebRTC 传输安全机制:一文读懂 DTLS 协议》详细阐述了 DTLS。本文将结合 DTLS 开发中遇到的问题,详细解读 DTLS 的一些基础概念以及 Fragment 的机制,并进一步深究 DTLS 协议。
作者|泰一
审校|进学、莫战
前言
最近在做 J 和 G 这两套 RTC 系统的 DTLS-SRTP 握手加密工作,要求使用 CA 机构颁发的证书。在本机调试的过程中发现:G 系统使用 CA 证书,DTLS 握手成功,而 J 系统则握手失败。
经过几番调试与分析,定位到了原因:J 系统相较于 G 系统多了一个 TURN 转发模块,该模块设置的接收缓冲区的上限值为 1600 字节,而 CA 证书的大小则有近 3000 字节,因此 TURN 模块转发给客户端的证书不完整,导致 DTLS 握手失败。
大家都知道, WebRTC 的 DTLS 使用的是自签名的证书,这个证书一般不会太大,如下图所示,只有 286 字节。
然而,如果要使用 CA 颁发的证书,那么这个证书可能会很大,如下图所示,竟达到了 2772 字节,显然超出了 TURN 模块的接收缓冲区的大小。
上图中,你可能注意到了这个 CA 证书被分成了两片(two fragments),这其实是 DTLS 协议层做的。不过值得思考的是,CA 证书的每一片的大小都未超出 TURN 模块接收缓冲区的 1600 字节的限制,但是为什么 J 系统的 TURN 转发模块依然会接收失败呢?
这是因为证书虽然被分片,但是在发送到 TURN 模块时并没有按照分片独立发送,仍然是全部打包到了同一个 UDP 数据报中进行发送,所以接收肯定会失败。
下面,我们将一起了解下 DTLS Fragment 的机制。首先要理清几个概念。
Message、Record、Flight
DTLS 协议分为两层:底层的 record protocol 和上层的 handshake protocol、change cipher spec protocol、alert protocol 以及 application data protocol。
Remark:握手协议、密码规格变更协议、警告协议、应用数据协议均在 DTLS 记录协议的上层,这四种协议统称为 DTLS 握手协议。
Note:关于记录和握手这两层协议各自的作用,这里就不再赘述,可以参考 WebRTC 中 DTLS 的应用。
DTLS Message 是一条完整的 DTLS 消息。比如握手消息:Client Hello、Certificate、Client Key Exchange 等;比如密码规格变更消息:Change Cipher Spec。
DTLS Record 是记录层(Record Layer)的概念,可以认为它是一个壳子,里面装载着 DTLS Message,如下图:
Message 和 Record 是一对一或者一对多的关系。也就是说,一个 Record 不一定装了一条完整的 Message。因为有可能是多个 Record 组成一个完整的 Message。
如果 Message 很小,未超过 MTU 的限制,那么一个 Record 足以装下一条 Message;如果 Message 很大,超过 MTU 的限制,那么就需要多个 Record 来装这条 Message。即这条 DTLS Message 会被分割为多个 Fragment,然后分别装入多个 Record。
Remark:最大传输单元(Maximum transmission Unit, MTU)是数据链路层的概念,MTU 限制的是数据链路层的 payload 大小,也就是其上层协议的大小,比如 IP、ICMP。在以太网中,链路层的 MTU 是 1500 字节。
比如,Certificate 这个握手消息,证书大小很容易就超过 MTU 的限制,那么这个消息就会被分割为多个 Fragment 并被分别存放到多个 DTLS Record,每个 Fragment 的大小要保证不超过 MTU 的限制(PS:导读的第二张图就是一个实际的例子)。
Flight 中文解释为 “航班” 或者 “航程”,是一个或者一组打包好的 Message,这组 Message 属于同一个 “航程”,视为一个整体,通过单个 UDP 数据报发送。
如上图所示,本次 DTLS 握手一共有 4 个 Flight。Flight2 是 Server Hello、Certificate、Server Hello Done 这三条 Message 的组合,其中 Certificate 这条 Message 被分割为两个 Fragment,装到两个 Record 中。Flight2 通过大小为 2969 字节的 UDP 数据报发送出去。
Remark:Flight2 这个 2969 字节的 UDP 包是在本机环境下调试、抓包得到的,并不代表 MTU 有这么大,在实际的网络中,不会出现这种远超 MTU 限制的数据包。
到这里,关于 Message、Record、Flight 的概念就讲完了,三者之间的关如下图:
Fragment
下面我们谈谈,DTLS 为什么要对 DTLS Message 做分片。
我们知道,受以太网 MTU 影响,UDP 数据报最大为 1500 字节,超出这个限制就会被 IP 层分片(PS:以太网 MTU 设置为 1500 字节是为了最大化信道传输利用率)。
但是如果 IP 层分片机制被禁止呢?这就会导致大于 1500 字节的 UDP 数据报在 IP 层被丢弃。因此,DTLS 要对消息做分片,来满足 IP 层对报文大小的要求。DTLS1.2: Message Size 这一节解释了这个原因。
By contrast, UDP datagrams are often limited to < 1500 bytes if IP fragmentation is not desired. In order to compensate for this limitation, each DTLS handshake message may be fragmented over several DTLS records, each of which is intended to fit in a single IP datagram.
因此,DTLS 的分片机制很简单:在发送时把 DTLS Message 分割成多个连续的 DTLS Record,在接收时缓存分片,直到拥有完整的 DTLS Message。
我们可以使用 OpenSSL 的这两个 API 设置 MTU 的大小:
SSL_set_options(dtls, SSL_OP_NO_QUERY_MTU);
SSL_set_mtu(dtls, 1500);
上面的代码设置了 MTU 为 1500,那么当 DTLS Message 大小超过 1500 字节,就会触发 DTLS 的分片机制,同理,如果设置 MTU 为 300,那么当 DTLS Message 大小超过 300 字节,就会分片。如果不进行设置,那么 MTU 会走默认值,如下图所示,证书消息被分割成了若干个大小为 288 字节的固定的 Fragment。
Remark:TLS 底层是 TCP 协议,为字节流式传输,因此 TLS 没有消息分片机制。
我们还可使用下面的 API 设置 Fragment 的大小的上限:
SSL_set_max_send_fragment(dtls, 1500);
最后,我们回到导读描述的问题:证书消息实际上确实被分割为两片并分别存储到两个 Record,但是由于在发送的时候还是打包到了一个 UDP 数据报,因此,过大的 UDP 数据报导致 TURN 模块并未接收完整。
更详细的原因是:我们使用的是内存型的 BIO,在应用层调用 BIO_get_mem_data
得到的是关于 DTLS Message 的一块连续的内存(虽然这块内存中的证书消息已经被 DTLS 切成两个连续的 Fragment 并存在两个 Record 中),而应用层在获取到这块内存后就直接通过 sendto
函数发送给了对端,因此,这个 UDP 报文当然还是特别大,导致接收失败。
回过头来再看下导读中证书消息分片的这张图,两个 Record 的 message sequence
字段值相同,说明这是同一个 DTLS Message 的两个 Fragment。且每个 Record 都有 fragment offset
和 fragment length
这两个字段,用来标识分片的边界。所以,我们可以根据这两个字段去解析出每一个独立的 Fragment。
当然,根据 Record 头部的 Length
字段足以确定边界,这会使应用层的解析更加方便。所以,要解决这个问题,应用层要做的是:对从 BIO 获取到的这块消息内存进行解析,得到每个 Record 的边界,然后将每个 Record 以独立的 UDP 报文发送出去。具体的解析代码这里就不贴出来了,非常简单。
最后,在实践中发现,DTLS Record 不能跨 UDP 数据报发送,DTLS 1.2: Transport Layer Mapping 这一节也交代了这一点。也就是说,应用层要严格的按照 Record 的边界解析出每一个 Record,分别通过独立的 UDP 数据报发送,而不能按照自己的意愿随意划分为若干个 UDP 数据报发送。因为这可能会导致某个 DTLS Record 被切分到多个 UDP 数据报发送,从而导致接收端 DTLS 无法将收到的 DTLS Records 重组为完整的 DTLS Message。
下图是 DTLS 分片独立发送后的效果:
有兴趣的读者可以参考我写的 DTLS demo,它实现了简单的 DTLS 握手和分片独立发送。也可以参考 开源视频服务器 SRS 的 DTLS 实现,更加简洁和详尽。
总结
对于超过 MTU 限制的 DTLS Message,DTLS 会把它分割为多个 Fragment, 并分别存储到各个 DTLS Record 中,因此一个 Fragment 一定是一个 DTLS Record。对于未超过 MTU 限制的 DTLS Message,则不会被分片,也是存储到 DTLS Record 中,因此一个 DTLS Record 不一定是一个 Fragment,也有可能是一个完整的 DTLS Message。另外,MTU 的大小以及 Fragment 的最大值都可以使用 OpenSSL 的 API 进行设置。
由于我们通过内存型 BIO 获取到了存储了各个 DTLS Message 的这块连续内存后,直接将其打包为 Flight,并通过单独的 UDP 数据报文发送,因此这个 UDP 包仍然还是那么大,超出了 TURN 模块接收缓冲区的上限和 MTU 的限制。所以为了做到真正的分片独立发送,需要应用层自己去做 Fragment 的解析(其实就是解析 Record 的边界),并分别通过独立的 UDP 报文发送。
我们在解决了一个问题后,还要再问一下自己有没有引入新的问题。
独立发送每个 DTLS Record,虽然解决了 DTLS Message 超过 MTU 限制的问题,但是这也增加了 UDP 报文的数量,因此丢包的概率也会相应的增加,DTLS 重传次数增加,握手的成功率降低。解决这个问题的一个方法是:不必每个 DTLS Record 都单独 UDP 发送,可以多个 DTLS Record 发送,只要能保证它们加起来的大小不超过 MTU 的限制就可以。
同时,我们也要问一下自己有没有更好的方法。
比如目前的解决方法是应用层自己实现 Record 解析并独立发送,那么 OpenSSL 是否已经有相关的 API 实现类似的功能,再比如 BIO 有没有相关的 API 可以告诉我们读取的内存数据中 Record 的数量以及每个 Record 的边界?这个问题,以后有时间再调研吧。
感谢阅读。
参考
「视频云技术」你最值得关注的音视频技术公众号,每周推送来自阿里云一线的实践技术文章,在这里与音视频领域一流工程师交流切磋。公众号后台回复【技术】可加入阿里云视频云技术交流群,和作者一起探讨音视频技术,获取更多行业最新信息。