WebRTC 端到端加密(E2EE)方案

avatar
FE @字节跳动

概述

随着WebRTC技术的广泛运用,越来越多的人享受到了实时音视频通信的便捷。与此同时互联网用户对隐私的关注度也较以往更高。因此亟需一种端到端(用户终端到用户终端)加密解决方案保障用户的私密通讯不被第三方监听。

WebRTC要求建立连接的两点间的的媒体流需要加密,使用的是DTLS-SRTP的方式。它能有效保障通信两点的信息安全。而目前主流的RTC服务供应商多采用SFU的架构,需要建立通信的两个用户节点并不直接进行WebRTC链接,而是各自和RTC的媒体服务器建立WebRTC链接后,经由媒体服务器中转传输媒体数据。

类似架构带来的问题是数据安全需要由RTC服务商保障,用户和RTC供应商的通信相对是安全的,一旦供应商存在信息监听行为或者媒体服务器有安全漏洞,进行通信的两个用户节点的媒体数据将变得不安全。

针对这一问题,本文提出一种端到端的加密方案,在使用RTC供应商进行通信时,依然能保障两个用户节点的数据安全。该方案能同时适用于Web端和Native端。

常见RTC架构

image.png

(图一)
目前常见的WebRTC架构主要有三种,分别是Mesh, MCU (MultiPoint Control Unit)及SFU(Selective Forwarding Unit)。

Mesh:每个端均和需要的节点直接进行链接,通信安全由链接的两个端点保障。典型用户场景有P2P实时直播。

MCU:每个端点分别和服务端进行链接,服务端会对端点的媒体流进行混流,将多个媒体流合并成一个流后再分发给各个端点。传统视频会议设备供应商多基于MCU架构。

SFU:每个端点分别和服务端进行链接,服务端负责转发媒体流给对应需要建立链接的端点。互联网视频会议服务商多基于SFU架构。

MCU和SFU的架构比较接近,核心区别是MCU需要对发送端用户的媒体流进行混流后再发给接收端用户,而SFU只是简单的对媒体流进行转发,极大的节省了服务侧的计算资源。Mesh架构和MCU/SFU架构相比,用户两端直接建立链接,不经过服务端。优点是几乎无需占用服务端计算资源,但是在某些场景下用户端直连可能存在通信链路非常差甚至无法建立链接的情况,而MCU/SFU通常会有RTC供应商优化边缘节点,极大的保障了链接的传输可靠性。

目前RTC服务商的架构多为SFU,本文的方案也是针对SFU架构设计。Mesh架构由于是用户端直连,因此WebRTC协议要求的DTLS-SRTP已经能满足安全要求;MCU架构需要混流,而需要加密的链接是不希望第三方了解流内容,因此端到端加密场景天然不支持MCU架构。

实现思路

image.png

(图二)
为了能实现对用户的媒体流数据进行用户定义的加解密操作,需要在帧处理流程中插入一个额外的加密&解密操作。

图二是WebRTC内部从采集到发送的核心过程。可以看到,理论上可选的加解密流程大致有除raw frame以外三个点。主要是因为编码对媒体数据有依赖,所以无法在编码前加密。

由于加解密行为可能改变数据大小,极端情况当单个包超出MTU时,需要对包进行额外的拆包组包,增加了处理流程的复杂度,所以最理想的加解密点应该是encoded frame & decoded frame这个节点。

对于Native端而言,插入一个自定义的处理环节相对较容易,内部WebRTC的实现可以完全自主。但是对Web端而言,WebRTC的处理流程受限于浏览器的实现。因此在确定具体实现时要了解浏览器内部处理逻辑,充分兼容浏览器,才能保证端到端加密是一个适用于全端的操作。

Chrome浏览器的帧处理流程

W3C针对端到端加密等应用场景,引入了WebRTC Insertable Streams接口。该接口允许Web端对编码帧数据进行处理。Web端示例代码如下:

