AES-GCM实现指南:正确实施认证加密
在Web应用中正确实现AES-GCM加密。学习nonce管理、密钥处理和认证加密中要避免的常见陷阱。
AES-GCM(Galois/Counter Mode)将 AES-CTR 加密与 GHASH 认证相结合,产生带关联数据的认证加密(AEAD)。正确实现需要:256 位密钥、96 位(12 字节)的 nonce(在同一密钥下必须唯一、绝不重用)、128 位认证标签,以及可选的已认证但未加密的关联数据(AAD)。NIST SP 800-38D 规定了具体的构造方式。其中任何一点出错,尤其是 nonce 重用,GCM 的安全性就会崩溃:一对重复的(密钥, nonce)让攻击者可以恢复认证密钥,并伪造任意密文。本指南涵盖在浏览器、Node.js 和服务器环境中正确使用 AES-GCM 的方法。
GCM 实际提供的安全保证
两个属性:
机密性:没有密钥就无法恢复明文。AES-GCM 的 CTR 模式加密层提供此保障。
完整性与真实性:对密文、nonce 或关联数据的任何修改都会导致解密失败。GHASH 产生一个 128 位标签,在解密时以恒定时间进行验证。
GCM 不提供的保证:不可抵赖性(它是对称的,任何持有密钥的人都能产生有效密文)、重放保护(这是更高层面的关注点)或排序(对于流,你需要在外层进行某种链接)。
核心洞察:GCM 只有在 nonce 对每个密钥唯一时才保持安全。不是基本唯一,不是通常唯一,而是真正唯一。安全证明在重用时就会瓦解。
Nonce 管理:最重要的事情
96 位 nonce 有两种生成方式:
随机:crypto.getRandomValues(new Uint8Array(12))。在单一密钥下使用随机 96 位 nonce,生日界碰撞约在 2^48 次加密后出现。NIST 建议留有安全余量,因此每个密钥限制使用 2^32 次。
计数器:递增 96 位整数。保证在 2^96 条消息之前的唯一性。需要可靠的单调状态,在分布式系统中较难实现。
对于每个文件使用新密钥的文件传输,随机 nonce 完全安全——你永远不会在一个密钥下进行 2^32 次加密。对于在单一文件密钥下分块加密,使用在 nonce 中编码块索引的计数器:
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setUint32(0, messageId);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex));
灾难性场景:多个进程使用随机 nonce 在同一共享密钥下加密,每秒扩展到数百万次加密。生日碰撞变得可能。如果必须在进程间共享密钥,使用带进程 ID 前缀的协调计数器。
不要使用 64 位 Nonce
AES-GCM 支持可变 nonce 长度,但只有 96 位 nonce 使用 NIST 800-38D 规定的优化构造。其他长度(通常 64 位或 128 位)会触发降低性能且增加复杂度的 GHASH 预处理步骤。Web Crypto API 接受非 96 位的 IV,但规范推荐 96 位。直接使用 96 位即可。
标签长度:不要截短
GCM 标签最长 128 位。某些规范允许截断至 96、64 甚至 32 位。不要这样做。截短的标签使伪造攻击更容易,而节省的空间(每条消息 4–12 字节)对文件传输而言微不足道。Web Crypto 的 AES-GCM 通过 tagLength 参数默认使用 128 位标签。保持默认即可。
关联数据(AAD)的使用
AAD 是已认证但未加密的数据。用于将你希望绑定到密文的元数据:文件名、内容类型、过期时间戳、上传者 ID。
await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
additionalData: new TextEncoder().encode(JSON.stringify({
filename: "report.pdf",
contentType: "application/pdf",
expires: 1712345678,
})),
},
key,
plaintext
);
如果攻击者修改了 AAD,解密会失败。这可以防止有人替换存储密文上的文件名而不被发现的替换攻击。接收者必须知道确切的 AAD 才能解密,因此要将 AAD 与密文一起存储。
密钥生成与派生
每文件密钥:
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
2026 年默认使用 256 位。128 位 AES 仍然安全,但量子后安全余量更低(Grover 算法将有效强度减半)。
密码派生密钥:
const aesKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: crypto.getRandomValues(new Uint8Array(16)),
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
盐值与密文一起存储。它不保密,只是每个密码必须唯一。
关键代码路径
最简加密函数:
async function encrypt(key, plaintext, aad = new Uint8Array()) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
plaintext
)
);
return { nonce, ciphertext, aad };
}
解密,带有正确的错误处理:
async function decrypt(key, { nonce, ciphertext, aad }) {
try {
return await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
ciphertext
);
} catch (e) {
// 认证失败
throw new Error("解密失败:密文被篡改或密钥错误");
}
}
decrypt 调用在标签不匹配、密文过短或密钥错误时会抛出 OperationError。将任何异常视为完整性失败;不要试图区分不同情况。
大文件的分块处理
对于数百兆字节以上的文件,进行分块以避免内存压力:
async function encryptChunks(key, file, chunkSize = 1024 * 1024) {
const chunks = [];
let chunkIndex = 0;
for (let offset = 0; offset < file.size; offset += chunkSize) {
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, chunk
);
chunks.push(new Uint8Array(ct));
}
return chunks;
}
注意:分块 AES-GCM 无法检测截断攻击。攻击者可以丢弃末尾的块,每个存活的块解密时都没有问题。防御措施:在每块的 AAD 中包含总块数,或使用 libsodium 的 crypto_secretstream(能处理这种情况)。
服务器端解密(Node.js)
Node 的 crypto 模块可以解密在浏览器中加密的数据:
const { createDecipheriv } = require('crypto');
function decrypt(key, nonce, ciphertextWithTag) {
const tag = ciphertextWithTag.slice(-16);
const ct = ciphertextWithTag.slice(0, -16);
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
}
Web Crypto 将 128 位标签附加到密文后;Node 的 API 需要分别提供标签和密文。按此拆分即可。
性能参数
在配备 AES-NI 的典型 2024–2026 年硬件上:
- 原生(OpenSSL,AES-NI):每核心 3–5 GB/s
- Web Crypto(带硬件加速的浏览器):1–2 GB/s
- libsodium.js WASM AES-GCM:400–800 MB/s
- 纯 JavaScript(@noble/ciphers):50–150 MB/s
对于 1 GB 文件,Web Crypto 加密需要 0.5–1 秒。纯 JS 需要 7–20 秒。根据这一现实选择实现方式;对于大文件传输用户体验,Web Crypto 是实际可用的选择。
常见错误汇总
- Nonce 重用:灾难性的。最大的单一失败模式。
- 使用
Math.random()而非crypto.getRandomValues()。 - 忘记用 AAD 认证关联元数据。
- 使用 CBC 模式"因为已经习惯了"。CBC 需要单独的 MAC 才能达到 GCM 的完整性;HMAC-CBC 构造正确但复杂,GCM 避免了这个坑。
- 静默捕获解密错误并返回垃圾数据。始终大声失败。
- 自己实现 GCM。使用 Web Crypto、libsodium 或 node:crypto。GHASH 实现存在侧信道隐患,专家花了多年时间才做对。
HexaTransfer 使用 Web Crypto 的 AES-256-GCM,配合 96 位随机 nonce、128 位标签,且不使用 AAD,因为密钥是每文件独立的,文件名单独存储在 AEAD 保护的元数据中。简洁、正确、高效。
何时选择其他方案
AES-GCM 是文件传输的最优选择,但在特定情况下可以考虑替代方案:
- XChaCha20-Poly1305:192 位 nonce 使随机 nonce 在任意规模下都可以安全使用。在有 AES-NI 的硬件上稍慢,在没有 AES-NI 的旧 ARM 设备上更快。libsodium 提供支持。
- AES-GCM-SIV:抵抗误用;nonce 重用不会泄露密钥,只会暴露明文是否相等。适用于无法保证 nonce 唯一性的场景。
对于标准 Web 技术栈中的大多数文件传输场景,每文件新密钥配合随机 96 位 nonce 的 AES-256-GCM 是正确的选择,也是最简单的正确实现方式。
在 hexatransfer.com 上试试 — 免费、无需注册、最多 10 GB。