实时通信的端到端加密与安全

讲清 WebRTC 的内建安全机制与局限:拆解 DTLS 握手与 SRTP 密钥导出、指纹验证与中间人风险、为什么 SFU 能看到明文、基于 Insertable Streams 与 SFrame 的端到端加密方案、MLS 密钥协商,以及信令鉴权与身份认证的工程实践与常见坑。

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 选层仍可用(因为只需读头部),但无法做内容感知的码率分配。
功能非 E2EEE2EE
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 与加密实践 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播