Skip to content
返回博客
安全

AES 解密失败:密钥、IV、模式与填充的排查方法

AES 解密失败时报错常常误导:密钥错会抛填充异常,IV 错只损坏第一个块,GCM 认证标签在各语言里位置不同,CryptoJS 的口令派生又自成一套。用免费在线工具逐项排查。

16 分钟

AES 解密失败:密钥、IV、模式与填充的排查方法

日志里出现 AES 解密失败时,你拿到的那句报错,描述的多半不是真正的问题。四种毫不相干的 bug 会给出几乎一样的症状,而其中最常见的那个,也就是密钥用错,偏偏以填充(padding)错误的形式出现。

从一次抛出 BadPaddingException 的 CBC 解密开始,按这个顺序查最省时间:

  1. 两边的密钥字节不一样。 概率上的第一名,甩开后面几项很远。
  2. 密钥派生方式不一样。 同一个口令,不同的 KDF 或不同的迭代次数,得到的就是不同的密钥字节。
  3. 密文在传输途中被损坏: 被截断、base64 被搅乱,或者在某个文本编码里往返了一趟。
  4. IV 错了。 这确实会发生,但它不会抛填充错误。它只毁掉十六个字节,然后一声不吭。

这个排序是由结构决定的。CBC 把填充校验放在解密的最后一步,此时密钥已经用过、链式异或也已经解开,所以填充相当于对上游所有环节的一次校验和:不管上游坏在哪一环,它都会大声报错。急着动手的话,把密文粘进 AES 在线解密,照第 9 节的二分流程走一遍。

下面所有数据都是在 java 1.8.0_162node v25.8.2openssl 3.6.2 上实测的。默认值会随版本变化,所以请把版本号当作结果的一部分。

1. 先看报错到底排除了什么

AES 的报错几乎不告诉你原因是什么,却能告诉你原因不可能是什么。所以拿它砍分支,别指望它替你选一个。

你看到的报错它排除了什么还剩哪些可能
BadPaddingExceptionbad decryptwrong final block lengthGCM;单纯的 IV 错误;解码失败密钥错、KDF 错、密文被截断、IV 被当成密文吃掉、模式不匹配、填充方案不匹配
GCM Authentication failedUnsupported state or unable to authenticate data填充问题;任何涉及部分输出的猜想密钥错、nonce 错、标签被分离或放错位置、标签长度错、AAD 不一致
不抛异常,但输出是乱码所有认证加密模式ECB、CTR、蒙对了的 CBC、模式不匹配、IV 错

BadPaddingExceptionbad decryptwrong final block length

同一件事在三个生态里的三种说法:Java、OpenSSL 和 .NET。它在 CBC 或 ECB 解密的最后一步触发,条件是最后一个明文块的结尾不符合合法的 PKCS#7 模式。

有用的信息在反面:能走到这一步,说明你的 base64 或 hex 解码成功了,字节数是 16 的非零整数倍,所以传输没有把数据搅碎,你也不在 GCM 里。wrong final block length 是例外:这时字节数不是 16 的倍数,矛头指向截断,密钥可以先放一放,直接跳到第 8 节。

GCM 的 Authentication failed 及其同类

按 NIST SP 800-38D 的要求,GCM 在放出任何一个明文字节之前先比对标签。这让它比填充错误诚实得多:(密钥、nonce、密文、附加认证数据、标签)这个五元组里,有东西和加密方用的对不上。它不会告诉你是哪一个,而且永远不会,因为把范围缩小到某一项本来就是算法刻意不做的事。第 6 节讲的就是跨语言时最常出问题的那一项:标签的位置,而不是标签的值。

不报错,但输出是乱码

这是最危险的结局,因为监控面板会把它记成成功。CTR 从不抛异常,ECB 也从不抛。CBC 只在最后一个字节的模式过不了填充校验时才抛,而密钥错时那个字节基本上是随机的,所以大约每 256 次就有一次落在 0x01 上并通过校验。用错密钥的 CBC 解密里,略低于 0.4% 会「成功」。不过乱码是有形状的,形状能指出 bug 在哪,第 4 节和第 5 节各给了一个指纹。

2. AES 里最误导人的那个报错

