ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# AES填充模式详解:PKCS7、Zeros、None选型与跨语言对接避坑指南

C# AES填充模式详解:PKCS7、Zeros、None选型与跨语言对接避坑指南

1. 从一个踩坑现场说起:为什么AES加解密总在“填充”上翻车

做过C#后端对接的人,大概率都遇到过这种场景:本地用一套代码加密出来的密文,发给对方系统,对方解密直接抛异常,报的是“Padding is invalid and cannot be removed”,或者更隐蔽一点,解密出来一堆乱码,前几个字节对得上,后面全是垃圾。你反复核对密钥、IV、模式,全都一致,最后发现问题出在一个最不起眼的地方——填充模式(Padding Mode)。

AES本身是分组加密算法,分组长度固定128位,也就是16字节。它只能处理长度恰好是16字节整数倍的数据。但现实里的明文长度是随机的,一段JSON可能37字节,一个手机号可能11字节,一条订单备注可能203字节。多出来的零头怎么办?这就是填充(Padding)要解决的问题。填充规则选错了,或者加密端和解密端对填充的理解不一致,密文就废了。

这篇内容围绕C#里的AES填充加密解密展开,把填充模式的选择逻辑、代码实现、参数计算、跨语言对接的坑、以及实际排查问题的思路全部拆开讲。适合正在做接口加密对接的后端开发、需要和Java/Python/Go系统互通的C#工程师,以及被“填充异常”折磨过想彻底搞明白的人。读完你至少能做到三件事:第一,知道五种填充模式各自在什么场景用;第二,能写出加密端和解密端完全对齐的C#代码;第三,遇到填充报错时有一套可执行的排查路径,而不是靠猜。

我先把结论放在前面:绝大多数跨系统对接场景,用PKCS7填充;只有在对接某些历史遗留系统、且对方明确要求时,才考虑Zeros或None;ISO10126和ANSIX923在国内对接里出现频率极低,但你必须知道它们的存在,否则看到别人的密文会一脸懵。

2. AES填充模式全解析:五种模式到底差在哪

2.1 为什么必须有填充:从16字节分组说起

AES的分组是16字节,这是算法层面的硬约束,改不了。加密时,明文按16字节切块,最后一块如果不足16字节,就必须补到16字节;如果明文长度恰好是16的整数倍,那还要不要再补一整块?这取决于填充模式的定义。

这里有个很多人忽略的细节:PKCS7在明文长度恰好为16整数倍时,会额外补一整块16字节的填充数据,每个字节的值都是0x10(即16)。而Zeros填充在同样情况下不补任何东西。这个差异直接导致同一个明文、同一个密钥,用不同填充模式加密出来的密文长度可能差16字节。对接时如果对方按固定长度解析,长度对不上就会出问题。

我用一个具体例子说明。明文是"ABCDEFGHIJKLMNOP",正好16字节:

  • PKCS7填充后:变成32字节,后16字节全是0x10
  • Zeros填充后:还是16字节,不补
  • None填充后:还是16字节,但要求明文本身必须是16整数倍,否则直接报错

你看,同样一句明文,填充策略不同,密文长度差一倍。这就是为什么对接文档里必须明确写清楚填充模式,含糊不得。

2.2 五种填充模式逐一拆解

C#的System.Security.Cryptography命名空间下,PaddingMode枚举定义了五种值,我逐个说清楚它们的规则和适用场景。

PKCS7:最通用、最推荐。填充字节的值等于填充的字节数。比如缺3字节,就补03 03 03;缺5字节,就补05 05 05 05 05。如果明文正好16整数倍,补一整块16个0x10。解密时读最后一个字节的值,就知道要去掉多少填充。这个模式跨语言支持最好,Java的AES/CBC/PKCS5Padding实际上就是PKCS7(Java里PKCS5和PKCS7在AES场景下等价),Python的padding.PKCS7(128)也是它。

