---
title: "AES-GCM 解密已经读到明文，为什么还要等 final()？"
date: 2026-11-13
slug: node-aes-gcm-verify-before-use
canonical: https://kernelranch.com/blog/2026/11/node-aes-gcm-verify-before-use
markdown_url: https://kernelranch.com/blog/2026/11/node-aes-gcm-verify-before-use.md
tags: ["security", "cryptography", "nodejs"]
---使用 Node.js 解密 AES-GCM 数据时，`decipher.update()` 返回的字节要等 `decipher.final()` 成功后才能使用。若提前解析、写入数据库或把内容返回给调用方，篡改的数据可能在认证失败前就产生副作用。

## 1. 用篡改样本观察认证时机

保存为 `gcm_auth_lab.mjs`，运行 `node gcm_auth_lab.mjs`。样本使用随机密钥和 12 字节随机 nonce，仅加密一条短消息。AAD 表示接收方预期的租户上下文。

```javascript
import assert from 'node:assert/strict';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const algorithm = 'aes-256-gcm';
const key = randomBytes(32);
const nonce = randomBytes(12);
const aad = Buffer.from('tenant=42');
const message = Buffer.from('status=paid');
const options = { authTagLength: 16 };

const cipher = createCipheriv(algorithm, key, nonce, options);
cipher.setAAD(aad);
const encrypted = Buffer.concat([cipher.update(message), cipher.final()]);
const tag = cipher.getAuthTag();

function decrypt(ciphertext, associatedData, observe = () => {}) {
  const decipher = createDecipheriv(algorithm, key, nonce, options);
  decipher.setAAD(associatedData);
  decipher.setAuthTag(tag);
  const pending = decipher.update(ciphertext);
  observe(pending.length);
  return Buffer.concat([pending, decipher.final()]);
}

assert.deepEqual(decrypt(encrypted, aad), message);
console.log('original: accepted');

const tampered = Buffer.from(encrypted);
tampered[0] ^= 1;
for (const [label, ciphertext, context] of [
  ['changed ciphertext', tampered, aad],
  ['wrong AAD', encrypted, Buffer.from('tenant=43')],
]) {
  let pendingBytes;
  assert.throws(() => decrypt(ciphertext, context, n => { pendingBytes = n; }));
  assert.equal(pendingBytes, message.length);
  console.log(label + ': update returned ' + pendingBytes + ' bytes; final rejected');
}
```

```text
original: accepted
changed ciphertext: update returned 11 bytes; final rejected
wrong AAD: update returned 11 bytes; final rejected
```

## 2. 把认证成功作为交付边界

短消息可以先把待认证的字节缓存在内存，`final()` 成功后再一起返回。大文件若使用单个 GCM 标签，可以把结果写到应用私有的临时文件，成功后才对读者可见，失败时清理临时文件。需要边接收边交付时，采用定义了分块认证、块序号和完整性规则的协议。

格式还需要约定 nonce、标签和 AAD 的来源。AAD 必须和加密时的字节一致，例如从当前已验证的租户上下文构造。生产系统需要确保同一密钥下的每次加密使用唯一 nonce。样本每次生成新密钥只加密一次，重复解密同一个密文用于对照认证结果。

## 小结

把 `update()` 的结果留在内部。认证成功后才交付明文，失败时丢弃待认证数据。

## 数据来源与检索时间

| 来源 | URL | 检索日期 | 对应内容 |
|---|---|---|---|
| Node.js 24.19.0 Crypto | https://nodejs.org/download/release/v24.19.0/docs/api/crypto.html#decipherfinaloutputencoding | 2026-11-13 | GCM 的认证时机和解密输出边界 |
| RFC 5116 §5.1.1 | https://www.rfc-editor.org/rfc/rfc5116.html#section-5.1.1 | 2026-11-13 | GCM 的 nonce 重用后果 |

## 核验说明

- Windows、Node.js 24.19.0 实测有效输入、密文变动和 AAD 不匹配。验证范围限于短消息 API，未执行大文件或密钥管理流程。