这组实测会重排大多数人的排查优先级。密钥 0123456789abcdef,全零 IV,AES/CBC/PKCS5Padding,明文 hello world,环境是 java 1.8.0_162 自带的 SunJCE provider:

场景改动实测结果
A密钥错 1 个字节(最后一个字符 fX抛出 javax.crypto.BadPaddingException: Given final block not properly padded。填充本身从头到尾都没有问题,这个报错完全是误导
B密钥正确,IV 错 1 个字节不抛异常,明文 hello world 解出来是 iello world。只有第一个块里对应的那个字节被毁了
C密钥正确,用 AES/ECB 去解 CBC 的密文静默成功,不抛任何异常。模式不匹配未必会报错

场景 A 能让人整个下午找错方向。场景 C 会把坏数据送进生产环境。

为什么密钥错了却报填充错误

填充没有任何问题。加密方给 hello world 补了五个 0x05 字节,把它凑到十六字节,加密了这个块,它此刻正完好无损地待在你的密文里。

出问题的是回来的路上。CBC 解密先把分组密码反着跑一遍,把每个结果和前一个密文块异或,最后才去读末块的尾部,据此决定要剥掉几个字节。密钥错时,密码输出的是十六个字节的噪声,而噪声几乎不可能正好以合法的 PKCS#7 模式结尾。库如实报告了它看到的东西:填充不对。这句话是真的,也是没用的。

BadPaddingException 读成「我重建出来的明文,结尾不像一段带填充的明文」。重建结果不对,最可能的原因就是密钥,所以搜 aes decrypt wrong key 和搜 bad padding exception 会落到同一批帖子里:这两个症状本来就是一个症状。顺带说一条设计上的注意事项:绝不要把这个区别暴露给调用方,因为「填充非法」和「填充合法但内容不对」之间的可区分性,正是填充预言机攻击(padding oracle attack)赖以生存的东西(Vaudenay,EUROCRYPT 2002)。

GCM 的不同之处

GCM 把顺序反了过来:先验标签,再产出明文,所以根本不存在「部分正确的字节」这样一个窗口。GCM 失败时你不会纠结输出是不是真的,因为压根没有输出。GCM 底层是计数器模式,完全没有填充,所以密文长度等于明文长度。也就是说,一个你以为在跑 GCM 的系统抛出填充错误,恰恰证明它不是 GCM,通常是某处配置退回到了 CBC。

3. 两边用的是同一串密钥字节吗

AES 看不见你的密钥字符串,它看到的是 16、24 或 32 个字节。两个系统的配置文件里可以存着一模一样的密钥材料却仍然对不上,因为「一模一样」说的是文本,不是字节。

密钥字符串变成字节的三种方式

把字面量 0123456789abcdef 交给三个不同的库:

按 hex 解析        -> 8 bytes    (AES 密钥长度非法)
按 base64 解析     -> 12 bytes   (AES 密钥长度非法)
按 raw UTF-8 解析  -> 16 bytes   (合法的 AES-128)

十六个字符,三种字节数。这个例子之所以恶心,恰恰是因为三种读法它都合法:每个字符同时属于 hex 和 base64 的字母表,十六个字符对两个解码器来说也都是合法长度,所以解析阶段什么都不会报错。

JWT 报 invalid signature:逐个排查真正的原因 里有一张完整的跨库矩阵,列出各个生态怎么解读一个密钥字符串;放到 AES 上,结论很短:写清楚你的密钥材料是哪种编码,并让两边都显式解码。同一个 bug 的 HMAC 版本专坑 webhook 接收方,见 Webhook 签名验证失败:全部原因与修复方法

AES 很严格:只认 16、24 或 32 字节

这正是 AES 和多数开发者最先接触的那个原语不一样的地方。HMAC 接受任意长度的密钥:RFC 2104 会把超过分组长度的先哈希一遍,不足的补零,所以 HMAC 生成器 收下 7 字节或 700 字节的密钥都不会有意见。AES 只有三种合法密钥长度,其余一律在处理第一个分组之前就被拒掉。

这种严格是好事,因为长度错误是 AES 里唯一一个会直接点名自己原因的失败,它不躲在填充后面。我们的工具把它写成 Key must be 16, 24, or 32 bytes (AES-128/192/256).。会造成长度不对的坑:

  • 末尾多一个换行符,来自 KEY=$(cat key.txt)echo "$KEY"。改用 printfecho -n。从密钥管理界面里复制时粘上的一个尾随空格,效果一样。
  • 从调试器里连 0x 前缀一起复制:三十四个字符,已经不是合法的 hex 了。
  • 非 ASCII 字符。 contraseña 是 10 个字符,UTF-8 下是 11 个字节,所以一个带了一个重音字母的「32 字符」口令实际上是 33 字节。

SecretKeySpec 与平台默认字符集

Java 有一个只在部署之后才现身的版本。不带参数的 "my secret".getBytes() 用的是平台默认字符集,在 JDK 18 之前,它来自 file.encoding 属性,也就是来自这台机器的操作系统和 locale。一台跑 UTF-8 的笔记本和一个跑 ANSI_X3.4-1968 的容器,对任何非 ASCII 字符都会给出不同的字节。JEP 400 在 JDK 18 里把 UTF-8 定为默认,这修好的只有新代码,别的什么都没修。

// 错:字节取决于机器
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");

// 对:字节不取决于任何东西
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");

如果你的代码本地正常、到服务器上报填充错误,而口令里含有任何非 ASCII 字符,先查这一条。

4. IV:放在哪里,以及错了长什么样

aes iv mismatch 是大家最先怀疑、最后才确诊的一种失败,因为它的行为和别的都不一样:它很安静,而且影响是局部的。

IV 错了只毁掉一个块

回头看场景 B。密钥正确,IV 错一个字节:

hello world   ->   iello world

不抛异常,只错了一个字符。把第一个块的 CBC 步骤写出来就一目了然:P1 = D(C1) XOR IV。IV 直接异或进第一个明文块,此外不碰任何东西,所以 IV 翻一个 bit,明文就在同一位置翻同一个 bit。这里 h(0x68)变成了 i(0x69),说明 IV 的第一个字节正好偏了 0x01。

这个指纹要记住。在 CBC 里,前 16 个字节是乱码、后面全是干净的,说明 IV 错了而密钥是对的。每个块都是乱码,说明密钥错了。就这一个观察,不用改一行代码就能把两个最常见的原因分开;AES 在线解密 会把解出的字节显示出来,可以直接读。

至于为什么什么都没抛:hello world 是 11 字节,只有一个块,PKCS#7 填充占的是这个块的第 11 到 15 字节。被改动的 IV 字节是第 0 字节,填充区域毫发无损,校验自然通过。要是把第 11 字节或更靠后的 IV 字节改坏,你拿到的就是填充错误了,这是填充错误骗你的又一条路径。

三种传输约定

IV 放在哪里没有标准,只有三种彼此互操作性很差的习惯。

前置。 iv || ciphertext,最常见的约定,也是我们工具里的默认做法。两边必须对「剥掉多少」达成一致:CBC 和 CTR 是 16 字节,GCM 是 12 字节。镜像式的 bug 是生产方前置了、消费方却没剥。于是「密文」的前 16 个字节其实是 IV,所有块整体错位,你就拿到了一个填充错误。

放在单独字段里。 {"iv": "...", "ciphertext": "..."} 原理上更干净,同时把编码可能对不上的地方翻了一倍,因为现在 IV 也有了它自己的「base64 还是 hex」问题。

写死成一个常量。 通常是全零,被硬编码是因为有人需要确定性的输出。它的互操作性完美无缺,这恰恰是它危险的地方:在 CBC 里,固定 IV 会泄漏记录之间的相等关系;在 GCM 里,同一把密钥下重用 nonce 会暴露两段明文的异或值,还可能暴露用于认证标签的 GHASH 子密钥。SP 800-38D 对唯一性的要求写得很明确。

工具里的「裸密文」开关加上显式指定 IV,一分钟就能拿同一串字节把三种约定全试一遍。中文开发者碰上 AES 解密失败,场景最集中的是微信支付和微信小程序的回调解密,以及国内云厂商 SDK 的加解密封装,它们同样用 AES,同样要求两边把模式、IV 和填充对齐。中文资料里填充也叫补位,初始化向量常直接写成 IV,一种说法搜不到就换另一种再搜。

GCM 的 IV 是 12 字节,不是 16

靠改现有 CBC 代码路径来上 GCM 的团队,会把 16 字节的 IV 一并带过去,结果失败得毫无线索。

SP 800-38D 规定的 IV 是 96 bit。其他长度也被允许,但它们不是简单的「更长的 IV」:IV 不是 96 bit 时,GCM 不再直接拿它当初始计数器块,而是先把 IV 过一遍 GHASH 再导出。所以同样这 16 个字节当 nonce 用,产生的密钥流和标签,跟只取前 12 个字节完全不同,你收获的是一个笼统的认证失败。如果密文来自别处、布局只能靠猜,那就从后往前数:标签是最后 16 个字节,nonce 几乎总是最前面 12 个。

5. 模式不匹配,包括静默的那一种

Cipher.getInstance("AES") 就是 ECB

Java 允许你只报密码算法的名字,不报模式,也不报填充方案。它既不拒绝也不警告。在 JDK 自带的 SunJCE provider 下,它把空缺填成 ECB 加 PKCS5Padding。

要证明这一点,做个对照实验:用密钥 0123456789abcdef 加密 32 个相同的字节(两个块的 A),然后看两个密文块是否相同。在 java 1.8.0_162 上:

getInstance("AES")           ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
  block1 = 3bfd04cc0d7ed55358e2cbe19de21383
  block2 = 3bfd04cc0d7ed55358e2cbe19de21383   <- 两个块完全相同 = ECB 的指纹(明文结构会泄漏)
getInstance("AES/CBC/PKCS5Padding")  两个块不同 = 链式模式生效

逐字节完全一致。这就是 ECB 的特征签名,也正是那张著名的「加密企鹅」图在加密后依然像只企鹅的原因。这个实验只有在明文块相同时才成立:十六个 A 字节后面接十六个 B 字节,在 ECB 下同样会产出两个不同的密文块,你会由此错误地得出默认是 CBC 的结论。

结论的适用范围要划清楚:它描述的是上面那个版本里 JDK 自带的 SunJCE provider。默认的 transformation 由 provider 决定,所以像 BouncyCastle 这样的第三方 provider 完全可能把同一个简写解析成别的东西。能推广的结论不是「Java 就等于 ECB」,而是「一个不完整的 transformation 字符串意味着由你的 provider 说了算,所以这种字符串一个都别写」。

模式用错未必会报错

场景 C 用 AES/ECB 解 CBC 的密文,返回了正确的明文,没有任何异常。这看起来不可能,直到你把算式写出来。第一个块的 CBC 加密是 C1 = E(P1 XOR IV),而对这个块做 ECB 解密得到的是 D(C1) = P1 XOR IV。这里的 IV 是全零,所以 P1 XOR 0 = P1,第一个块解得完美无缺。hello world 只有一个块长,所以「第一个块」就是整条消息。

由此可以记一条通则:IV 为全零时,ECB 和 CBC 在第一个块上结果一致,从第二个块起全部不一致。把一条很长的 CBC 消息当 ECB 解,你会得到十六个干净字节后面跟着一片噪声,正好是 IV 出错那个指纹的反面。两个 bug 的形状正好相反,而且都不报错。硬编码的全零 IV 太常见了,这不是实验室里才有的奇观。

各语言里「最短的那句调用」会给你什么

生态最短调用你实际拿到的模式
Java(SunJCE)Cipher.getInstance("AES")静默给你 ECB 加 PKCS5Padding
Node cryptocreateDecipheriv('aes-256-cbc', key, iv)算法字符串写什么就是什么;不存在默认值
Web Cryptocrypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...)必须显式命名;ECB 根本没实现
Python cryptographyCipher(algorithms.AES(key), modes.CBC(iv))模式对象是必填的
PyCryptodomeAES.new(key, AES.MODE_ECB)参数必填,但 ECB 就摆在自动补全里
Go crypto/aesaes.NewCipher(key) 返回一个裸的 cipher.Block对这个 block 直接调 Decrypt 就是 ECB;要用 cipher.NewCBCDecryptercipher.NewGCM 包一层
CryptoJSCryptoJS.AES.decrypt(ct, "passphrase")CBC、PKCS#7、EVP_BytesToKey 配 MD5(见第 7 节)