Zeros:缺多少字节就补多少个0x00。问题在于,如果明文本身末尾就有0x00字节,解密后无法区分哪些是填充、哪些是原始数据,会误删。所以Zeros只适合明文确定不含0x00结尾的场景,比如纯文本、纯数字。对接老系统时偶尔会遇到。

ANSIX923:填充的最后一个字节是填充长度,前面的填充字节全是0x00。比如缺3字节,补00 00 03。它比Zeros多了一个长度标记,解密时能准确知道去掉多少,解决了Zeros的歧义问题。但在国内对接里用得少。

ISO10126:填充的最后一个字节是填充长度,前面的填充字节是随机数。随机填充增加了密文的随机性,理论上更安全,但解密时只读最后一个字节,前面的随机数直接丢弃。因为引入了随机性,同样的明文每次加密结果都不同,调试时不好对比。实际项目里我几乎没用过。

None:不填充。要求明文长度必须是16的整数倍,否则加密直接抛CryptographicException。什么时候用?当你的数据本身就是固定16字节对齐的,比如某些二进制协议包、或者你自己在上层已经做好了填充。用None必须自己保证长度,否则就是给自己挖坑。

为了让你一眼看清差异,我列个对照表:

填充模式填充规则明文16整数倍时跨语言兼容性推荐场景
PKCS7补N个值为N的字节额外补16个0x10极好绝大多数对接场景
Zeros补N个0x00不补一般明文不含0x00结尾的老系统
ANSIX923前N-1个0x00,最后1个为N额外补一整块一般特定规范要求
ISO10126前N-1个随机,最后1个为N额外补一整块较差几乎不用
None不填充不补取决于上层数据已16字节对齐

2.3 填充模式选型的三个判断依据

选填充模式不是拍脑袋,我一般按这三条来判断。

第一条,看对接方文档。如果对方是Java系统,文档写AES/CBC/PKCS5Padding,那C#这边就用PKCS7,因为Java的PKCS5在AES下就是PKCS7的行为。如果对方是Python,用的pycryptodome库,默认也是PKCS7。这条最省事,照抄就行。

第二条,看数据特征。如果明文是二进制数据,可能包含任意字节,绝对不能用Zeros,因为无法区分原始0x00和填充0x00。这时候PKCS7是唯一安全的选择。如果明文是纯ASCII文本,Zeros勉强能用,但我还是建议PKCS7。

第三条,看历史包袱。有些运行了十几年的老系统,当年用的是Zeros或None,现在改不动。这时候C#这边必须迁就对方,用对应的模式,同时在代码注释里写清楚原因,避免后人误改。

注意:填充模式一旦确定,加密端和解密端必须完全一致。我见过一个团队,加密用PKCS7,解密代码复制粘贴时手滑改成了Zeros,结果测试环境一直报填充异常,查了两天才发现。这种低级错误在赶工期时特别容易犯。

3. C#实现AES填充加解密的完整代码与参数计算

3.1 核心API选型:Aes vs AesManaged vs AesCryptoServiceProvider

C#里做AES,有三个常用类:Aes、AesManaged、AesCryptoServiceProvider。很多人纠结用哪个,我直接给结论。

Aes是抽象基类,从.NET Core开始推荐用Aes.Create()来创建实例,它会根据当前平台返回最优实现。AesManaged是纯托管实现,跨平台一致性好,但在某些平台上性能不如系统原生实现。AesCryptoServiceProvider底层调用Windows的CryptoAPI,性能好,但跨平台受限。

我的建议是:新项目一律用Aes.Create()。它自动选择当前平台最优实现,代码也简洁。只有在需要严格控制实现版本、或者对接特定加密硬件时,才考虑显式指定具体类。

