大文件渐进式加密:流式加密
使用流式API渐进式加密大文件。通过逐块加密处理数GB文件而不会耗尽内存。
渐进式(流式)加密逐块处理文件,始终不将完整内容加载到内存。对于浏览器中的 10 GB 上传,这是应用能正常运行与直接崩溃的分水岭。处理模式如下:通过 File.stream() 读取一个块,使用唯一 nonce 以 AES-256-GCM 加密,将密文通过 fetch 的 ReadableStream 请求体直接传输到上传流,释放缓冲区,继续处理下一块。无论文件大小,内存保持在 4–16 MB 以内。libsodium 的 crypto_secretstream_xchacha20poly1305 还添加了正式的流式 AEAD 语义,包含截断攻击检测。以下是在真实硬件上经过验证的具体实现。
为什么缓冲式加密行不通
对 10 GB 文件调用 FileReader.readAsArrayBuffer(file) 会在浏览器内存中分配 10 GB。在配有 32 GB 内存的桌面 Chrome 上或许勉强可行,但在每个标签页内存上限为 400 MB 的移动 Safari 上,加载完成前就会崩溃。Firefox 中超过 2 GB 的 ArrayBuffer 会触及内部限制并抛出 RangeError。
即便硬件能处理这种分配,持有 10 GB 的内存也会阻碍垃圾回收并触发严重的内存换页。正确做法是从根本上避免分配完整缓冲区。
流式加密模式
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
async function enqueueEncrypted(controller, chunk, index, key) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(index));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
controller.enqueue(new Uint8Array(ct));
}
两个关键 API:File.stream() 提供文件内容的 ReadableStream;带 ReadableStream 请求体的 fetch 流式上传而不缓冲整个请求体。Chrome 105+ 要求 duplex: "half" 才能使用流式请求体。
内存使用:任意时刻仅持有一个源块、一个缓冲余量、一个加密块,4 MB 块大小下峰值约 12–16 MB。
流中的 Nonce 管理
每个块需要唯一的 nonce,有三种方案:
计数器模式:将块索引嵌入 96 位 nonce。高 32 位设为随机前缀(避免使用相同密钥的不同文件之间的碰撞),低 64 位设为块索引:
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
每块随机:crypto.getRandomValues(new Uint8Array(12))。对于每文件唯一密钥是安全的,跨块生日碰撞概率约在 2^48 处触及。需将 nonce 与每块密文一起存储。
HKDF 派生:使用 HKDF 为每块派生独立密钥,然后使用固定 nonce。对大多数场景属于过度工程。
对于全新的每文件密钥,计数器模式最为简单,且无需为每块单独存储 nonce。
截断攻击及其检测方法
朴素分块 AES-GCM 有一个关键缺陷:攻击者可以丢弃尾部块,而每个保留的块都能正常解密。检测截断需要将各块绑定在一起。
方案一:在每块的 AAD 中包含总块数。接收方验证收到的块数与声明的一致:
const aad = new TextEncoder().encode(JSON.stringify({
totalChunks,
fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
key,
chunk
);
方案二:使用 libsodium 的 crypto_secretstream_xchacha20poly1305,它以密码学方式将块链接在一起,并通过 TAG_FINAL 标记让接收方验证完整性:
const { state, header } =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// 每个块用 TAG_MESSAGE push
// 最后一块用 TAG_FINAL push
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
state, lastChunk, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);
接收方的 pull 调用会验证链条并检测丢失的尾部块。愿意引入 libsodium.js 时,这是最简洁的方案。
接收端流式解密
接收端采用对称模式:
async function streamDecrypt(downloadURL, key, onChunk) {
const response = await fetch(downloadURL);
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // 加上 GCM 标签
while (true) {
const { done, value } = await reader.read();
if (done) break;
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer);
newBuf.set(value, buffer.length);
buffer = newBuf;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
const iv = makeNonce(chunkIndex++);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
onChunk(new Uint8Array(pt));
}
}
// 处理最后的不完整块
if (buffer.length > 0) {
const iv = makeNonce(chunkIndex);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
onChunk(new Uint8Array(pt));
}
}
onChunk 回调可通过 File System Access API 将解密字节直接写入磁盘,或拼接成 Blob 触发浏览器原生下载。
通过 File System Access API 写入磁盘
对于超大文件下载,将完整解密结果加载到 Blob 中会使流式处理失去意义。File System Access API(Chrome 86+,Safari 通过 OPFS 部分支持)允许接收方选择本地文件并直接写入块:
const handle = await window.showSaveFilePicker({
suggestedName: "decrypted-file",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
块直接写入磁盘,内存始终保持有界。界面显示真实进度,用户可随时取消。Firefox 桌面端尚不支持 showSaveFilePicker,对于几百 MB 以下的文件可回退到内存中构建 Blob,超大文件则可使用 Origin Private File System。
10 GB 文件的基准数据
在 2024 年 MacBook Pro(M3 Max)配快速 SSD 上:通过 File.stream() 从磁盘读取速度为 2.5 GB/s,AES-256-GCM via Web Crypto 为 1.7 GB/s,组合流水线为 1.1 GB/s(受串行链路限制),千兆以太网上传为 115 MB/s(受网络制约),无论文件大小内存峰值仅 14 MB。移动端数据约为桌面端的 30–50%。10 GB 文件在千兆网络上约需 90 秒,在典型家庭网络上约需 15 分钟——加密不是瓶颈,网络才是。
错误恢复
10 GB 上传期间网络中断很常见,可采用以下策略:
- 分片上传(multipart):每个分片独立上传,仅需重传失败的部分
- tus 协议:开放的可恢复上传标准,原生支持流式处理
- 保持源文件句柄:
File.slice可重复调用,可从最后成功的块重新开始
HexaTransfer 的 10 GB 上限之所以能在单个浏览器标签页中完成,正是因为这种流式流水线保持了内存有界,并通过分片重试优雅地处理网络中断。
核心原则
不要分配整个文件。读取分块、加密分块、上传分块、用完即释放每个块。通过 AAD 或流式 AEAD 以密码学方式绑定各块以防止截断。对所有环节添加进度条。在移动端测试,而不只是桌面端。
在 hexatransfer.com 上试试 — 免费、无需注册、最多 10 GB。