模式写在字符串或对象里的生态永远不会给你惊喜。提供了「就要 AES」这种调用的那两家,Java 和 Go,正是误用 ECB 的重灾区。拿不准自己那串密文走的是哪种模式,就挨个模式解一遍

6. GCM:字节相同,API 不同

跨语言的 aes gcm auth tag 失败,大多不是密码学问题。两边算出的是同样的 16 个字节,只是对这 16 个字节该待在哪儿意见不一致。

实测

密钥 = 32 字节的 0123456789abcdef0123456789abcdef,IV = 12 个零字节,明文 hello world,环境是 node v25.8.2java 1.8.0_162

Node   ciphertext = a616cd6d7d2328379d41e5                    (11 B)   <- update+final
       authTag    = c87af9f8ad7148e873fa797292c0af3f          (16 B)   <- 通过 getAuthTag() 单独取出
Java   doFinal()  = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f   (27 B)   <- 密文与标签已经拼在一起

Node ciphertext || authTag 就是 Java doFinal(),27 个字节一个不差。没有编码差异,也没什么可协商的:Node 把两块分开递给你,Java 把它们粘在一起递过来。另外注意,11 字节的明文给出的是 11 字节的密文,因为 GCM 不加填充。这也是为什么真正的 GCM 路径永远不会产生填充错误。

