跳转到内容
HexaTransfer
返回博客
加密与安全

大文件渐进式加密:流式加密

使用流式API渐进式加密大文件。通过逐块加密处理数GB文件而不会耗尽内存。

渐进式(流式)加密逐块处理文件,始终不将完整内容加载到内存。对于浏览器中的 10 GB 上传,这是应用能正常运行与直接崩溃的分水岭。处理模式如下:通过 File.stream() 读取一个块,使用唯一 nonce 以 AES-256-GCM 加密,将密文通过 fetchReadableStream 请求体直接传输到上传流,释放缓冲区,继续处理下一块。无论文件大小,内存保持在 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。

通过端到端加密安全发送大文件

通过端到端加密免费传输最大10GB的文件。无需注册账户。文件在上传前在浏览器中加密,其他人无法读取。

发送文件