用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输

用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输

💡 原文中文,约18400字,阅读约需44分钟。
📝

内容提要

本文介绍如何用 TypeScript、WebRTC DataChannel 和 WebCrypto 构建浏览器内 P2P 加密文件流式传输引擎。朴素实现会无上限写入 SCTP 缓冲区,约 100 MB 即崩溃。方案采用 64 KB 分块、AES-GCM 逐块加密、SHA-256 完整性校验,并通过 bufferedAmountLowThreshold 实现背压流控,使内存保持有界。内容还涵盖信令、密钥交换、重组下载及生产加固建议。

🔎

延伸解读

为什么朴素实现会在约 100 MB 崩溃

文章指出,常见做法是在紧密循环中直接调用 dataChannel.send(),把数据无上限地灌入 SCTP 发送缓冲区。小文件尚可,但当文件超过可用系统内存余量(通常约 100 MB 及以上)时,bufferedAmount 会不受控攀升,内存压力可能导致标签页崩溃。这解释了为什么必须引入背压流控,而不是单纯依赖 DataChannel 本身。

64 KB 分块与 256 KB 阈值的取舍

文章选择 64 KB 块,是因为 RFC 8831 将 64 KB 视为 SCTP 消息大小的互操作性安全下限,且更细粒度便于背压响应,AES-GCM 每块仅增加 16 字节认证标签。阈值设为 256 KB,配合 64 KB 块约允许四个块在途。阈值过低会频繁暂停、饿死网络;过高则内存占用回升。高带宽局域网可翻倍至 128 KB 块与 512 KB 阈值,但再往上瓶颈会转为 SCTP 拥塞控制。

IV 唯一性与密钥轮换的硬性要求

AES-GCM 要求同一密钥下每次加密使用唯一的 12 字节 IV,复用 IV 会导致明文可被恢复。文章用 4 字节大端块索引加 8 个随机字节拼接成 IV,将生日界推到每密钥约 2^32 个块(按 64 KB 算约 274 TB),并明确要求每次传输后轮换密钥。此外,原始密钥经 DataChannel 传输只能防被动窃听,被攻陷的信令服务器仍可替换密钥,生产环境应改用 ECDH 密钥协商。

当前实现的内存与信令限制

文章坦承两处局限:接收端会把所有解密后的块缓存在内存中,超出可用内存的文件需改用 File System Access API 或 StreamSaver.js;清单方式会把文件读两遍,内存受限时可考虑传输中计算摘要、传输后校验。信令方面,BroadcastChannel 仅限同源同一浏览器,只能用于本地开发,生产需替换为 WebSocket 或 SSE,并为对称 NAT 后的对等方配置 TURN 回退。

❓

Q&A

为什么朴素的 WebRTC 文件传输在传输大文件时会崩溃?

朴素实现会在紧密循环中调用 dataChannel.send(),把无上限的数据倾倒进 SCTP 发送缓冲区。对于超出可用系统内存余量的文件(通常约 100 MB 及以上),RTCDataChannel.bufferedAmount 会不受控制地攀升,内存压力可能导致标签页崩溃。

如何用 bufferedAmountLowThreshold 实现背压流控?

设置 dataChannel.bufferedAmountLowThreshold = 256 * 1024(256 KB),当 bufferedAmount 超过阈值时暂停发送,等待 bufferedamountlow 事件触发后恢复。配合 64 KB 的块,这大约允许任意时刻有四个块在途。

为什么选择 64 KB 作为分块大小?

按 RFC 8831,SCTP 消息大小的互操作性安全下限是 64 KB;浏览器会协商出更大的值,但 64 KB 块是安全且可移植的默认选择。更小的 64 KB 块能提供更细粒度的背压响应,而 AES-GCM 每 64 KB 块只增加 16 字节(0.02%)的认证标签开销。它们还能降低峰值内存占用,因为任意时刻在途的字节更少。

如何为每个块生成唯一的 AES-GCM IV?

AES-GCM 要求在同一密钥下的每一次加密操作都使用唯一的 12 字节初始化向量(IV)。这里的策略是把 4 字节大端块索引与 8 个随机字节拼接起来,降低跨传输的碰撞概率;有了 8 个随机字节,生日界约为每密钥 2^32 个块(按 64 KB 块算约 274 TB)。每次传输后请轮换密钥。IV 会被前置到密文上,以便接收端在解密前取出。

接收端如何校验每个块的完整性?

接收端用 crypto.subtle.digest('SHA-256', decryptedChunk) 计算每个块的 SHA-256 摘要。在传输开始之前,发送端会计算一份清单,其中包含每个明文块的十六进制 SHA-256 摘要,并作为控制消息传输出去。接收端把每个解密后的块与清单中对应条目比对,一旦不匹配立即中止。

生产环境需要做哪些加固?

把 BroadcastChannel 信令替换为基于 WebSocket 或 SSE 的信令服务器。为处于对称 NAT 之后的对等方配置 TURN 服务器回退,否则直连会失败。使用临时 ECDH(ECDHE):每次会话用 crypto.subtle.generateKey 生成一对全新的 P-256 密钥。静态 ECDH 不提供前向保密,只有临时密钥对才有。若要在接收端实现真正流式下载而不完整缓冲 Blob,在 Chromium 浏览器中优先使用 File System Access API(showSaveFilePicker)。StreamSaver.js 是一个兼容性更广的替代方案,但已不再活跃维护。设置 CSP 头以允许用于触发下载的 blob: URL。

🏷️

标签

➡️

继续阅读