一、引子

1.1 保密的需求

想象一下这个场景:你的数据库中存着用户的身份证号、手机号、银行卡号。某天,数据库文件被拖走了——可能是 SQL 注入、可能是运维误操作、也可能是服务器被入侵。

如果这些数据是明文存储的,那就等于把所有用户的敏感信息直接交给了攻击者。这就是为什么我们需要加密

1.2 什么是加密?

加密,简单来说就是:把可读的数据(明文)通过一个算法和一把密钥,转换成不可读的乱码(密文)。反过来,用同一个密钥(对称加密)或配对密钥(非对称加密)可以从密文恢复出明文——这个过程叫解密。

加密: 明文 + 密钥 → 密文(不可读)
解密: 密文 + 密钥 → 明文(恢复可读)

一个加密方案是否安全,有两个基本判断标准:

  1. 没有密钥的情况下,从密文推算出明文在计算上是不可行的——也就是说,攻击者可以拿到完整密文,但只要没有密钥,就没法解密
  2. 加密算法本身应该是公开的,安全性完全依赖于密钥的保密性——这就是 Kerckhoffs 原则(柯克霍夫原则):一个密码系统应该即使被攻击者知道了所有实现细节,只要密钥没泄露就是安全的。换句话说,不要把安全性寄托在"算法保密"上

加密要解决的核心问题有三个:

安全目标 含义 通俗理解
机密性(Confidentiality) 数据不可被未授权者读取 “别人看不懂”
完整性(Integrity) 数据未被篡改 “别人改不了”
认证(Authentication) 数据来源可信 “别人冒充不了”

这篇文章主要关注前两个——机密性和完整性。不同加密模式对这些目标的满足程度不同。

1.3 加密的分类

从密钥的使用方式上,加密分为两大类:

类别 密钥关系 速度 典型用途
对称加密 加密和解密用同一个密钥 快(硬件加速) 数据加密存储、文件加密
非对称加密 加密用公钥,解密用私钥 慢(数学运算) 密钥交换、数字签名、HTTPS 握手

对称加密和非对称加密各有长短。在数据库字段加密这个场景——需要加密大量数据、加密双方(应用和数据库)是同一个系统——对称加密是唯一合理的选择。非对称加密在这个场景下太慢、密文太大,完全不适用。

1.4 对称加密的两大类:块加密 vs 流加密

对称加密按照处理数据的方式,分为两大流派:

块加密(Block Cipher):把数据切成固定大小的块,逐块加密。好比搬砖——一次搬一块,每块一样大。代表算法有 AES、DES、SM4。

流加密(Stream Cipher):逐字节处理,数据像流水一样通过加密器。好比水管——水流过阀门,一滴一滴地处理。代表算法有 ChaCha20、RC4。

对比维度 块加密 流加密
处理方式 切块,逐块加密 逐字节加密
数据长度 必须是块大小的倍数(不够就填充) 任意长度,不需要填充
并行性 可以并行加密多个块 一般不能并行
典型代表 AES、DES、SM4 ChaCha20、RC4

本文重点讨论的是块加密——AES 就是块加密中最重要的一员。

1.5 AES 的历史:从 DES 到 AES 的演进

在讲 AES 之前,先了解一下它的前辈们,这样你才能理解 AES 为什么是今天这个样子。

DES(Data Encryption Standard,1977 年)

DES 是第一个被广泛使用的对称加密标准,由 IBM 设计、美国国家安全局(NSA)修改后发布。它的特点是:

  • 密钥长度 56 位——在 1977 年足够安全,但到了 1999 年,EFF(电子前沿基金会)用一台专用机器 22 小时就暴力破解了
  • 块大小 64 位(8 字节)——每次只能加密 8 字节,太小了

3DES(Triple DES,1998 年)

DES 被破解后,人们没有设计新算法,而是把 DES 加密三次——用两个或三个不同的密钥,加密→解密→加密。安全性有所提升,但:

  • 速度是 DES 的三分之一(加密三次)
  • 实际安全性只有 112 位(远不如 AES-256)
  • 2017 年 NIST 正式弃用

AES(Advanced Encryption Standard,2001 年)

1997 年,NIST(美国国家标准与技术研究院)意识到 DES 即将过时,于是发起了一场公开竞赛——全球密码学家提交自己的算法,经过三轮公开评审和攻击测试,最终在 2001 年选出了比利时密码学家 Joan Daemen 和 Vincent Rijmen 设计的 Rijndael 算法作为 AES。

AES 之所以能胜出,是因为它在安全性、性能和实现简洁性之间取得了最佳平衡。值得一提的是,现代 CPU 都内置了 AES-NI 指令集(AES New Instructions),用硬件电路直接实现 AES 的核心运算。在有 AES-NI 的 CPU 上,AES 的速度可以达到每秒处理几个 GB 的数据——比纯软件实现快 10 倍以上。在 2026 年的今天,几乎所有的 x86 服务器和 ARM 芯片都支持 AES-NI。

1.6 AES 的三个核心参数

要理解 AES,需要先弄清楚三个参数:块大小密钥长度加密轮数。这三个概念相互关联,我们逐一解释。

什么是块大小?

块大小是 AES 每次能处理的数据量。AES 的块大小固定为 128 位(16 字节)

为什么需要固定大小?因为 AES 的内部运算(SubBytes、ShiftRows、MixColumns、AddRoundKey)都是在 4×4 的字节矩阵上进行的,这个矩阵正好是 16 字节。算法的数学结构决定了它只能处理 16 字节的数据。

AES 的 4×4 字节矩阵(状态矩阵):

┌────┬────┬────┬────┐
│ b0 │ b4 │ b8 │ b12│
├────┼────┼────┼────┤
│ b1 │ b5 │ b9 │ b13│
├────┼────┼────┼────┤
│ b2 │ b6 │ b10│ b14│
├────┼────┼────┼────┤
│ b3 │ b7 │ b11│ b15│
└────┴────┴────┴────┘

16 个字节填入这个矩阵,经过多轮变换后输出 16 字节密文。

这意味着什么?如果你要加密 50 字节的数据,AES 本身无法一次性处理——它只能一次吃 16 字节。你需要把 50 字节切成 4 块(16+16+16+2),然后想办法处理最后一块不够 16 字节的问题。这就是后面要讲的"加密模式"和"填充"的由来。

什么是密钥长度?

密钥长度决定了加密的安全强度。AES 支持三种密钥长度:

版本 密钥长度 安全强度 性能
AES-128 128 位(16 字节) 足够高 最快
AES-192 192 位(24 字节) 更高 中等
AES-256 256 位(32 字节) 最高 略慢(差距约 20%)

密钥越长意味着什么?暴力破解需要尝试的密钥空间越大:

  • AES-128:2¹²⁸ 种可能 ≈ 3.4 × 10³⁸——即使用全球所有超级计算机一起算,也需要几十亿年
  • AES-256:2²⁵⁶ 种可能 ≈ 1.1 × 10⁷⁷——这个数字比可观测宇宙中的原子总数还大

一个常见的误区:“AES-256 每次加密 256 位数据”——这是错的。256 指的是密钥长度,不是每次加密的数据量。不管用哪种密钥长度,AES 每次加密的数据量都是 16 字节(块大小),这是固定的。

实际项目中,AES-128 的安全性已经足够抵御所有已知攻击。但很多团队仍然选择 AES-256——不是因为 128 不够安全,而是"用最高规格"不需要向审计解释为什么没用 256。

什么是加密轮数?

AES 并不是用密钥对数据做一次变换就结束了。它会反复执行多轮变换,每一轮都用密钥的一部分对数据做一次"搅乱"。轮数越多,数据被搅乱的程度越高,安全性也越高。

版本 密钥长度 加密轮数
AES-128 128 位 10 轮
AES-192 192 位 12 轮
AES-256 256 位 14 轮

每一轮包含四个步骤:

第 1 轮到第 N-1 轮(标准轮):
  ① SubBytes(字节替换):每个字节通过一个替换表(S-Box)映射到另一个字节
  ② ShiftRows(行移位):矩阵的每一行循环左移不同的位数
  ③ MixColumns(列混合):每一列的 4 个字节做矩阵乘法
  ④ AddRoundKey(轮密钥加):与本轮的子密钥做 XOR

最后一轮(略有不同):
  ① SubBytes
  ② ShiftRows
  ③ AddRoundKey
  (没有 MixColumns——这是 AES 标准的设计选择)

为什么要这么多轮?因为单轮变换不足以让数据充分"搅乱"。密码学中有一个概念叫扩散(Diffusion)——明文的一个比特变化应该影响到密文的多个比特。多轮变换确保了这种扩散效果。

但轮数也不是越多越好——轮数越多,加密速度越慢。10/12/14 轮是 NIST 经过安全分析后确定的平衡点:足够安全,又不至于太慢。

密钥长度和轮数的关系:密钥越长,需要的轮数越多。这是因为更长的密钥意味着更大的密钥空间,需要更多轮变换才能让密钥的每一位都充分参与到加密过程中。

1.7 为什么 AES 还需要"加密模式"?

AES 本身只解决一个问题:给定一个密钥和 16 字节数据,输出 16 字节密文

但实际要加密的数据远远不止 16 字节——一个身份证号 18 个字符、一个 JSON 对象几百字节、一份文件几 MB。这些数据怎么切成 16 字节一块?块与块之间有没有关系?最后一块不够 16 字节怎么办?密文被篡改了能不能检测到?

这些问题,AES 本身不回答。回答它们的是加密模式(Block Cipher Mode of Operation)

市面上的加密模式有很多:ECB、CBC、GCM、CTR、CFB、OFB、CCM……每种模式在安全性、性能、功能特性上各有取舍。对于绝大多数后端开发者来说,不需要了解所有模式,但必须能回答三个问题:

  1. ECB 为什么不能用?
  2. CBC 有什么致命的隐藏缺陷?
  3. GCM 为什么是当前的最佳选择?

这篇文章会逐一回答这三个问题。如果时间只够看一句话,那就是:选 AES-256-GCM


二、先弄清楚三个基础问题

在深入模式之前,有三个基础问题必须先说清楚。跳过这一步直接看模式对比,你可能会知其然而不知其所以然。

2.1 块加密和流加密有什么区别?

对称加密分两大类:

类型 处理方式 代表算法 特点
块加密(Block Cipher) 每次加密固定大小的数据块 AES、DES、SM4 需要填充,需要加密模式
流加密(Stream Cipher) 逐字节加密,数据流式处理 ChaCha20、RC4 不需要填充,天然支持任意长度

