Web Crypto API:文件加密完整教程
掌握Web Crypto API实现浏览器端文件加密。JavaScript应用中的AES-GCM、RSA-OAEP和密钥管理。
国家网信办(CAC)在《个人信息出境标准合同办法》中要求:出境数据必须采取"技术保护措施"。浏览器端加密是满足这一要求最彻底的方案——文件在离开设备前已被加密,服务器永远看不到明文,数据出境时传输的只是密文。Web Crypto API 通过原生的 window.crypto.subtle 方法实现浏览器内文件加密,无需外部库。
标准流程:通过 PBKDF2(210,000 次迭代,SHA-256)从密码派生密钥,然后用 AES-GCM 加密文件字节,使用 96 位 IV 和 128 位认证标签。公钥工作流使用 4096 位 RSA-OAEP 包裹对称密钥。该 API 在所有现代浏览器的 HTTPS 环境下可用,运行于原生加密后端而非 JavaScript 层。
为何 SubtleCrypto 优于纯 JS 库
window.crypto.subtle 调用浏览器的原生加密后端,在 Chromium 中通常是 BoringSSL,在 Safari 中是 CommonCrypto。与 CryptoJS 或 sjcl 等纯 JS 方案相比,SubtleCrypto 的 AES-GCM 运行速度快 30–80 倍,避免了 JavaScript 解释器中的时序侧信道攻击,且向用户分发零字节额外代码。代价是基于 Promise 的 API 只操作 ArrayBuffer 和 CryptoKey 对象,因此需要在 Uint8Array、Blob 和 ReadableStream 之间频繁转换。对于超过 100 MB 的文件,这些管道处理比原始加密速度更影响性能。
用 PBKDF2 从密码派生密钥
绝不直接将密码用作 AES 密钥。正确做法是先导入密码作为原始材料,再派生 256 位密钥:
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 210000, hash: 'SHA-256' },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
OWASP 2026 指南建议 SHA-256 至少 600,000 次迭代,低风险场景 210,000 次仍可接受。用 crypto.getRandomValues 为每个文件生成全新的 16 字节盐值,并将其与密文一起存储。Argon2id 会更强,但 SubtleCrypto 尚未暴露该算法。
用 AES-GCM 加密文件
AES-GCM 一次通过即可提供机密性和真实性验证。关键规则是绝不复用 (key, IV) 对:
async function encryptFile(file, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = await file.arrayBuffer();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
plaintext
);
return { iv, ciphertext };
}
对于 2 GB 文件,file.arrayBuffer() 会分配整个缓冲区,这在移动端 Safari 上常常崩溃。将文件分割为 4 MB 块,每块用从计数器与随机前缀拼接派生的唯一 IV 加密,并在开头添加版本字节和盐值,供解密方识别格式。
通过 TransformStream 流式处理大文件
为避免内存爆炸,将加密封装在 TransformStream 中,让文件流经其中:
const chunkSize = 4 * 1024 * 1024;
const encryptor = new TransformStream({
async transform(chunk, controller) {
const iv = nextIV(counter++);
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, chunk);
controller.enqueue(new Uint8Array([...iv, ...new Uint8Array(ct)]));
}
});
await file.stream()
.pipeThrough(sliceByChunks(chunkSize))
.pipeThrough(encryptor)
.pipeTo(uploadSink);
file.stream() 从磁盘惰性读取返回 ReadableStream<Uint8Array>。切片器生成固定大小的块,使 GCM 标签对齐可预测。即使上传 10 GB 文件,峰值内存也控制在 20 MB 以内。
用 RSA-OAEP 包裹对称密钥
当需要与特定接收方共享文件时,一次性生成其 RSA 密钥对并发布公钥:
const keypair = await crypto.subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 4096,
publicExponent: new Uint8Array([1,0,1]), hash: 'SHA-256' },
true, ['wrapKey', 'unwrapKey']
);
const wrapped = await crypto.subtle.wrapKey(
'raw', fileKey, keypair.publicKey,
{ name: 'RSA-OAEP' }
);
根据 NIST SP 800-57,4096 位 RSA 密钥在 2030 年前提供约 150 位安全强度。若需要前向保密或后量子抗性,可将 RSA-OAEP 与 P-384 上的 ECDH 配合使用,或在 WebCrypto 工作组落地后迁移至 ML-KEM(Kyber)。
在 IndexedDB 中安全存储密钥
CryptoKey 对象默认不可提取,意味着可以将其持久化到 IndexedDB 而无需将原始字节暴露给 JavaScript:
const db = await openDB('keystore', 1);
await db.put('keys', keypair.privateKey, 'user-signing-key');
浏览器使用结构化克隆算法序列化密钥,实际字节保存在加密后端。被入侵的脚本可以调用存储密钥进行 encrypt 或 decrypt,但无法读取其原始材料。与将 base64 密钥塞入 localStorage 相比,这是有实质意义的加固措施。
实际陷阱与注意事项
Firefox 安卓版对 deriveKey 的迭代次数上限约 100 万次,超过会冻结 UI 线程数秒——需在专用 Worker 中运行密钥派生。16.4 以下版本的 Safari 不支持 PSS 填充的 crypto.subtle.verify。Chrome 对每次 getRandomValues 调用的大小上限约 64 KB,需要更多熵时需循环调用。通过 postMessage 传输 ArrayBuffer 是零拷贝的,但会解除原始引用,这常让开发者措手不及。
HexaTransfer 对每次上传都使用这套 AES-GCM 加 PBKDF2 流水线,密钥在 Worker 中派生,密文流式传输到存储层,服务器始终看不到明文。立即体验:https://hexatransfer.com——免费,无需注册,最大支持 10 GB。