跳到主要内容

端到端加密E2EE

WebRTC 默认的 DTLS-SRTP 只是逐跳加密(hop-by-hop)。在 SFU 架构里,SFU 作为中间盒持有 SRTP 密钥,能解密所有媒体——这意味着默认链路加密保护不了"连服务商也不可见"的场景。真正的端到端加密(End-to-End Encryption, E2EE)需要在编码后、SRTP 前对 payload 再加一层壳,让 SFU 只看得见路由元数据,看不见内容。

本文回答四个问题:为什么 DTLS-SRTP 不等于 E2EE,怎么用 Insertable Streams + SFrame + MLS 搭出真 E2EE,SFU 在这套体系下还能做什么(以及做不了什么),以及落地时常见的反模式。

1. 为什么 DTLS-SRTP 不等于 E2EE

1.1 hop-by-hop 加密模型

P2P 场景下 DTLS-SRTP 就是 E2EE——两端直接协商密钥,没有第三方。但生产级 WebRTC 几乎都走 SFU(Selective Forwarding Unit),链路被切成两段:

Alice ──DTLS-SRTP(K1)── SFU ──DTLS-SRTP(K2)── Bob

解密 K1 → 重新打包 → 用 K2 加密
SFU 看到的是明文 RTP payload
  • SFU 与每个参与者单独协商 DTLS-SRTP 密钥。
  • 转发媒体时,SFU 必须先用 K1 解密(至少解到 SRTP 头层面去做包改写、PT 重映射、SSRC 重写、扩展头改写),然后用 K2 再加密。
  • 出于性能考虑,部分 SFU(如 mediasoup)做的是密钥级的转封装而不解 payload,但密钥本身仍在 SFU 内存里——如果攻击者拿到 SFU root,就能旁路出明文。

结论:DTLS-SRTP 保护的是"网络上没人能偷听",不保护"服务商不能看"。

1.2 什么时候 E2EE 是必需的

场景是否需要 E2EE
一般业务音视频会议不需要,DTLS-SRTP 够
端到端 1v1(已 P2P)不需要
多方会议但服务商不可信 / 跨境合规需要
医疗远程问诊、律师会见、金融客户尽调强需要
政企内网,物理已隔离通常不必,复杂度不划算

1.3 什么时候不适用

  • 录制 / 转录 / 直播:业务需要服务端能解 payload,E2EE 会把这条路堵死。要么放弃 E2EE,要么把这些功能下放到客户端做。
  • AI 字幕、内容审核:同上,需要明文。
  • 客户端能力受限:低端机大量 H.264 软编 + E2EE Worker 加密会撞 CPU 上限。

1.4 失败时的样子

  • 接收端解密失败 → 帧被丢 → 画面冻结 / 黑屏 / 马赛克,但连接还在(SRTP 层正常)。
  • 密钥轮换时窗口不对齐 → 几秒内全员花屏。
  • Worker 异常崩溃 → RTCRtpScriptTransform 静默丢帧,getStats 看不出异常,要靠业务层心跳。

2. SFU 在 E2EE 下能看到什么

这是最容易被误解的部分。E2EE 只加密 RTP payload,SFU 仍然必须能读到所有用于路由和拥塞控制的字段,否则 SFU 没法工作。

2.1 字段对比表

字段hop-by-hop DTLS-SRTPE2EE(SFrame + Insertable Streams)
RTP 头(V/P/X/CC/M/PT/SeqNum/Timestamp/SSRC/CSRC)SFU 可见可改SFU 可见可改
RTP 扩展头(abs-send-time、TWCC、video-orientation、mid/rid)SFU 可见SFU 可见
RTP paddingSFU 可见SFU 可见
RTP payload(编码后的 VP8/H264/Opus 比特流)SFU 可见,明文加密,SFU 不可见
SFrame 头(KID、CTR)SFU 可见,但不含密钥
帧内 NAL/OBU 结构、帧类型 keyframe/deltaSFU 可见SFU 不可见 payload,但可从 marker bit、frame marking 扩展头判断 keyframe
内容(图像、声音)SFU 可见SFU 不可见