AES 是块加密,块大小固定为 16 字节(128 位)。不管你用的是 AES-128、AES-192 还是 AES-256,块大小都是 16 字节——密钥越长不代表每次加密的数据量越大。

一个常见的误区:“AES-256 每次加密 256 位数据”——错。AES 的块大小永远是 128 位(16 字节),密钥长度只是决定了加密的轮数:

版本 密钥长度 加密轮数 块大小
AES-128 128 位(16 字节) 10 轮 128 位
AES-192 192 位(24 字节) 12 轮 128 位
AES-256 256 位(32 字节) 14 轮 128 位

轮数越多,暴力破解的难度越大,但性能也会相应降一些(差距通常不超过 20%)。

2.2 为什么需要填充?

如果你要加密 30 字节的数据,而 AES 每次只吃 16 字节,怎么办?

30 字节 = 16 字节(第 1 块)+ 14 字节(第 2 块,还差 2 字节)

第 2 块差 2 字节才满 16 字节,需要补上。最广泛使用的填充方案是 PKCS7(Public Key Cryptography Standards #7),它的规则非常简洁:缺几个字节就补几个值为 N 的字节

举例 1:明文 5 字节,缺 11 个字节 → 补 11 个 0x0B

明文原文: 48 45 4C 4C 4F           → "HELLO"(5 字节)
          H  E  L  L  O

PKCS7 填充: 在末尾加上 11 个 0x0B
48 45 4C 4C 4F 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B
H  E  L  L  O  ←──── 11 个 0x0B ────→

填充后正好 16 字节。

举例 2:明文刚好 16 字节——这时需要补一个完整的 16 字节块

明文原文: 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 50
          A  B  C  D  E  F  G  H  I  J  K  L  M  N  O  P

PKCS7 填充: 在末尾加上 16 个 0x10
41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 50
10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10

为什么明文刚好 16 字节也要填充?——为了让解密端能确定哪些字节是填充

解密端的填充删除规则很简单:读取最后一个字节的值 N,如果最后 N 个字节都是 N,就把它们删掉。如果明文正好 16 字节,解密端读到的最后一个字节是实际数据的一个字节。假设这个字节恰好是 0x01——它到底是数据还是填充?无法判断。

所以 PKCS7 规定:明文即使对齐也必须补满一块。这样解密时读到最后一个字节是 0x10,就知道后面 16 个 0x10 都是填充,全部删掉。

这个填充规则本身很简单,但它直接导致了后面将要介绍的 Padding Oracle 攻击——攻击者正是利用填充校验的错误信息来逐字节爆破明文的。

2.3 还有哪些填充方式?

PKCS7 虽常见,但不是唯一的填充方案:

填充方式 规则 特点
PKCS7 缺 N 字节补 N 个 N 最常用,有 Padding Oracle 风险
Zero Padding 缺 N 字节补 N 个 0x00 简单但无法区分数据中的 0x00
ISO 10126 缺 N 字节:最后 1 字节补 N,前面补随机值 已弃用
ANSI X.923 缺 N 字节:最后 1 字节补 N,前面补 0x00 较少用
No Padding(CTS) 通过密文窃取避免填充 数据必须至少 16 字节

GCM 模式最大的优势之一就是完全不需要任何填充——原因会在 GCM 章节详细解释。

2.4 什么是加密模式?

有了块加密算法和填充方案,我们有了"如何加密 16 字节数据"的能力。但实际要加密的可能是几个字节的身份证号、几百字节的 JSON、甚至几 MB 的文件。

加密模式(Block Cipher Mode of Operation) 就是一套规则,它定义了:

  1. 块之间是否有依赖关系?

    • 没有依赖 → 可以并行计算(快),但相同明文块产生相同密文块(不安全因素)
    • 有依赖 → 不能并行,但安全性更好
  2. 如何处理最后一块的填充?

    • 有些模式不需要填充(天然支持任意长度)
    • 有些模式依赖填充,也就带来了填充相关的攻击面
  3. 是否提供完整性校验?

    • 提供 → 能检测密文是否被篡改
    • 不提供 → 攻击者可以修改密文而不被发现

把这三个问题记在心里,接下来我们逐个分析 ECB、CBC 和 GCM。


有了这些基础知识,我们就可以开始分析具体的加密模式了。先来看最早诞生但也最危险的模式——ECB。

三、ECB 模式——电子密码本的致命缺陷

3.1 工作原理

ECB(Electronic CodeBook,电子密码本模式)是最简单、最原始的模式。它的规则就是四个字:各自为政

每个 16 字节块都用同一个密钥独立加密,块与块之间没有任何关系:

明文:  [Block 1]  [Block 2]  [Block 3]  [Block 4]
        ↓ AES      ↓ AES      ↓ AES      ↓ AES
密文:  [Block 1]  [Block 2]  [Block 3]  [Block 4]

加密可以并行(快),解密也可以并行(快),而且实现起来极其简单。很多加密库甚至把 ECB 作为默认模式——Java 的 Cipher.getInstance("AES") 默认就是 AES/ECB/PKCS5Padding。

但 ECB 有两个不可饶恕的安全缺陷。

3.2 致命缺陷一:相同明文块 → 相同密文块

这是 ECB 最根本的问题,也是它被称为"电子密码本"的原因。

"电子密码本"这个名字非常形象:想象一本厚重的密码本,每一页上记录着一个明文条目和它对应的密文。当你要加密时,去这个本子里查:Block 1 的密文是多少?Block 2 的密文是多少?——同一个明文永远查出来同一个密文

这就跟二战时期的恩尼格玛密码机一样——同一个字母永远被加密成同一个密文字母,结果是频率分析可以轻松破解。ECB 犯的也是同样的错误。

来看一个具体的 Java 例子

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;

public class EcmDisaster {

    public static void main(String[] args) throws Exception {
        // 密钥(16 字节,AES-128)
        byte[] keyBytes = "0123456789abcdef".getBytes();
        SecretKeySpec key = new SecretKeySpec(keyBytes, "AES");

        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");

        // 对比两条明文:用户不同,但 dept 字段完全相同
        String json1 = "{\"user\":\"Alice\",\"role\":\"Admin\",\"dept\":\"Finance\"}";
        String json2 = "{\"user\":\"Bob\",\"role\":\"User\",\"dept\":\"Finance\"}";

        byte[] enc1 = cipher.doFinal(json1.getBytes());
        byte[] enc2 = cipher.doFinal(json2.getBytes());

        String hex1 = HexFormat.of().formatHex(enc1);
        String hex2 = HexFormat.of().formatHex(enc2);

        System.out.println("密文1: " + hex1);
        System.out.println("密文2: " + hex2);

        // 分块对比(ECB 每 16 字节一块)
        System.out.println("\n逐块对比:");
        String[] blocks1 = splitByBlock(hex1);
        String[] blocks2 = splitByBlock(hex2);
        for (int i = 0; i < Math.min(blocks1.length, blocks2.length); i++) {
            boolean match = blocks1[i].equals(blocks2[i]);
            System.out.printf("  块 %d: %s\n", i, match
                    ? "🟥 完全相同 → " + blocks1[i].substring(0, 8) + "..."
                    : "🟩 不同");
        }
    }

    static String[] splitByBlock(String hex) {
        int blockLen = 32; // 16 字节 = 32 hex 字符
        int count = hex.length() / blockLen;
        String[] blocks = new String[count];
        for (int i = 0; i < count; i++) {
            blocks[i] = hex.substring(i * blockLen, (i + 1) * blockLen);
        }
        return blocks;
    }
}

运行这段代码,你会看到输出类似这样(实际 hex 值取决于密钥):

密文1: 3a1f8c...(80 个 hex 字符,5 个块)
密文2: 6d7e2b...(80 个 hex 字符,5 个块)

逐块对比:
  块 0: 🟩 不同(对应 "{"user":"Alice" vs "{"user":"Bob"——用户名不同,密文不同)
  块 1: 🟥 完全相同 → d4f16a2b...(对应 ",role":"Admin" vs ",role":"User"——不同?等一下)
  块 2: 🟥 完全相同 → 8c3e91f7...
  块 3: 🟥 完全相同 → a2b4c6d8...
  块 4: 🟥 完全相同 → 7e5f3a1c...

等一下,第 1 块怎么会相同? "Admin""User" 不是不同的字符串吗?

原因是 16 字节边界切割导致的。来手动算一下边界位置:

明文1: {"user":"Alice","role":"Admin",  "dept":"Finance"}
        0         1         2         3          ← 16 字节边界
        0 1234567890123456 7890123456789012
块 0: {"user":"Alice",  → Alice 在块 0,所以与 Bob 不同
块 1: "role":"Admin",   → 块 1 包含 "Admin"
块 2: "dept":"Finance"  → 块 2 包含 "Finance"

明文2: {"user":"Bob","role":"User","  "dept":"Finance"}
块 0: {"user":"Bob","r  → 这里的用户名部分不同
块 1: ole":"User","de   → 块 1 的内容是 "ole":"User","de"
块 2: pt":"Finance"}    → 块 2 的内容是 'pt":"Finance"}'

由于 JSON 的结构导致分块边界错位,"Admin""User","de" 被切割进了不同的块位置。但关键观察是:如果数据中有重复的结构性内容出现在同一偏移位置,它们就会产生相同的密文块

这就是频率分析攻击的切入点。攻击者不需要解密,只需要统计密文中重复块的出现模式,就能推断出明文的很多信息。

一个更直观的例子:假设你在数据库里存储加密的 JSON 用户数据。通过观察密文的块重复模式,攻击者可以:

  1. 确定数据中有重复字段:某些字段值(如性别 "male"/"female"、部门名、角色名)可能只有有限的几种取值
  2. 推断枚举值:比如 "role": "Admin""role": "User" 对应的密文块不同,但每种角色内部所有用户的对应块都相同——攻击者就知道"这里是一个枚举字段"
  3. 块重排攻击:把 A 用户的某个块复制到 B 用户的对应位置,篡改其角色

经典的企鹅图案例:如果你用 ECB 加密一张黑白企鹅图像,加密后的图像仍然能看到企鹅的轮廓。因为图像中大面积的黑色背景被加密后产生相同的密文块,白色肚皮也同样。ECB 没有消除模式,只是把它"隐藏"在了密文里——但模式仍然可见。

3.3 致命缺陷二:块重排攻击(Block Replay)

由于每块独立加密,攻击者可以重新排列密文块的顺序,解密后得到完全不同的语义,而解密端完全检测不到。

来看一个更具体的场景。假设有一条加密的 JSON 记录:

原始明文(单行 JSON):
{"user":"Alice","role":"Admin","dept":"Finance"}

每 16 字节切割:
块 0: {"user":"Alice",   字节 0-15
块 1: "role":"Admin",   字节 16-31
块 2: "dept":"Finance"} 字节 32-47(含 PKCS7 填充)

攻击者找到自己账户的加密记录:

攻击者的加密记录:
{"user":"Eve","role":"User","dept":"Finance"}

块 0: {"user":"Eve",      (与 Alice 的块 0 不同——用户名不同)
块 1: "role":"User","     (与 Alice 的块 1 不同——角色不同)
块 2: "dept":"Finance"}   (与 Alice 的块 2 可能相同——部门相同)

攻击者可以做的事:

第一步:用 Alice 的 块 1 替换自己的 块 1

替换后:
块 0: {"user":"Eve",       ← 自己的,保持不变
块 1: "role":"Admin",     ← Alice 的,替换进来!
块 2: "dept":"Finance"}   ← 自己的

第二步:解密这个篡改后的密文,系统读到的 JSON 变成了:

{"user":"Eve","role":"Admin","dept":"Finance"}

攻击者成功把自己从普通用户提升为管理员。整个过程中攻击者不需要知道密钥,没有任何加密知识,只是复制粘贴了几个字节的二进制数据

这就是 ECB 的块重排攻击。在 CBC 和 GCM 中,由于块之间存在依赖关系,这种简单的块交换会被检测到——但 ECB 的设计让这种攻击天然可行。

3.4 还有没有其他 ECB 的问题?

除了上面两个致命缺陷,ECB 还有:

  1. 信息泄露:通过统计密文块的重复模式,攻击者可以推断出明文的统计特征(比如 JSON 结构中哪些字段值相同)。这在需要语义安全的场景中是完全不可接受的。

  2. 字典攻击:如果攻击者能够选择明文进行加密(比如注册时输入自己的名字),他可以建立一个"明文块 ←→ 密文块"的对照表,然后通过观察未知密文的块来反向查找明文。这在某些场景下是可行的。

3.5 ECB 到底什么时候能用?

答案是:几乎永远不能用。

唯一的争议场景是加密长度小于 16 字节的随机值(比如随机 Token),此时只有一个块,看不出重复模式和块重排问题。但即使如此,用 GCM 也完全没坏处——为什么还要冒风险用 ECB?

作为一个原则:ECB 不应该出现在任何生产代码中。代码审查中见到 ECB,不要妥协,直接打回。


ECB 的问题很明显:相同明文块产生相同密文块,而且没有完整性保护,块重排攻击就能篡改数据。有没有一种模式能至少解决"相同明文→相同密文"这个问题?

答案是肯定的——CBC 模式通过在块之间建立链式依赖,让相同的明文在不同的上下文中产生不同的密文。但这是有代价的,我们来看看。

四、CBC 模式——比 ECB 好,但带来了新问题

4.1 设计思想:引入链式依赖

CBC(Cipher Block Chaining,密码分组链接模式)的设计者显然意识到了 ECB 的问题。他们的解决方案是:让块之间产生依赖关系

具体的做法是:每个明文块在加密之前,先与前一个块的密文做 XOR(异或)运算

加密过程(从第一块到最后一层,串行):
IV  → XOR 第 1 个明文块 → AES 加密 → 第 1 个密文块 →
↑                                                        ↓
第 1 个密文块 → XOR 第 2 个明文块 → AES 加密 → 第 2 个密文块 →
                                                           ↓
第 2 个密文块 → XOR 第 3 个明文块 → AES 加密 → 第 3 个密文块 →
...

解密过程(也是串行,但方向不同):
第 1 个密文块 → AES 解密 → XOR IV → 第 1 个明文块
↓                                                 ↑
第 2 个密文块 → AES 解密 → XOR 第 1 个密文块 → 第 2 个明文块
↓                                                 ↑
第 3 个密文块 → AES 解密 → XOR 第 2 个密文块 → 第 3 个明文块
...

XOR(异或)运算在这里为什么重要?回顾一下它的性质:

如果 A XOR B = C
那么 C XOR B = A(自逆性)
而且 C XOR A = B(对称性)

用比特位看:
0 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 0

在 CBC 解密中,要恢复第 N 块明文,需要:

  1. 解密第 N 块密文(得到中间值,不是明文)
  2. 将中间值与第 N-1 块密文(或 IV)做 XOR
  3. 结果是第 N 块明文

这就是链式依赖的体现——每一块的明文都依赖于前一块的密文。

4.2 IV(初始化向量)

CBC 引入了一个之前 ECB 没有的概念:IV(Initialization Vector,初始化向量)

第一个块在加密前没有"前一个密文块"可用,所以需要一个替代品。这个替代品就是 IV——一个随机生成的 16 字节值。

IV 的规则:

  • 必须随机:每次加密都必须使用不同的 IV
  • 不需要保密:IV 通常和密文一起存储(明文前缀)
  • 必须不被预测:攻击者在加密前不能知道 IV 的值(否则某些攻击利用场景可行)

打个比方:如果 ECB 是用同一个模板给每份文件盖章(每次盖章位置完全一致),CBC 就是每次盖章时都随机微调位置(IV 不同),导致即使文件相同,盖章结果也不同。

4.3 CBC 解决了什么?

解决了 ECB 的"相同明文→相同密文"问题

原因很简单:每个块的密文依赖于前一个块的密文和 IV。如果 IV 是随机的,那么即使两条数据完全相同:

数据 1: {"user":"Alice","role":"Admin","dept":"Finance"}   IV = 0x3a4f...
数据 2: {"user":"Alice","role":"Admin","dept":"Finance"}   IV = 0xb8c2...

因为 IV 不同,第一条密文的第 1 块就不同,
第 1 块不同导致第 2 块的加密输入不同,
第 2 块不同导致第 3 块的加密输入不同……
最终两条密文完全不同。

这就是"链式依赖"的效果——IV 的变化会沿着整条链条传播到每一块密文上。频率分析失效了。

但链式依赖也带来了新问题。

4.4 比特翻转攻击——没有完整性的代价

这是 CBC 最典型的攻击。CBC 只解决了机密性问题(密文不可读),但完全没有提供完整性保护(密文被篡改后无法检测)。

攻击的核心原理

回顾 CBC 的解密公式:

第 1 块明文 = AES⁻¹(第 1 块密文) XOR IV
第 2 块明文 = AES⁻¹(第 2 块密文) XOR 第 1 块密文
第 3 块明文 = AES⁻¹(第 3 块密文) XOR 第 2 块密文

关键发现:修改第 N 块密文只会影响第 N+1 块明文的一个比特,而第 N 块明文会变成随机乱码。

具体来说,如果你修改密文块 C_i 的第 j 个字节,那么:

  • P_i(第 i 块明文):会被 AES 解密成完全随机的内容(因为 C_i 变了,AES⁻¹ 的输出完全变了)
  • P_{i+1}(第 i+1 块明文):第 j 个字节会被翻转,其他字节不变

为什么 P_{i+1} 只影响一个字节?因为 P_{i+1} = AES⁻¹(C_{i+1}) XOR C_iC_i 只参与了 XOR 运算。修改 C_i 的一个字节,XOR 后只影响对应字节。

更可怕的是对 IV 的攻击:对于第一块,P_1 = AES⁻¹(C_1) XOR IV。修改 IV 的第 j 个字节,只会影响 P_1 的第 j 个字节,其他完全不受影响。而且第一块不会变成乱码——因为 IV 没有被 AES 加密,不存在"AES 解密后变随机内容"的问题。所以攻击者可以精确控制解密后的第一个块的任意一个字节

来看一个完整的可运行攻击代码

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

/**
 * CBC 比特翻转攻击演示
 *
 * 场景:攻击者拦截到密文和 IV,虽然不知道密钥,
 *       但通过修改 IV 将解密后的 admin 从 0 改为 1。
 *
 * 攻击目标:将 {"admin":0,"user":"guest"} 中的 0 改为 1
 *          从而获得管理员权限
 */
public class CbcBitFlipAttack {

    public static void main(String[] args) throws Exception {
        // ========== 模拟服务器端加密 ==========
        byte[] key = "0123456789abcdef".getBytes(); // 16 字节密钥
        SecretKeySpec secretKey = new SecretKeySpec(key, "AES");

        // 原始明文
        String plaintext = "{\"admin\":0,\"user\":\"guest\"}";

        // 服务器生成随机 IV
        byte[] iv = new byte[16];
        new java.security.SecureRandom().nextBytes(iv);

        // 加密
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

        System.out.println("=== 原始数据 ===");
        System.out.println("明文:    " + plaintext);
        System.out.println("IV:      " + HexFormat.of().formatHex(iv));
        System.out.println("密文:    " + HexFormat.of().formatHex(ciphertext));

        // ========== 攻击者操作:修改 IV ==========
        // 攻击者不知道密钥,但窃取到了 IV 和密文
        // 目标是让解密后的 admin:0 变成 admin:1
        //
        // 分析明文结构(逐字节):
        // 索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
        // 字符: {  "  a  d  m  i  n  "  :  0  ,  "  u  s  e  r
        //
        // 索引 9 处的字符是 '0',ASCII 0x30
        // 要变成 '1'(ASCII 0x31),需要翻转的比特位:0x30 XOR 0x31 = 0x01
        //
        // 因为解密公式:P1[9] = AES⁻¹(C1)[9] XOR IV[9]
        // 修改 IV[9] = IV[9] XOR 0x01
        // 则 P1[9] 会变成 P1[9] XOR 0x01 → '0' → '1'

        byte[] evilIv = iv.clone();
        int targetPosition = 9;  // 第 10 个字节,'0' 的位置
        evilIv[targetPosition] = (byte) (evilIv[targetPosition] ^ 0x01);

        System.out.println("\n=== 攻击者操作 ===");
        System.out.println("目标: 将索引 " + targetPosition + " 的 '0'(0x30) 改为 '1'(0x31)");
        System.out.println("翻转值: 0x30 XOR 0x31 = 0x01");
        System.out.println("原 IV:    " + HexFormat.of().formatHex(iv));
        System.out.println("攻击 IV:  " + HexFormat.of().formatHex(evilIv));

        // ========== 服务器端用攻击者修改后的 IV 解密 ==========
        Cipher decipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        decipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(evilIv));
        byte[] decrypted = decipher.doFinal(ciphertext);

        String result = new String(decrypted, StandardCharsets.UTF_8);
        System.out.println("\n=== 解密结果 ===");
        System.out.println("篡改后明文: " + result);
        System.out.println("攻击结果: " + (result.contains("\"admin\":1")
                ? "✅ 攻击成功!admin 被从 0 改成了 1"
                : "❌ 攻击失败"));

        // 验证原始密文未被修改
        System.out.println("\n注意:密文本身未被修改,只修改了 IV。");
        System.out.println("服务器端无法检测这种篡改——因为没有完整性校验。");
    }
}

这个攻击的关键点

  1. 不需要知道密钥——攻击者只修改 IV,不碰密文
  2. 精确控制——只改变目标字节,其他字节不受影响
  3. 不可检测——因为没有完整性校验(如 HMAC 或 GCM Tag),解密端不知道数据被篡改过

4.5 CBC 的解密 XOR 传播链——理解 CBC 攻击的关键

为了彻底理解 CBC 的安全性缺陷,我们来看密文修改在不同位置的传播效应:

情况 1:修改 C1 的第 3 个字节
↓
P1 被 AES⁻¹ 解密 → 变成完全随机的内容(16 字节全乱,不是因为 XOR,而是因为 AES 解密输入变了)
P2 = AES⁻¹(C2) XOR C1' → C1' 的第 3 字节变化 → P2 的第 3 字节翻转

所以:修改 C1 的一个字节 → P1 全乱码(1 块)+ P2 一个字节翻转

情况 2:修改 C5 的第 8 个字节
→ P5 全乱码 + P6 的第 8 字节翻转

情况 3:修改 IV 的第 5 个字节
→ P1 的第 5 字节翻转(P1 不变乱,因为 IV 不经过 AES 解密)
→ 其他块完全不受影响

情况 3 是最危险的——攻击者可以在不引发任何乱码的情况下精确修改第一块的任意字节。对于敏感数据(如 "admin":0"amount":10000"role":"user"),攻击者可以完全控制这些值。

这就是为什么安全专家反复强调:只用 CBC 是不够的,必须搭配完整性校验

4.6 Padding Oracle 攻击——CBC 最著名的死穴

这是 CBC 模式最致命的攻击。2002 年被发现,二十多年来仍然不断有系统因此被攻破。它利用了PKCS7 填充校验的错误信息泄露

攻击背景

CBC 解密后,系统需要验证 PKCS7 填充是否合法。填充验证规则很简单:检查最后一个字节的值 N,然后确认最后 N 个字节都是 N。

合法填充示例:
... 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B
  最后 1 个字节是 0x0B,往前 10 个也都是 0x0B → 合法

非法填充示例:
... 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B 05
  最后 1 个字节是 0x05,按理应该往前数 4 个都是 0x05
  但往前第 5 个是 0x0B 不是 0x05 → 非法!填充校验失败

如果填充不合法,系统通常会抛出特定的异常或返回特定的错误码。这个"错误提示的不同"就是攻击者的突破口。

攻击的完整逐步推演

假设攻击者拿到了一组密文 IV || C1 || C2,他想恢复 P2 的明文而不需要知道密钥。

第一步:攻击者构造自己的 IV’(初始为空,逐字节破解),将 IV' || C1 发给服务器

攻击者发送: 伪造的 IV' || 真正的 C1
服务器解密 P' = AES⁻¹(C1) XOR IV'

攻击者不知道 AES⁻¹(C1) 的值,记它为 I1(中间值)。
P' = I1 XOR IV'

第二步:攻击者从最后一个字节开始爆破

攻击者的目标是让 P' 的最后一个字节 = 0x01(PKCS7 的合法填充:补 1 个 0x01)。

他需要找到 IV’[15] 的值,使得 I1[15] XOR IV'[15] = 0x01

由于不知道 I1[15],他需要

尝试 IV'[15] = 0x00 → 服务器解密,检查填充 → 非法(最后一个字节不是 0x01)
尝试 IV'[15] = 0x01 → 服务器解密,检查填充 → 非法
尝试 IV'[15] = 0x02 → 非法
...
...
尝试 IV'[15] = 0x42 → 服务器返回"解密成功"或"业务逻辑错误"(总之不是填充错误)
                        → 说明填充合法!P'[15] = 0x01
                        → I1[15] XOR 0x42 = 0x01
                        → I1[15] = 0x42 XOR 0x01 = 0x43

第三步:破解倒数第二个字节

现在把 P' 的最后两个字节构造为 0x02 0x02(补 2 个 0x02)。

已知 I1[15] = 0x43,要让 P’[15] = 0x02:

IV'[15] = I1[15] XOR 0x02 = 0x43 XOR 0x02 = 0x41

固定 IV’[15] = 0x41,然后爆破 IV’[14]:

尝试 IV'[14] = 0x00 → 填充非法
尝试 IV'[14] = 0x01 → 填充非法
...
尝试 IV'[14] = 0xA7 → 填充合法!→ P'[14] = 0x02
                        → I1[14] XOR 0xA7 = 0x02
                        → I1[14] = 0xA7 XOR 0x02 = 0xA5

以此类推,16 × 256 = 4096 次请求就能恢复 I1 的所有字节。拿到 I1 后:

P1 = I1 XOR IV(真正的 IV)

攻击成功!攻击者完全恢复了第 1 块明文,不需要知道密钥。

这为什么在 Java 中尤其危险

Java 的 Cipher.getInstance("AES/CBC/PKCS5Padding") 解密时:
  - 填充不合法 → 抛出 javax.crypto.BadPaddingException
  - 密钥错误 → 抛出 java.security.InvalidKeyException
  - 其他业务逻辑 → 其他异常

如果全局异常处理器对不同异常返回不同 HTTP 状态码或消息:
  400 Bad Request(填充错误)
  vs 403 Forbidden(业务校验不通过)
  vs 200 OK(成功)

攻击者就能通过观察响应状态码,精确区分"填充错误"和"其他错误",
从而实施上面描述的攻击。

防御方法(按推荐程度排序):

  1. 换用 GCM(最彻底)——GCM 不需要填充,从根本上消除了 Padding Oracle 攻击的可能性
  2. 统一错误消息——无论什么解密错误,都返回统一的"解密失败"消息,不给攻击者区分错误类型的机会。但这实现起来比看起来难——异常可能在不同层级被处理,性能差异也可能被侧信道攻击利用
  3. Encrypt-then-MAC——先加密再对密文做 HMAC,验证 HMAC 再解密。如果 HMAC 不匹配,根本不解密,也就不会暴露填充校验信息

4.7 CBC 还有哪些工程问题?

问题:加密不能并行

每个块的加密都依赖前一个块的密文:

第 1 块: 需要 IV → 不能提前算
第 2 块: 需要第 1 块的密文 → 第 1 块算完前不能开始
第 3 块: 需要第 2 块的密文 → 第 2 块算完前不能开始
...

如果要加密一个 1 GB 的文件,这个串行过程就是明显的瓶颈。解密可以并行(因为所有密文块都已知),但加密不行。

问题:IV 必须是不可预测的

CBC 要求 IV 不可被攻击者预测。如果 IV 可预测(比如是时间戳或递增计数器),攻击者可能发起"选择明文攻击"。这在 SSL/TLS 的 BEAST 攻击(2011 年)中曾被利用。

4.8 如果一定要用 CBC,正确的做法是什么?

如果有遗留系统必须兼容 CBC,绝对不能裸用

// ❌ 错误:裸用 CBC,无完整性校验
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

// ✅ 正确:CBC + HMAC(Encrypt-then-MAC 模式)
// 核心原则:永远先验证完整性,再做解密

// 加密端:
public static String encryptThenMac(String plaintext,
                                     SecretKey encKey,
                                     SecretKey macKey) throws Exception {
    // 1. 生成随机 IV
    byte[] iv = new byte[16];
    new SecureRandom().nextBytes(iv);

    // 2. CBC 加密
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, encKey, new IvParameterSpec(iv));
    byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

    // 3. 对 IV + 密文做 HMAC
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(macKey.getEncoded(), "HmacSHA256"));
    mac.update(iv);
    byte[] macValue = mac.doFinal(ciphertext);

    // 4. 存储: IV + 密文 + MAC
    byte[] result = new byte[iv.length + ciphertext.length + macValue.length];
    System.arraycopy(iv, 0, result, 0, iv.length);
    System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);
    System.arraycopy(macValue, 0, result, iv.length + ciphertext.length, macValue.length);
    return Base64.getEncoder().encodeToString(result);
}

