WebRTC 强制要求所有媒体加密,这让很多人误以为它天然就是端到端安全的。事实并非如此:WebRTC 的加密终止在每一个参与媒体转发的节点上,在一对一通话里确实是端到端,但在 SFU 架构下,SFU 解密后再加密转发,中间节点看到的是明文。
本文先讲清 WebRTC 内建安全的边界,再展开真正的端到端加密方案。读完你应该能判断自己的业务是否需要 E2EE,以及如何用 Insertable Streams 与 SFrame 落地。
一、WebRTC 的内建安全
1.1 DTLS 握手
WebRTC 媒体通道使用 DTLS(Datagram TLS)做握手,它把 TLS 的握手机制搬到 UDP 上。SDP 中的 a=fingerprint 携带自签名证书的哈希,a=setup 决定谁是客户端谁是服务端:
a=fingerprint:sha-256 6B:8B:5F:...:2A
a=setup:actpass
actpass 表示「我既能主动也能被动」,由对端在 Answer 中选择 active 或 passive。这一步保证了媒体通道的机密性与完整性。
1.2 SRTP 密钥导出
DTLS 握手完成后,双方通过 DTLS 导出 SRTP 的密钥材料(RFC 5764 的密钥导出),随后所有 RTP 包都用 SRTP 加密。SRTP 提供机密性、完整性以及重放保护。
| 层 | 协议 | 作用 |
|---|---|---|
| 握手 | DTLS 1.2/1.3 | 身份验证与密钥协商 |
| 媒体 | SRTP | 音视频包加密与完整性 |
| 数据 | SCTP over DTLS | 数据通道加密 |
| 密钥导出 | RFC 5764 | 从 DTLS 导出 SRTP 密钥 |
二、DTLS-SRTP 的信任模型
2.1 指纹验证与中间人
DTLS 使用自签名证书,因此证书本身不提供身份保证。真正建立信任的是「SDP 指纹」:只要双方交换的指纹与对方实际使用的证书一致,就能确认没有被中间人替换。
问题在于指纹通过信令传输,如果信令服务器被攻破,它可以同时替换双方的指纹,从而实施中间人攻击。这就是所谓的「信令是信任根」。
// 观察本地证书指纹
const pc = new RTCPeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdp = pc.localDescription.sdp;
const match = sdp.match(/a=fingerprint:(\S+) (\S+)/);
console.log("本地指纹:", match[1], match[2]);
// 带外验证:通过其他渠道比对指纹,可抵御信令服务器作恶
2.2 带外验证
要抵御信令服务器作恶,需要带外验证:通过另一条可信通道(如扫码、短码比对)确认指纹一致。Signal、WhatsApp 的「安全码」就是这一机制。多数 Web 会议产品不做带外验证,这意味着它们的安全上限取决于信令服务器。
三、为什么内建加密不够
3.1 SFU 看到明文
在 SFU 架构下,媒体路径是「客户端 A 加密 → SFU 解密 → SFU 重新加密 → 客户端 B」。SFU 必须解密才能做转发决策(比如 Simulcast 选层),因此它看到的是明文。这意味着:
- SFU 运营方可以看到全部音视频内容。
- SFU 被攻破意味着所有通话泄露。
- 合规场景(如医疗、金融)无法接受。
| 架构 | 加密终点 | 是否端到端 |
|---|---|---|
| P2P | 对端设备 | 是 |
| SFU | 每个转发节点 | 否 |
| MCU | 每个混流节点 | 否 |
| SFU + E2EE | 对端设备 | 是 |
四、端到端加密方案
4.1 Insertable Streams / Encoded Transform
浏览器提供的 Encoded Transform(早期叫 Insertable Streams)允许在编码后、加密前介入媒体帧,以及解密后、解码前介入。E2EE 的实现思路是:在 SRTP 加密之上再套一层应用层加密,密钥只有通话参与者持有,SFU 无法解密。
const pc = new RTCPeerConnection();
// 发送侧:编码后、加密前插入自定义加密
const sender = pc.addTrack(track, stream);
const senderStreams = sender.createEncodedStreams();
const transformStream = new TransformStream({
async transform(chunk, controller) {
const frame = chunk.data;
const encrypted = await encryptFrame(frame, sharedKey);
chunk.data = encrypted;
controller.enqueue(chunk);
},
});
senderStreams.readable
.pipeThrough(transformStream)
.pipeTo(senderStreams.writable);
// 接收侧:解密后再交给解码器
const receiver = pc.getReceivers()[0];
const receiverStreams = receiver.createEncodedStreams();
const decryptStream = new TransformStream({
async transform(chunk, controller) {
const decrypted = await decryptFrame(chunk.data, sharedKey);
chunk.data = decrypted;
controller.enqueue(chunk);
},
});
receiverStreams.readable
.pipeThrough(decryptStream)
.pipeTo(receiverStreams.writable);
4.2 帧级加密与头部保留
E2EE 的关键约束是:SFU 仍需读取部分元数据才能转发,因此不能加密整个包,只能加密载荷。通常的做法是加密 chunk.data 的载荷部分,保留 RTP 头部与必要的扩展。
async function encryptFrame(frame, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
frame
);
// 把 iv 与密文拼接,接收端据此解密
const result = new Uint8Array(iv.length + ciphertext.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ciphertext), iv.length);
return result.buffer;
}
4.3 SFrame
SFrame 是专为实时媒体设计的帧级加密格式,它定义了每个帧如何加密、如何携带密钥标识与计数器,从而支持密钥轮换与多发送者。相比裸的 AES-GCM,SFrame 处理了媒体场景特有的问题:帧序、密钥切换、成员变更。
| 方案 | 粒度 | 密钥管理 | 适用 |
|---|---|---|---|
| 裸 AES-GCM | 帧载荷 | 自行设计 | 简单场景 |
| SFrame | 帧 | 内建密钥标识 | 多人 E2EE |
| MLS | 群组 | 完整群组密钥协商 | 大规模会议 |
4.4 密钥协商:从 SDP 到 MLS
E2EE 最难的其实是密钥管理:如何让所有参与者协商出同一个密钥,并在成员进出时安全轮换。简单方案是用 SDP 传递公钥并做 ECDH,但它在多人场景下需要 N² 次交换,且成员变更时无法安全轮换。
MLS(Messaging Layer Security,RFC 9420)是为此设计的群组密钥协议,它用树状结构把密钥协商的复杂度降到 O(log N),并支持前向安全与后向安全。大规模 E2EE 会议应基于 MLS 而非自研密钥交换。
// 概念示意:通过信令交换公钥,导出共享密钥
async function deriveSharedKey(pc) {
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey"]
);
const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
signaling.send({ type: "e2ee-pubkey", key: Array.from(new Uint8Array(publicKeyRaw)) });
return keyPair;
}
五、信令鉴权与身份认证
E2EE 的安全性最终仍依赖「谁是谁」的确认。信令层必须做到:
- 用短期 token 鉴权,身份由服务端从 token 推导,不信任客户端自报的 peerId。
- 全链路
wss://加密,避免 token 在传输中泄露。 - 对 E2EE 公钥做绑定:公钥应与身份绑定并经过验证,防止中间人替换公钥。
- 记录密钥指纹供带外验证,用户可主动比对。
六、数据通道的端到端加密
6.1 数据通道同样需要保护
RTCDataChannel 基于 SCTP over DTLS,它的加密同样终止在 SFU。若白板协作、文件传输走数据通道,SFU 也能看到内容。E2EE 场景下数据通道必须单独加密。
const channel = pc.createDataChannel("e2ee-data", { ordered: true });
async function sendEncrypted(channel, payload, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoded
);
const packet = new Uint8Array(iv.length + ciphertext.byteLength);
packet.set(iv, 0);
packet.set(new Uint8Array(ciphertext), iv.length);
channel.send(packet);
}
channel.onmessage = async (event) => {
const packet = new Uint8Array(event.data);
const iv = packet.slice(0, 12);
const ciphertext = packet.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext
);
const payload = JSON.parse(new TextDecoder().decode(plaintext));
handlePayload(payload);
};
6.2 媒体与数据共享密钥的取舍
媒体流与数据通道可以共用同一组密钥,也可以各自独立。共用实现简单,但一处泄露会波及全部;独立则更安全,代价是需要维护多套密钥与轮换逻辑。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 共享密钥 | 实现简单,轮换一次即可 | 泄露影响面大 |
| 独立密钥 | 隔离性好 | 管理复杂 |
| 按轨道独立 | 最细粒度 | 开销最大 |
多数产品选择媒体共用一套、数据通道独立一套的折中方案。
七、E2EE 的工程代价
7.1 功能受限
启用 E2EE 后,SFU 无法解密媒体,因此所有依赖「看懂媒体」的功能都会失效:
- 服务端混流(MCU 式合成画面)不可用。
- 服务端转码(不同编解码互通)不可用。
- 服务端录制只能录密文,需要端侧解密或额外密钥托管。
- 云端语音识别、实时字幕不可用。
- 服务端 Simulcast 选层仍可用(因为只需读头部),但无法做内容感知的码率分配。
| 功能 | 非 E2EE | E2EE |
|---|---|---|
| Simulcast 选层 | 支持 | 支持(读头部) |
| 服务端混流 | 支持 | 不支持 |
| 服务端转码 | 支持 | 不支持 |
| 服务端录制 | 支持 | 需密钥托管 |
| 云端字幕 | 支持 | 不支持 |
7.2 性能开销
帧级加密会给每个媒体帧增加一次加解密操作。在低码率下开销可忽略,但在高清多路场景下不可忽视:
// 用计数器粗略评估加解密开销
let totalFrames = 0;
let totalEncryptMs = 0;
const timedTransform = new TransformStream({
async transform(chunk, controller) {
const t0 = performance.now();
chunk.data = await encryptFrame(chunk.data, sharedKey);
totalEncryptMs += performance.now() - t0;
totalFrames++;
controller.enqueue(chunk);
},
});
// 定期输出平均每帧加密耗时
setInterval(() => {
if (totalFrames > 0) {
console.log(`平均每帧加密耗时: ${(totalEncryptMs / totalFrames).toFixed(3)} ms`);
}
}, 5000);
若平均每帧加密耗时超过帧间隔的 5%,就应考虑用硬件加速的 AES 或降低加密粒度。
7.3 密钥轮换与成员变更
成员加入或离开时必须轮换密钥,否则离开者仍能解密后续内容。轮换的难点在于「同步切换」:所有参与者必须在同一时刻切到新密钥,否则会出现解密失败的花屏。
工程上的做法是给密钥分配版本号,帧携带版本标识,接收端按版本选择对应密钥,并保留旧密钥一段时间以处理在途帧。
class KeyRing {
constructor() {
this.keys = new Map(); // version -> CryptoKey
this.current = 0;
}
addKey(version, key) {
this.keys.set(version, key);
if (version > this.current) this.current = version;
}
get(version) {
return this.keys.get(version);
}
// 保留最近两个版本,处理在途帧
prune() {
for (const version of this.keys.keys()) {
if (this.current - version > 1) this.keys.delete(version);
}
}
}
八、常见坑清单
- 误以为 WebRTC 内建加密就是端到端,忽略了 SFU 能看明文。
- E2EE 只加密载荷却破坏了 RTP 头部,导致 SFU 无法转发。
- 自研密钥交换,未处理成员进出时的密钥轮换。
- 公钥通过未验证的信令传递,中间人可替换。
- 加密后的帧尺寸变化未考虑,超出 MTU 导致分片异常。
- 密钥轮换时新旧帧混用,接收端解密失败产生花屏。
- 只加密视频不加密数据通道,敏感信息从旁路泄露。
- 忘记带外验证,安全上限被信令服务器锁死。
小结
WebRTC 的内建加密(DTLS-SRTP)保证了「每一跳」的安全,但在 SFU 架构下并不等于端到端。真正的 E2EE 需要在 SRTP 之上再套一层应用层帧加密,并解决密钥协商与轮换问题。Insertable Streams 提供了介入点,SFrame 处理了媒体特有的帧序与密钥标识,MLS 解决了群组密钥协商。是否上 E2EE 取决于业务对隐私与合规的要求,代价是 SFU 无法做转码与混流,功能受限。加密与身份是实时通信安全的两个面,信令侧的鉴权设计见 WebRTC 架构与信令设计 ,浏览器加密 API 的细节可参考 WebCrypto 与加密实践 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。