using System; using System.Security.Cryptography; using System.Text; public class AesHelper { // 加密 public static string Encrypt(string plainText, byte[] key, byte[] iv) { using (Aes aes = Aes.Create()) { aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV); byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); } } // 解密 public static string Decrypt(string cipherText, byte[] key, byte[] iv) { using (Aes aes = Aes.Create()) { aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV); byte[] cipherBytes = Convert.FromBase64String(cipherText); byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); return Encoding.UTF8.GetString(plainBytes); } } }

这段代码是骨架,但直接用会踩坑。下面我把每个参数的计算和选择讲透。

3.2 密钥长度与IV的生成逻辑

AES支持三种密钥长度:128位(16字节)、192位(24字节)、256位(32字节)。选哪个?256位是当前主流,128位在合规要求下也够用。192位用得少,因为它的密钥调度算法和另外两种不同,某些硬件加速不支持。

密钥不能随便写个字符串就用。很多人图省事,直接Encoding.UTF8.GetBytes("1234567890123456"),这样得到的16字节确实能当128位密钥,但安全性极差,因为可猜测。正确做法是用RandomNumberGenerator生成随机密钥:

byte[] key = new byte[32]; // 256位 using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(key); }

IV(初始化向量)的长度必须等于分组长度,也就是16字节,这个没得选。IV的作用是让同样的明文在同样的密钥下,每次加密结果不同。CBC模式下,IV必须是随机的,且每次加密都换新的。IV不需要保密,可以和密文一起传输,通常拼在密文前面或者单独一个字段。

这里有个实操细节:IV每次加密都变,那解密方怎么知道用的哪个IV?答案是加密方把IV和密文一起发过去。常见做法是IV + 密文拼接后整体Base64,解密方取前16字节当IV,剩下的当密文。我一般这么写:

// 加密时:IV拼在密文前 byte[] combined = new byte[iv.Length + cipherBytes.Length]; Buffer.BlockCopy(iv, 0, combined, 0, iv.Length); Buffer.BlockCopy(cipherBytes, 0, combined, iv.Length, cipherBytes.Length); return Convert.ToBase64String(combined); // 解密时:拆出IV和密文 byte[] combined = Convert.FromBase64String(cipherText); byte[] iv = new byte[16]; byte[] cipherBytes = new byte[combined.Length - 16]; Buffer.BlockCopy(combined, 0, iv, 0, 16); Buffer.BlockCopy(combined, 16, cipherBytes, 0, cipherBytes.Length);

提示:如果对接方要求IV固定不变(有些老系统这么设计),那安全性会打折扣,但为了兼容只能照做。这种情况下要在文档里标注风险,并建议对方升级。

3.3 加密模式的选择:CBC还是ECB

AES有多种工作模式,C#的CipherMode枚举里有CBC、ECB、CFB、CTS等。实际对接中,CBC是绝对主流,ECB要尽量避免。

ECB模式的问题在于,相同的明文块加密后得到相同的密文块。如果你加密一张图片,ECB模式下能看出原图的轮廓,因为相同颜色的像素块加密结果一样。这就是所谓的“ECB企鹅图”现象。所以ECB只适合加密极短且无规律的数据,比如单个密钥。

CBC模式通过IV把前一块的密文混入下一块的加密,相同明文块在不同位置加密结果不同,安全性好得多。代价是加密必须串行,不能并行,性能略低。但在绝大多数业务场景下,这个性能差异可以忽略。

CFB和CTS属于流式或特殊模式,对接中很少见。如果对方文档没写模式,默认按CBC处理,这是行业惯例。

3.4 完整可运行的加解密示例

把上面的点串起来,给一个完整可运行的例子,包含密钥生成、加密、解密全流程:

using System; using System.Security.Cryptography; using System.Text; public class AesFullDemo { public static void Run() { // 1. 生成256位密钥 byte[] key = new byte[32]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(key); } // 2. 生成16字节IV byte[] iv = new byte[16]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(iv); } string plainText = "订单号:20240501,金额:199.00,备注:测试数据"; Console.WriteLine($"明文: {plainText}"); Console.WriteLine($"明文字节数: {Encoding.UTF8.GetBytes(plainText).Length}"); // 3. 加密 string cipherText = Encrypt(plainText, key, iv); Console.WriteLine($"密文(Base64): {cipherText}"); // 4. 解密 string decrypted = Decrypt(cipherText, key, iv); Console.WriteLine($"解密结果: {decrypted}"); } public static string Encrypt(string plainText, byte[] key, byte[] iv) { using (Aes aes = Aes.Create()) { aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); Console.WriteLine($"加密前字节数: {plainBytes.Length}"); Console.WriteLine($"PKCS7填充后字节数: {((plainBytes.Length / 16) + 1) * 16}"); using (ICryptoTransform encryptor = aes.CreateEncryptor()) { byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); } } } public static string Decrypt(string cipherText, byte[] key, byte[] iv) { using (Aes aes = Aes.Create()) { aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; byte[] cipherBytes = Convert.FromBase64String(cipherText); using (ICryptoTransform decryptor = aes.CreateDecryptor()) { byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); return Encoding.UTF8.GetString(plainBytes); } } } }

跑一遍,你会看到明文37字节,PKCS7填充后变成48字节(37除以16得2余5,缺11字节,补11个0x0B,总共48字节)。密文Base64长度是64字符(48字节Base64编码后是64字符)。这些数字对上了,说明填充逻辑正确。

4. 跨语言对接实战:C#与Java/Python的填充对齐

4.1 C#与Java对接的填充陷阱

Java的AES加密标准写法是Cipher.getInstance("AES/CBC/PKCS5Padding")。很多人看到PKCS5就以为和C#的PKCS7不一样,其实在AES场景下,Java的PKCS5Padding就是PKCS7的行为,因为AES分组是16字节,PKCS5原本定义是8字节分组,Java沿用了这个名字但实现按16字节走。

对接时,Java端代码通常长这样:

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainBytes);

C#这边只要用PaddingMode.PKCS7,两边结果完全一致。我实测过,同样的密钥、IV、明文,Java加密出来的Base64和C#加密出来的Base64一模一样。

但有个坑:Java的doFinal在明文为空时会抛异常,而C#的TransformFinalBlock对空数组返回空数组。如果业务里可能出现空字符串,Java端要特殊处理,C#端也要判断。我一般约定空字符串不加密,直接传空,避免这个边界问题。

4.2 C#与Python对接的填充对齐

Python用pycryptodome库时,填充需要手动做:

from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 key = b'...' # 32字节 iv = b'...' # 16字节 cipher = AES.new(key, AES.MODE_CBC, iv) padded = pad(plain_bytes, AES.block_size) # 默认就是PKCS7 encrypted = cipher.encrypt(padded) result = base64.b64encode(encrypted).decode()

pad函数默认用的就是PKCS7,AES.block_size是16。C#这边用PKCS7,两边对齐。解密时Python用unpad去掉填充,C#用TransformFinalBlock自动去填充。

如果Python端用的是cryptography库,填充写法不同:

from cryptography.hazmat.primitives.padding import PKCS7 padder = PKCS7(128).padder() # 128位=16字节 padded_data = padder.update(plain_bytes) + padder.finalize()

注意这里的128是位数,不是字节数。我见过有人写成PKCS7(16),结果报错,因为参数是位。这种细节在对接文档里经常被忽略,但错了就是解密失败。

4.3 跨语言对接的验证方法

对接时怎么快速验证两边是否一致?我的做法是用固定密钥、固定IV、固定明文,两边各加密一次,对比Base64结果。如果Base64完全一样,说明填充、模式、编码全对齐了。如果不一样,再逐步排查。

排查顺序我一般这么走:

  1. 先确认密钥长度是否一致。C#的32字节密钥,Java那边是不是也32字节?Python是不是也32字节?长度不对直接报错。
  2. 再确认IV是否一致。IV是16字节,两边传的是不是同一个?
  3. 再确认模式。都是CBC吗?有没有一边用了ECB?
  4. 最后确认填充。都是PKCS7吗?
  5. 如果以上都对,检查编码。明文是UTF-8编码吗?Base64是标准Base64还是URL安全的Base64?

这个顺序是从粗到细,能快速定位问题。我遇到过最隐蔽的一次,是Java端用了Base64.getUrlEncoder(),把+和/换成了-和_,C#端用标准Base64解码就失败了。这种问题看密文特征能发现,标准Base64里出现-或_就是URL安全变体。

5. 填充异常排查实录:常见报错与解决路径

5.1 “Padding is invalid and cannot be removed”的六种成因

这个异常是C# AES解密时最常见的报错,字面意思是“填充无效,无法移除”。它可能由多种原因引起,我按出现频率从高到低排列:

第一种,密钥或IV不对。这是最常见的原因。密钥错一个字节,解密出来的最后一个字节就不是合法的填充值,直接抛异常。排查方法:确认密钥和IV的字节数组完全一致,可以用BitConverter.ToString(key)打印出来对比。

第二种,填充模式不匹配。加密用PKCS7,解密用Zeros,必然报错。排查方法:检查两边代码的Padding属性。

第三种,密文被截断或篡改。Base64传输过程中如果少了字符,解码后的字节数不对,解密也会失败。排查方法:对比密文的Base64长度,或者用MD5校验密文完整性。

第四种,编码问题。明文用UTF-8编码,解密后却用GBK解码,虽然不会直接报填充异常,但会得到乱码。如果乱码里恰好包含非法填充字节,也可能触发异常。排查方法:统一用UTF-8。

第五种,IV拼接顺序错误。如果IV拼在密文前面,解密时却从后面取IV,那IV就是错的。排查方法:打印拼接后的字节数组,确认IV的位置。

第六种,密钥派生方式不同。有些系统不直接用原始密钥,而是用PBKDF2、MD5等派生。如果一边用原始密钥,一边用派生密钥,必然对不上。排查方法:确认密钥是直接使用还是派生得到。

我把这六种成因和排查方法整理成速查表:

报错成因典型现象排查方法解决方式
密钥/IV不对必现异常打印字节数组对比统一密钥和IV
填充模式不匹配必现异常检查Padding属性两边改成一致
密文截断偶现或必现校验Base64长度检查传输环节
编码不一致乱码或异常统一UTF-8修改编码设置
IV位置错误必现异常打印拼接字节修正拼接顺序
密钥派生不同必现异常确认派生算法统一派生方式

5.2 解密出来是乱码但没报异常

这种情况比直接报异常更麻烦,因为程序不报错,但数据是错的。常见原因有三个。

第一个,IV不对但恰好能解密。CBC模式下,IV只影响第一个明文块。如果IV错了,第一个16字节的明文块会乱,但后面的块可能正常。如果业务数据的前16字节恰好不重要,或者被截掉了,就发现不了。排查方法:对比解密后完整数据的第一个块。

第二个,填充模式用了Zeros但明文末尾有0x00。Zeros填充去掉末尾所有0x00,如果原始数据末尾本来就有0x00,会被误删。排查方法:改用PKCS7,或者确认明文不含0x00结尾。

第三个,字符编码不一致。加密端用UTF-8,解密端用GBK,中文会乱码。排查方法:统一编码,或者在解密后打印字节数组的十六进制,对比原始明文的十六进制。

实操心得:我习惯在解密方法里加一个可选的日志开关,把解密后的字节数组以十六进制打印出来。这样即使出现乱码,也能通过对比十六进制快速定位是编码问题还是数据问题。这个习惯帮我省了大量排查时间。

5.3 填充相关的性能考量

填充本身的计算量极小,对性能的影响可以忽略。但有一个场景需要注意:如果加密大量短数据,PKCS7在明文恰好16整数倍时会多补一整块,导致密文长度增加16字节。如果业务对密文长度敏感(比如有字段长度限制),这个额外开销要算进去。

举个例子,明文是32字节,PKCS7填充后变成48字节,Base64后是64字符。如果数据库字段限制是60字符,就会截断。这种情况要么改字段长度,要么换用Zeros填充(但要注意0x00问题),要么在上层做压缩。

另外,Aes.Create()每次调用都会创建新实例,如果高频调用,建议复用Aes实例或者用对象池。不过Aes实例不是线程安全的,复用时要加锁或者用ThreadLocal。我一般在高频场景下用ThreadLocal<Aes>来避免重复创建的开销。

6. 进阶技巧与安全加固建议

6.1 密钥管理:别把密钥硬编码在代码里

我见过太多项目把AES密钥直接写在代码里,private static readonly byte[] Key = { 0x01, 0x02, ... }。这样做的风险是,代码一旦泄露,密钥就泄露了。更糟的是,如果代码进了版本控制,密钥就永久留在历史记录里了。

正确的做法是把密钥放在配置文件或环境变量里,并且配置文件不纳入版本控制。如果条件允许,用密钥管理服务(KMS)来托管密钥。C#里可以用Environment.GetEnvironmentVariable读取环境变量,或者用ConfigurationManager读取加密后的配置文件。

如果非要在代码里存密钥,至少做一层混淆,比如把密钥拆成几段,运行时拼接。但这只是提高门槛,不能从根本上解决问题。安全的核心是密钥不落地、不硬编码。

6.2 完整性校验:加密不等于防篡改

AES的CBC模式只保证机密性,不保证完整性。也就是说,攻击者可以在不知道密钥的情况下,翻转密文中的某些位,导致解密后的明文发生可预测的变化。这在某些业务场景下是危险的,比如加密的是转账金额,攻击者可能通过篡改密文把金额改大。

解决办法是加一个消息认证码(MAC),比如HMAC-SHA256。加密后,用另一个密钥对密文计算HMAC,把HMAC和密文一起传输。解密方先验证HMAC,再解密。这样任何篡改都会导致HMAC验证失败。

C#里可以用HMACSHA256类来实现:

using (var hmac = new HMACSHA256(macKey)) { byte[] mac = hmac.ComputeHash(cipherBytes); // 把mac和cipherBytes一起传输 }

注意:MAC密钥必须和加密密钥不同,否则会引入安全漏洞。这是密码学的基本要求,别图省事用同一个。

6.3 大数据的填充处理策略

如果要加密的数据很大,比如一个几百MB的文件,一次性读入内存加密会导致内存暴涨。这时候要用流式加密,分块读取、分块加密。C#的CryptoStream就是干这个的:

using (FileStream fsInput = File.OpenRead("plain.dat")) using (FileStream fsOutput = File.Create("cipher.dat")) using (Aes aes = Aes.Create()) { aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using (CryptoStream cs = new CryptoStream(fsOutput, aes.CreateEncryptor(), CryptoStreamMode.Write)) { fsInput.CopyTo(cs); } }

CryptoStream会自动处理分块和填充,最后一块自动补PKCS7。解密时同理,用CreateDecryptor。这种方式内存占用恒定,适合大文件。

但要注意,流式加密时IV只能影响第一个块,后续块靠前一块的密文链接。如果中途某一块损坏,后面的块全部解密失败。所以流式加密适合对完整性要求不极端的场景,极端场景还是要配合MAC。

6.4 填充模式None的正确使用姿势

PaddingMode.None不是不能用,而是要知道什么时候用。我总结了两类适用场景。

第一类,数据本身已经16字节对齐。比如某些二进制协议,包头固定16字节,包体也是16字节的整数倍。这时候用None可以省去填充开销,密文长度和明文长度完全一致。

第二类,上层已经做了填充。比如你自己实现了一套填充规则,或者用了某种编码(如Base64)把数据长度补齐到16的倍数。这时候AES层用None,避免双重填充。

用None时,加密前必须校验数据长度:

if (plainBytes.Length % 16 != 0) { throw new ArgumentException("明文长度必须是16的整数倍"); }

这个校验不能省,否则TransformFinalBlock会直接抛异常,错误信息不如自定义的清晰。

7. 我踩过的坑与实战经验汇总

7.1 一个真实对接案例的完整复盘

之前对接一个外部系统,对方文档写的是“AES加密,CBC模式,PKCS5填充,密钥32字节,IV16字节”。我按这个写了C#代码,用PKCS7填充,测试环境一直报填充异常。查了半天,发现对方实际用的是ECB模式,文档写错了。ECB模式下IV被忽略,我传的IV根本没起作用,解密时对方用ECB解,自然对不上。

这个案例的教训是:文档不一定准,一定要用固定向量验证。我后来养成了习惯,对接前先让对方提供一个加密后的样例密文和对应的明文,我用同样的密钥和IV加密,对比结果。如果对不上,再逐项排查。这个步骤花不了几分钟,但能避免几天的扯皮。

7.2 填充模式选择的经验法则

经过多个项目,我总结了一个简单的选择法则:

  • 新项目、无历史包袱:PKCS7,没有例外。
  • 对接Java系统:PKCS7,因为Java的PKCS5Padding在AES下就是PKCS7。
  • 对接Python系统:PKCS7,pycryptodome和cryptography默认都是PKCS7。
  • 对接老系统且文档明确写Zeros:Zeros,但要确认明文不含0x00结尾。
  • 对接硬件设备或二进制协议:None,但要自己保证16字节对齐。
  • 其他情况:先问对方,别猜。

这个法则覆盖了我遇到过的95%以上的场景。剩下的5%,基本都是文档写错或者对方也不清楚,需要实际验证。

7.3 调试AES加解密的实用技巧

最后分享几个我常用的调试技巧。

技巧一,用固定向量测试。密钥全0,IV全0,明文"Hello AES Test!!",加密后对比Base64。这个向量可以在不同语言、不同平台间快速验证实现是否一致。

技巧二,打印中间结果。加密前打印明文字节数组的十六进制,填充后打印填充后的字节数组,加密后打印密文的十六进制。这样每一步都能看到数据变化,定位问题很快。

技巧三,用在线工具交叉验证。有些在线AES工具可以指定模式、填充、密钥、IV,输入明文输出密文。用同样的参数在C#里跑一遍,对比结果。但要注意,在线工具的安全性无法保证,绝对不要用真实业务数据去测试,用测试数据即可。

技巧四,写单元测试。把加解密逻辑写成单元测试,用固定的密钥、IV、明文,断言加密结果等于预期的Base64,解密结果等于原始明文。这样每次改代码都能快速回归,避免改出填充问题。

[TestMethod] public void TestAesPkcs7RoundTrip() { byte[] key = new byte[32]; // 全0密钥,仅测试用 byte[] iv = new byte[16]; // 全0 IV,仅测试用 string plainText = "Hello AES Test!!"; string cipher = AesHelper.Encrypt(plainText, key, iv); string decrypted = AesHelper.Decrypt(cipher, key, iv); Assert.AreEqual(plainText, decrypted); }

这个测试虽然简单,但能覆盖填充的核心逻辑。如果哪天有人改了填充模式,测试立刻就会失败。

7.4 关于填充模式None的一个补充

用None时,如果明文长度不是16的整数倍,TransformFinalBlock会抛CryptographicException,错误信息是“Length of the data to decrypt is invalid”。这个错误和填充异常不同,它发生在解密阶段,因为密文长度不是16的整数倍。排查时看到这个错误,先检查密文长度,而不是填充模式。

另外,用None加密时,如果明文长度是16的整数倍,密文长度等于明文长度。但如果明文长度不是16的整数倍,加密直接失败。所以用None的场景,数据长度必须是可控的、确定的。

我在实际项目里用None的次数屈指可数,基本都是对接特定的二进制协议。绝大多数业务场景,PKCS7是唯一正确的选择。记住这一点,能帮你避开大部分填充相关的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进