2.2 为什么必须留这些元数据可见

SFU 在转发时必须做这些事,全部依赖 RTP 头和扩展头:

  1. 拥塞控制:根据 TWCC(Transport-Wide Congestion Control,RFC 8888)扩展头估算带宽,下发 REMB / TWCC 反馈。
  2. 丢包重传:NACK 需要 SeqNum 才能定位丢失包。
  3. 关键帧请求:PLI/FIR 触发依赖识别帧边界(marker bit + frame marking RFC 7941)。
  4. Simulcast 层选择:根据 mid / rid 扩展头分流不同分辨率层。
  5. 包改写:SFU 切层时要重写 SeqNum 和 Timestamp,保持接收端连续性。

如果把这些字段一起加密,等于把 SFU 降级成纯 TCP 转发,丢掉了它存在的所有价值。这也是 SFrame 设计的核心约束:只动 payload,不动 RTP 头

红色框是 E2EE 加解密点,黄色框是 SFU 必须明文操作的元数据。可以看到 SFU 仍然在做"读 RTP 头、改写、转发",只是它看不到 payload 的语义

3. Insertable Streams:浏览器侧的加解密插桩点

W3C WebRTC Encoded Transform 规范定义了 RTCRtpScriptTransform,让 JavaScript 能在编码后、SRTP 前介入处理 EncodedFrame。

3.1 为什么放在 Worker 里

  • 加解密是 CPU 密集 + 高频(每帧、每包),主线程跑会卡 UI。
  • 规范强制 RTCRtpScriptTransform 接收一个 Worker,通过 MessagePortReadableStream / WritableStream零拷贝转移 EncodedFrame 的 ownership
  • 旧 API createEncodedStreams() 还能用但已经被规范标记为 legacy,新代码用 RTCRtpScriptTransform

3.2 EncodedFrame 是什么

  • 视频:RTCEncodedVideoFrame,含 data: ArrayBuffer(一整帧编码后的比特流,可能跨多个 RTP 包)、timestamptype: 'key' | 'delta'getMetadata() 返回 SSRC / PT / contributing sources 等。
  • 音频:RTCEncodedAudioFrame,每个 Opus 帧一个。

注意 Insertable Streams 给的是,不是 RTP 包——分包 / 组包仍由浏览器底层做。这正好匹配 SFrame 的"帧级加密"模型。

3.3 主线程:挂载 transform

// main.js(主线程)
const pc = new RTCPeerConnection({ /* ... */ });

const senderWorker = new Worker('/e2ee/sender-worker.js', { type: 'module' });
const receiverWorker = new Worker('/e2ee/receiver-worker.js', { type: 'module' });

// 把当前用户的密钥材料传给 Worker(密钥从 MLS / KMS 拿到,见第 5 节)
senderWorker.postMessage({ type: 'set-key', kid: 7, key: currentKeyBytes });
receiverWorker.postMessage({ type: 'set-key', kid: 7, key: currentKeyBytes });

pc.addEventListener('track', (event) => {
const receiver = event.receiver;
receiver.transform = new RTCRtpScriptTransform(receiverWorker, {
role: 'receiver',
trackKind: event.track.kind,
});
});

async function addLocalTrack(track) {
const sender = pc.addTrack(track);
sender.transform = new RTCRtpScriptTransform(senderWorker, {
role: 'sender',
trackKind: track.kind,
});
}

3.4 Worker 里的 sender transform

// sender-worker.js
// 极简骨架:实际生产应使用 SFrame 库(见第 4 节),不要自己撸 AES
let currentKey = null;
let currentKid = 0;
let frameCounter = 0n;

