Skip to content

端到端加密 ​

WebRTC 的媒体始终会用 DTLS-SRTP 做逐跳加密。这能保护网络传输中的数据,但中间的媒体服务器(比如 SFU)会解开这层加密,能读到每一帧。(TURN 服务器没有这个问题,它只转发加密后的包。)E2EE 在编码后的帧上再加一层加密,而且是在帧被拆成 RTP 包之前完成的,所以中继节点只能看到密文。

难点不在加密本身,而在于让浏览器和三套原生 SDK 生成完全相同的字节,任意两个客户端之间才能互相解密。

平台机制代码
WebInsertable Streams:createEncodedStreams()(Chrome)或 RTCRtpScriptTransform(Safari、Firefox),在 worker 中运行e2ee.ts、encryptionWorker.ts、frameCrypto.ts
Androidwebrtc-sdk 的 FrameCryptor + FrameCryptorKeyProviderE2eeManager.kt、FrameCryptors.kt
iOS、macOSwebrtc-sdk 的 RTCFrameCryptor + RTCFrameCryptorKeyProviderFrameEncryption.swift
Windowslibwebrtc 的 frame cryptor,通过 C shim 调用shim_peer.cpp

原生的 FrameCryptor 使用 LiveKit 的帧格式。Web 客户端逐字节实现了同样的格式,Web、Android、iOS、macOS 和 Windows 正是因此才能互通。

帧格式 ​

text
┌──────────────────────┬───────────────────────────────────┬────────────┬──────┬──────────┐
│ unencrypted header   │ AES-128-GCM ciphertext + 16B tag  │ IV (12 B)  │ 0x0C │ keyIndex │
└──────────────────────┴───────────────────────────────────┴────────────┴──────┴──────────┘
  additional data (AAD)                                       per frame    IV len  1 byte

每一帧开头的若干字节保持明文,因为打包器和解码器需要读取它们:

Codec不加密的头部原因
VP8关键帧 10 字节,增量帧 3 字节这是 payload header,保留它才能继续解析帧类型和尺寸
H264直到第一个 slice NAL header(含)再加 1 字节NAL 结构和 SPS/PPS 保持可读。其余部分经过 RBSP 转义,保证不会出现起始码。
Opus1 字节,即 TOC帧配置信息

头部虽然不加密,但会作为 AES-GCM 的附加数据参与认证,所以篡改头部会导致解密失败。

动手试试。下面这个组件用 Web 客户端的真实代码处理一个示例帧:

用 Web 客户端自己的代码加密一帧

这里在你的浏览器中运行 web/src/e2ee.ts 里的 encryptStream() 和 decryptStream(),和 Web 客户端在 worker 里运行的是同一份代码。

帧类型
Key material,即 "encryption key" 消息里发送的内容
编码器输出 · 0 字节
加密后,交给打包器 · 0 字节
  • 不加密的头部。打包器和解码器仍然可以读取它,并作为附加数据受 GCM tag 保护。
  • 密文:帧的其余部分经过 AES-128-GCM 加密,长度和明文相同。
  • GCM tag,16 字节。校验不通过时,接收方丢弃这一帧。
  • IV,12 字节:SSRC、RTP 时间戳和发送计数,每一帧都不同。
  • IV 长度,固定为 12。
  • Key index,这里固定为 0。

无法加密或解密的帧(还没有密钥、密钥错误、帧已损坏)会被丢弃,绝不会以明文形式发送或解码。空帧(比如 Opus 的静音帧)会原样通过,和原生 cryptor 的行为一致。

密钥 ​

在网络上传输的只有 32 字节的密钥材料,以 base64 形式放在 encryption key 消息中。每一端在本地自行派生 AES 密钥。在 1:1 通话中,由 offer 方生成密钥材料,并在 offer 之前发送。

key provider 的选项在所有平台上必须完全一致。只要有一项不同,两端派生出的密钥或写入的帧尾(trailer)就会不同,对方便无法解密:

选项值
Shared key modetrue
Ratchet salt"LKFrameEncryptionKey"
Ratchet window size0
Uncrypted magic bytes无
Failure tolerance-1
Key ring size16
Discard frame when cryptor not readyfalse
Key derivationPBKDF2
Key index0

Codec 选择 ​

开启 E2EE 时,所有平台都会在创建 offer 或 answer 之前调用 setCodecPreferences,把 VP8 排在视频 codec 的首位。VP8 的头部结构最简单,各实现之间也最一致。iOS 则不管是否开启 E2EE 都会把 VP8 排在首位,原因见 iOS 屏幕共享。

Web 客户端还会解析协商后的 SDP,把 payload type 映射到具体的 codec(parseCodecMap),因为并非所有浏览器都提供 getMetadata().mimeType。

Cryptor 生命周期 ​

双方都必须开启 E2EE。如果只有一方开启,另一方收到的帧既无法解码也无法解密,通话会一直黑屏、没有声音。E2EE 在大厅中选择,通话过程中不能切换。

在多人通话中,SFU 转发的是密文,而它对此毫无感知。房间创建者的密钥材料会成为整个房间的密钥。见多人通话(SFU)。

对 Demo 来说够用

密钥材料以明文形式经过信令服务器。真实的应用应该使用密钥协商(比如 ECDH)或通过其他渠道共享的口令,并在有人离开多人通话时轮换密钥。