做过后端开发的应该都跟 AES 打过交道。数据加密、接口签名、配置脱敏——AES 几乎无处不在。但说实话,很长一段时间我对 AES 的理解只停留在"调用 OpenSSL 函数"的层面。直到有次我需要在一个实现 AES 加密,没有 OpenSSL,没有现成的库,只能自己手写。那个过程让我把 AES 的每个步骤都摸了一遍。
分组加密的基本逻辑
AES 是分组密码。它把明文分成固定大小的块,每个块独立加密。AES 的块大小固定是 128 位(16 字节),不管密钥是 128、192 还是 256 位。
这里有个容易混淆的地方:AES-128、AES-192、AES-256 的区别不是块大小——块永远 128 位——而是密钥长度和轮数。
| 变体 | 密钥长度 | 轮数 |
|---|---|---|
| AES-128 | 128 位 | 10 轮 |
| AES-192 | 192 位 | 12 轮 |
| AES-256 | 256 位 | 14 轮 |
轮数越多,安全强度越高,计算也越慢。大部分场景 AES-256 是默认选择,虽然有人说 AES-128 在量子计算面前也够用——那是另一个话题了。
AES 的四步内部运算
AES 每一轮包含四个步骤:字节代换(SubBytes)、行移位(ShiftRows)、列混合(MixColumns)和轮密钥加(AddRoundKey)。最后一轮少一步列混合。
先说字节代换。这一步叫 SubBytes,本质是一个查表操作。AES 定义了一个 16×16 的 S 盒(S-Box),把输入的每个字节映射到输出。比如 0x00 映射到 0x63,0x01 映射到 0x7c。设计这个 S 盒的数学基础是有限域 GF(2^8) 上的乘法逆元,加上一个仿射变换。
我做测试的时候,把一张全零的图片加密过——理论上加密后应该看起来是随机噪声。SubBytes 这一步就在做"打乱"。S 盒的设计保证了一个极端特性:输入改变 1 位,输出至少有 2 位变化。加上后面的步骤,最终实现雪崩效应。
然后是行移位(ShiftRows)。把 16 字节的状态矩阵排成 4×4:
a0 a4 a8 a12
a1 a5 a9 a13
a2 a6 a10 a14
a3 a7 a11 a15
ShiftRows 做的事:第一行不移,第二行循环左移 1 个字节,第三行左移 2 个,第四行左移 3 个。结果是这样:
a0 a4 a8 a12
a5 a9 a13 a1
a10 a14 a2 a6
a15 a3 a7 a11
这个步骤看起来简单,但它的作用是让列之间的数据产生交叉。如果没有 ShiftRows,每一列的数据永远只跟自己列交互,加密强度会大打折扣。
第三步是列混合(MixColumns)。这是最复杂的一步。它对每一列的 4 个字节做矩阵乘法,用的也是 GF(2^8) 上的运算。具体的矩阵是:
02 03 01 01
01 02 03 01
01 01 02 03
03 01 01 02
每一列乘这个矩阵,结果替换原来的列。MixColumns 的复杂度保证了"扩散"——改变一个字节,整列都会受影响。加上 ShiftRows 的行列交换,最终一轮下来,任何输入的微小变化都能扩散到整个块。
最后一步是轮密钥加(AddRoundKey)。就是把状态矩阵和当前轮的轮密钥做 XOR。轮密钥是从主密钥通过密钥扩展算法生成的。AES-256 需要 15 个轮密钥(初始密钥 + 14 轮各一个),每个 128 位。
密钥扩展有自己的逻辑:对于 AES-256,每轮生成 256 位密钥材料,包含 RotWord、SubWord 和轮常数 XOR 等操作。这个过程确保了即使两个密钥只有 1 位不同,扩展出来的所有轮密钥都完全不同。
四步运算合起来就是一个完整的轮。AES-256 重复 14 轮,加上开头的一次 AddRoundKey(白化步骤),总共 15 次 AddRoundKey、14 次 SubBytes、14 次 ShiftRows、14 次 MixColumns。
分组模式
AES 本身只定义了怎么加密一个 128 位的块。但实际要加密的数据远远超过 128 位,而且多个块之间的关联方式直接决定了安全性。这就是分组模式要做的事。
ECB 为什么是危险的选择
ECB(电子密码本模式)是最简单也是最危险的分组模式。每个 128 位分组独立用密钥加密。
问题在哪?相同明文块产生相同密文块。加密一张图片,如果图片背景是大片白色,ECB 模式加密后的密文依然能看到图案轮廓。经典案例是加密 Linux 企鹅 Tux 的 Logo——ECB 模式加密后,企鹅的轮廓清晰可见。
曾经在一个内部配置中心用 ECB 加密数据库连接串。配置内容很短,看起来没什么问题。但某天配置里新增了一个字段,和已有的字段值相同,加密后发现两段密文一模一样。一眼就能看出配置结构。虽然值本身还在(攻击者需要密钥才能解密),但这已经泄露了信息——至少攻击者知道"这两个字段的值相同"。
ECB 不安全,任何正经项目都不应该用。选 ECB 基本上等于没选对。
CBC 的串行链
CBC(密码分组链接模式)解决了 ECB 的问题。每个明文块加密前先和前一个块密文做 XOR。第一个块没有前一个密文,所以需要一个初始向量(IV)。
CBC 模式下,相同的明文块会得到不同的密文——只要它们在明文中的位置不同。而且密钥只需要一个,解密时 IV 也不需要保密,可以随密文一起传输。
代价是不能并行加密。每个块的加密依赖前一个块的输出。解密可以并行——解密每个块只需要前一个块的密文,这两者是独立的。
CBC 还有一个问题:密文篡改。如果攻击者修改了某一块密文,解密时 XOR 会导致下一块明文损坏。好在这种损坏是可检测的——解出来的明文通常有明显乱码。
GCM 是推荐的选择
GCM(Galois/Counter Mode)是目前最推荐的模式。它基于 CTR(计数器模式)实现,同时叠加了 GMAC 认证。
GCM 的加密流程:用一个递增的计数器生成伪随机流,然后与明文做 XOR。计数器从 IV 开始递增。这种方式支持并行加密——每块加密独立,只需要不同的计数器值。
GCM 自带的认证标签解决了 CBC 的"密文篡改"问题。认证标签是一个固定长度的校验值(通常 128 位或 96 位),由额外的 GHASH 运算生成。解密时先验证标签,验证通过才解密。如果攻击者篡改了密文,认证标签对不上,解密会直接失败。
我迁移一个旧系统的加密模块时,把 CBC 换成了 GCM。需要改的东西不多:密钥长度不变,但 IV 要求 12 字节(96 位)效率最高。旧的密文需要先用 CBC 解密再用 GCM 重新加密,过渡期花了一周。
GCM 也有注意事项。IV 绝对不能重复使用。如果同一个密钥下 IV 重复了,攻击者可以恢复出密钥流,加密就形同虚设。这在分布式系统中是个隐患——多台机器如果共享密钥,需要保证 IV 的唯一性。
AES-256 的安全性和性能
没听说 AES-256 被真正攻破过。目前最有效的攻击是旁路攻击——不攻击算法本身,而是攻击实现。比如通过测量加密操作的耗时推测密钥,或者通过功耗分析获取内部状态。抵御旁路攻击需要在实现层面做防护,比如常量时间实现、功耗掩码等。
在性能方面,大部分现代 CPU 都有 AES-NI 指令集,一条指令就能完成一个 AES 轮的大部分操作。有 AES-NI 的情况下,AES-256 软件实现的吞吐量能达到 1 GB/s 以上。没有硬件加速时,GCM 模式中 GHASH 的运算会成为瓶颈——因为它的有限域乘法在软件上比较慢。不过 AES-NI 已经下放到几乎所有 x86-64 和 ARMv8 处理器了,除非你在做微控制器开发,否则性能不成问题。
如果你需要在线验证 AES 加密结果,可以用 AES 加密解密工具。支持 ECB、CBC、CFB、OFB 和 GCM 模式,密钥长度可选 128/192/256。我用它来验证手写实现是否正确——加密一段已知数据,跟工具的输出对比,一致的说明实现没毛病。
写在最后
AES 是 NIST 从 15 个候选算法中选出的,最终胜出的是 Rijndael 算法。二十多年过去了,它仍然是使用最广泛的对称加密标准。但它在量子计算机面前可能撑不了多久——Grover 算法可以把 AES-256 的安全强度从 256 位降到 128 位。128 位在可预见的未来还算安全,但长期加密需求现在就应该考虑后量子密码方案了。
写这篇的时候我一直在想,AES 和 MD5 两个东西放在一起看很有意思。AES 的设计者刻意避免了 MD5 踩过的坑——简化的轮函数、不够严格的扩散。当然,MD5 是哈希而 AES 是加密,设计目标不同,但密码学里"今天的安全是明天的漏洞"这个道理是通用的。