Web Crypto API实战指南:加密解密签名验签全链路解析
项目初期的技术选型
去年下半年接手一个内部审计系统,核心需求是:用户上传的敏感文档(PDF、Excel)必须在浏览器端完成加密后再发到后端。不是简单加个密码保护,而是要实现「用户A上传的文件,只有用户A能解密查看」——也就是说,密钥不能经过服务端,也不能存在本地明文存储里。
一开始我看了下 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 对 deriveKey 的 iterations 值特别敏感。设成 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 优化技巧,我还在找更优解。
