端到端加密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-SRTP | E2EE(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 padding | SFU 可见 | SFU 可见 |
| RTP payload(编码后的 VP8/H264/Opus 比特流) | SFU 可见,明文 | 加密,SFU 不可见 |
| SFrame 头(KID、CTR) | — | SFU 可见,但不含密钥 |
| 帧内 NAL/OBU 结构、帧类型 keyframe/delta | SFU 可见 | SFU 不可见 payload,但可从 marker bit、frame marking 扩展头判断 keyframe |
| 内容(图像、声音) | SFU 可见 | SFU 不可见 |
2.2 为什么必须留这些元数据可见
SFU 在转发时必须做这些事,全部依赖 RTP 头和扩展头:
- 拥塞控制:根据 TWCC(Transport-Wide Congestion Control,RFC 8888)扩展头估算带宽,下发 REMB / TWCC 反馈。
- 丢包重传:NACK 需要 SeqNum 才能定位丢失包。
- 关键帧请求:PLI/FIR 触发依赖识别帧边界(marker bit + frame marking RFC 7941)。
- Simulcast 层选择:根据
mid/rid扩展头分流不同分辨率层。 - 包改写: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,通过MessagePort传ReadableStream/WritableStream,零拷贝转移 EncodedFrame 的 ownership。 - 旧 API
createEncodedStreams()还能用但已经被规范标记为 legacy,新代码用RTCRtpScriptTransform。
3.2 EncodedFrame 是什么
- 视频:
RTCEncodedVideoFrame,含data: ArrayBuffer(一整帧编码后的比特流,可能跨多个 RTP 包)、timestamp、type: '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);
});
要点:
- 解密失败不要 throw:
TransformStream出 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
参考实现 / 库:
- Cisco sframe-rs(Rust + WASM 绑定)
- LiveKit client-sdk-js 的 E2EE 模块(生产可用)
- Jitsi
lib-jitsi-meet的 e2ee 模块
生产部署一律用现成库,因为 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 选型建议
| 维度 | 中心化 KMS | MLS |
|---|---|---|
| 实现复杂度 | 低 | 高(建议用 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 头(约 4
12 字节)+ 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 流程,不用 SFrame | IV 重用直接被破解;互操作做不到 | 用 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. 权威资料
- W3C WebRTC Encoded Transform(Insertable Streams):https://w3c.github.io/webrtc-encoded-transform/
- IETF SFrame WG:https://datatracker.ietf.org/wg/sframe/about/
- IETF draft Secure Frame (SFrame):https://datatracker.ietf.org/doc/draft-ietf-sframe-enc/
- RFC 9420 The Messaging Layer Security (MLS) Protocol:https://www.rfc-editor.org/rfc/rfc9420
- RFC 8888 Transport-Wide Congestion Control Feedback:https://www.rfc-editor.org/rfc/rfc8888
- RFC 5764 DTLS-SRTP:https://www.rfc-editor.org/rfc/rfc5764
- Google WebRTC Insertable Streams Explainer:https://github.com/w3c/webrtc-encoded-transform/blob/main/explainer.md
- Chrome RTCRtpScriptTransform 实现说明:https://developer.chrome.com/docs/web-platform/webrtc-encoded-transform
- LiveKit E2EE 文档:https://docs.livekit.io/home/client/tracks/encryption/
- Jitsi E2EE 设计:https://jitsi.org/blog/e2ee/
- OpenMLS Rust 实现:https://github.com/openmls/openmls
- 核对日期:2026-06-22