// 发送示例
let pc = new RTCPeerConnection({ encodedInsertableStreams: true });
...
const sender = pc.addTrack(videoTrack, mediaStream);
let senderTransform = (chunk, controller) => {
    chunk.data = cryptData(chunk.data); // 对编码帧进行加密操作
    controller.enqueue(chunk);
}
let { readableStream, writableStream } =  sender.createEncodedStreams();
readableStream
    .pipeThrough(new TransformStream({transform: senderTransform))
    .pipeTo(writableStream)

启用WebRTC Insertable Streams后,浏览器的帧处理流程会增加一个额外的帧编辑(transform)环节,见图三。

image.png

(图三)
在帧编辑环节,浏览器会回调JavaScript的函数transform。并传入chunk和controller两个对象,其中chunk包含编码帧相关的信息,chunk.data为编码帧二进制流,我们可以在这对编码帧加密,接着调用controller.enqueue(chunk),返回已经处理好的加密帧。然后在接收者收到chunk.data时进行解密。

存在问题

在transform里面修改编码帧,会发现接收者有时会出现音频正常,但是视频显示异常,花屏或者停止解码的情况。以h264编码为例,这是因为编码帧的二进制流中包含帧头部的描述信息。而这些信息在拆包前,组包后的时候会用到,一旦加密修改了这部分内容,会导致浏览器处理异常。因此,在对编码帧进行处理时,需要避开帧头部的描述信息。

Chrome中H264拆,组包逻辑

在Chrome中,发送方视频采集到raw frame在编码后会输出连续的NALU(Network Abstraction Layer Unit)流。H264拆包时会根据NALU开始码(0x000001或0x00000001)对编码流进行分隔,确认NALU边界,并依次将NALU送入RTP拆包模块,使其符合STAP(Single-Time Aggregation Packet)或FU-A(Fragmentation unit A)两种RTP包格式的一种。一般PPS,SPS,SEI会封装成一个STAP包,而大的视频帧会被切割成若干个FU-A包。因此在帧数据被加密后需要保证加密数据中不能存在开始码。否则会导致编码帧的NALU被错误分隔,这样接收端将无法解码出正确的视频帧。

而对于接收方而言,Chrome收到同一视频帧的所有RTP包时,会先将RTP包的Payload组成一个视频帧,然后读取视频帧的PPS_ID,然后将该视频帧送入帧编辑(transform)函数。这就意味着在对编码帧进行编辑时不能修改PPS_ID。参照h264标准,视频帧的PPS_ID通常位于视频帧的第三个哥伦布字段,见图四。因此在帧编辑时,需要跳过PPS_ID,仅对其后的数据进行处理。

(图四)

H264帧编辑细节

限于Chrome浏览器的拆组包实现,在对编码帧编辑时,需要考虑两个问题,一是不能产生NALU开始码(0x000001或0x00000001);二是不能修改视频帧PPSID之前的数据。同时由于非视频帧像PPS,SPS及SEI通常不涉及敏感信息,为了简化处理逻辑,我们针对视频帧进行加密并设计了如下帧编辑流程。

image.png

(图五)

AES加密

确认了媒体帧中的可修改部分,接下来就需要选择一种可靠的加密方案。

image.png

(图六)

为了实现Web端和Native端的加密数据能互通,选用了AES-256-CBC对称加密算法,该算法在不同客户端下兼容性较好。对称加密是指加密和解密的时候需要使用同一个密钥。AES加密时,会把文件切分成若干个明文块,每个块是128bit共16字节,如果最后一个块不足128bit会根据填充策略补齐成128bit。前一个密文块会参与后一个明文块的加密计算。为了提高加密可靠性,通常会需要一个随机生成的初始向量(Initialization Vector)用于计算首个密文块。解密是加密的逆过程,只需要把加密步骤倒序执行就能完成解密。

image.png

(图七)

在RTC通信时,通常同一个房间可以分享同一个密钥。然后对每帧数据进行加密时创建一个随机IV,并基于该IV和密钥对帧数据加密。传输时,IV置于头部占用16字节,紧接着是AES加密数据。发送端解密时,使用相同密钥,头部16字节IV以及密文作为输入,即可得到编码帧数据。

由于IV需要额外占用16字节,视频帧通常是数k到数十k,影响比较小,而音频帧在一次发送几十个字节到几百个字节,插入IV就会显得影响较大。一个可行的优化方案是利用chunk对象中音频帧的timestamp(时间戳)作为随机变量。然而timestamp只占用4个字节,不足16字节,因此剩余部分可以根据自定义的规则进行数组填充或者简单的平铺timestamp四次作为IV。

验证

image.png

(图八)

在Web端,iOS端以及Android端实现了该方案,经过若干小时的测试以及弱网测试,音视频收发均未发现异常。当不具备解密能力的端播放加密流时,显示的是绿屏,同时rtc统计信息指示解密失败。