JavaScript文件加密:分步教程
用JavaScript在浏览器中加密文件。实用教程涵盖AES加密、密钥派生和安全的文件处理。
《个人信息保护法》(PIPL)第五十一条要求数据处理者采用"加密"等技术措施保护个人信息。浏览器端加密是最彻底的实现方式——文件在设备上加密完成后才传输,服务器仅存储密文,从源头满足法律要求。JavaScript 可通过 Web Crypto API 完全在浏览器内加密文件,无需服务器介入。
标准流程:将文件读取为 ArrayBuffer,通过 PBKDF2-SHA256(600,000 次迭代)从密码派生 256 位 AES-GCM 密钥,使用随机 12 字节 IV 加密,将盐值、IV 和密文打包为可下载的 Blob。每个主流浏览器都通过 window.crypto.subtle 原生支持这一流程,2–3 GB 以内的文件在现代笔记本上 10 秒内即可完成,无需任何第三方库。
算法选择的关键决策
选 AES-GCM,不选 AES-CBC。GCM 一次通过提供认证加密,用 128 位标签检测篡改,而 CBC 需要单独的 HMAC 步骤,大多数教程对此处理有误。选 256 位密钥——与 128 位相比性能差距可忽略,有 AES-NI 硬件加速时尤其如此。选 PBKDF2-SHA256 进行基于密码的密钥派生,除非可以通过 WebAssembly 提供更强的 Argon2id(但会增加约 50 KB 下载体积)。
避免:ECB 模式(根本上已破损)、手工实现的填充(十年的 CBC 填充预言机攻击史)、MD5 或 SHA-1(碰撞已破解)以及默认用 CBC 配合 PKCS7 的 crypto-js 包。
派生密钥
绝不将原始密码传入 encrypt。先派生密钥:
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey(
'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
用 crypto.getRandomValues(new Uint8Array(16)) 为每个文件生成全新的 16 字节盐值,与密文一起存储。跨文件复用盐值会使 PBKDF2 失去意义。OWASP 2026 年 12 月密码存储备忘单目前推荐 PBKDF2-SHA256 使用 600,000 次迭代,在中端手机上约需 500 ms 的密钥派生时间。
加密文件
有了密钥,加密是对每个缓冲区的一次 subtle.encrypt 调用:
async function encryptBuffer(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
plaintext
);
return { iv, ciphertext };
}
GCM 在复用 (iv, key) 对时会灾难性失败——密钥流碰撞会同时泄露两段明文。96 位随机 IV 对单个密钥提供约 2^48 次安全加密,足以用于文件加密。若用同一密钥加密多个块,从计数器加随机 32 位前缀派生 IV。
打包输出
解密方需要盐值、IV 和密文。将它们打包为带小型头部的单个 Blob:
function pack(salt, iv, ciphertext) {
const magic = new TextEncoder().encode('ENC1');
return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}
对头部进行版本化(ENC1、ENC2……),以便以后迁移算法而不破坏旧文件。通过创建临时 URL 触发下载:
const url = URL.createObjectURL(packed);
const a = document.createElement('a');
a.href = url; a.download = `${file.name}.enc`; a.click();
URL.revokeObjectURL(url);
解密文件
解密反转流程,密码错误或文件被篡改时抛出 OperationError:
async function decryptFile(blob, password) {
const buf = await blob.arrayBuffer();
const view = new Uint8Array(buf);
const magic = new TextDecoder().decode(view.slice(0, 4));
if (magic !== 'ENC1') throw new Error('Unknown format');
const salt = view.slice(4, 20);
const iv = view.slice(20, 32);
const ct = view.slice(32);
const key = await deriveKey(password, salt);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new Blob([pt]);
}
显示单一错误信息——"无法解密,密码错误或文件已损坏"——而非区分标签验证失败和结构失败。这消除了向攻击者泄露信息的类填充预言机风险。
不耗尽内存地加密大文件
超过 500 MB 的文件,切勿对整个文件调用 arrayBuffer()。使用唯一 IV 独立加密块:
function ivForChunk(baseIV, index) {
const iv = new Uint8Array(baseIV);
const view = new DataView(iv.buffer);
view.setUint32(8, index, false);
return iv;
}
将文件通过 TransformStream 管道传输,加密每个 4 MB 块,并通过 File System Access API 将结果写入指向磁盘的 WritableStream。即使对 20 GB 文件,峰值内存也控制在约 10 MB。分块格式需要在头部记录块大小和块数量,以便解密方正确重组。
生产环境中出现的常见错误
真实代码审查中反复出现三种模式:
第一,将原始密钥存储在 localStorage 中图方便。localStorage 是同步的、同源作用域的,任何 XSS 都可读取。改用 IndexedDB 中的不可提取 CryptoKey。
第二,使用 Math.random() 生成 IV 或盐值。Math.random() 是可预测的;始终使用 crypto.getRandomValues。
第三,假设 subtle.encrypt 是恒定时间的。在原生浏览器实现中它是,但任何围绕它的 JavaScript 包装几乎肯定不是。让自己的代码远离热路径。
HexaTransfer 应用这套完整流水线——PBKDF2 600k 次迭代,AES-256-GCM,流式分块——服务器只存储不透明密文。立即体验:https://hexatransfer.com——免费,无需注册,最大支持 10 GB。
测试实现
编写测试套件对随机 10 MB Blob 进行往返测试,改变一个字节,断言解密抛出异常。针对格式错误的头部和截断密文进行模糊测试——常见 Bug 是 slice() 缺少边界检查,导致崩溃而非拒绝。在 iPhone SE、中端 Android 和 Chromebook 上进行基准测试;任何密钥派生超过 2 秒的情况对移动用户来说都太慢。上线前进行第二人审核——加密代码看起来简单,却以测试难以发现的微妙方式失效。