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

加密性能优化:浏览器中的快速加密

优化浏览器中大文件传输的加密性能。流式加密、Web Workers和分块处理技术。

在浏览器中加密一个 5 GB 的文件而不卡死界面,需要一套具体的技术组合:使用 Web Crypto API 实现硬件加速的 AES-256-GCM(AES-NI 下可达 1–2 GB/s),以 1–4 MB 分块处理来控制内存占用,通过 Web Workers 保持主线程响应,用 File.stream() 替代 FileReader.readAsArrayBuffer() 进行流式读取,以及精心管理 nonce 以支持并行加密。纯 JavaScript 加密库比 Web Crypto 慢 10–20 倍,仅在浏览器不原生支持的算法上才有必要使用。下面介绍如何在真实用户硬件上达到数百兆字节每秒的吞吐量。

先建立基准测量

优化前先测量。在 2024 年 MacBook Air M2 的 Chrome 120 上,通过 Web Crypto 对 1 GB 缓冲区执行 AES-256-GCM 加密,吞吐量约为 1.7 GB/s。在中端 Android 手机(Pixel 7)上约为 600 MB/s,在仅有软件 AES 的 2015 年 Intel 笔记本上约为 250 MB/s。

这说明:对大多数加密文件传输流程而言,Web Crypto AES-GCM 并不是瓶颈。瓶颈通常在于文件读取、ArrayBuffer 之间的 JavaScript 封送,或者网络上传。应先优化这些环节。

应用内基准测试代码示例:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 MB
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} MB/s`);

分别在 Chrome、Firefox、Safari 上运行。Apple Silicon 上的 Chrome 最快,Firefox 稍慢,Intel 上的 Safari 略微落后。移动端速度大约是桌面端的 30–50%。

优先使用 Web Crypto,而非 JavaScript 库

对于 AES-GCM、AES-CBC、PBKDF2、HMAC、RSA、ECDH、ECDSA、SHA-256/384/512,浏览器原生 Web Crypto API 在可用时会使用硬件加速(x86 上的 AES-NI,移动端的 ARMv8 加密扩展)。@noble/ciphers 或 crypto-js 等纯 JavaScript 库无法访问这些指令,只能在解释器中运行。

1 MB 输入的 AES-256-GCM 典型速度对比:

  • Web Crypto(硬件加速):1–2 GB/s
  • libsodium.js WASM:400–800 MB/s
  • @noble/ciphers 纯 JS:100–200 MB/s
  • crypto-js 纯 JS:30–80 MB/s

对于 5 GB 文件,Web Crypto 与纯 JS 的差距约为 3 秒对 50 秒,用户完全感知得到。凡是 Web Crypto 支持的算法,一律优先使用。仅在浏览器不原生支持的算法(ChaCha20-Poly1305、Argon2id、旧浏览器上的 X25519)时,才使用 WASM 库(libsodium.js、argon2-browser)。

大文件分块处理

超过约 500 MB 的文件在大多数设备上无法放入单个 ArrayBuffer。将其分成 1–4 MB 的块分别加密:

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

为什么选 4 MB?块太小(如 64 KB)会产生较大的 Web Crypto API 调用开销,在小输入时占主导;块太大(如 64 MB)则超出 L2/L3 缓存,内存压力更高。在桌面端和移动端,1–4 MB 通常是最优区间。

使用 Web Workers 保持界面响应

在主线程上加密会阻塞渲染和输入响应。超过约 500 ms 的工作量,应将其卸载到 Web Worker:

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// 主线程
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* 处理密文 */ };

使用 postMessage 的第二个参数传递 ArrayBuffer——这会转移所有权(零拷贝),而非克隆。不使用 transfer 时,克隆一个 4 MB 的块会增加约 20 ms 的开销。Web Crypto API 在 Worker 内部同样可用,加密性能与主线程完全一致。

并行分块加密

只要 nonce 不发生碰撞,每块 AES-GCM 加密可以独立进行。通过块索引确定性派生 nonce,即可跨多个 Worker 并行加密。但在典型硬件上,超过约 4 个 Worker 后收益递减,因为 Web Crypto 速度极快,瓶颈会转移到文件读取和线程间消息传递。在投入复杂度之前先做基准测试——有时单 Worker 顺序加密与多 Worker 并行加密一样快,因为开销抵消了并行收益。

使用 Readable Streams 流式处理

对于真正的超大文件(20 GB 以上),应避免一次性将块加载到内存。使用 File.stream()

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

这样内存使用量始终限制在一个块的大小。浏览器从磁盘读取、加密、写入上传流、再读取下一块,无论文件多大,内存峰值都在 10 MB 以下。

上传并发控制

加密与上传应并行进行,而非串行。在第 N 块上传时,同时加密第 N+1 块,使用流水线模式:

const MAX_IN_FLIGHT = 4;
// 加密器生产,上传器消费
// 有界队列保持内存使用可预测

TCP 慢启动和 TLS 握手开销导致初始上传较慢。对单一源维持 4–8 个并发上传可保持管道满负荷,同时不触发浏览器连接限制(Chrome/Firefox 每个源最多 6 个连接)。

进度反馈

大文件加密需要进度反馈,否则用户会以为应用卡死。统计已加密字节数并发送进度事件:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

通过 requestAnimationFrame 或简单的时间戳检查将界面更新节流至约 10 Hz。更高频率的更新只会浪费重绘周期,人眼根本察觉不到差异。

内存上限与垃圾回收压力

每个 ArrayBuffer 在不再被引用前都占用内存。若同时持有 20 个 4 MB 的加密块,将固定约 80 MB 内存。在内存紧张的移动浏览器上(iOS Safari 每个标签页上限约 200–400 MB),这不可忽视。

及时释放引用:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // 让 GC 回收
  const ct = await encrypt(chunk);
  await upload(ct);
}

避免在内存中持有完整的加密块数组;应边加密边发送给上传器,并随时丢弃引用。

实际性能目标

对于浏览器标签页中 1 GB 加密文件传输:加密时间 1–3 秒(硬件加速),300 Mbps 连接下上传约 30 秒,整体约 35 秒(主要受网络制约),正确流式处理下内存峰值低于 50 MB,全程界面响应(主线程阻塞不超过 50 ms)。未达标则用户会有明显感知。HexaTransfer 的 10 GB 上限之所以能在浏览器内实现,正是因为 Web Crypto 加上分块流式处理使整个路径保持高效。

在 hexatransfer.com 上试试 — 免费、无需注册、最多 10 GB。

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

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

发送文件