self.addEventListener('message', async (e) => {
if (e.data.type === 'set-key') {
currentKid = e.data.kid;
currentKey = await crypto.subtle.importKey(
'raw',
e.data.key,
{ name: 'AES-GCM' },
false,
['encrypt'],
);
}
});

self.addEventListener('rtctransform', (event) => {
const { readable, writable } = event.transformer;
const transform = new TransformStream({
async transform(frame, controller) {
if (!currentKey) {
controller.enqueue(frame); // 密钥未就绪时透传,业务侧应阻断发送
return;
}
const ctr = frameCounter++;
const header = buildSFrameHeader(currentKid, ctr);
const iv = deriveIV(currentKid, ctr); // 12 字节
const aad = header; // SFrame 头作为附加认证数据
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 },
currentKey,
frame.data,
);
// 输出 = SFrame header || ciphertext || tag
const out = new Uint8Array(header.byteLength + ciphertext.byteLength);
out.set(new Uint8Array(header), 0);
out.set(new Uint8Array(ciphertext), header.byteLength);
frame.data = out.buffer;
controller.enqueue(frame);
},
});
readable.pipeThrough(transform).pipeTo(writable);
});

function buildSFrameHeader(kid, ctr) {
// 真实 SFrame 头按 draft-ietf-sframe-enc 的变长编码,这里只是占位
const buf = new ArrayBuffer(9);
const v = new DataView(buf);
v.setUint8(0, kid & 0xff);
v.setBigUint64(1, ctr, false);
return buf;
}

function deriveIV(kid, ctr) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, ctr, false);
iv[0] = kid & 0xff;
return iv;
}

3.5 Worker 里的 receiver transform

// receiver-worker.js
const keys = new Map(); // kid -> CryptoKey

self.addEventListener('message', async (e) => {
if (e.data.type === 'set-key') {
const ck = await crypto.subtle.importKey(
'raw', e.data.key, { name: 'AES-GCM' }, false, ['decrypt'],
);
keys.set(e.data.kid, ck);
} else if (e.data.type === 'drop-key') {
keys.delete(e.data.kid);
}
});

self.addEventListener('rtctransform', (event) => {
const { readable, writable } = event.transformer;
const transform = new TransformStream({
async transform(frame, controller) {
try {
const { kid, ctr, header, payload } = parseSFrame(frame.data);
const key = keys.get(kid);
if (!key) {
// 旧密钥已轮换出去,或新密钥还没拿到 → 丢弃,等下一关键帧
return;
}
const iv = deriveIV(kid, ctr);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, additionalData: header, tagLength: 128 },
key,
payload,
);
frame.data = plaintext;
controller.enqueue(frame);
} catch (err) {
// 解密失败:通常是密钥还没换上 / 包被篡改
// 不要 throw,否则整条 stream 会关闭
self.postMessage({ type: 'decrypt-error', kind: frame.type });
}
},
});
readable.pipeThrough(transform).pipeTo(writable);
});

要点:

  • 解密失败不要 throwTransformStream 出 error 会关闭整个 pipe,整条轨道直接挂掉。要么丢帧,要么打错误日志。
  • 密钥轮换时新旧密钥要并存一小段时间(典型 2~5 秒),覆盖网络抖动延迟到达的旧帧。
  • 视频要等下一个 keyframe 才能正确解码,所以换密钥时应主动 PLI 一次。

4. SFrame:与编解码器无关的帧级加密

直接调 WebCrypto 自己造一套 AES 流程,问题是:

  • 没有标准化的 header → 互操作做不到。
  • 没有 replay 防护、密钥 ID 协商、签名机制。
  • 容易在 IV/nonce 重用上踩坑(一旦 IV 在同密钥下复用,AES-GCM 直接破)。

IETF SFrame WG 解决这个问题,draft-ietf-sframe-enc 给出标准化的帧级加密协议。

4.1 SFrame 设计要点

