AES-256-GCM 可逆密码存储的四个致命坑
某后台管理系统有一个需求:管理员可以查看员工的初始密码并下发给本人。bcrypt 是单向哈希,用了就没有回头路——所以必须用可逆加密存密码。听起来很直白,但从 crypto.createCipheriv('aes-256-gcm', ...) 到上线,前后踩了四个坑,每个都能安静地坏掉,没有明显报错。
现象
- 开发环境加密/解密一切正常,换了一套服务端后
decrypt抛 "Unsupported state or unable to authenticate data" - POST 创建用户接口的响应体里出现了明文密码字段——前端框架顺手
return { ...body }就带出去了 - CI 构建通过,部署后运行时
PASSWORD_ENCRYPTION_KEY is not configured报错 - 传入一个极短的 Base64 字符串时,Node.js native crypto 直接 panic,没有友好的错误提示
根因
GCM 是 AEAD 模式,相比 CBC 多了完整性认证(Auth Tag),这正是它的优势——但也是坑最密集的地方。
坑一:IV 长度用了 16 字节而不是 12 字节。 NIST SP 800-38D 明确规定 GCM 推荐 IV 长度是 96 位(12 字节)。CBC 的 IV 是 16 字节,很多示例代码把两者混淆了。12 字节和 16 字节都能跑,但跨服务解密时两端必须一致,更重要的是 16 字节 IV 会导致 GCM 内部额外做一次 GHASH 运算,轻微弱化安全性。
坑二:Auth Tag 没有附带存储。
GCM 加密后必须调 cipher.getAuthTag() 取出 16 字节认证标签,解密时用 decipher.setAuthTag() 还原。如果只存了 iv + ciphertext,解密时跳过 Auth Tag,GCM 的完整性保护等于白做,而且换环境后解密必然失败。
坑三:API 响应体里带出了明文密码。
路由层 return NextResponse.json({ id, ...body }) 把入参对象整体展开,body.password 就这样出现在了 HTTP 响应里。这个问题不会有任何运行时错误,只有人工 review 或安全扫描才能发现。
坑四:Next.js output: 'standalone' 在构建时锁定环境变量。
服务端专用的环境变量(不带 NEXT_PUBLIC_ 前缀)必须在 pnpm build 执行时就存在于环境中。.env.local 在本地能用,但 CI 流水线里如果没有显式注入 PASSWORD_ENCRYPTION_KEY,构建产物里就找不到这个变量,运行时报 undefined。
// 错误示例:IV 长度参照 CBC,Auth Tag 没有附带
const iv = crypto.randomBytes(16) // ❌ GCM 应该是 12
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
// ❌ 忘记 cipher.getAuthTag(),也忘记把它存进去
return Buffer.concat([iv, encrypted]).toString('base64')这段代码能跑,本地自加自解没问题。但换一台服务器、换一个 Node 版本、或者跟另一个语言的服务互操作,立刻 "unable to authenticate data"。
正解
把 iv(12 字节)、authTag(16 字节)、ciphertext 打包成一个 Base64 字符串原子存储,解密时按偏移拆开。
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12 // NIST 推荐 96 bits
const AUTH_TAG_LENGTH = 16
const MIN_DECRYPT_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH + 1 // 输入合法性守卫
export function encryptPassword(plaintext: string): string {
const key = getKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag() // ✅ 必取
// 打包:iv || authTag || ciphertext
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
}
export function decryptPassword(encrypted: string): string {
if (!encrypted) throw new Error('encrypted string is empty')
const packed = Buffer.from(encrypted, 'base64')
if (packed.length < MIN_DECRYPT_LENGTH) throw new Error('payload too short') // ✅ 守卫
const iv = packed.subarray(0, IV_LENGTH)
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag) // ✅ 还原完整性校验
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
}密钥用 openssl rand -hex 32 生成一次,写入 .env.local,同时加到 CI 的 secrets 并在 build step 显式注入:
- name: Build
env:
PASSWORD_ENCRYPTION_KEY: ${{ secrets.PASSWORD_ENCRYPTION_KEY }}
run: pnpm buildAPI 响应里永远不要带出明文:
// ✅ 明确置 undefined,不依赖"刚好没传"
return NextResponse.json({ id, ...body, password: undefined })GCM 的安全性来自 Auth Tag;GCM 的互操作性来自标准 12 字节 IV。两者都漏掉,加密形同虚设。把三段拼成一个 Base64 列存数据库,读写都是原子操作,不会出现"IV 存哪了"的问题。
一句话外卖
AES-256-GCM 不是"填对参数就能用"的黑盒——IV 长度、Auth Tag、响应过滤、CI 环境变量,每一项缺失都会在上线后悄悄失效,不会报错。