各运行时:拼接还是分离

运行时加密 API标签最后在哪
Node cryptoupdate() + final(),再 getAuthTag()分离
Java(SunJCE,AES/GCM/NoPaddingdoFinal()追加在末尾
Go cipher.AEADSeal()追加在末尾
Python cryptographyAESGCMencrypt()追加在末尾
Python cryptographyCipher + modes.GCMfinalize(),再取 encryptor.tag分离
Web Cryptocrypto.subtle.encrypt追加在末尾

在这些高层 API 里 Node 是唯一的异类,所以「Node 发给任何人」这个方向的求助也最多。把 Node 的输出打包给 Java、Go、Python 或浏览器端的消费方:

const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]);   // 现在和 doFinal() 一致了

把一个拼接好的 blob 拆开给 Node 用:

const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16));  // 必须在 final() 之前调用
const pt = Buffer.concat([
  decipher.update(packed.subarray(0, packed.length - 16)),
  decipher.final(),
]);

这个顺序约束是实打实的:把 setAuthTag() 放在 final() 之后调用,哪怕每个字节都对,Node 也会抛 Unsupported state or unable to authenticate data。不确定对方把标签放在哪一头时,先拿同一串字节去核对标签的位置,再动代码。

标签长度是可变的,单位却各说各话

GCM 允许 128、120、112、104 或 96 bit 的标签,64 和 32 保留给受限场景(SP 800-38D 附录 C)。几乎所有人都用 128,麻烦出在每个 API 索要这个值的方式上:

  • Java: new GCMParameterSpec(128, iv)。第一个参数的单位是 bit
  • Web Crypto: { name: 'AES-GCM', iv, tagLength: 128 }。也是 bit,默认 128。
  • Node: createCipheriv(algo, key, iv, { authTagLength: 16 })。单位是字节