// 解密端:
public static String verifyThenDecrypt(String encryptedData,
                                        SecretKey encKey,
                                        SecretKey macKey) throws Exception {
    byte[] all = Base64.getDecoder().decode(encryptedData);

    // 1. 解析
    byte[] iv = Arrays.copyOfRange(all, 0, 16);
    byte[] ciphertext = Arrays.copyOfRange(all, 16, all.length - 32);
    byte[] expectedMac = Arrays.copyOfRange(all, all.length - 32, all.length);

    // 2. 先验证 HMAC
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(macKey.getEncoded(), "HmacSHA256"));
    mac.update(iv);
    byte[] computedMac = mac.doFinal(ciphertext);
    if (!MessageDigest.isEqual(computedMac, expectedMac)) {
        throw new SecurityException("数据完整性校验失败");
    }

    // 3. HMAC 验证通过后才解密
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, encKey, new IvParameterSpec(iv));
    byte[] decrypted = cipher.doFinal(ciphertext);
    return new String(decrypted, StandardCharsets.UTF_8);
}

重要细节

  1. HMAC 密钥必须和加密密钥不同——如果使用同一个密钥,攻击者可以通过某些密码学分析绕过 HMAC
  2. HMAC 覆盖 IV + 密文——防止攻击者同时修改 IV 和密文
  3. MessageDigest.isEqual() 比较 MAC——防止时序攻击
  4. 验证完 HMAC 才解密——这是一个原则:永远不要解密未被验证完整性的数据

