Web Crypto API实战指南:加密解密签名验签全链路解析

打工人可慧 安全 阅读 1,719
赞 10 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年接手一个内部审计系统,核心需求是:用户上传的敏感文档(PDF、Excel)必须在浏览器端完成加密后再发到后端。不是简单加个密码保护,而是要实现「用户A上传的文件,只有用户A能解密查看」——也就是说,密钥不能经过服务端,也不能存在本地明文存储里。

Web Crypto API实战指南:加密解密签名验签全链路解析

一开始我看了下 CryptoJS,写起来快,但问题太明显:它用的是纯 JS 实现的 AES,性能差、容易被逆向、不支持真正的密钥派生(PBKDF2 还得自己补全盐值和迭代次数逻辑)。查了一圈,Web Crypto API 就成了唯一靠谱的选择。虽然 MDN 上写着「实验性」,但 Chrome 85+、Firefox 79+、Safari 16.4+ 都已稳定支持,我们业务只跑内网 Chrome,所以直接上了。

最大的坑:性能问题

第一版代码很简单:用户输入密码 → deriveKey 生成 AES-GCM 密钥 → encrypt 整个文件 ArrayBuffer → base64 发给后端。测试时用 2MB 的 PDF,加密耗时 1.8 秒。用户反馈「点上传按钮像点了暂停键」。

我原以为是 deriveKey 慢,结果 console.time 一测,deriveKey 只占 30ms,真正卡住的是 encrypt。后来发现:Web Crypto 的 encrypt() 是同步阻塞主线程的(哪怕你用 await 包着),而我的文件读取用了 FileReader.readAsArrayBuffer(),整个流程串在主线程里,UI 直接冻结。

折腾了半天发现,根本解法不是优化算法,而是把加密丢进 Worker。但 Web Crypto 在 Worker 里默认不可用——除非你显式启用。而且 postMessage 传 ArrayBuffer 必须用 transferable,不然会拷贝,大文件更慢。

最终的解决方案

最后定了三件事:

  • 主页面只做 UI 和文件读取,把 ArrayBuffer 通过 postMessage(..., [arrayBuffer]) 转移过去
  • Worker 里用 self.crypto.subtle 做密钥派生和加密
  • 加密完立刻 postMessage({ encrypted: base64, iv, salt }) 回主线程

完整 Worker 代码如下(我把它抽成独立的 crypto-worker.js):

// crypto-worker.js
self.onmessage = async function(e) {
  const { password, arrayBuffer } = e.data;

  // 生成随机 salt 和 iv
  const salt = self.crypto.getRandomValues(new Uint8Array(16));
  const iv = self.crypto.getRandomValues(new Uint8Array(12));

  // PBKDF2 派生密钥(注意:iterations 我们定死 500000,实测够用且不卡)
  const keyMaterial = await self.crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );
  const key = await self.crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 500000, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt']
  );

  // 加密
  const encrypted = await self.crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    arrayBuffer
  );

  // 返回 base64 编码的密文 + 元信息
  const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encrypted)));
  self.postMessage({
    encrypted: encryptedBase64,
    iv: Array.from(iv),
    salt: Array.from(salt)
  });
};

主线程调用逻辑也简单:

// 主页面
const worker = new Worker('/js/crypto-worker.js');

worker.onmessage = function(e) {
  const { encrypted, iv, salt } = e.data;
  // 发给后端,例如:
  fetch('https://jztheme.com/api/upload', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      encrypted,
      iv,
      salt,
      filename: file.name
    })
  });
};

// 读取文件并发送
const reader = new FileReader();
reader.onload = () => {
  const arrayBuffer = reader.result;
  worker.postMessage({ password: pwdInput.value, arrayBuffer }, [arrayBuffer]);
};
reader.readAsArrayBuffer(file);

还有几个小坑想提醒你

第一个:btoa() 对大于 2^16 字节的 ArrayBuffer 会报错「InvalidCharacterError」。别问为什么,这是浏览器限制。我改用 Array.prototype.map.call(str, c => c.charCodeAt(0)) 手动转,或者更稳妥的方案是用 Uint8Array + String.fromCharCode.apply(null, ...) 分块处理。但我最后偷懒用了 new TextDecoder().decode(new Uint8Array(encrypted))btoa(),只要不超 10MB 就没问题——我们业务里最大文件是 8MB,够了。

第二个:Safari 对 deriveKeyiterations 值特别敏感。设成 1000000 就卡住几秒不动,500000 刚好平衡安全与体验。这个值我试了 7 次才确定下来,过程很枯燥。

第三个:密钥不能导出,也不能序列化。所以千万别想着「我把密钥存 localStorage 以后复用」——Web Crypto 明确禁止。每次都要重新 derive,这也是为什么我们把密码输入框放在上传前一步,而不是存起来反复用。

回顾与反思

这套方案上线三个月,没出过加密/解密失败的问题,用户侧几乎无感知(加密时间压到了 400ms 左右)。相比之前 CryptoJS 方案,安全性提升是实打实的:密钥 never 离开 SubtleCrypto 上下文,salt 和 iv 都随机生成且随文件保存,后端完全看不到原始数据或密钥材料。

当然也有妥协点:比如解密流程还没做(当前只做上传加密),因为审计人员都是在后台管理系统里看原文,他们用的是另一套 Java 解密服务;再比如我没做进度条,因为 encrypt() 不支持流式处理,大文件只能等,加 loading 动画反而让用户更焦虑……这些都留着下个迭代再说。

另外,Web Crypto 的错误提示真的非常不友好。比如传错参数类型,它就 throw DataError,不告诉你哪错了。我靠在 catch 里打印 e.toString() + 看 Chrome DevTools 的 Sources 断点,才搞明白 iv 必须是 12 字节——文档里写了,但我第一次漏看了。

总的来说,Web Crypto API 不是银弹,但它足够可靠。只要你愿意花半天时间踩一遍坑,后面基本就是复制粘贴的事。比起自己维护一套 JS 加密库,它省下的审计成本和心理负担,远超学习成本。

以上是我踩坑后的总结,希望对你有帮助。如果你也用 Web Crypto 做过类似事情,欢迎评论区交流——特别是 Safari 下的 performance 优化技巧,我还在找更优解。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
极客治柯
文章里的鼓励和支持让我很感动,也让我更有勇气去面对学习和工作中的困难。
点赞 3
2026-02-16 13:25