维度说明
加密单位一帧媒体(视频帧 / 音频帧),不是 RTP 包
编解码器无关不关心是 VP8 / VP9 / AV1 / H.264 / Opus,全当 opaque bytes
算法AES-CTR-128/256 + HMAC-SHA256 截断,或 AES-GCM-128/256
头部变长,含 Key ID(KID)和 帧计数器(CTR),用于 IV 推导和密钥选择
完整性AEAD 自带 tag;可选 EdDSA / ECDSA 签名(用于发送者身份认证)
密钥派生base key → per-sender 子密钥(HKDF),避免 SSRC 复用

4.2 SFrame 与传输无关

SFrame 不是 WebRTC 专属。Zoom、Teams 都把 SFrame 用在自家协议上。WebRTC 体系下,SFrame 输出当成新的 payload 喂回 RTCEncodedVideoFrame.data,由浏览器底层切包成 RTP 发出去——SFrame 本身不碰 RTP 头。

4.3 不要自己实现 SFrame

参考实现 / 库:

生产部署一律用现成库,因为 nonce 管理、密钥轮换、签名都极易出错。

5. 密钥分发:KMS vs MLS

E2EE 真正的难点不是加密本身,是密钥怎么发

5.1 中心化 KMS 模型

最朴素:

所有客户端 → 业务后端 KMS → 拿到房间密钥
  • 优点:简单,能复用现有鉴权。
  • 缺点:KMS 拿得到密钥——这意味着"服务商不可见"这个 E2EE 的承诺被打破。只能挡外部攻击者,挡不住内部人员或法务请求。
  • 适用:威胁模型只在意"传输不被窃听 + SFU 不解密",但接受业务后端是可信方。

5.2 MLS 群组密钥(RFC 9420)

Messaging Layer Security (RFC 9420) 是 IETF 标准化的群组端到端加密协议,原生支持:

  • 前向保密 (Forward Secrecy):旧密钥泄露不影响后续会话。
  • 后向保密 / Post-Compromise Security:被攻陷的成员通过 update 操作可恢复安全。
  • 群组动态成员:加人/踢人是 O(log N) 的操作(基于 TreeKEM)。
  • 传输无关:MLS 只定义群组密钥协商,不定义传输——可以走信令通道 piggyback。

WebRTC 会议场景里 MLS 的角色:

关键性质:信令服务器(Delivery Service)只是消息中转,不参与密钥协商。即使 DS 被攻破,攻击者也只能拒绝服务,拿不到密钥。

5.3 选型建议

维度中心化 KMSMLS
实现复杂度高(建议用 OpenMLS 等成熟库)
前向 / 后向保密自己做才有协议内置
群组成员变更性能O(N) 重新广播O(log N)
服务商可看到密钥不能
适合规模<50 人会议群组、社区、大型会议
客户端身份认证复用业务鉴权需要单独的 AS(Authentication Service)

实际工程里也常见混合方案:MLS 协商 base key,每个 sender 再用 HKDF 派生媒体子密钥,SFrame 用子密钥加密。

6. 与 simulcast / SVC 的兼容性

E2EE 必须和 simulcast / SVC 兼容,否则会议带宽自适应直接没了。

6.1 Simulcast

发送端同时编 3 路(low/mid/high),SFU 根据接收端带宽选转发哪一路。

  • E2EE 下:每一路独立加密,共用同一密钥但 IV / CTR 独立(按 RID 区分子流)。
  • SFU 仍然能根据 rid 扩展头切层,因为 RID 在扩展头里,不在 payload。
  • 失败模式:CTR 管理实现错误 → SFU 切层时接收端解密失败花屏。

6.2 SVC(Scalable Video Coding,AV1 / VP9)