new GCMParameterSpec(16, iv) 是一行看上去很正常的 Java 代码,它要的是一个 16 bit 的标签;有些 JDK 会拒绝它,而在接受它的地方,你等于把完整性保证换成了一次 1/65,536 的抛硬币。两边对标签长度的理解不一致时,打包后的总长度也会不一样,接收方就会在错误的位置切一刀,然后拿到一个和密钥毫无关系的认证失败。

7. 你手上的是口令,不是密钥

只要有一边接收的是人手敲进去的字符串,那么这个字符串和 AES 之间就横着一个密钥派生函数(KDF),而 KDF 不一致是隐形的。它从不报错。它返回 32 个完全合格的字节,只不过是错的那 32 个;失败要到下一层才浮出水面,形式是(你已经知道了)一个填充错误。

PBKDF2 有四样东西必须对齐

  • 盐(salt)。 在 OpenSSL 的 Salted__ 格式里,它是密文内部的 8 个字节;在我们工具的口令格式里,它是 16 字节的前缀;在自己手搓的方案里,它经常是个硬编码常量。
  • 迭代次数。 openssl enc -pbkdf2 默认 10,000。OWASP 目前对 PBKDF2-HMAC-SHA256 的建议是 600,000,我们的口令模式用的就是这个数。各个框架自己定自己的。
  • 哈希算法。 SHA-1、SHA-256 还是 SHA-512。老代码和一部分移动端 SDK 至今默认 SHA-1。
  • 输出长度。 AES-256 是 32 字节,AES-128 是 16 字节。有些方案会用一次更长的派生调用同时导出密钥和 IV,这和单纯派生 32 字节的做法永远对不上。

