开门见山:一秒钟的选型结论
新项目一律 AES-256-GCM,IV/Nonce 用 os.urandom(12) 生成且绝对不可复用;CBC 仅用于遗留系统兼容且必须外挂 HMAC-SHA256(Encrypt-then-MAC);ECB 在多块数据的任何场景下都是错误的,唯一的合法用途是加密小于一个块且天然无结构的数据(比如 16 字节的密钥包装) 。
如果你赶时间,记住上面这句话就可以关掉本文。如果你往下读,这篇文章会回答一个更深刻的问题:为什么 AES 这个"世界上最安全的分组密码",在 2026 年的生产环境里仍然天天被攻破? 答案几乎从来不在 AES 本身,而在它外面那一层薄薄的"工作模式"和工程师对 IV、填充、认证的处理方式上。
我在这篇文章里会带你看三样东西:三张真实的历史翻车现场(一只企鹅、一次 Lucky13、一次 wolfSSL 的 nonce 重用)、三种模式各自的数学结构和攻击面,以及一份可以贴在工位上的七层防坑清单。
一、先看清地图:一张表 + 一个流程图
1.1 三种主流模式总览
| 维度 | ECB | CBC | GCM |
|---|---|---|---|
| 中文俗称 | 电子密码本 | 密文块链接 | 伽罗瓦计数器 |
| 加密结构 | 每块独立加密 | 块间链式异或 | CTR 流加密 + GHASH 认证 |
| IV/Nonce | 无 | 16 字节随机 IV,需不可预测 | 12 字节 Nonce,需绝对唯一 |
| Padding | 需要(PKCS#7) | 需要(PKCS#7) | 不需要 |
| 机密性 | ⚠️ 仅单块有意义 | ✅(IV 正确时) | ✅ |
| 完整性 | ❌ | ❌ | ✅(内置认证标签) |
| 并行加密 | ✅ 可并行 | ❌ 串行 | ✅ 可并行 |
| 硬件加速 | ✅ | ✅ | ✅(AES-NI + PCLMULQDQ) |
| 典型攻击 | 模式泄露、块重排 | 填充预言、比特翻转、BEAST、POODLE、Lucky13 | Nonce 重用导致全盘崩溃 |
| NIST 状态 | 不推荐(多块场景禁用) | 遗留可用 | 推荐 |
| 正确使用场景 | 仅密钥包装、小于单块的数据 | 遗留系统 + 外挂 HMAC | 一切新项目 |
1.2 选型决策流程图
text
数据 > 16 字节?
├─ 否(≤16 字节且无重复结构)→ ECB 可以接受(如密钥包装)
└─ 是
├─ 能否保证 Nonce 在同一密钥下绝对唯一?
│ ├─ 能(单机、有计数器)→ AES-256-GCM ✅
│ ├─ 不能(多副本分布式、设备重启、无状态)→ AES-256-GCM-SIV ✅
│ └─ 说不清楚 → 默认按"不能"处理,选 GCM-SIV
├─ 遗留系统只能用 CBC?
│ └─ 必须外挂 HMAC-SHA256(Encrypt-then-MAC),否则视为不安全
└─ 想用 ECB 偷懒?
└─ 停下,回到第一个分支
二、ECB:那只企鹅,和它背后被忽视的整个世界
2.1 ECB 的数学结构:为什么"简单"等于"危险"
AES 是一个分组密码 ,一次只处理 16 字节 。不管你的密钥是 128 位还是 256 位,块大小永远 16 字节。当你要加密的数据超过 16 字节时,就必须有一个"模式"来决定多个块之间怎么处理------ECB 的答案是最粗暴的:每个 16 字节块独立用同一个密钥加密,块与块之间零关联 。
数学上,ECB 是这样的:
text
C_i = E_K(P_i) 对所有 i
这个公式的第一个直接推论就是它的死刑判决书:如果 P_i == P_j,那么 C_i == C_j。相同的明文块永远产生相同的密文块,毫无例外。
2.2 那只企鹅:一张图胜过一千字的安全性论证
密码学教育里最著名的图片------"ECB Penguin"------把这件事讲得比任何论文都清楚。
把 Linux 吉祥物 Tux 的位图用 AES-128-ECB 加密:企鹅的白色肚皮有几千个像素,RGBA 值完全相同,切出来的 16 字节明文块也完全相同。ECB 把这些相同的块加密成相同的密文块。结果是------加密后的图片里,企鹅的轮廓、眼睛、脚蹼、背景边界清清楚楚,只是调色板被换了一组颜色 。
关键事实:这个过程没有作弊 ,用的是真 AES-128、随机生成的密钥、标准库的正确实现。AES 本身没被攻破,密钥仍然是安全的。出问题的是 ECB 这个模式------它把"数据结构"原样泄露给了攻击者。换成 CBC 或 GCM,同一张图加密后就是纯噪声,什么都看不出来。
2.3 "教科书玩具"还是"生产事故"?
很多人以为 ECB 企鹅只是教学示例,真实项目里没人会犯这么蠢的错误 。事实恰恰相反------ECB 至今仍在生产环境里大量出现,原因非常朴素:很多加密库把 ECB 列为可选模式,甚至放在文档靠前的位置;开发者搜"AES encryption example"复制粘贴第一段代码,往往就是 ECB。
案例一:Zoom(2020) 。CitizenLab 的研究报告披露,Zoom 当年宣称的"端到端加密"实际上用的是服务端生成的单一 AES-128 密钥 ,且密钥本身是用 ECB 模式加密 分发的。CitizenLab 把 Zoom 的视频流做 ECB 解密可视化后,视频的轮廓在密文里清晰可辨 ------这直接引爆了那场 Zoombombing 危机后的信任崩塌。
案例二:数据库字段加密 。大量内部系统用 AES-ECB 加密用户字段。一张用户表里,所有"男"性别字段都加密成同一个密文,所有"北京"地址字段也是同一个密文。攻击者拿到数据库导出文件,不需要解密,直接统计密文频率就能画出用户画像 ------哪个密文出现最多,对应的就是最常见的性别/城市/状态字段。这叫统计分析攻击 。
案例三:IoT 周期性上报 。传感器每 30 秒上报一次固定格式的温湿度 JSON,用 ECB 加密。相同的数据帧产生一致的密文,攻击者坐在网络侧不用解密就能识别设备身份、判断通信状态、甚至推断物理量的区间 ------因为 25.3 和 25.4 的 JSON 字节绝大部分相同,只有最后几位不同,密文会呈现规律性的"相似但不相同"模式。
案例四:等保合规红线 。国密 SM4 相关标准 GM/T 0028-2014 明确禁止在敏感场景使用 ECB;等保三级及以上系统中,ECB 属于一票否决项。金融、政务等关键信息基础设施里使用 ECB 的密码模块无法通过安全等级认证。
2.4 比"泄露模式"更狠:块重排与块替换
ECB 的第二个致命缺陷是密文是可塑的。因为每块独立加密,攻击者可以在密文层面做手脚,解密结果会照单全收:
- 块重排 :把密文中第 3 块和第 5 块交换位置,解密后明文就跟着交换。如果明文是
{"amount":100,"to":"alice"}这类 JSON,攻击者可以把"to": "alice" 那块的密文换成"to": "mallory" 那块的密文(前提是先通过某种方式拿到 mallory 的合法密文); - 块复制:把某块的密文复制一份追加,解密后就多了一段合法的明文;
- 块删除:删掉某块,解密后那段内容就消失;
- 字典攻击 :如果攻击者能让系统加密任意明文(选择明文攻击),可以建立"明文块 → 密文块"字典,然后用这个字典逐块翻译 他人的密文。
防御这一切的唯一方法是让密文带上完整性保护------这就是为什么 CBC 必须配 HMAC、为什么 GCM 内置认证标签、为什么 ECB 在多块场景下无药可救。
2.5 ECB 的"合法用途"------少得可怜
严格来说,ECB 在一种情况下是安全的:数据不超过一个 AES 块(16 字节),且该数据本身高熵、无重复结构 。最典型的场景就是密钥包装 (Key Wrapping)------用一把密钥加密另一把 16/32 字节的随机密钥,此时明文本身就是高熵随机数,"模式泄露"没有意义。但即使在这个场景,行业标准也更推荐 AES-KW(RFC 3394)或 AES-KWP,因为它们提供了额外的完整性保护。
一句话总结 ECB:除了密钥包装,ECB 在 2026 年的任何生产代码里出现都应该被视为一个 P2 级安全事件。
三、CBC:曾经的默认选择,如今的重灾区
3.1 CBC 的数学结构:链式异或 + 随机 IV
CBC 的核心思想是把块串起来,让相同明文块产生不同密文块:
text
加密:
C_0 = IV
C_i = E_K(P_i ⊕ C_{i-1})
解密:
P_i = D_K(C_i) ⊕ C_{i-1}
第一块没有"前一块",就跟一个随机生成的 IV 做异或。这一步异或把块与块之间串了起来:相同的两块明文,因为前一块的密文不同,异或之后的输入也不同,加密后输出的密文也不同。重复模式被打散,输出变成纯噪声。
CBC 是 TLS 1.0-1.2 时代(2010 年代)的绝对主流,直到今天仍大量存在于遗留系统、企业内部协议、ZIP 加密、PGP 旧版本、各种自研 SDK 里。
3.2 陷阱一:IV 复用与 IV 可预测
CBC 的安全性强烈依赖 IV 的质量。两条铁律:
- 不可重复:同一个 IV 加密两份不同明文,密文前缀相同;
- 不可预测 :攻击者不能在看到密文之前猜出下一个 IV 。
第二条比第一条更隐蔽也更致命。一个反直觉的事实是:用递增计数器或时间戳做 CBC 的 IV,会破坏 CBC 对选择明文攻击(IND-CPA)的防御 ------TLS 1.0 就是因为允许"上一个记录的最后一个密文块作为下一个记录的 IV",被 BEAST 攻击(2011)打穿。
正确的 IV 生成方式只有一个:每次加密都用 CSPRNG 生成 16 字节随机数,随密文一起(明文形式)传输。
python
import os
iv = os.urandom(16) # 正确:每次加密都新生成
3.3 陷阱二:比特翻转攻击(Bit-Flipping)
CBC 没有认证,且解密公式里有个危险的特性:
text
P_i = D_K(C_i) ⊕ C_{i-1}
注意 C_{i-1} 这一项------上一块密文的任何比特变化,都会直接异或到当前块的明文上 。攻击者不需要知道密钥,只需要翻转 C_{i-1} 里的某一位,就能可预测地翻转 P_i 里的对应位 。
举个具体例子。假设一段明文是:
text
Transfer 100 to Alice
攻击者想把它改成 Transfer 999 to Alice。对比两个字符串的二进制:
text
001 = 0x30 30 31
999 = 0x39 39 39
XOR = 0x09 09 08
攻击者只需要把前一密文块 对应位置的 3 个字节分别 XOR 上 0x09、0x09、0x08,解密出来的明文金额就从 100 变成 999。服务端既不会报错,也不会察觉 ------除非有独立的完整性校验(HMAC 或认证标签)在解密前检查密文。
同理,"给 Alice 转 100" 可以变成 "给 Eve 转 999","allow=false" 可以变成 "allow=true"------只要攻击者能在密文里定位到目标字节。
这是所有 CBC 部署都必须外挂 HMAC 的根本原因。
3.4 陷阱三:Padding Oracle------CBC 家族最著名的惨案
这是本文篇幅最长的一节,因为 Padding Oracle 是密码学史上的经典连环惨案,前后打了 20 年,从 1998 年打到 2026 年还在出新变种。
3.4.1 攻击原理:一行代码引发的血案
AES 是块密码,只能处理 16 字节整数倍的数据。明文不足 16 字节的整倍数时要填充,最常用的是 PKCS#7:缺 n 个字节就补 n 个值为 n 的字节。
text
明文长度 13 字节 → 补 3 个 0x03
明文长度 16 字节 → 补一个完整块的 0x10
解密方在解出明文后要检查 padding 是否合法 ,不合法就报错。问题来了:"报错"本身就是一种信息泄露 。如果攻击者能区分"padding 错误"和"其他错误"(比如 HTTP 500 vs 200、抛异常 vs 正常返回、响应时间快 vs 慢),就获得了一个"Oracle"(预言机),可以逐字节解密任意密文,完全不需要知道密钥。
3.4.2 攻击步骤
回顾 CBC 解密公式:P_i = D_K(C_i) ⊕ C_{i-1}。攻击者想解出 D_K(C_i) 的最后一个字节(从而推出 P_i 的最后一个字节)。
Step 1 :攻击者构造一个假的密文块 F',把 F' || C_i 送给服务器。
Step 2 :服务器解密,得到的"明文"最后一块是 D_K(C_i) ⊕ F'。攻击者遍历 F' 的最后一个字节 0x00-0xFF,每次发送给服务器,观察响应。
Step 3 :当某个值 i 使得服务器没有报 padding 错误时,可以高概率推断:解密后的最后一个字节恰好是 0x01(因为 padding 合法的概率远高于其他情况)。此时:
text
D_K(C_i) 最后一个字节 = 0x01 ⊕ F'_n = 0x01 ⊕ i ⊕ 0x01 = i
Step 4 :有了 D_K(C_i) 的最后一个字节,再利用 P_i = D_K(C_i) ⊕ C_{i-1} 就能算出真实明文最后一个字节。
Step 5 :把攻击目标改为倒数第二个字节------构造 padding 为 0x02 0x02 的场景,继续遍历。以此类推,16 个字节只需最多 16 × 256 = 4096 次查询 就能完整解出一个密文块。
关键:整个攻击不需要密钥,不需要 IV,只需要"报错行为不一致"这一个侧信道。
3.4.3 二十年连环惨案时间线
这段历史值得每个工程师看一遍,因为它不是" theoretical attack"------它真实地把各大系统打了一遍:
| 年份 | 事件 | 影响 |
|---|---|---|
| 1998 | Bleichenbacher 首次提出针对 RSA PKCS#1 v1.5 的填充预言攻击 | 开山之作 |
| 2002 | Vaudenay 证明攻击适用于对称 CBC 模式 | 理论奠基 |
| 2003 | 结合时序攻击破解 IMAP/Outlook 的 SSL/TLS | 第一次实战 |
| 2007 | 攻击未认证的 IPsec | 恢复载荷明文 |
| 2010 | POET 工具发布,攻破 JSF、Ruby on Rails、ASP.NET | 引爆 Web 框架 |
| 2011 | BEAST 攻击 TLS 1.0 | 利用 IV 链式复用 |
| 2012 | 攻击 USB 安全令牌、智能卡 | 硬件也沦陷 |
| 2013 | Lucky13:利用 HMAC-SHA1 处理时间差异打穿 TLS/DTLS | OpenSSL/GnuTLS/AWS s2n 全家打补丁 |
| 2014 | POODLE:降级到 SSLv3 后填充预言 | SSLv3 彻底死刑 |
| 2016 | CVE-2016-2107 ("LuckyNegative20"):OpenSSL 修复 Lucky13 的补丁里又引入了新的 padding oracle | 修复本身引入漏洞 |
| 2019-2020 | Apache Shiro SHIRO-721:RememberMe 功能用 AES-128-CBC + 可区分的 padding 错误响应 | 远程代码执行 |
3.4.4 CVE-2016-2107:一个"修复引入新漏洞"的教科书案例
这是密码学工程最讽刺的一章。OpenSSL 为了修复 Lucky13,写了"常数时间"的 CBC padding 校验代码。可惜写错了:当 padding 长度字段的值 ≥ 16 时,某个变量会变成 -20 (因此这个漏洞绰号 "LuckyNegative20"),导致常数时间保证彻底失效,攻击者又能通过时间差区分 padding 是否合法。
教训:密码学代码不能手写,更要审计第三方库的版本。你以为升级就安全了,结果升级也带来新攻击面。
3.4.5 如何防御 Padding Oracle
根本方法只有一个:在解密之前,先用 MAC 验证密文。这就是 Encrypt-then-MAC(EtM):
text
发送方:C = AES-CBC-Encrypt(K_e, P)
T = HMAC-SHA256(K_m, IV || C)
发送 (IV, C, T)
接收方:先算 HMAC-SHA256(K_m, IV || C)
用常数时间比较验证 T
通过才解密,不通过直接丢弃
关键:MAC 是对密文算的,且必须在解密前验证 。这样攻击者修改的密文会在 MAC 检查这一步就被拒掉,根本不会进入解密流程,padding 错误根本不会发生,Oracle 也就消失了。
TLS 1.2 之后的 EtM 扩展(RFC 7366)和 TLS 1.3 彻底淘汰 CBC,就是基于这条血泪经验。
同时 ,padding 校验失败和 MAC 验证失败必须返回完全相同的错误 、消耗完全相同的时间------否则时间侧信道依然存在(这就是 Lucky13 的原理)。
3.5 CBC 的现代地位:能用,但不该用
需要客观地说一句:CBC 本身不是 broken ,它只是"用起来容易错"。在一个遗留系统里,AES-256-CBC + 随机 IV + HMAC-SHA256(EtM)+ 常数时间比较,是一套完全安全的方案。但没有任何理由在新项目里继续用它 ------同样的工作量下,GCM 提供更强保证、更快性能、更少代码,为什么不呢?
CBC 的正确用法总结:
- IV 每次随机生成,不可预测;
- 必须 EtM,MAC 覆盖 IV 和密文;
- MAC 验证用常数时间比较;
- 解密失败和验证失败返回相同的错误、耗时相同;
- 新项目直接用 GCM,别碰 CBC。
四、GCM:现代默认选择,以及它的"阿喀琉斯之踵"
4.1 GCM 是什么:CTR 加密 + GHASH 认证
GCM(Galois/Counter Mode)由 NIST 在 SP 800-38D 标准化,是目前应用最广的 AEAD(认证加密)模式。它由两部分组成:
(1)CTR 模式加密(机密性) :内部维护一个计数器,每次递增,对每个计数值做 E_K(counter) 得到密钥流,与明文 XOR 得到密文。没有 padding,密文长度等于明文长度 ------这是一个工程上的额外好处。
(2)GHASH 认证(完整性) :用 GF(2^128) 上的多项式乘法,把密文和 AAD(Additional Authenticated Data,附加认证数据)哈希成一个值,再与 E_K(J0) 异或,得到 16 字节的认证标签 Tag。
AAD 是一个非常重要的设计:它让你声明"这段数据不加密,但必须防止被篡改"。比如 TLS 记录头、HTTP 头、协议版本号------这些必须明文传输(接收方需要先读它们才知道怎么处理),但又不能被中间人篡改(否则可能导致降级攻击)。GCM 是第一个把这些需求优雅统一的模式。
4.2 为什么 GCM 成为现代默认
- 一次操作同时提供机密性和完整性,不需要再外挂 HMAC;
- 硬件加速近乎免费:现代 CPU(近十年)都有 AES-NI 和 PCLMULQDQ 指令,AES-GCM 在这些硬件上的开销几乎可以忽略;
- 无 padding,密文长度可控:对网络协议、存储格式非常友好;
- 可并行:CTR 部分天然可并行,高吞吐场景(10Gbps+ 网卡)能做到线速加密;
- 杜绝了 padding oracle:没有 padding,就没有 padding oracle 这个攻击面;
- TLS 1.3 的两个 AEAD 套件之一(另一个是 ChaCha20-Poly1305);WireGuard、IPsec、Signal、磁盘加密全都在用。
4.3 那个必须严肃讲清楚的事:Nonce 重用 = 灾难
GCM 的安全性建立在一个前提上:同一把密钥下,Nonce 绝对不可重复。这不是"降低安全性"的警告,而是"一旦违反整个防线崩溃"的红线。
4.3.1 灾难一:Two-Time Pad(泄露明文 XOR)
GCM 内部是 CTR 模式。Nonce 决定了初始计数块 J0,J0 决定了整个密钥流。Nonce 相同 = 密钥流相同:
text
C1 = P1 ⊕ KS
C2 = P2 ⊕ KS
C1 ⊕ C2 = P1 ⊕ P2 ← 密钥流相互抵消!
攻击者拿到两条密文做 XOR,直接得到两条明文的 XOR 。如果其中一条明文是已知的(比如 HTTP 协议的固定头部),另一条立刻完全可解 。
这个攻击的条件低得惊人------只要复用一次 Nonce,攻击者就立刻占尽优势。
4.3.2 灾难二:Forbidden Attack(伪造认证标签)
更可怕的是对完整性 的破坏。GCM 的认证密钥 H = E_K(0^128) 对同一把主密钥是固定值 。当同一密钥下两个消息使用了相同 Nonce,攻击者计算 T1 ⊕ T2,可以消去 E_K(J0) 这一项 ,得到 GHASH 的线性方程组。
由于 GHASH 是 H 的多项式,这个方程组可以通过多项式求根 在多项式时间内解出 H。H 一旦泄露,攻击者就能为任意伪造的密文计算合法的认证标签 ------也就是说,AEAD 提供的完整性保护被完全摧毁 。
这个攻击有个专门的学术名字------Forbidden Attack (禁忌攻击),出自 Joux 2006 年的经典分析。它在 2026 年仍有现实版本:wolfSSL 的 CVE-2026-5446 就是因为集成方误用了 Nonce 导致认证体系崩溃。
一句话:GCM 的 Nonce 重用不是"轻微风险",而是机密性和完整性同时崩溃,等效于把密钥公开。
4.3.3 为什么 GCM 的 Nonce 是 96 位(12 字节)
这是个很多人问过的问题。GCM 内部用 E_K(J0) 作为计数器的起始密钥流。NIST 设计上规定:96 位的 Nonce 直接进入 J0 的前 96 位,后 32 位是计数器 ------这样可以最大化单密钥下可加密的消息数量(2^32 个块)。
如果 Nonce 不是 96 位 ,GCM 会通过 GHASH 把 Nonce 变换成 J0------但这个变换本身有碰撞概率,生日悖论 会导致 2^48 次左右就可能出现 Nonce 碰撞。NIST 因此规定:随机 Nonce 模式下,单密钥最多加密 2^32 条消息 (确保碰撞概率低于 2^-32)。
工程含义:
- 如果走随机 Nonce 路线:每把密钥最多加密 2^32 次(约 43 亿),超过必须换密钥;
- 如果走计数器 Nonce 路线:可以是单调递增的 96 位计数器,理论上可以无限多,但必须保证计数器永不回滚(重启、多副本、负载均衡都要小心)。
4.3.4 Nonce 重用的"现实诱因"
CSDN 上有个很好的总结:
| 诱因类别 | 根本原因 | 典型场景 | 检测难度 |
|---|---|---|---|
| 熵枯竭 | /dev/random 阻塞或 /dev/urandom 初始化不足 |
嵌入式设备冷启动后首条加密请求 | 高 |
| 状态丢失 | 事务失败导致计数器未持久化 | 支付网关重试逻辑绕过 Nonce 递增 | 中 |
| 并发冲突 | 无锁计数器竞态 | K8s 多副本共享同一密钥 + 本地内存计数器 | 极高 |
| 协议误用 | 自研 AEAD 扩展未隔离密钥域 | MQTT 自定义 AEAD 扩展 | 中高 |
这四类里最容易踩坑的是"并发冲突" :分布式系统里,多个节点持有同一把主密钥,各自在内存里维护计数器。一旦某个节点重启,计数器回到 0,就开始和其他节点撞 Nonce。
生产环境的对策:
- 密钥隔离 :每个节点用 KMS 派生不同的密钥(用
EncryptionContext区分),从根上避免共享密钥; - Nonce 前缀:每个节点随机分配一个 32 位实例 ID 作为 Nonce 前缀,后 64 位是本地计数器------冲突概率从 1 变成 2^-32;
- KMS 生成的随机 Nonce:由中心化服务统一分配,避免本地熵不足。
4.3.5 防御方案二:AES-GCM-SIV(Nonce Misuse-Resistant AEAD)
如果说 GCM 的设计哲学是"假设你遵守规则,一旦违反必死",那 AES-GCM-SIV(RFC 8452)的设计哲学是"假设你会违反规则,违反了也别死得太惨" 。
核心机制 :SIV 模式不再要求 Nonce 唯一,而是把 Nonce、明文、AAD 一起送入 POLYVAL(GHASH 的变体)计算出一个"合成 IV" ,再用这个合成 IV 派生本轮加密的密钥。结果是------加密变成 (nonce, plaintext, aad) 的确定性函数:
- Nonce 重用时:唯一泄露的信息是"两条消息是否相同"(如果明文不同,密文就不同,但攻击者无法进一步推出任何信息);
- 性能 :解密速度约为 GCM 的 95%,加密速度约为 GCM 的 2/3(因为要两遍)。
适用场景: - 多副本/多设备共享同一把密钥,无法协调 Nonce;
- 无状态加密器(如某些存储场景);
- 设备可能重启、计数器可能丢失;
- 任何"对 Nonce 唯一性没有十足把握"的场合。
RFC 8452 的建议非常直白:"只要对 Nonce 唯一性有一丝一毫的怀疑,就应该用 AES-GCM-SIV "。
2026 年的现实是:主流加密库都已经支持 GCM-SIV(OpenSSL 3.2+、libSodium 1.0.19+、RustCrypto、BoringSSL),但很多开发者还不知道它的存在。
4.4 GCM 实战代码(Python)
python
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# 密钥生成------必须来自 CSPRNG
key = AESGCM.generate_key(bit_length=256) # AES-256
def encrypt(plaintext: bytes, aad: bytes = b""):
"""AES-256-GCM 加密"""
nonce = os.urandom(12) # 每次加密生成新 12 字节 Nonce,绝不复用
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext, aad or None)
return nonce, ct
def decrypt(nonce: bytes, ct: bytes, aad: bytes = b""):
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, aad or None)
# 认证失败抛 InvalidTag,说明密文被篡改或来源不对
# 使用示例
nonce, ct = encrypt(b"Transfer 100 to Alice", aad=b"user:1001")
plaintext = decrypt(nonce, ct, aad=b"user:1001")
注意事项:
aad是可选的"认证但不加密"数据。如果协议里有版本号、密钥 ID、消息类型这类必须明文传输的字段,一定要把它们放进 AAD------这是免费的防篡改保护;nonce和ct都要传输/存储,nonce 可以明文 ,但绝不能修改(修改会导致认证失败);- 解密时
InvalidTag异常必须统一处理,不能向调用方泄露"是 AAD 错"还是"是密文错"这样的细节。
五、加密与认证的组合学:四种模式,只有一种是正确的
当使用 CBC 这类没有内置认证的模式时,你必须自己组合加密和 MAC。组合方式有四种,只有一种是真正安全的:
| 组合方式 | 流程 | 安全性 | 典型案例 |
|---|---|---|---|
| MAC-then-Encrypt (MtE) | MAC(明文) → 拼接 → 加密 | ⚠️ 中等,易受 padding oracle | TLS 1.0-1.2 的大部分 CBC 套件 |
| Encrypt-and-MAC (E&M) | 加密(明文) ∥ MAC(明文) | ⚠️ 中等,MAC 基于明文可能泄露信息 | SSH |
| Encrypt-then-MAC (EtM) | 加密(明文) → MAC(密文) | ✅ 最安全,可证抗 CCA | IPsec、TLS 1.2+ EtM 扩展、TLS 1.3 |
| Hash-then-Encrypt (HtE) | Hash(明文) → 拼接 → 加密 | ❌ 低,受长度扩展攻击 | 某些自研系统(禁用) |
关键原理 :EtM 之所以安全,是因为MAC 验证在解密之前发生 ,攻击者伪造的密文根本进不了解密流程,padding oracle、比特翻转这些攻击面就消失了。
MtE(TLS 的老做法)的致命缺陷在于解密发生在 MAC 验证之前 ------解密时必须先处理 padding 才能取出 MAC,这中间就给了攻击者一个 Oracle。TLS 1.3 淘汰 CBC,本质上是淘汰了 MtE 这种结构。
如果使用 GCM,这个问题自动解决 ------GCM 是 AEAD,认证和加密融为一体,顺序由标准库保证。这是 AEAD 的核心价值:它把"组合学"这个容易出错的问题消除了。
六、密钥管理:被忽视的另一半
AES 模式选对了、代码写对了,也不代表安全------密钥管理出错,前面的努力全部白费。这部分值得专门开一节讲。
6.1 密钥从哪里来:绝不能硬编码
生产代码里最常见的密钥来源问题:
| 来源 | 安全性 | 评价 |
|---|---|---|
| 硬编码在源码里 | ❌ 极不安全 | git 历史永远找得到 |
| 硬编码在配置文件 | ❌ 极不安全 | 配置泄露 = 密钥泄露 |
| 环境变量 | ⚠️ 弱 | .env 文件、容器内存泄露风险 |
| 用户密码直接当 key | ❌ 极不安全 | 熵不足,可被字典攻击 |
| 用户密码 + KDF(Argon2id)派生 | ⚠️ 有条件安全 | 适用于客户端本地加密 |
| CSPRNG 生成 | ✅ 正确 | os.urandom/getrandom(2) |
| KMS/HSM 生成并托管 | ✅ 最佳 | 生产环境首选 |
6.2 信封加密:生产环境的标准架构
当数据量大、密钥需要轮换、应用服务器不可信时,信封加密是标准答案:
text
┌──────────────┐ ┌──────────────┐
│ Application │ │ KMS │
│ │ │ │
│ │─ GenerateDataKey ───────>│ │
│ │<─ DEK (plaintext) ───────│ │
│ │<─ EDEK (encrypted) ──────│ │
│ │ │ │
│ DEK ────────┼─ AES-256-GCM ──> Data │ │
│ │ │ │
│ (DEK 在内存│ EDEK + Data 一起存储 │ │
│ 用完丢弃) │ │ │
│ │ │ │
│ │─ Decrypt(EDEK) ────────>│ │
│ │<─ DEK ──────────────────│ │
└──────────────┘ └──────────────┘
关键设计:
- CMK(Customer Master Key) :主密钥,永远不离开 KMS/HSM 的硬件边界,KMS 内部生成、内部使用,应用只能通过 API 调用;
- DEK(Data Encryption Key) :数据加密密钥,由 KMS 的
GenerateDataKey返回明文 DEK + 加密后的 EDEK; - 工作流程 :应用拿到明文 DEK,本地用 AES-256-GCM 加密数据(在内存里进行,速度极快),然后用完立刻从内存丢弃 DEK;EDEK 和密文一起存储;
- 解密流程 :应用调用 KMS 的
Decrypt接口,把 EDEK 发过去,KMS 返回明文 DEK,应用再本地解密数据。
为什么这么设计:
- 性能:数据加密在本地进行,速度是内存级的;KMS 只处理 32 字节的 DEK,不会成为瓶颈(直接加密数据会让 KMS API 调用成为性能瓶颈);
- 安全:主密钥不进应用内存,应用服务器被攻破也拿不到主密钥;
- 成本:KMS API 调用减少 99% 以上;
- 密钥轮换:只需要在 KMS 里轮换 CMK,所有 DEK 的重新加密由 KMS 异步完成,应用无感知。
6.3 AWS KMS 实战代码
python
import boto3
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
kms = boto3.client('kms')
KEY_ID = "alias/myapp-prod-data"
def encrypt_data(plaintext: bytes, context: dict):
"""信封加密:生成 DEK → 本地加密 → 返回 EDEK 和密文"""
resp = kms.generate_data_key(
KeyId=KEY_ID,
KeySpec='AES_256',
EncryptionContext=context, # 关键!加密和解密必须用相同 context
)
dek = resp['Plaintext'] # 明文 DEK(32 字节)
edek = resp['CiphertextBlob'] # 加密后的 DEK
nonce = os.urandom(12)
ct = AESGCM(dek).encrypt(nonce, plaintext, None)
# 立刻从内存擦除 DEK
del dek
return {
'edek': edek, # 存储
'nonce': nonce, # 存储(明文)
'ct': ct, # 存储
'context': context, # 存储(用于解密时验证)
}
def decrypt_data(edek: bytes, nonce: bytes, ct: bytes, context: dict):
resp = kms.decrypt(
CiphertextBlob=edek,
EncryptionContext=context, # 必须与加密时完全一致
)
dek = resp['Plaintext']
return AESGCM(dek).decrypt(nonce, ct, None)
EncryptionContext 的妙用 :它是一组键值对,绑定到密文上 。解密时必须传入完全相同的 context 才能解开。可以用来绑定:{"tenant": "user_1001", "purpose": "file_storage"}------这样即使 DEK 泄露,攻击者也不能把它用于其他租户的数据,因为它会触发 KMS 的 context 校验失败。
6.4 密钥轮换
轮换频率:行业惯例 90 天一轮,敏感数据可以更短。KMS 支持自动轮换:
bash
# AWS KMS 自动轮换
aws kms enable-key-rotation --key-id alias/myapp-prod-data
# GCP Cloud KMS
gcloud kms keys update myapp-data-key \
--keyring=myapp-keyring --location=us-central1 \
--rotation-period=90d \
--next-rotation-time=$(date -d '+90 days' --iso-8601)
轮换的关键设计:旧密文必须能继续解密(保留旧密钥版本),新数据用新密钥加密。解密时通过密文里携带的 key ID 或 key version 定位正确的密钥。
七、FAQ:最常见的 5 个问题
Q1:AES-128 和 AES-256 该选哪个?
严格从暴力破解难度看,AES-256 更强(2^256 vs 2^128)。但 AES-128 的 2^128 在可预见的未来也足够安全。实践中,性能差异极小 (AES-NI 下几乎一样),一般建议选 AES-256 ------因为成本几乎为零,而未来量子计算对 128 位安全性的威胁(Grover 算法把对称安全性减半到 64 位)让 256 位更稳妥。TLS 1.3 默认套件、AWS KMS 默认都是 AES-256。
Q2:GCM 的 Nonce 可以用消息序号吗?
可以,但有几个前提:序号从 0 开始单调递增、永不重置 ;同一个密钥下永远不重复使用相同序号 ;发送方和接收方对序号的同步有可靠协议 。实践中更好的做法是"实例前缀 + 序号":前 32 位是实例 ID(每个节点独立分配),后 64 位是本地序号------这样既保证唯一性,又支持多实例。
Q3:听说 GCM 有 2^32 次加密限制?
对,这是针对随机 Nonce 模式 的。原因是生日悖论:96 位随机 Nonce 在 2^48 次左右可能碰撞,NIST 把上限压到 2^32 以保证碰撞概率低于 2^-32。如果用计数器 Nonce,没有这个限制 (最多可加密 2^32 个块 × 每块 2^39 位 ≈ 海量数据)。所以工程上优先用计数器 Nonce,随机 Nonce 只在"无法维护计数器"的场景使用,且要严格限制单密钥加密次数。
Q4:为什么不推荐 CBC?它不是仍然安全吗?
CBC 的"仍然安全"有苛刻前提:随机 IV、外挂 HMAC、常数时间比较、统一错误响应。任何一条违反就可能出事 (看第三节那 20 年惨案时间线)。GCM 在所有这些方面都是结构性的安全------你不遵守也安全。当两种模式工作量差不多时,选择"结构上安全"而不是"用起来小心才安全",这是工程上的常识。
Q5:自己实现 AES 安全吗?
绝对不推荐 。AES 本身的实现有侧信道攻击 风险(缓存时序、功耗分析、电磁泄漏),需要常数时间表查找、比特切片等技巧,专业团队都要仔细审计。用标准库 ------Python 的 cryptography、Go 的 crypto/aes、Java 的 JCE、OpenSSL、libsodium------这些都经过了十几年实战检验。你的创造力应该花在正确的模式选择、密钥管理、协议设计上,而不是重新发明 AES。
结语:安全的另一半在"外围"
这篇文章讲了很多攻击和陷阱,但如果你只能带走一句话,我希望是这一句------
AES 之所以出问题,几乎从来不是 AES 本身被破解,而是模式选错、IV 复用、没有认证、密钥管理混乱。 这四类错误中的任何一个,都足以让"世界上最安全的对称密码"在实际系统里毫无防护力。而每一次翻车的根源,几乎都是工程师以为"调用了 AES 就安全了"。
密码学工程的第一铁律:不要自己发明密码学,也不要"只调用一下 AES"就万事大吉。用标准库的 AEAD 模式(AES-GCM/GCM-SIV/ChaCha20-Poly1305),把 Nonce 管理当作 P0 级设计问题,把密钥托管给 KMS,把完整性认证当作必需而非可选------这些看似平凡的工程纪律,才是真正区分"密码学玩具"和"生产级安全系统"的东西。