4.9 CBC 小结

方面 评价
机密性 ✅ 解决了 ECB 的重复模式问题
完整性 ❌ 无,需要额外实现 HMAC
加密并行性 ❌ 不能并行
IV 要求 必须随机、不可预测
填充 需要 PKCS7,导致 Padding Oracle 攻击面
工程复杂度 中(需要自行处理 HMAC、IV、填充异常)

CBC 在历史上曾是主流选择,但如今已经有了更好的替代方案——GCM。

回看我们从 ECB 到 CBC 的演进过程,你会发现密码学是一门不断"打补丁"的学问:

  • ECB 有重复模式问题 → CBC 用链式依赖解决
  • 但 CBC 缺失完整性校验 → 引出了比特翻转攻击和 Padding Oracle 攻击
  • 解决方案是额外加 HMAC → 但 HMAC 的密钥管理、顺序问题、实现复杂度都增加了出错概率

这就引出一个问题:能不能设计一种模式,从设计上就一次性提供机密性+完整性,而不是加密和认证分离?

GCM 就是这个答案。它把加密和认证合并为一个原子操作,不需要额外拼凑 HMAC,从根源上避免了 CBC 的问题。


五、GCM 模式——加密 + 认证一体化

5.1 什么是 AEAD?

在介绍 GCM 之前,先理解一个更高层次的概念:AEAD(Authenticated Encryption with Associated Data,认证加密伴随数据)