EVP_BytesToKey,以及 CryptoJS 为什么总是解不开

cryptojs aes decrypt not working 通常就是某一个特定的不匹配。CryptoJS.AES.encrypt(text, "passphrase") 用的不是 PBKDF2,而是 EVP_BytesToKey,也就是 OpenSSL 1.1 之前的派生方式,配 MD5,只迭代一次。

EVP_BytesToKey 还做了一件 PBKDF2 不做的事:它从口令和盐里一次性导出密钥和 IV。这就是为什么 OpenSSL 的 Salted__ 文件里没有单独的 IV 字段,也是为什么「用 PBKDF2 加一个随机 IV」去复现 CryptoJS 的输出是错了两处。

这个格式一眼就能认出来:8 个 ASCII 字节 Salted__ 后面跟 8 字节的盐,做 base64 编码后开头永远是 U2FsdGVkX1。如果你的密文是这样开头的,它就是从口令派生来的,你需要弄清是哪一种派生;AES 在线解密 会识别这个前缀,并在三种派生之间切换,不需要改代码。

同一个口令为什么会给出不同的密钥

世上没有「AES 的密码」这种东西。每个库都自己发明了一条从字符串到密钥的路径:

生产方派生方式同一个口令得到的结果
CryptoJS AES.encrypt(text, pass)EVP_BytesToKey,MD5,1 次迭代密钥 A
openssl enc 1.0.2 及更早EVP_BytesToKey,MD5,1 次迭代密钥 A
openssl enc 1.1+ 不加 -pbkdf2EVP_BytesToKey,SHA-256,1 次迭代密钥 B
openssl enc -pbkdf2PBKDF2-HMAC-SHA256,10,000 次迭代密钥 C
我们的口令模式PBKDF2-HMAC-SHA256,600,000 次迭代密钥 D
Java、Python、Go完全没有默认;派生要你自己写你写成什么就是什么

还没有人犯任何错误,一个口令就已经变出了四把密钥。1.0.2 升到 1.1 时,默认摘要从 MD5 换成了 SHA-256,这就是为什么老脚本产出的密文,在新机器上用同样的命令解不开了。如果你接手了一批数据、没人记得当年的工具链,就按表里的顺序把这些派生挨个试过去。这是一次三选一的测试,不是一场搜索。

8. 传输链路对你的字节做了什么

密文是均匀随机的二进制,这让它对任何把字节当文本处理的东西都极不友好。相当一部分 AES 失败根本没碰到密码算法本身。

base64 的变体与丢失的补位符

标准 base64(RFC 4648 §4)用 +/;URL-safe 变体(§5)用 -_。把一个 URL-safe 的字符串交给标准解码器,要么直接抛异常,要么在宽松的解码器里静默丢掉这些字符、返回一串偏短且错位的字节。Java 之所以把 Base64.getUrlDecoder()Base64.getDecoder() 做成两个独立对象,就是这个原因。另外,有些编码器会去掉末尾的 =,有些解码器却非要不可,而 JWT 相关的代码路径默认就把它剥掉。

在怀疑密钥之前,先把密文解码出来,按模式核对它的长度:

  • CBC 和 ECB: 16 的非零整数倍。不是的话,问题出在截断或解码上,跟密钥没关系。
  • GCM: 密文长度等于明文长度,加上 16 字节的标签;如果 nonce 是前置的,再加上开头的 12 字节。
  • CTR: 任意长度,所以这项检查什么都告诉不了你。

Base64 在线编码解码 粘一次就给出字节数,往往是整场排查里最快的一次测量。

换行、智能引号与 UTF-8 往返

不加 -A 时,openssl base64 会在第 64 列处折行,而有些解码器会跳过内嵌的换行、有些则直接拒收,于是同一个文件在一台机器上解得开、在另一台上失败。经由聊天工具或文档编辑器复制,会把直引号变成弯引号、把连字符变成短破折号,这些差别在终端里几乎看不出来。