单一码流分时间层 / 空间层,SFU 通过丢弃高层 NAL/OBU 实现降级。

  • E2EE 下问题更大:SFrame 加密的是整帧,SFU 看不到 NAL/OBU 边界,没法在加密后丢层
  • 解决方案:编码器输出按层切成多个 EncodedFrame,每层独立 SFrame 加密,依赖 Dependency Descriptor RTP 扩展头(非加密)让 SFU 识别层依赖关系。
  • 这是 AV1 + E2EE 推荐做法,VP9 SVC + E2EE 实现复杂度高,社区项目大多直接用 simulcast 替代。

7. 录制、字幕、内容审核的冲突

E2EE 与"服务端处理媒体"天然冲突。常见折衷:

功能折衷方案
云端录制在客户端旁路一份解密后媒体推 RTMP 上去("客户端录制 + 上传");或者明确告知用户"开启录制 = 关闭 E2EE"
实时字幕(ASR)客户端本地 ASR(Whisper / 系统 API),结果走信令同步给其他人
内容审核客户端本地审核 + 上报哈希 / 摘要;服务端只能审核可观察信号(说话人切换频率、连接行为)
多设备同账号新设备加入要重新走 MLS welcome;或共享身份密钥到多设备(注意安全风险)
离线会议回放录制时连同每段 epoch 密钥一起加密保存到用户私钥下;服务端存的仍是密文

工程上最现实的做法是:默认 E2EE,需要录制 / 字幕时由发起方明示并降级到 hop-by-hop,UI 上必须有强提示。

8. 性能影响

8.1 CPU

  • AES-GCM 在现代浏览器走硬件 AES-NI,单帧加解密通常 < 0.5 ms。
  • 主要成本在 Worker 进出的消息传递和 ArrayBuffer 拷贝(Insertable Streams 用 transferable 后基本零拷贝)。
  • 视频 30 fps × 3 路 simulcast × (加密 + 发送) ≈ 90 次/秒加密操作,桌面端可忽略,低端 Android 上要测。

8.2 延迟

  • 帧级加密增加 1~3 ms 端到端延迟(每端各一次 Worker 跳转 + 加密)。
  • 首包延迟受密钥就绪时机影响:必须等 MLS welcome / 密钥拿到才能发,否则只能丢或缓存。
  • 首画面延迟会变长:接收端必须等下一个 keyframe 才能开始解码。如果 keyframe 间隔 2s,最坏 2s 才出画面 → 建议加入会议时主动请求 keyframe。

8.3 带宽开销

  • 每帧增加 SFrame 头(约 412 字节)+ AES-GCM tag(16 字节)= 约 2028 字节/帧。
  • 30 fps 视频每秒 840 字节额外开销,相对码率 13 Mbps 完全可忽略。
  • 音频 50 帧/秒 × 28 字节 = 1.4 KB/s,相对 Opus 32 kbps 约 4% 增长——音频要稍微留意。

9. 实战代码:完整 E2EE 集成骨架

// e2ee-controller.js(主线程)
import { MLSGroup } from './mls-client.js'; // 假设是 OpenMLS WASM 封装

export class E2EEController {
constructor(pc, signaling) {
this.pc = pc;
this.signaling = signaling;
this.senderWorker = new Worker('/e2ee/sender-worker.js', { type: 'module' });
this.receiverWorker = new Worker('/e2ee/receiver-worker.js', { type: 'module' });
this.mls = null;
this.currentKid = 0;
}

async join(roomId, identityKey) {
this.mls = await MLSGroup.join(roomId, identityKey, this.signaling);
this.mls.on('epoch-change', (epoch) => this.rotateKey(epoch));
await this.rotateKey(this.mls.currentEpoch);
}

async rotateKey(epoch) {
// MLS exporter 派生 SFrame base key
const baseKey = await this.mls.exportSecret('sframe', 32);
const kid = epoch.id;
this.currentKid = kid;
this.senderWorker.postMessage({ type: 'set-key', kid, key: baseKey });
this.receiverWorker.postMessage({ type: 'set-key', kid, key: baseKey });

// 切换发送密钥后主动请求一次关键帧,缩短花屏窗口
await this.requestKeyframeFromAllSenders();

// 老密钥保留 5 秒以解迟到的旧帧
setTimeout(() => {
this.receiverWorker.postMessage({ type: 'drop-key', kid: kid - 1 });
}, 5000);
}

attachSender(sender) {
sender.transform = new RTCRtpScriptTransform(this.senderWorker, {
role: 'sender', trackKind: sender.track.kind,
});
}

attachReceiver(receiver) {
receiver.transform = new RTCRtpScriptTransform(this.receiverWorker, {
role: 'receiver', trackKind: receiver.track.kind,
});
}

async requestKeyframeFromAllSenders() {
for (const sender of this.pc.getSenders()) {
if (sender.track?.kind === 'video') {
const params = sender.getParameters();
// 触发本地编码器立刻吐 keyframe
await sender.setParameters({
...params,
encodings: params.encodings.map((e) => ({ ...e })),
});
}
}
}
}

