AES 模式、IV 与认证:安全加密真正需要什么
AES 已经二十五岁了。1997 年 NIST 启动公开征集,2000 年 10 月在五个候选中选中了 Joan Daemen 与 Vincent Rijmen 提交的 Rijndael。这个结果一点都不“新”——而正是这一点让它值得信赖。AES 是密码学史上被研究得最透彻的密码算法,所有研究都公开进行,至今没有被攻破。
整个话题先放一句永久警告:不要自己造密码学轮子。 下面讲的分组密码与 mode 在理论上是正确的,在实践中却极度容易用错。请用 libsodium、平台原生 crypto API、或你的 TLS 库。本文是为了帮你理解你的库在做什么,不是替你的库。
它到底是什么
AES 是一种分组密码:输入一个 128 bit 的明文块和一个密钥,输出一个 128 bit 的密文块。这就是这个原语的全部。同样的密钥加密同样的块两次,得到同样的密文。解密密文,拿回明文。
密钥可以是 128、192 或 256 bit,对应 AES-128、AES-192、AES-256——同一个算法,轮数不同(分别是 10、12、14),密钥扩展不同。AES-128 更快;AES-256 更慢但对未来攻击留出了更宽的安全余量。今天两者都被认为是安全的。
"按块"加密这个性质,正是"工作模式(modes of operation)"存在的全部理由。真实的消息不是 128 bit。要加密更长的数据,就必须决定块与块之间如何串联——而野外几乎所有的 AES 漏洞都来自这个串联决策,而不是 AES 本身。
为什么 ECB 是禁区
最简单的 mode 是 ECB:把消息切成 128 bit 的块,每块用同一个密钥独立加密。
问题立刻就来:相同的明文块产生相同的密文块。如果数据有任何重复结构——重复的 header、连续相同的字节、重复的字典词——这种结构会原样泄露到密文里。
教科书式的演示是用 ECB 加密一张 Linux 吉祥物 Tux 的位图:像素值有规律地重复,加密后的图片里 Tux 仍然清晰可见,只是颜色被打乱了。搜一下"ECB penguin",看一眼就忘不了。
ECB 不掩盖明文的任何结构,几乎在所有真实场景下都是不安全的。唯一合法的用途是加密本来就是随机或伪随机、毫无结构的数据,但你几乎永远不会有这种数据。
如果你在生产代码里看到 ECB,那就是一个 bug。修掉。
CBC:遗留方案
CBC(Cipher Block Chaining,密文分组链接)解决 ECB 的结构泄露问题:每个明文块在加密之前,先和前一个密文块异或。第一个块没有"前一个",所以和一个每条消息一个的 IV(初始化向量)异或。最终的密文 = IV + 加密块序列。
CBC 的硬要求:
- 每条消息用唯一的 IV。 同一密钥下复用 IV 会泄露信息——不会破解出密钥,但是有相同前缀的两条消息会在密文里露出同样的前缀。
- 如果攻击者能选择明文,IV 必须不可预测。 可预测的 IV 让 2011 年的 BEAST 攻击成功打穿了 TLS。
- 填充。 AES 块是 128 bit,消息长度未必是其整数倍。标准做法是 PKCS#7 填充。
- 额外的认证 tag。 单独的 CBC 是可塑的——攻击者可以在密文里翻转某些 bit,让解密后的明文产生可预测的修改。这正是填充预言(padding oracle)攻击家族的根源。
正因为后两条要求,CBC 在现代设计里基本被认证型 mode 替代了。TLS 1.3 直接砍掉了 CBC。新代码不该再选它。
CTR:安全但不带认证
CTR(Counter mode,计数器模式)把 AES 变成流密码:用密钥加密一个递增的计数器,得到 keystream,再和明文异或。无需填充、可并行、解密和加密代码完全相同。
CTR 的硬要求:永远不要复用 (key, counter) 对。 一旦复用,攻击者只要知道或猜对其中一条消息的任意一段明文,就能恢复出 keystream,进而解密所有复用了同一计数器的消息。让 CTR 高效的那条性质——两条密文异或等于两条明文异或——同时也让计数器复用变成灾难。
单独的 CTR 不带认证,需要配合一个 MAC(比如 HMAC-SHA256)来保证完整性。要把这件事做对——encrypt-then-MAC、用独立密钥、对 IV+ciphertext 整体做 MAC——属于"看上去简单、实际上微妙"的范畴,也正是"别自己造"那条规则存在的原因。
GCM:现代默认值
GCM(Galois/Counter Mode)等于 CTR mode 加上一个内置的认证 tag,提供"带关联数据的认证加密"(AEAD)。它输出三样东西:密文、IV(在 GCM 里叫 nonce)、128 bit 的认证 tag。解密时先验证 tag,被篡改过的密文直接解密失败。
这是 TLS 1.3、IPsec、SSH 以及大多数应用层加密的默认选择。2026 年,AES-128-GCM 与 AES-256-GCM 是新代码该伸手去拿的东西。
GCM 有一条不能违反的规则:永远不要在同一个密钥下复用 nonce。 GCM 的 nonce 复用不只是泄露——它能让攻击者直接恢复出认证密钥,从而以你的密钥伪造任意消息。这比 CTR 的 nonce 复用严重得多。
标准 nonce 长度是 96 bit。常见做法是用计数器,或者直接用 96 bit 随机数。随机做法存在生日界问题:当同一密钥下加密了大约 2⁴⁸ 条消息时,nonce 碰撞概率会变得不可忽视。对绝大多数应用没问题;高吞吐场景请用确定性计数器或者轮换密钥。
如果你想用 AES-GCM-SIV 替代——那是一个抗误用变体,nonce 复用只是泄露而不会让认证彻底崩。如果你的 nonce 生成纪律不够严格,用它。
AES vs ChaCha20-Poly1305
AES 在每一颗现代 CPU 上都有硬件加速(AES-NI、ARMv8 加密扩展)。在这些平台上它快得离谱——每字节只要几个时钟周期。
但是在没有加速的硬件上——较老的移动设备、嵌入式系统、某些受限环境——纯软件实现的 AES 又慢,又容易被时序攻击。ChaCha20-Poly1305 就是为这种场景设计的:纯软件下又快又是常数时间,不依赖硬件支持。TLS 1.3 同时支持两者。
如果是面向现代硬件的客户端 / 服务端:AES-256-GCM。如果是未知设备或纯软件路径:ChaCha20-Poly1305。两者都是 AEAD,都被认为是安全的,性能基本上是大多数平台上唯一的取舍依据。
密钥派生:大多数应用翻车的地方
你几乎永远不会手里就攥着一个 256 bit 的随机密钥。你通常有的是密码、口令短语,或者一个 API key。直接拿这些东西当 AES 密钥,至少有两层错:它们既不是均匀随机,长度也不对。
正确做法是过一个密钥派生函数:
- PBKDF2 —— 老但兼容性好。建议用 SHA-256,2025 年 OWASP 指引是至少 600,000 次迭代。它是有意被设计成在现代 GPU 上慢的。
- scrypt —— 内存敏感,GPU 上更难暴力破解,是个合理默认。
- Argon2id —— 当前最佳实践,2015 年密码哈希竞赛冠军。如果你的平台有靠谱实现,用它。
- HKDF —— 用来从已有的高熵秘密派生子密钥(不是用来处理密码的)。
每次加密都重新从密码派生一次密钥,不是正确形态。派生一次,会话内把密钥缓存在内存,磁盘上什么都不存。每个文件用不同的 IV / nonce。
常见坑
- ECB。那只企鹅就够当反面教材了。
- IV / nonce 复用。CBC:泄露。CTR:严重泄露。GCM:灾难性。
- 把 IV 当成秘密。它不是,明文跟着密文一起发就行。
- 把 IV(用于 CBC)和 nonce(用于 GCM/CTR)混为一谈。同一个字段,要求不同。
- Encrypt-then-MAC vs MAC-then-encrypt。前者正确。后者顺序在历史上造成了真实的攻击(POODLE、Lucky 13)。
- 因为"数据太短不需要 mode"就用 ECB"加密"小块。不存在这种例外。
- 忘记认证。没有认证的对称加密就是在等着被攻击。
- 把密钥放在源码里、或者会被日志吐出来的环境变量里。请用平台密钥管理(AWS KMS、Cloud KMS、操作系统 Keychain)。
实用规则
- 新代码直接 AES-256-GCM,或者 ChaCha20-Poly1305。
- GCM 用 96 bit 随机 nonce,并记录下来,能在被审计时证明唯一性。
- 用 Argon2id 派生密钥(如果没 Argon2 就用 PBKDF2 加大迭代数)。
- 用经过审查的库:libsodium / NaCl、平台 crypto、TLS 栈。不要用自己写的实现。
- 只用认证加密。如果你正想伸手去拿"只加密不认证"的 API,那你已经走错路了。
- 同一个密钥不要既加密又签名。加密和签名用各自单独派生的密钥。
- 轮换密钥。代价很低,而一旦发生密钥泄露,回收成本又被显著降低。
主要参考资料
用于核对本文技术细节的标准与官方文档。
在浏览器里直接加密 / 解密
本站 AES 工具支持 CBC、CTR、GCM 三种 mode,内置 PBKDF2 派生密钥。适合用例子去理解各种 mode 的取舍。所有运算都在浏览器里完成,数据不会离开本机。
打开 AES 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。