不可挽回的那一种是 UTF-8 往返。只要 AES 的原始输出没先编码就被当成字符串保存过(Java 里的 new String(cipherBytes)、Python 里的 bytes.decode('utf-8', errors='replace')、任何地方的 TextDecoder),每一段不是合法 UTF-8 的字节序列都会坍缩成 U+FFFD,再编码回去,原来放数据的位置就变成了 EF BF BD。随机字节里大约有一半是非 ASCII 的,所以密文的大部分已经被毁掉,任何密钥都救不回来;UTF-8 vs UTF-16 vs Unicode 编码完全指南 讲了这种损失为什么是单向的。二进制密文要么以 base64 或 hex 的形式传输,要么就以二进制传输。绝不要以字符串的形式传输。

数据库字段

存储层会以更安静的方式造成同样的损坏。密文写进 VARCHAR(255),只要多出一个块就会被截掉,而 MySQL 在非严格模式下不会报任何错。尾部正是填充块和 GCM 标签所在的位置,于是几个月前「成功」写入的一行现在解不开了;而且如果截断正好落在 16 字节边界上,上面那项长度检查也抓不住它。字符集转换负责剩下的部分:一个 latin1 字段接收 UTF-8 字节,会在写入的路上重写你的数据。

把密文存进 VARBINARYBLOBbytea,或者把 base64 存进一个留足余量的文本字段。

9. 五分钟定位问题的二分流程

上面每一节都收窄一个变量。拿一个你自己控制的参考实现按顺序跑一遍,收敛得很快;浏览器里的工具很适合当这个参考,因为你可以一次只改一个设置,并且直接看到字节,而这些工具都在浏览器本地运行,密钥和密文不会离开这个页面,也不上传到服务器。

  1. 第 0 步:量一下形状。 把密文解码出来,记下字节数、开头几个字节,以及它是不是以 U2FsdGVkX1 开头。按第 8 节核对字节数。如果它不是 16 的倍数、而你认为自己在 CBC 里,就停下:这是一个传输 bug。

  2. 第 1 步:加密一段已知明文。AES 在线加密工具 里,用你以为生产环境在用的参数加密一小段已知字符串,然后比较两份输出的形状而不是它们的值:总长度、开头的字节、有没有盐头部。对不上,就说明你对格式或 KDF 的假设错了,再怎么折腾密钥都没用。

  3. 第 2 步:把派生方式轮一遍。 对于从口令派生的数据,在 AES 在线解密 里先用准确的迭代次数跑 PBKDF2,再跑 EVP-SHA256,再跑 EVP-MD5。只可能有一个是对的。如果都不行,bug 在 KDF 之上。

  4. 第 3 步:把所有约定都去掉。 切到裸密钥,打开裸密文,显式提供 IV。此时你是在明确声明哪些字节是密钥、哪些是 IV、哪些是密文,没有任何东西靠推断。如果在这里解得开、在你的代码里解不开,那你的 bug 是封装格式的问题(没剥掉的 IV 前缀、放错位置的标签),而不是密码学问题。

  5. 第 4 步:换模式。 拿同一串字节依次试 CBC、CTR、GCM。CBC 失败而 CTR 返回了可读文本,那就是模式不匹配,没有别的解释。

  6. 第 5 步:读乱码。 第一个块坏、其余干净,说明是 IV。第一个块干净、其余全坏,说明你在全零 IV 下把 CBC 当 ECB 解了。全都坏,说明是密钥或派生方式。

10. FAQ

为什么我的 AES 代码本地正常、生产环境却失败?

本地正常、生产环境失败,说明环境改动了某个不在版本控制里的东西。按可能性排序的常见嫌疑:密钥从环境变量或密钥管理服务里取出来时带了一个尾随换行;Java 的平台默认字符集在笔记本和容器上不一样,导致 getBytes() 产出不同的字节(第 3 节);生产环境的 OpenSSL 是 1.1+,而你本地脚本面向的是 1.0.2,EVP_BytesToKey 的摘要从 MD5 变成了 SHA-256;或者只有某一个环境里的数据库字段把密文截断了。先把两边的密钥字节数和密文字节数打出来,这两个数字通常就能定案。

我在 Node 里加密、在 Java 里解不开,该从哪查起?