AEAD 表示这种加密模式同时提供三样东西:

  1. 机密性(Confidentiality):密文不可读,ECB 和 CBC 也提供
  2. 完整性(Integrity):密文被篡改能被检测到,CBC 不提供
  3. 认证附加数据(Authentication of AAD):关联的明文数据也被认证,CBC 不提供

GCM(Galois/Counter Mode,伽罗瓦计数器模式)是 AEAD 模式的一种,也是目前 Java 生态中最广泛使用的 AEAD 模式。

GCM 的数学构成非常简洁:

GCM = CTR(计数器模式加密) + GHASH(Galois 域认证)

CTR 负责加密(提供机密性),GHASH 负责认证(提供完整性)。两者独立但协同工作。

5.2 CTR 模式——GCM 的加密引擎

CTR(CounTeR)模式的核心思想:把块加密转换成流加密

CTR 的做法很有意思:它不是直接加密明文,而是先加密一个计数器,用加密结果(密钥流)与明文 XOR 得到密文。

详细过程

步骤 1:构造初始计数器
Nonce(12 字节) + Counter(4 字节)
例如:A1B2C3D4E5F6A7B8C9D0E1F2 00000001
      ←────── 12 字节 Nonce ──→ ← 4B 计数器 →

步骤 2:生成密钥流(每个计数器值用 AES 加密一次)
AES( Nonce || 0x00000001 ) → K1(16 字节密钥流)
AES( Nonce || 0x00000002 ) → K2(16 字节密钥流)
AES( Nonce || 0x00000003 ) → K3(16 字节密钥流)
...

步骤 3:密钥流与明文逐块 XOR
       P1(16 字节明文)  → XOR K1 → C1(16 字节密文)
       P2(16 字节明文)  → XOR K2 → C2(16 字节密文)
       P3(16 字节明文)  → XOR K3 → C3(16 字节密文)
       ...

如果明文不是 16 的倍数,最后一块只需要生成对应长度的密钥流:
       P_last(10 字节)  → XOR K_last 的前 10 字节 → C_last(10 字节)

CTR 的三个关键特性

特性一:不需要填充

这是 CTR(以及基于它的 GCM)对比 CBC 的巨大优势。

在 CBC 中,明文必须切成完整的 16 字节块,不够就填充。但在 CTR 中,密钥流是按需生成的——你需要加密 10 字节,就生成 16 字节密钥流,只取前 10 字节 XOR。不存在"最后一块不满"的问题,所以不需要填充

没有填充 → 没有 PKCS7 填充校验 → 没有 Padding Oracle 攻击的可能性。这个因果关系是 GCM 在安全性上的重要优势。

特性二:加密和解密是同一个操作

CTR 的加密是:明文 XOR 密钥流 = 密文
CTR 的解密是:密文 XOR 密钥流 = 明文

因为 XOR 是自逆的((A XOR B) XOR B = A),所以加密和解密是完全相同的代码。这减少了实现错误的风险——不像 CBC 那样加密和解密流程完全不同。

特性三:可以并行

CTR 的密钥流生成是完全可并行的:

我可以同时计算:
K1 = AES( Nonce || 0x00000001 )
K2 = AES( Nonce || 0x00000002 )
K3 = AES( Nonce || 0x00000003 )
...

不需要等 K1 算完再算 K2。
在有 AES-NI 指令集的 CPU 上,这种并行可以做到极致。

5.3 GHASH——GCM 的认证组件

GHASH 是 GCM 中用来计算认证标签(Authentication Tag) 的算法。它的数学基础是在 GF(2¹²⁸)(二元伽罗瓦域)上的多项式乘法

不深入数学细节,我们只需要理解 GHASH 以下这些关键特性:

GHASH 计算的输入范围

┌─────────┬────────┬──────────┬─────────────────────┐
│   AAD   │  密文  │  密文    │  len(AAD) || len(密文) │
│ (任意长) │  块 1  │  块 2…  │  (各 8 字节)         │
└─────────┴────────┴──────────┴─────────────────────┘

                        ↓
                 GHASH 多项式乘法
                        ↓
               中间认证值(128 位)
                        ↓
           ⊕ E(K, Nonce || 0x00000000)   ← 用 AES 加密计数器 0
                        ↓
               最终认证标签 Tag(128 位)

关键设计要点:

  1. AAD 也参与 Tag 计算——附加数据不加密,但被认证。如果 AAD 被篡改,Tag 验证失败
  2. 密文参与 Tag 计算——密文本身也被认证。如果密文被篡改,Tag 验证失败
  3. 长度信息参与 Tag 计算——防止长度相关的攻击
  4. 初始计数器值(0)用于加密 Tag——防止攻击者伪造 Tag

解密时的验证流程

1. 接收 IV + 密文 + 预期的 Tag
2. 用相同的 key 和 IV 重新计算 GHASH,得到 computedTag
3. 用 Constant-Time 比较 computedTag 与 expectedTag
   - 如果一致 → 数据未被篡改,输出明文
   - 如果不一致 → 抛出 AEADBadTagException,拒绝输出任何数据

Tag 长度的选择

GCM 支持不同的 Tag 长度:32、64、96、104、112、120、128 位。

Tag 长度 安全性 场景
128 位(16 字节) ✅ 最高 默认推荐
96 位(12 字节) ⚠️ 中等 传输效率敏感的场景
64 位(8 字节) ❌ 低 2³² 次尝试后 50% 碰撞概率
32 位(4 字节) ❌ 极低 几乎无安全性

始终使用 128 位 Tag。Tag 只增加 16 字节的存储开销,相对于安全性的提升来说完全不值一提。

5.4 IV(Nonce)设计的核心原则——以及为什么不能出错

IV 长度

GCM 的 IV 推荐 12 字节(96 位),这是 NIST SP 800-38D 规范推荐的最优长度。

为什么 12 字节?

12 字节 IV:IV 直接作为计数器的高 96 位
            Low 32 位从 0x00000000 开始递增
            不需要额外的哈希处理

非 12 字节 IV:先用 GHASH 将 IV 哈希为 12 字节
              多一步计算,且碰撞性质更复杂
              应尽量避免

IV 绝对不能重复——这是 GCM 唯一的、但极其致命的安全规则

为什么 IV 重复会导致灾难?来看攻击者的视角:

假设同一个密钥下,两条不同的消息 M1M2 用了同一个 IV N

加密 M1:
  C1 = M1 ⊕ KeyStream(N, 1)   // 密钥流块 1
  C2 = M1 ⊕ KeyStream(N, 2)   // 密钥流块 2

加密 M2(同一个 N!):
  C1' = M2 ⊕ KeyStream(N, 1)  // 完全相同的密钥流块 1
  C2' = M2 ⊕ KeyStream(N, 2)  // 完全相同的密钥流块 2

攻击者拿到 C1C1'

C1 ⊕ C1' = (M1 ⊕ KeyStream) ⊕ (M2 ⊕ KeyStream)
         = M1 ⊕ M2            ← 密钥流抵消了!

如果攻击者知道 M1 的一部分(比如时间戳和 JSON 结构),
就能恢复 M2 的对应部分。

更糟的是,如果攻击者已知 M1 的全部内容,
就能直接恢复 M2 的全文。

但 CTR 部分的问题还不是最严重的。来看 GHASH 部分:

GHASH 的最终输出:
  Tag = GHASH_H(AAD || Ciphertext || len) ⊕ E(K, N || 0x00000000)

注意 E(K, N || 0x00000000)——IV 重复意味着这个值完全相同。
攻击者拿到两条消息的 Tag(T1 和 T2):
  T1 ⊕ T2 = GHASH_H(A1 || C1) ⊕ GHASH_H(A2 || C2)
           = 只有 GHASH_H 的差值

GHASH_H 是 GF(2¹²⁸) 上的多项式乘法,密钥是 H = E(K, 0¹²⁸)。
通过 T1 ⊕ T2,攻击者可以解方程算出 H。
有了 H,就能伪造任意数据的 Tag。
完整性保护完全失效——攻击者可以任意篡改密文,自己计算合法 Tag。

用一句话总结 IV 重复的后果机密性和完整性同时被完全攻破。这不是"越狱 iPhone"级别的攻击,而是"直接把 iPhone 密码锁去掉"级别的。

所以 IV 的生成必须使用密码学安全的随机数生成器

// ✅ 正确:SecureRandom 是 CSPRNG(密码学安全伪随机数生成器)
// CSPRNG 的特点是:输出的随机性不可被预测,即使攻击者知道前面所有的输出
SecureRandom secureRandom = new SecureRandom();
byte[] iv = new byte[12];
secureRandom.nextBytes(iv);

// ❌ 错误:java.util.Random(线性同余生成器,可预测)
// 给定前两个输出,可以预测后续所有输出
Random random = new Random();
random.nextBytes(iv);

// ❌ 错误:时间戳作为 IV
// 高并发下同一毫秒的多个请求生成相同 IV
long timestamp = System.currentTimeMillis();
ByteBuffer.wrap(iv).putLong(timestamp); // 危险!
// 如果同一毫秒有两个加密请求 → IV 完全相同 → 灾难

// ❌ 错误:UUID 直接作为 IV
// UUID 是 16 字节,GCM 需要 12 字节。截断后随机性只有 96 位但不好控制
UUID uuid = UUID.randomUUID();
ByteBuffer.wrap(iv).putLong(uuid.getMostSignificantBits()).putInt(...); // 复杂且容易出错
// 而且 UUID 不是 CSPRNG,它用了 SecureRandom 但 API 不保证

5.5 单密钥加密量的上限——容易被忽略的一个工程约束

GCM 有一个重要但很多人不知道的限制:同一个密钥下,加密的数据总量不能超过 64 GB

限制的来源是计数器只有 32 位:

计数器从 1 开始,最大到 2³² - 1
每个计数值产生 16 字节密钥流
因此最大加密量 = (2³² - 1) × 16 ≈ 68.7 GB

