跳转到内容
HexaTransfer
返回博客
加密与安全

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。

通过端到端加密安全发送大文件

通过端到端加密免费传输最大10GB的文件。无需注册账户。文件在上传前在浏览器中加密,其他人无法读取。

发送文件