Node 加密、Java 解不开,先从 GCM 的标签查起,它最常见也最不显眼。Node 把密文和标签分开返回;Java 的 doFinal() 要的是 ciphertext || tag 这样拼在一起的形式,而第 6 节表明除此之外字节完全一致。如果用的是 CBC,就从 IV 的约定查起:Node 有没有把它前置,Java 那边解密前有没有剥掉 16 个字节?第三个查密钥本身,同一个字符串经 Buffer.from(k, 'hex')k.getBytes(StandardCharsets.UTF_8) 会得到不同的长度。

Java 的 PKCS5Padding 和 PKCS#7 是一回事吗?

Java 的 PKCS5Padding 和 PKCS#7 对 AES 来说效果上是一回事。PKCS#5(RFC 8018)只为 8 字节的分组定义;PKCS#7(RFC 5652)把这个方案推广到 1 到 255 字节的分组大小。Java 的 PKCS5Padding 用在 16 字节分组的密码上时,实现的就是 PKCS#7 的行为,这个名字只是历史遗留,所以它永远不会是你的 bug。NoPadding 才会是:它要求明文本身已经是 16 的倍数,而解密时它会把填充当作数据一起交还给你,于是你看到的是一段看着挺正常的文本,尾部拖着 \x05\x05\x05\x05\x05 这样的字节。

我的密钥是 32 个字符,AES 却说密钥长度非法,为什么?

长度错误意味着库拿到的字节数不是 16、24 或 32。对一个 32 字符的字符串来说,通常是末尾多了个换行(33 字节)、带了个让它不再是合法 hex 的 0x 前缀,或者有个非 ASCII 字符在 UTF-8 下占了两三个字节。更危险的是另一种情形:根本不报错。32 个 hex 字符解码出 16 个合法字节,32 个 base64 字符解码出 24 个合法字节,两者都是合法的 AES 长度。库照单全收,用了错的密钥,然后甩给你一个填充失败。要数的是字节数,不是字符数。

解密「成功」了,输出却是乱码,哪里出了问题?

解密「成功」却输出乱码,说明你在一个什么都不校验的模式里。CTR 和 ECB 从不抛异常,CBC 只在最后一个字节的模式过不了填充校验时才抛,而错误的密钥有略低于 0.4% 的概率能蒙混过关。读形状:前 16 个字节坏、其余干净,是 IV;前 16 个干净、其余坏,是你在全零 IV 下把 CBC 密文当 ECB 解了;均匀地全坏,是密钥或派生方式。文本可读但尾部拖着几个奇怪的字节,是对带填充的数据用了 NoPadding。长期的修法是换成 GCM,好让「成功」这两个字有意义。

IV 丢了还能解密吗?

IV 丢了,在 CBC 里仍然能解密:除了前 16 个字节,其余都能解出来。第 2 个块起可以按 D(C_i) XOR C_{i-1} 还原,而这个式子的每一个输入都已经在密文里了,所以只有第一个块需要 IV。如果你还知道明文是怎么开头的,比如一段以 {"userId": 开头的 JSON,那就可以直接按 D(C1) XOR P1 把 IV 反推出来。在 CTR 里,IV 是整条密钥流的种子,丢了它就等于全丢。在 GCM 里,nonce 同时喂给计数器和标签,所以不存在部分恢复。

GCM 标签被截断或丢失了,还能恢复明文吗?

GCM 标签被截断或丢失后,数学上仍然能恢复明文,实际操作要费点力气。GCM 底层是 CTR 模式,所以只靠密钥和 nonce 就能复现密钥流。没有哪个主流库会替你做这件事:Java、Go、Python 和 Web Crypto 都拒绝在标签不合法时放出明文,这是设计如此。绕过的办法是把同样的字节当作 AES-CTR 来解,初始计数器块设为 12 字节的 nonce 后面跟 00000002,那正是 GCM 第一个数据块的起点。你能把数据拿回来,代价是放弃全部完整性保证,所以要把结果当作不可信数据对待。如果 16 个标签字节都还在、认证却仍然失败,那标签就不是丢了,你的 bug 是这个页面上的别的某一条。把它拿到 AES 在线解密 里,从第 0 步开始。

标签: aes encryption debugging cryptography interoperability