NIST 更保守,建议:
  单密钥加密不超过 2³² 个数据项
  每项不超过 2³⁹ - 256 位
  实用上限约 64 GB

在大多数后端业务场景中(加密身份证号、手机号、银行卡号),这个上限远远达不到。但如果你在做一个文件加密系统:

10GB 的文件 × 1 个密钥 → 可以(在 64 GB 内)
100GB 的文件 × 1 个密钥 → 不行(可能触及上限)

解决方案也很简单:

  1. 每个文件使用独立的密钥(推荐——密钥粒度更细也更安全)
  2. 分块加密——每 64 GB 切一块,每块用一个子密钥

5.6 GCM 的 AAD(附加认证数据)——一个强大但易被忽略的能力

AAD(Additional Authenticated Data)是 AEAD 模式独有的特性。它允许你传入"需要认证但不需要加密"的数据。

AAD 能解决什么实际问题?

场景一:防止密文被复制到另一个用户

你在数据库里存储加密的手机号,同时有一个明文的 user_id 字段用于索引。如果攻击者把 A 用户的加密手机号复制粘贴到 B 用户的记录上,AAD 可以阻止这种操作:

加密时:AAD = user_id(明文)
        Tag = GHASH(AAD || 密文)
存储:user_id(明文索引)+ IV + 密文 + Tag

解密时:必须提供同样的 user_id 作为 AAD
        如果 user_id 不匹配,Tag 验证失败
        因为 AAD 参与 Tag 计算

场景二:API 请求的完整性保护

API 返回加密的响应体,同时有明文的响应头(如 timestampnonce)。把这些响应头作为 AAD 传入 GCM:

加密响应体时:AAD = timestamp + nonce
解密时:校验 timestamp 和 nonce 是否被篡改

攻击者不能替换响应的 timestamp 来发起重放攻击,
因为 timestamp 虽然不加密,但有 GCM Tag 保护完整性。

场景三:多字段关联

加密多个关联字段,但只允许一起读取:

加密身份证号时,把姓名和出生日期作为 AAD
这样解密时必须提供匹配的姓名和出生日期
防止攻击者只拿到身份证号而不匹配其他信息

关于 AAD 的代码实现会在后面 Java 实战部分详细展示。

5.7 GCM 与 ChaCha20-Poly1305 选型对比

GCM 不是唯一的 AEAD 方案。Google 提出的 ChaCha20-Poly1305 是另一个广泛使用的选择:

对比维度 AES-256-GCM ChaCha20-Poly1305
加密算法 AES(块加密,1960 年代设计) ChaCha20(流加密,2008 年设计)
认证算法 GHASH(Galois 域乘法) Poly1305(多项式 MAC)
硬件加速 ✅ AES-NI 指令集(x86/64) ARM NEON 加速
AES-NI 性能 极快(~1GB/s/核心) 略慢于 GCM
无硬件加速时 较慢(~50MB/s) 较快(始终 ~300MB/s)
安全强度 经过 20+ 年验证 经过 10+ 年验证
标准化 NIST、FIPS 140 RFC 8439、非 FIPS
IV 敏感性 极高(重复即 GHASH 密钥泄露) 高(但不泄露认证密钥)
短数据(<128B) 需要额外计算 Tag 略快
移动端 一般(除非有硬件加速) 优秀

选择建议

后端服务器(x86,有 AES-NI)→ AES-256-GCM
移动端 / 嵌入式设备        → ChaCha20-Poly1305
FIPS 合规要求            → AES-256-GCM(唯一选择)
无 AES-NI 的云环境        → ChaCha20-Poly1305

在 Spring Boot 后端场景下(x86 架构,AES-NI 默认启用),AES-256-GCM 是当之无愧的推荐选择。


前面三节我们从原理层面分析了 ECB、CBC、GCM 的差异。知道 ECB 为什么不能用、CBC 有什么坑、GCM 好在哪里——这些是"道"的层面。但一个加密方案如果不能落到代码上,知道再多也只是纸上谈兵。

接下来进入"术"的层面:用 Java 代码完整实现 AES-256-GCM。我们会从一个基础工具类开始,然后逐步加入 AAD 支持、多版本密钥管理、线程安全处理,最终形成一个可以直接用于生产环境的方案。

六、Java 实战:从基础到生产级

6.1 基础版 GCM 工具类

下面这个工具类包含了 GCM 加密的完整实现,每一行代码都有注释解释为什么这么做。

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;

/**
 * AES-256-GCM 加密工具(基础版)
 *
 * <p>使用 GCM 认证加密模式,一次操作同时保证机密性和完整性。
 * 存储格式为 IV(12字节) + 密文(含16字节Tag),hex编码后存储。
 *
 * <p>线程安全:每次加密/解密都创建新的 Cipher 实例,不共享状态。
 */
public class AesGcmUtil {

    // 算法规范:AES/GCM/NoPadding
    // GCM 的 NoPadding 是因为基于 CTR 模式,天然不需要填充
    private static final String ALGORITHM = "AES/GCM/NoPadding";

    // IV 长度:12 字节(96 位),NIST 推荐
    // 12 字节是最优长度——IV 直接作为计数器的高 96 位
    private static final int GCM_IV_LENGTH = 12;

    // Tag 长度:128 位(16 字节)
    // 提供最好的防伪造安全性
    private static final int GCM_TAG_LENGTH = 128;

    // 密钥长度:256 位
    private static final int AES_KEY_SIZE = 256;

    // SecureRandom 实例复用——它是线程安全的
    // 提前初始化避免第一次调用时的熵收集延迟
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    // 静态初始化:预热 SecureRandom,避免首次调用阻塞
    static {
        SECURE_RANDOM.nextBytes(new byte[1]);
    }

    /**
     * 生成 AES-256 密钥
     */
    public static SecretKey generateKey() throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(AES_KEY_SIZE, SECURE_RANDOM);
        return keyGen.generateKey();
    }

    /**
     * 加密:生成随机 IV → GCM 加密 → 返回 IV+密文+Tag 的 hex 字符串
     *
     * <p>存储格式设计说明(为什么 IV 前缀存储而非单独字段):
     * 1. 自包含——每个密文字符串包含解密所需的全部信息(除密钥外)
     * 2. 可迁移——复制数据时不会忘记 IV
     * 3. 原子性——IV 和密文不会因同步问题分离
     */
    public static String encrypt(String plaintext, SecretKey key) throws Exception {
        // 1. 生成随机 12 字节 IV
        // 每次加密必须使用不同的 IV——GCM 的安全核心
        byte[] iv = new byte[GCM_IV_LENGTH];
        SECURE_RANDOM.nextBytes(iv);

        // 2. 初始化 Cipher
        // GCMParameterSpec 的两个参数:tagLength 和 IV
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
        cipher.init(Cipher.ENCRYPT_MODE, key, spec);

        // 3. 加密
        // doFinal 输出 = 密文 + 16 字节 Tag(GCM 自动附加在末尾)
        byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

        // 4. 输出 hex 编码
        // 解密时:前 24 个 hex 字符 = 12 字节 IV
        //        后面的 hex 字符 = 密文 + Tag
        return HexFormat.of().formatHex(iv) + HexFormat.of().formatHex(encrypted);
    }

    /**
     * 解密:解析 IV → GCM 解密 → Tag 验证 → 返回明文
     *
     * <p>如果数据被篡改,doFinal 会抛出 javax.crypto.AEADBadTagException
     */
    public static String decrypt(String encryptedData, SecretKey key) throws Exception {
        // 1. 从 hex 还原完整的字节数组
        byte[] all = HexFormat.of().parseHex(encryptedData);

        // 2. 提取 IV(前 12 字节)和密文+Tag(后面部分)
        ByteBuffer buf = ByteBuffer.wrap(all);
        byte[] iv = new byte[GCM_IV_LENGTH];
        buf.get(iv);
        byte[] ciphertext = new byte[buf.remaining()];
        buf.get(ciphertext);

        // 3. 初始化 Cipher
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));

        // 4. 解密
        // 如果 Tag 验证失败,这里抛 AEADBadTagException
        // 注意:Tag 验证在 doFinal 内部自动完成
        byte[] decrypted = cipher.doFinal(ciphertext);
        return new String(decrypted, StandardCharsets.UTF_8);
    }

    /**
     * 从字节数组构造 SecretKey(用于从 KMS 或配置恢复密钥)
     */
    public static SecretKey bytesToKey(byte[] keyBytes) {
        if (keyBytes.length != 32) {
            throw new IllegalArgumentException("AES-256 需要 32 字节密钥,实际: " + keyBytes.length);
        }
        return new SecretKeySpec(keyBytes, "AES");
    }

    /**
     * 从 hex 字符串构造 SecretKey
     */
    public static SecretKey hexToKey(String keyHex) {
        return bytesToKey(HexFormat.of().parseHex(keyHex));
    }
}

逐行使用示例

public class UsageDemo {
    public static void main(String[] args) throws Exception {
        // ========== 1. 密钥生成 ==========
        // 首次运行时生成密钥,保存到安全的密钥管理系统(KMS)
        // 后续运行从 KMS 加载密钥,不再重新生成
        SecretKey key = AesGcmUtil.generateKey();

        // ========== 2. 加密 ==========
        // 加密敏感数据
        String idCard = "110101199001011234";
        String encrypted = AesGcmUtil.encrypt(idCard, key);

        System.out.println("加密前: " + idCard);
        System.out.println("加密后: " + encrypted);
        // 输出类似: 4a8f1c2be3d5...(68 个 hex 字符)
        // 其中前 24 个字符是 IV(12 字节),后面是密文+Tag
        // 注意:每次运行结果不同(因为 IV 随机)

        // 输出长度分析:
        System.out.println("  总长: " + encrypted.length() + " hex 字符");
        System.out.println("  IV:   24 hex 字符(12 字节)");
        System.out.println("  密文: " + (encrypted.length() - 24) + " hex 字符(含 32 hex 字符 Tag)");

        // ========== 3. 解密 ==========
        String decrypted = AesGcmUtil.decrypt(encrypted, key);
        System.out.println("解密后: " + decrypted);
        System.out.println("一致性: " + idCard.equals(decrypted));

        // ========== 4. 验证篡改检测 ==========
        // 模拟攻击:篡改密文的第 5 个 hex 字符
        String tampered = encrypted.substring(0, 5) + "f" + encrypted.substring(6);
        try {
            AesGcmUtil.decrypt(tampered, key);
            System.out.println("❌ 篡改检测失败!");
        } catch (javax.crypto.AEADBadTagException e) {
            System.out.println("✅ 篡改检测成功——密文被篡改,拒绝解密");
        }
    }
}

