4. 使用 AES-256-GCM 对称加密业务数据

4.1 什么时候应该加密

当业务必须在以后拿回原文时,才考虑加密,例如:

用户登录密码不属于这种情况。服务端只需要验证密码,不需要恢复密码,因此应使用 scrypt 或 Argon2id 哈希。

4.2 为什么选择 AES-GCM

只把明文变成密文还不够。攻击者可能修改密文,而服务端不能悄悄解出被篡改的数据。

AES-GCM 属于认证加密模式,同时提供:

它需要四类数据:

输入:明文 + Key + IV + 可选 AAD
输出:密文 + Auth Tag

IV 和 Auth Tag 可以与密文一起保存,它们不是密钥。

4.3 先生成密钥

可以用 Node.js 一次性生成 32 字节密钥:

import { randomBytes } from "node:crypto";

console.log(randomBytes(32).toString("base64"));

把结果放入部署平台的 Secret 管理或本地未提交的 .env

DATA_ENCRYPTION_KEY_BASE64=这里放生成的Base64字符串

不要每次启动程序都重新生成 Key,否则服务重启后旧数据无法解密。也不要把 Key 和密文保存在相同的数据库记录里,否则数据库泄露时加密几乎失去意义。

读取并验证密钥:

function readEncryptionKey(): Buffer {
  const encodedKey = process.env.DATA_ENCRYPTION_KEY_BASE64;

  if (!encodedKey) {
    throw new Error("缺少 DATA_ENCRYPTION_KEY_BASE64");
  }

  const key = Buffer.from(encodedKey, "base64");

  if (key.length !== 32) {
    throw new Error("AES-256 密钥必须正好是 32 字节");
  }

  return key;
}

4.4 完整的加密与解密代码

下面把结果封装为:

版本.IV.AuthTag.密文

每一部分使用 Base64URL 表示,便于存储和传输。

import {
  createCipheriv,
  createDecipheriv,
  randomBytes,
} from "node:crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const AAD = Buffer.from("nloop:protected-value:v1", "utf8");

export function encryptText(plaintext: string, key: Buffer): string {
  if (key.length !== 32) {
    throw new Error("AES-256 密钥必须正好是 32 字节");
  }

  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv(ALGORITHM, key, iv, {
    authTagLength: AUTH_TAG_LENGTH,
  });

  cipher.setAAD(AAD);

  const ciphertext = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]);
  const authTag = cipher.getAuthTag();

  return [
    "v1",
    iv.toString("base64url"),
    authTag.toString("base64url"),
    ciphertext.toString("base64url"),
  ].join(".");
}

export function decryptText(envelope: string, key: Buffer): string {
  if (key.length !== 32) {
    throw new Error("AES-256 密钥必须正好是 32 字节");
  }

  const parts = envelope.split(".");

  if (parts.length !== 4) {
    throw new Error("密文格式不正确");
  }

  const [version, ivText, authTagText, ciphertextText] = parts;

  if (
    version !== "v1" ||
    ivText === undefined ||
    authTagText === undefined ||
    ciphertextText === undefined
  ) {
    throw new Error("密文格式不正确");
  }

  const iv = Buffer.from(ivText, "base64url");
  const authTag = Buffer.from(authTagText, "base64url");
  const ciphertext = Buffer.from(ciphertextText, "base64url");

  if (iv.length !== IV_LENGTH || authTag.length !== AUTH_TAG_LENGTH) {
    throw new Error("密文参数不正确");
  }

  const decipher = createDecipheriv(ALGORITHM, key, iv, {
    authTagLength: AUTH_TAG_LENGTH,
  });

  decipher.setAAD(AAD);
  decipher.setAuthTag(authTag);

  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);

  return plaintext.toString("utf8");
}

使用示例:

const key = readEncryptionKey();
const encrypted = encryptText("third-party-api-token", key);
const decrypted = decryptText(encrypted, key);

console.log(encrypted);
console.log(decrypted);

若 Key、IV、Auth Tag、AAD 或密文中任何必要部分不匹配,decipher.final() 通常会抛出异常。调用方应捕获错误并返回统一的业务错误,不要把底层解密细节暴露给客户端。

4.5 每一步究竟做了什么

加密:

1. randomBytes(12) 为本次加密生成新 IV
2. createCipheriv() 用算法、Key、IV 创建加密器
3. setAAD() 绑定不加密但必须防篡改的上下文
4. update() 接收明文字节
5. final() 完成本次加密
6. getAuthTag() 取得认证标签
7. 保存版本、IV、Tag 和密文

解密则反过来:解析各部分,使用同一 Key、IV、AAD 和 Auth Tag 创建解密器,最后调用 final() 完成认证。

4.6 AAD 有什么实际价值

假设分别加密“用户 A 的支付 Token”和“用户 B 的支付 Token”。如果只保护密文本身,攻击者可能尝试把两条数据库记录对调。

可以把稳定的上下文作为 AAD:

const aad = Buffer.from(`payment-token:${userId}:v1`, "utf8");

解密时必须提供完全相同的 AAD。密文被挪到另一个用户后,认证就会失败。

AAD 不会被加密,所以不要把秘密放进去;它必须能在解密时稳定、准确地重建。示例为了让函数简单使用了固定 AAD,真实项目可以把业务上下文作为参数传入。

4.7 高频错误

错误一:固定 IV

// 严禁在同一个 Key 下重复使用
const iv = Buffer.alloc(12, 0);

GCM 对 IV 唯一性非常敏感。每次加密都应生成新的随机 IV,并与密文一起保存。

错误二:把用户密码直接当 AES Key

// 错误:长度和熵都不可靠
createCipheriv("aes-256-gcm", Buffer.from(password), iv);

若确实要从密码生成密钥,应使用 scrypt 等 KDF 加随机盐派生,并设计好参数与盐的存储。服务端字段加密更常见的方案是直接使用安全生成并妥善管理的随机 Key。

错误三:只使用 AES-CBC,不做认证

未认证的加密可能隐藏明文,却不能可靠发现篡改。新设计优先选择 AES-GCM 这类认证加密,不要自行拼凑“加密后再随便哈希”。

错误四:把密钥写进源码

// 错误:会进入 Git 历史和构建产物
const key = Buffer.from("固定写死的密钥");

使用环境变量只是入门方案。生产中更理想的是部署平台 Secret、KMS/HSM,并建立密钥版本和轮换流程。

错误五:记录敏感日志

不要记录明文、Key、完整密文包或第三方 Token。错误日志应包含可排查的记录 ID 和错误类别,而不是秘密本身。

4.8 密钥轮换为什么需要版本号

如果所有密文都只写成一串字节,将来更换 Key 时就不知道应该使用哪把旧 Key 解密。可以在信封中保存非秘密的 Key ID:

v1.key-2026-08.IV.AuthTag.密文

解密时根据 Key ID 从安全密钥存储中选择旧 Key;新写入使用当前 Key。后台任务可以逐步把旧数据解密后再用新 Key 加密。

版本、Key ID、算法和密文格式属于协议设计的一部分。数据一旦持久化,就要考虑未来升级,而不是只保证今天能解密。