要点:

  • 密钥轮换主动触发 keyframe,缩短花屏。
  • 旧密钥延迟丢弃,覆盖网络乱序。
  • MLS epoch 变化时同步更新 sender 和 receiver worker。

10. 反模式

反模式后果正确做法
自己写 AES-CTR / AES-GCM 流程,不用 SFrameIV 重用直接被破解;互操作做不到用 SFrame 标准 + 成熟实现
密钥放在信令服务器 / KMS 又宣称 E2EE用户被误导,合规风险用 MLS 做端到端密钥协商,或如实标注为"传输加密"
createEncodedStreams 在主线程跑主线程卡顿,UI 掉帧强制走 Worker + RTCRtpScriptTransform
解密失败 throw 关闭 stream整条轨道挂掉,难恢复丢帧 + 计数 + 上报
密钥轮换不保留旧密钥抖动延迟到达的旧帧解不开,花屏至少保留前一个 epoch 几秒
切换密钥不触发 keyframe视频要等下一个自然 keyframe,最长 2s 黑屏换密钥后主动 PLI
对 RTP 头也加密SFU 无法路由,TWCC / NACK 全坏严格只加密 payload
simulcast 三路共用同一 IV 计数器IV 复用 → AES-GCM 安全性归零每路独立计数器(按 RID 隔离)或在 IV 里编码 RID
在 worker 用同步 XHR / postMessage 等长操作阻塞 transform帧堆积 → 内存爆炸或 SRTP 队列溢出transform 必须 async 且 O(1)
E2EE 开启时仍允许服务端录制录制内容为乱码或被迫关闭 E2EE录制必须在客户端做,并明示用户
忽略 getMetadata() 的 SSRC,盲信发送者身份SFU 改写 SSRC 后接收端误判说话人用 SFrame 签名 + MLS 身份绑定
号称 E2EE 但 SFU 仍能解密媒体安全审计当场翻车端侧自测:在 SFU 落盘 SRTP,确认拿不到明文 payload

11. 自查清单

  • 是否真的需要 E2EE?录制、字幕、审核功能是否会被砍掉?
  • 密钥分发用 MLS 还是 KMS?是否在 UI 上诚实标注信任模型?
  • 是否使用成熟 SFrame 库(LiveKit / Jitsi / Cisco),不是自撸 AES?
  • sender / receiver transform 都在 Worker 里?主线程零阻塞?
  • 密钥轮换时是否主动 PLI?旧密钥是否保留 2~5 秒?
  • simulcast 每路 IV / CTR 是否独立?
  • 解密失败是否走丢帧 + 上报,而不是抛错关 stream?
  • 端侧 getStats 之外有没有 E2EE 健康度上报(解密失败率、密钥就绪延迟)?
  • 移动端低端机 CPU 跑 simulcast + E2EE 是否实测过?
  • 安全审计 / 渗透测试是否覆盖了"SFU 落盘抓 SRTP 解密后能否拿到内容"这条路径?

12. 权威资料