两个系统对接,一端加密一端解密,结果对不上——这大概是 AES 相关问题里最常见的一类。排查时最容易走的弯路,是怀疑「是不是哪家实现有问题」。
几乎不会是实现的问题。 AES 是 2001 年发布的 FIPS-197 标准,分组变换、密钥扩展、轮数全部写死,任何一个能通过官方测试向量的实现,算出来的结果必然逐字节相同。真正会变的东西全在算法外面。
四个变量,一个都不能差
| 变量 | 常见取值 | 差错后的表现 |
|---|---|---|
| 密钥的解释方式 | UTF-8 / Hex / Base64 | 全篇乱码 |
| 模式与 IV | ECB / CBC / CTR / GCM,IV 来源 | CBC 下仅前 16 字节乱码 |
| 填充 | PKCS7 / 零填充 / 不填充 | 末尾多出或缺少字节 |
| 输出编码 | Base64 / Hex | 看着完全不像,实则同一串字节 |
下面逐个拆。
一、密钥:同一串字符可以是三把不同的密钥
这是最高频、也最隐蔽的错因。假设双方约定密钥是 1234567890abcdef:
- 按 UTF-8 理解:16 个 ASCII 字符 → 16 字节 → 合法的 AES-128 密钥
- 按 Hex 理解:16 个十六进制字符 → 8 字节 → 对 AES 来说长度非法
- 按 Base64 理解:解出 12 字节 → 同样非法
三种解释指向三串完全不同的字节。双方都觉得「密钥就是那一串啊」,但机器拿到的根本不是一个东西。
排查方法只有一个:把密钥转成 Hex 打印出来逐字节比对。 不要比对字符串,要比对字节。
长度不合法时,各家的补法不一样
AES 只认 16、24、32 三种密钥字节数。给一个 13 字节的密钥,实现必须做出选择:
- 补零到 16 —— 相当一部分在线工具的做法
- 截断 —— 超长时砍掉尾部
- 当作口令走 KDF —— 派生出合法密钥
- 直接报错
这四种互不兼容。所以「换个工具就能算出来」不代表算得对,只代表那个工具替你静默选了一种补法。本站工具选择直接报错——在联调场景下,一个不吭声的自动补齐会让你把时间花在完全错误的方向上。
密文以 U2FsdGVkX1 开头:那不是裸 AES
如果对方给你的密文 Base64 以 U2FsdGVkX1 开头,先停下。这十个字符解出来是 Salted__,是 OpenSSL enc 的容器格式:
Salted__ (8字节) + 随机盐 (8字节) + 真正的密文
它意味着对方填的是口令不是密钥,真正的 AES 密钥由口令加随机盐派生而来。可以实测一下,同样的口令和明文,两次加密结果完全不同:
$ printf 'hello' | openssl enc -aes-128-cbc -k mypass -a -pbkdf2
U2FsdGVkX1/GZvS3IZoxJbYLfO7vi09+82l6GvIo99U=
$ printf 'hello' | openssl enc -aes-128-cbc -k mypass -a -pbkdf2
U2FsdGVkX18YEybrpf46cREcddc0jI5Ty7ScvD+0hp8=
而裸密钥模式是完全确定的:
$ printf 'hello' | openssl enc -aes-128-cbc \
-K 00112233445566778899aabbccddeeff \
-iv 000102030405060708090a0b0c0d0e0f -a
FqK6ViMSEP8/Dbzrx3HmmA== # 跑一百次都是这个
前端最常见的来源是 CryptoJS。 CryptoJS.AES.encrypt(msg, "字符串") 第二个参数传字符串会走口令模式,传 WordArray 才是裸密钥模式。这两种写法在代码里长得很像,联调时后端怎么都对不上。
如果确实要用口令模式,两端还得对齐 KDF 参数——OpenSSL 1.1.0 之后 enc 的默认摘要从 MD5 改成了 SHA-256,跨版本对接时这一项也会咬人。更省事的做法是两端都换成裸密钥模式,明确给出 Hex 密钥。本站工具只做裸密钥,不做任何口令派生。
二、IV:错了不会全乱,只乱开头
CBC 模式的解密是这样的:每个分组解密后,要和前一个密文分组异或才得到明文。第一个分组没有前驱,用的就是 IV。
所以 IV 错误只影响第一个分组——明文的前 16 字节变成乱码,从第 17 字节开始完全正常。
这个特性极具迷惑性。你会看到一段「开头几个字是乱码、后面完全通顺」的文本,很自然地怀疑是编码问题、是 BOM、是截断。其实是 IV。
反过来这也是个好用的判据:
- 全篇乱码 → 密钥错
- 只有前 16 字节乱 → 密钥对,IV 错
IV 从哪来的三种约定
- 全零 —— 最省事,也最不该用。同密钥同明文会产生同密文,CBC 相对 ECB 的优势被抵消了
- 随机生成,拼在密文最前面 —— 正确做法。解密方先切走前 16 字节当 IV,剩下的才是密文。如果你不知道这个约定,会把 IV 当密文去解,全篇乱码
- 取密钥的前 16 字节 —— 老代码里能见到,安全性存疑但确实存在
一个判断技巧:如果密文总字节数减去 16 之后才是 16 的整数倍,那开头 16 字节很可能就是 IV。
GCM 是唯一「本该每次不同」的情况
GCM 要求每次加密用一个不重复的 nonce(推荐 12 字节)。如果工具替你随机生成 nonce,那同样的输入每次产生不同密文是完全正常的。GCM 还会额外输出一个 16 字节的认证标签(tag),解密时缺了它就验不了,很多对接问题是漏传 tag 造成的。
三、填充:只影响最后一个分组
ECB 和 CBC 要求明文长度是分组(16 字节)的整数倍,不够就得补。
| 填充 | 做法 | 问题 |
|---|---|---|
| PKCS7 | 缺 n 字节就补 n 个值为 n 的字节,正好整除时补一整个分组 | 事实标准,绝大多数实现的默认值 |
| 零填充 | 补 0x00 到整数倍 | 明文本身以 0x00 结尾时无法还原,且长度已经整除时不补,解密方分不清 |
| 不填充 | 不补,要求明文本来就整除 | 长度不对时直接失败 |
填充不一致的典型表现是:能解密,但末尾多出几个奇怪字节,或者少了几个字符。
CFB、OFB、CTR、GCM 是流式的,不分组也就不需要填充,明文多长密文就多长。这也是它们在对接时少一个变量的原因。
四、输出编码:最容易排除的一项
Base64 和 Hex 是同一串字节的两种写法:
Hex 16a2ba56231210ff3f0dbcebc771e698
Base64 FqK6ViMSEP8/Dbzrx3HmmA==
看着毫无关系,其实完全一样。把两端结果都转成 Hex 再比,能一次排除掉这一整类假性差异。
顺带检查 Base64 串里有没有混进换行或空格——从邮件、聊天工具、日志里复制出来的 Base64 经常带看不见的换行,直接解会报错。
一条实用的排查顺序
- 密钥转 Hex 逐字节比 —— 排除编码歧义,同时确认字节数合法
- 确认模式 —— ECB 还是 CBC 还是 GCM
- 确认 IV 来源 —— 固定值?拼在密文头部?看乱码分布判断
- 核对填充 —— 默认都用 PKCS7
- 两端结果都转 Hex 再比 —— 排除编码差异
- 拿官方测试向量做基准 —— 先确认自己这一端是对的,再去查对方
第 6 条常被跳过,但它很关键。没有基准的互相调试,很容易演变成两边同时改参数,越改越远。
用本站工具做对照
AES 加解密支持 AES-128/192/256 以及 DES、3DES,覆盖 ECB、CBC、CFB、OFB、CTR、GCM 六种模式,密钥和 IV 都可以按 UTF-8 / Hex / Base64 三选一输入——这个选择是显式的,不会替你猜。解密结果不是合法 UTF-8 时会明确提示并给出原始 Hex,方便直接比对字节。
实现基于 node-forge,在浏览器本地计算,密钥和明文都不会离开你的设备。AES 的三个密钥长度都用 FIPS-197 附录的官方测试向量验证过,并与 OpenSSL 逐字节交叉核对。
排查时的正确姿势是:先用工具把对方给的密文解出来,确认参数组合;再用同一组参数加密你自己的明文,把结果发过去。中间任何一步对不上,都说明有一个变量还没对齐。
参数对齐之后还有一个更上游的问题:这套参数本身选得对不对。ECB 会泄漏明文结构、CBC 只保密不防篡改、GCM 的 nonce 绝不能重用——见 AES 模式怎么选。