过去在浏览器中加密数据,意味着要引入一个庞大的 JavaScript 库,并祈祷它的实现是正确的。如今,每个现代浏览器都内置了 Web Crypto API——一个原生、高性能、经过审计的密码学接口,完全在客户端运行。本指南带你走完使用 Web Crypto API 进行 AES 加密的全过程,重点讲解 AES-256-GCM(你应该使用的模式)和 PBKDF2 密钥派生的实战用法。
本文所有代码都在浏览器本地运行。数据不会离开你的机器,不涉及任何服务端,也不需要第三方库。
AES 模式:为什么选 GCM 而不是 CBC
AES(Advanced Encryption Standard)是一种对称分组密码——加密和解密用同一把密钥。但 AES 本身只能加密固定 128 位的块。"工作模式"定义了这些块如何串联起来处理任意长度的数据。你最常遇到的两种模式:
AES-CBC(Cipher Block Chaining)
- 每个块在加密前先与前一个密文块做 XOR。
- 第一个块需要初始化向量(IV)。
- 提供机密性,但不提供完整性。 攻击者可以修改密文而不被发现(填充预言攻击、比特翻转攻击)。
- 要获得完整性,必须额外加 HMAC(Encrypt-then-MAC),而正确实现这一点很容易出错。
AES-GCM(Galois/Counter Mode)
- 在一次操作中同时完成加密和认证。
- 产生密文 + 认证标签(通常是 128 位)。
- 同时提供机密性和完整性。 密文或关联数据的任何篡改都会导致解密失败。
- 不需要填充(内部以流密码方式工作)。
- 硬件加速更快(现代 CPU 有专门加速 GCM 的 AES-NI + CLMUL 指令)。
建议很明确:除非有明确的 CBC 遗留需求,否则一律使用 AES-256-GCM。 GCM 开箱即用提供认证加密,消除了整整一类实现 bug。
AES-256-GCM 概览:
- 密钥大小:256 位(32 字节)
- IV/Nonce:96 位(12 字节)——每次加密必须唯一,同一密钥下绝不能复用
- 认证标签:128 位(16 字节)——自动追加到密文后
- 输出:密文(长度与明文相同)+ 16 字节标签
完整的 AES-256-GCM Web Crypto API 示例
下面是一套完整可运行的加解密实现,使用 AES-256-GCM,并通过 PBKDF2 从密码派生密钥:
用 PBKDF2 派生密钥
你很少会手头就有一把现成的 256 位密钥。通常起点是一个密码或口令。PBKDF2(Password-Based Key Derivation Function 2)通过对 HMAC-SHA-256 进行大量迭代,把密码"拉伸"成一把密码学密钥:
async function deriveKey(password, salt) {
// 把密码作为原始密钥材料导入
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
// 用 PBKDF2 派生 AES-256-GCM 密钥
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 600000, // OWASP 2023 对 PBKDF2-SHA256 的建议值
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
关键点:
- 盐值(Salt):每次加密都必须随机且唯一。把它和密文一起存储(它不是秘密)。
- 迭代次数:600,000 是 OWASP 目前对 PBKDF2-HMAC-SHA256 的建议值。迭代越多 = 暴力破解越慢。
- 派生出的密钥不可导出(
false),意味着 JavaScript 无法读回原始密钥字节——这是对抗基于 XSS 的密钥窃取的一道防线。
加密
async function encrypt(plaintext, password) {
// 生成随机盐值(16 字节)和 IV(12 字节)
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
// 从密码派生密钥
const key = await deriveKey(password, salt);
// 加密
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
new TextEncoder().encode(plaintext)
);
// 打包:盐值(16)+ IV(12)+ 密文
// 盐值和 IV 解密时需要,但不是秘密
const packed = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
packed.set(salt, 0);
packed.set(iv, salt.length);
packed.set(new Uint8Array(ciphertext), salt.length + iv.length);
// 返回 Base64 格式,方便存储/传输
return btoa(String.fromCharCode(...packed));
}
解密
async function decrypt(packed64, password) {
// 从 Base64 解包
const packed = Uint8Array.from(atob(packed64), c => c.charCodeAt(0));
// 提取盐值、IV 和密文
const salt = packed.slice(0, 16);
const iv = packed.slice(16, 28);
const ciphertext = packed.slice(28);
// 用密码 + 盐值派生出同一把密钥
const key = await deriveKey(password, salt);
// 解密(GCM 会自动校验认证标签)
const plainBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
);
return new TextDecoder().decode(plainBuffer);
}
用法
const encrypted = await encrypt('API secret: sk-abc123...', 'my-strong-passphrase');
console.log(encrypted);
// "dGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHN0cmluZy4uLg=="
const decrypted = await decrypt(encrypted, 'my-strong-passphrase');
console.log(decrypted);
// "API secret: sk-abc123..."
// 密码错误会抛异常(GCM 认证标签校验失败)
try {
await decrypt(encrypted, 'wrong-password');
} catch (e) {
console.error('Decryption failed: wrong password or tampered data');
}
关键实现细节
绝不在同一密钥下复用 IV
如果你用同一对(密钥, IV)加密两条消息,GCM 的安全性会彻底崩溃。看到两个用相同 IV 加密的密文后,攻击者可以对它们做 XOR 来还原两条明文的信息,并伪造合法密文。
// 每次加密都必须生成全新的 IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// 绝不要:const iv = new Uint8Array(12); // 全零 = 灾难性复用
用 crypto.getRandomValues,不要用 Math.random
Math.random() 不是密码学安全的——它的输出是可预测的。在密码学场景中,盐值、IV 和任何随机值都必须用 crypto.getRandomValues() 生成。
关联数据(AAD)
GCM 支持可选的附加认证数据(Additional Authenticated Data)——这部分数据会被认证(受完整性保护)但不加密。适用于把密文绑定到某个上下文:
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: new TextEncoder().encode('user-id:12345') // AAD
},
key,
plaintext
);
// 解密时必须提供相同的 AAD,否则失败
性能
Web Crypto 操作是异步的,在原生代码中执行。供参考,在一台现代笔记本上:
- AES-256-GCM 加密:约 2 GB/s(通过 AES-NI 硬件加速)
- PBKDF2 60 万次迭代:约 200-400ms(故意很慢——这正是目的)
密钥派生按设计就是瓶颈。如果你要用同一密码加密多条消息,派生一次密钥然后复用:
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt); // 慢:约 300ms
// 快:用同一把派生密钥加密多条消息
const msg1 = await encryptWithKey(key, 'message one'); // <1ms
const msg2 = await encryptWithKey(key, 'message two'); // <1ms
什么时候该用浏览器端加密
浏览器端 AES 加密适用于:
- 敏感数据不应离开客户端。 先加密再发送,意味着服务端只会看到密文。即使服务端被攻破,数据也是受保护的。
- 本地存储加密。 在存入 localStorage/IndexedDB 之前,先加密 API 密钥、token 或笔记,可以防范基于 XSS 的数据窃取(攻击者拿到的是密文,不是明文)。
- 端到端加密。 双方派生共享密钥并在客户端加密消息。服务端只转发密文,永远不持有密钥。
- 零知识架构。 服务提供方从根本上无法读取你的数据,因为加解密只发生在你的浏览器里。
不适合的场景:
- 服务端静态数据加密(用云厂商的加密或 KMS)。
- TLS/HTTPS(那是传输层加密,由浏览器自动处理)。
- 服务端需要读取/处理数据的场景(如果服务端需要明文访问,就不能加密)。
想不写代码就体验 AES 加密?DevToolkit Pro 的 AES 加解密 工具完全使用 Web Crypto API 在浏览器中运行 AES-256-GCM。你的明文和密码永远不会离开你的机器——加密在本地用原生代码完成,没有服务端往返。它是快速加密敏感字符串、验证密文或理解输出格式的好帮手,方便你在自己的应用中落地实现。