6.2 完整示例:带 AAD 的 GCM

AAD 是 GCM 对比 CBC 的核心优势之一。来看一个完整的应用场景。

业务需求:在数据库中加密存储用户的手机号。同时有一个明文的 user_id 字段用于查询和关联。要防止攻击者把 A 用户的手机号密文粘贴到 B 用户的数据库记录上——如果发生这种攻击,解密时应该能检测到。

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;

/**
 * AAD 示例:将密文与用户 ID 绑定,防止密文被复制到其他用户
 *
 * <p>AAD 的工作原理:
 * - AAD 参与 GCM Tag 的计算,但不被加密
 * - 相同的密文 + 不同的 AAD → Tag 验证失败
 * - 所以即使攻击者把密文复制到另一个用户,解密也会因为 AAD 不匹配而失败
 */
public class AadExample {

    public static final int GCM_IV_LENGTH = 12;

    /**
     * 加密手机号
     *
     * @param phone  明文手机号
     * @param userId 用户 ID,用作 AAD——不加密但参与认证
     * @param key    AES-256 密钥
     * @return hex 编码的 IV + 密文 + Tag
     */
    public static String encryptPhone(String phone, long userId, SecretKey key) throws Exception {
        // 生成随机 IV
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        // 初始化 Cipher
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));

        // 注入 AAD:将 userId 作为 AAD
        // updateAAD 必须在 doFinal 之前调用
        // AAD 不参与加密,只参与 Tag 计算
        cipher.updateAAD(longToBytes(userId));

        // 加密(AAD 已注册,会自动纳入 Tag 计算范围)
        byte[] encrypted = cipher.doFinal(phone.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(iv) + HexFormat.of().formatHex(encrypted);
    }

    /**
     * 解密手机号
     *
     * @param encryptedData hex 编码的密文
     * @param userId        用户 ID,解密时必须提供与加密时相同的 AAD
     * @param key           AES-256 密钥
     * @return 解密后的明文手机号,如果 userId 不匹配则抛 AEADBadTagException
     */
    public static String decryptPhone(String encryptedData, long userId, SecretKey key) throws Exception {
        byte[] all = HexFormat.of().parseHex(encryptedData);

        // 解析 IV 和密文
        ByteBuffer buf = ByteBuffer.wrap(all);
        byte[] iv = new byte[GCM_IV_LENGTH];
        buf.get(iv);
        byte[] ciphertext = new byte[buf.remaining()];
        buf.get(ciphertext);

        // 初始化解密
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));

        // 注入同样的 AAD
        // 如果 userId 与加密时不同,Tag 验证会失败
        cipher.updateAAD(longToBytes(userId));

        // 解密(内部验证 Tag)
        byte[] decrypted = cipher.doFinal(ciphertext);
        return new String(decrypted, StandardCharsets.UTF_8);
    }

    /**
     * 将 long 类型的 ID 转为 8 字节的字节数组
     */
    private static byte[] longToBytes(long value) {
        ByteBuffer buf = ByteBuffer.allocate(8);
        buf.putLong(value);
        return buf.array();
    }

    public static void main(String[] args) throws Exception {
        // 生成密钥
        SecretKey key = AesGcmUtil.generateKey();

        // 用户 A: 加密手机号,userId=1001
        String phoneA = "13800138000";
        long userIdA = 1001L;
        String encryptedA = encryptPhone(phoneA, userIdA, key);

        System.out.println("=== 正常解密 ===");
        String decryptedA = decryptPhone(encryptedA, userIdA, key);
        System.out.println("用户 1001 解密: " + decryptedA + " ✅");

        System.out.println("\n=== 攻击模拟:替换密文到其他用户 ===");
        try {
            // 攻击者把 A 的密文复制到用户 B(userId=1002)的记录上
            decryptPhone(encryptedA, 1002L, key);
            System.out.println("❌ 攻击成功!密文被跨用户使用了!");
        } catch (javax.crypto.AEADBadTagException e) {
            System.out.println("✅ 攻击拦截!userId=1001 的密文不能被 userId=1002 解密");
            System.out.println("   原因:AAD 不同,Tag 验证失败");
        }

        System.out.println("\n=== 其他用户不受影响 ===");
        // 注意:用户 B 自己的加密数据不受影响
        String phoneB = "13900139000";
        String encryptedB = encryptPhone(phoneB, 1002L, key);
        String decryptedB = decryptPhone(encryptedB, 1002L, key);
        System.out.println("用户 1002 自己的解密: " + decryptedB + " ✅");
    }
}

运行结果:

=== 正常解密 ===
用户 1001 解密: 13800138000 ✅

=== 攻击模拟:替换密文到其他用户 ===
✅ 攻击拦截!userId=1001 的密文不能被 userId=1002 解密
   原因:AAD 不同,Tag 验证失败

=== 其他用户不受影响 ===
用户 1002 自己的解密: 13900139000 ✅

这个模式在数据库加密、多租户系统、API 响应加密中非常实用。

6.3 AAD 还能用在哪些场景?

除了上面用户绑定的场景,AAD 还有非常广泛的应用:

场景一:API 响应的防篡改

// 加密 API 响应体时,把响应头的时间戳作为 AAD
long timestamp = System.currentTimeMillis();
String body = "{\"orders\":[...]}";

cipher.updateAAD(longToBytes(timestamp));
byte[] encrypted = cipher.doFinal(body.getBytes(StandardCharsets.UTF_8));

// 客户端解密时:
// 1. 检查 timestamp 是否在合理范围内(防重放)
// 2. 用 timestamp 作为 AAD 解密
// 如果 timestamp 被篡改,Tag 验证失败

场景二:数据库行级安全

// 加密某些字段时,把主键(row_id)作为 AAD
// 这样密文就和数据库中的特定行绑定了
// 即使攻击者拿到数据库导出,把某行的密文复制到另一行,解密也会失败

场景三:复合主键绑定

// 如果有复合主键(tenant_id, user_id),把两者都作为 AAD
byte[] aad = ByteBuffer.allocate(16)
    .putLong(tenantId)
    .putLong(userId)
    .array();
cipher.updateAAD(aad);

6.4 生产级方案:多版本密钥 + 密钥轮换

在实际生产系统中,密钥不可能一成不变。常见的需求:

  • 定期轮换密钥(如每 90 天)
  • 密钥泄露时紧急轮换
  • 支持新旧密钥并存(旧数据用旧密钥解密,新数据用新密钥加密)

下面是一个支持多版本密钥的管理器:

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 多版本密钥管理器
 *
 * <p>存储格式:1字节版本号 + 12字节IV + 密文(含Tag)
 * 版本号在开头,解密时先读取版本号,找到对应密钥再解密。
 *
 * <p>密钥轮换流程:
 * 1. 新数据用当前最新密钥加密
 * 2. 旧数据在读取时解密,并用新密钥重新加密(逐步迁移)
 * 3. 废弃的旧密钥最终可以删除
 */
public class KeyRotationManager {

    private static final int KEY_VERSION_SIZE = 1;  // 版本号占用 1 字节
    private static final int GCM_IV_LENGTH = 12;

    // 版本号 → 密钥 的映射
    // 实际生产中从 KMS 加载,而非硬编码
    private final Map<Byte, SecretKey> keyMap = new ConcurrentHashMap<>();
    private final byte currentVersion;

    public KeyRotationManager(Map<Byte, SecretKey> initialKeys, byte currentVersion) {
        this.keyMap.putAll(initialKeys);
        this.currentVersion = currentVersion;
    }

    public void addKey(byte version, SecretKey key) {
        keyMap.put(version, key);
    }

    /**
     * 加密:用当前最新密钥
     */
    public String encrypt(String plaintext) throws Exception {
        return encryptWithVersion(plaintext, currentVersion);
    }

    /**
     * 用指定版本密钥加密
     */
    private String encryptWithVersion(String plaintext, byte version) throws Exception {
        SecretKey key = keyMap.get(version);
        if (key == null) {
            throw new IllegalArgumentException("密钥版本不存在: " + version);
        }

        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));

        byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

        // 存储格式:[版本号(1B)] [IV(12B)] [密文+Tag]
        int totalLen = KEY_VERSION_SIZE + iv.length + encrypted.length;
        byte[] result = new byte[totalLen];
        result[0] = version;                          // 版本号
        System.arraycopy(iv, 0, result, 1, iv.length); // IV
        System.arraycopy(encrypted, 0, result, 1 + iv.length, encrypted.length); // 密文

        return HexFormat.of().formatHex(result);
    }

    /**
     * 解密:根据版本号自动选择对应密钥
     */
    public String decrypt(String encryptedHex) throws Exception {
        byte[] all = HexFormat.of().parseHex(encryptedHex);
        ByteBuffer buf = ByteBuffer.wrap(all);

        // 1. 读取版本号
        byte version = buf.get();

        // 2. 根据版本号选择密钥
        SecretKey key = keyMap.get(version);
        if (key == null) {
            throw new IllegalArgumentException("无法处理密钥版本: " + version
                    + "(可能是已废弃的密钥)");
        }

        // 3. 提取 IV
        byte[] iv = new byte[GCM_IV_LENGTH];
        buf.get(iv);

        // 4. 提取密文
        byte[] ciphertext = new byte[buf.remaining()];
        buf.get(ciphertext);

        // 5. 解密
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
        byte[] decrypted = cipher.doFinal(ciphertext);

        return new String(decrypted, StandardCharsets.UTF_8);
    }

    /**
     * 密钥轮换:用旧密钥解密,用新密钥重新加密
     * 通常在数据读取时触发,逐步迁移
     */
    public String reEncrypt(String oldEncrypted) throws Exception {
        String plaintext = decrypt(oldEncrypted);
        return encrypt(plaintext);
    }
}

关于 PBKDF2 密钥派生

如果密钥的来源是一个密码(用户输入的密码),而不是 32 字节的随机密钥,不能直接用它做 AES 密钥。原因:

用户输入的 "MyP@ssw0rd!":
- 字符不是均匀分布的(字母数字+特殊字符)
- 熵远低于 256 位
- 存在字典攻击的风险

直接作为 AES 密钥:
- 密钥不是均匀随机的 → 可能降低 AES 的安全性
- 密钥空间没有充分利用

正确做法:PBKDF2 + 盐 + 迭代
PBKDF2(密码, 盐, 迭代次数=100000, 输出长度=256位)

迭代次数的作用:
- 10 万次迭代于认证请求来说增加 0.1 秒开销
- 但对于攻击者来说,尝试 10 亿个密码需要 10 万秒(约 28 小时)
- 这就是"计算不对称性"——让合法用户慢一点,让攻击者慢几十万倍
/**
 * 从密码派生出 AES-256 密钥
 *
 * @param password 用户密码
 * @param salt     随机盐值(每次派生使用不同盐,与密文一起存储)
 * @param iterations 迭代次数(推荐 100000 以上,NIST 最小建议 10000)
 */
public static SecretKey deriveKey(String password, byte[] salt, int iterations) throws Exception {
    PBEKeySpec spec = new PBEKeySpec(
            password.toCharArray(),
            salt,
            iterations,
            256 // 输出 256 位密钥
    );
    SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    byte[] keyBytes = factory.generateSecret(spec).getEncoded();
    return new SecretKeySpec(keyBytes, "AES");
}

6.5 线程安全性分析

javax.crypto.Cipher 实例不是线程安全的。同一份 Cipher 实例被多个线程同时使用会导致不可预期的结果。

三种线程安全方案:

// 方案一:每次加密创建新实例(推荐)
// 优点:简单,不存在并发问题
// 缺点:创建 Cipher 实例有微小开销
// 适用:大多数后端场景
public String encrypt(String plaintext, SecretKey key) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
    return HexFormat.of().formatHex(cipher.doFinal(plaintext.getBytes()));
}

// 方案二:ThreadLocal 缓存
// 优点:避免重复创建
// 缺点:GCM 模式下 Cipher 每次 init 都需要重新设置 IV
// 适用:高并发且需要极致性能的场景
private static final ThreadLocal<Cipher> CIPHER_CACHE =
        ThreadLocal.withInitial(() -> {
            try {
                return Cipher.getInstance("AES/GCM/NoPadding");
            } catch (Exception e) {
                throw new RuntimeException("初始化 Cipher 失败", e);
            }
        });

// 方案三:对象池(Apache Commons Pool2)
// 适用:极高并发,Cipher 创建成本成为瓶颈时

对于大多数场景,方案一已经足够了。每次创建 Cipher 的耗时在微秒级别,相对于加密操作本身(毫秒级别)来说可以忽略。


七、存储格式与密钥管理

7.1 IV 前缀存储 vs 数据库单独字段

方案 优点 缺点
IV 前缀存储(推荐) 自包含、可迁移、无需额外字段、原子性 需要自行解析
数据库单独 IV 字段 结构清晰、查询方便 迁移问题、额外字段

推荐 IV 前缀存储,存储格式:

hex 编码:   [24 hex = IV] [剩余 hex = 密文 + Tag]
二进制编码: [12 字节 IV] [密文] [16 字节 Tag]
数据库列:   VARCHAR 或 TEXT

7.2 明文与密文的长度关系

原始明文: N 字节
GCM 加密后: N + 16(Tag)字节
加 IV 前缀: 12 + N + 16 = N + 28 字节
hex 编码后: 2 × (N + 28) hex 字符

示例:
身份证号(18 字符,UTF-8 编码后也是 18 字节):
  加密后: 18 + 16 = 34 字节
  加 IV: 12 + 34 = 46 字节
  hex 编码: 92 字符 → 数据库中 VARCHAR(128) 足够

7.3 密钥管理的五个等级

L0: 硬编码在代码里        → 严重违规,密钥随代码泄露
L1: application.yml       → 开发环境可用,不提交 Git
L2: 环境变量               → 测试环境,比配置文件略好
L3: 配置中心加密存储        → 预发环境,有权限控制
L4: KMS(密钥管理服务)    → 生产环境,密钥不离开 KMS,自动轮换

L4(KMS)为什么重要

没有 KMS 时:
  密钥在 application.yml 中 → 运维能看到 → 能泄露
  密钥轮换需要改配置重启 → 容易出错,不敢频繁换

有 KMS 时:
  应用程序向 KMS 请求加密/解密 → 密钥从不进入应用内存
  密钥自动轮换 → 应用无感知
  每次 KMS 调用有审计日志 → 谁在何时用了密钥都可追溯
  KMS 有权限控制 → 不同环境、不同应用使用不同密钥

八、常见陷阱

陷阱 1:Cipher.getInstance 用默认算法

// ❌ 错误:JDK 不同版本默认值不同
Cipher cipher = Cipher.getInstance("AES");
// Oracle JDK 默认: AES/ECB/PKCS5Padding(惨了!)
// IBM JDK 默认: 可能不同
// 升级 JDK 后默认值可能变 → 隐秘的行为变化

// ✅ 正确:显式指定完整算法
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");

陷阱 2:IV 生成用错了随机数生成器

// ❌ 错误:java.util.Random 可预测
Random random = new Random();
random.nextBytes(iv);

// ❌ 错误:时间戳,并发下重复
long now = System.currentTimeMillis();
ByteBuffer.wrap(iv).putLong(now); // 同一毫秒两个请求 = IV 相同

// ❌ 错误:Math.random(),只有 48 位精度,不够
iv[0] = (byte)(Math.random() * 256); // 远远不够 12 字节

// ✅ 正确:SecureRandom(CSPRNG)
SecureRandom sr = new SecureRandom();
sr.nextBytes(iv);

陷阱 3:密钥硬编码

// ❌ 错误:密钥写在代码里
private static final String SECRET_KEY = "my-secret-key-123456";

// ❌ 错误:密钥在配置里但不加密
// application.yml: encrypt.key=my-secret-key-123456

// ✅ 正确:密钥从环境变量或 KMS 加载
String keyBase64 = System.getenv("ENCRYPTION_KEY_BASE64");

陷阱 4:解密异常被一概吞掉

// ❌ 错误:一个 catch 吃掉所有异常
try {
    return decrypt(data, key);
} catch (Exception e) {
    log.error("解密失败", e);
    return null;
}
// 上层得到 null,不知道是因为密钥错误、数据损坏还是被篡改
// 如果是被篡改,应该告警而不是静默返回 null

// ✅ 正确:区分异常类型
try {
    return decrypt(data, key);
} catch (javax.crypto.AEADBadTagException e) {
    // 数据被篡改——需要告警
    log.warn("数据完整性校验失败,可能被篡改: {}", recordId);
    throw new SecurityException("数据异常");
} catch (java.security.InvalidKeyException e) {
    // 密钥问题——需要紧急处理
    log.error("解密密钥无效,请检查密钥版本");
    throw new RuntimeException("系统密钥异常");
} catch (Exception e) {
    // 其他异常
    log.error("解密过程异常", e);
    throw e;
}

陷阱 5:日志中输出密文

// ❌ 错误:日志泄露密文
log.info("加密后的身份证号: {}", encryptedIdCard);
// 日志文件被拖走 = 数据泄露
// 密文只是"暂时安全",日志里记录密文,以后可以从日志恢复明文

// ❌ 错误:日志记录明文(更离谱)
log.info("身份证号: {}", idCard);

// ✅ 正确:日志只记录元数据
log.info("身份证号已加密存储, userId={}", userId);

陷阱 6:Cipher 实例被多线程共享

// ❌ 错误:静态 Cipher 实例被多个线程同时使用
private static final Cipher SHARED_CIPHER = Cipher.getInstance("AES/GCM/NoPadding");
// 并发加密时两个线程同时调用 init + doFinal → 数据错乱

// ✅ 正确:每次加密创建新实例,或 ThreadLocal

陷阱 7:GCM 在 JDK 8-17 中 profiling 时的性能回退

这个问题比较隐蔽。GCM 在 JDK 8-17 中的实现使用 com.sun.crypto.provider.GaloisCounterMode,其中加解密操作有 native 实现(利用 AES-NI 指令集)。

当使用 -XX:+UnlockDiagnosticVMOptions 进行 CPU profiling 时,某些 JVM 会回退到纯 Java 实现,加密性能从 ~1 GB/s 骤降到 ~100 MB/s。

如果你的应用在 profiling 期间加密性能异常,检查是否误加了 profiling 参数。

JDK 18+ 对这个问题的处理有所改善(引入 GHASHAES 的 intrinsic)。


九、性能数据

测试环境:Intel Xeon Platinum 8375C @ 3.0 GHz,JDK 21,AES-NI 启用

操作 数据量 平均耗时 吞吐量
AES-256-GCM 加密 16 字节(1 块) ~500 ns -
AES-256-GCM 加密 1 KB ~2 μs ~500 MB/s
AES-256-GCM 加密 1 MB ~2 ms ~500 MB/s
AES-256-GCM 解密 1 KB ~2 μs ~500 MB/s
AES-256-CBC 加密(PKCS7) 1 MB ~2.5 ms ~400 MB/s
密钥生成(AES-256) - ~0.5 ms -

结论

  1. 有 AES-NI 时,GCM 比 CBC 略快(不需要填充处理)
  2. 加解密不是后端业务的性能瓶颈——网络 IO(一次 API 调用 ~50ms)和数据库 IO 是
  3. 1 万次 1KB 数据的加密 ≈ 20ms,对大多数接口来说可忽略

十、总结

一句话回答开头的问题

选 AES-256-GCM。

三者对比

维度 ECB CBC GCM
相同明文→相同密文 ✅ 是(致命) ❌ 否(有 IV) ❌ 否(有 IV)
完整性校验 ❌ 无 ❌ 无 ✅ 内置 Tag
需要填充 ✅ PKCS7 ✅ PKCS7 ❌ 不需要
Padding Oracle 攻击 - ⚠️ 高风险 ✅ 天然免疫
比特翻转攻击 ✅(块级) ✅(字节级) ❌ Tag 防护
并行加密
IV 要求 不需要 随机 16 字节 随机 12 字节
AAD 支持
工程复杂度 高(需 HMAC)

三个核心原则

  1. 不要自己设计密码方案——使用标准化的、经过广泛审核的算法和模式
  2. 不要裸用加密——机密性和完整性必须同时保证。ECB 和 CBC 单独使用时都不能保证完整性
  3. 密钥管理比算法选择更重要——AES-256-GCM 再好,密钥硬编码在代码里或者用 java.util.Random 生成 IV,也是白搭

选型速查

场景 推荐方案
后端敏感数据存储 AES-256-GCM + KMS
API 响应加密 AES-256-GCM
移动端 / IoT(无 AES-NI) ChaCha20-Poly1305
FIPS 合规 AES-256-GCM
老旧系统兼容 CBC + HMAC(Encrypt-then-MAC)

扩展阅读


下一篇预告:API 签名防重放——HMAC-SHA256 + Nonce + Timestamp 的完整实现

欢迎在评论区分享你踩过的加密坑。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