1. 项目概述:为什么你的AES加密总是不对?
如果你在Java里用过AES加密,尤其是CBC模式和PKCS5Padding填充,大概率遇到过这些让人抓狂的问题:加密出来的密文每次都不一样、解密时抛出“BadPaddingException”、或者和别的系统(比如前端、别的语言写的服务端)对不上。网上的代码片段满天飞,复制粘贴过来一跑,要么报错,要么结果不对,查了半天也找不到原因。这其实不是AES算法本身复杂,而是大家对它的“使用姿势”存在普遍的误解和遗漏。
AES(高级加密标准)作为一种对称加密算法,本身是可靠且标准的。问题往往出在模式(Mode)和填充(Padding)的配套使用,以及那些容易被忽略但至关重要的“参数”——初始化向量(IV)。很多人只知道用AES/CBC/PKCS5Padding这个字符串,却不知道CBC模式必须、必须、必须使用一个随机且唯一的IV,并且这个IV需要和密文一起传递给解密方。还有密钥的生成、编码解码的一致性(是Hex还是Base64?),这些细节上的“乱配”,直接导致了加密解密失败。
这篇文章,我就以一个踩过无数坑的老开发的身份,手把手带你搞懂在Java中正确使用AES/CBC/PKCS5Padding的完整流程。我不会只给你一段孤立的代码,而是会拆解每一个步骤背后的原理,告诉你为什么要这么做,常见的坑在哪里,并附上能直接在生产环境参考的、健壮的完整工具类代码。无论你是正在解决实际的对接问题,还是准备面试中被问到加密解密,这篇文章都能让你彻底弄明白。
2. 核心概念扫盲:AES、CBC、PKCS5Padding与IV
在动手写代码之前,我们必须统一理解几个核心概念。这就像做菜前先认全调料,不然肯定要串味。
2.1 AES算法:对称加密的基石
AES是一种对称加密算法,意思是加密和解密使用同一把密钥。它处理数据的基本单位是“块”(Block),AES的块大小固定为128位(16字节)。无论你的明文是1个字节还是100个字节,AES都会以16字节为单位进行处理。密钥长度则可以是128位、192位或256位,分别称为AES-128, AES-192, AES-256。密钥越长,安全性越高,但计算开销也略大。对于绝大多数应用场景,AES-128已经足够安全,也是默认和最常用的选择。
注意:在Java中,如果安装的是标准JRE,受限于出口管制法规,默认可能不支持256位密钥。如果需要使用AES-256,通常需要安装Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files。这是一个非常常见的坑!
2.2 CBC模式:让加密结果不再固定
AES本身是块加密(Block Cipher),它只能加密一个16字节的块。为了加密更长的或任意长度的数据,我们需要一种“模式”(Mode)来将多个块连接起来。**ECB(电子密码本)**是最简单的模式,它直接将每个明文块独立加密。这会导致一个严重问题:相同的明文块会产生相同的密文块。对于有重复模式的数据(比如一张BMP图片),加密后的密文依然会保留其模式特征,安全性很差。
CBC(密码块链接)模式就是为了解决这个问题而生的。它的核心思想是“让每一个块的加密都依赖于前一个块”。具体做法是:
- 将明文分成若干个16字节的块(最后一块可能不足16字节)。
- 首先,第一块明文在加密前,先与一个叫做**初始化向量(IV)**的随机数据块进行异或(XOR)操作。
- 将结果用AES算法和密钥加密,得到第一块密文。
- 接下来,第二块明文在加密前,先与第一块密文进行异或操作,然后再加密,得到第二块密文。
- 如此循环,每一块明文的加密都依赖于前一块的密文。
这样一来,即使完全相同的明文,只要IV不同,产生的整个密文就会完全不同。同时,密文中任何一位的错误,都会在解密时“链式”地影响后续所有块,这在一定程度上提供了数据完整性校验。
2.3 PKCS5Padding:解决最后一块的“长度对齐”问题
由于AES块大小是16字节,明文的长度不可能总是16的整数倍。对于最后一块不足16字节的情况,就需要进行“填充”(Padding)。PKCS5Padding(在Java中,PKCS5Padding实际对应PKCS7Padding)是一种最常用的填充方案。
它的规则很简单:假设最后一个块还差N个字节才到16字节,那么就用数值N(字节形式)填充这N个字节。
- 例如,最后一块明文是
[0x01, 0x02, 0x03](3个字节),还差13个字节。那么填充后的数据就是[0x01, 0x02, 0x03, 0x0D, 0x0D, ... , 0x0D](共13个0x0D)。 - 如果明文长度正好是16的倍数,则需要额外填充一个完整的块,内容全部为
0x10(16)。
解密时,读取密文的最后一个字节,其值就是填充的长度N,然后直接移除最后N个字节,就得到了原始明文。这种方案可以明确区分哪些是填充数据,哪些是原始数据。
2.4 初始化向量(IV):CBC模式的安全灵魂
这是整个CBC模式中最关键、也最容易被忽略的部分!从上面CBC的原理可知,IV是加密链的起点。它必须满足以下两个条件:
- 随机性:每次加密都应该使用一个全新的、不可预测的随机IV。绝对不能使用固定值(比如全零),否则会丧失CBC模式的安全优势。
- 唯一性:在同一个密钥下,每次加密使用的IV必须不同。重复使用IV会导致安全漏洞。
IV不需要保密,但它必须和密文一起安全地传递给解密方。通常的做法是,将IV和密文拼接在一起(例如,IV放在密文前面),然后一起进行Base64编码或存储。解密时,先分离出IV,再用它进行解密。
很多人写的AES/CBC代码出错,十有八九是因为IV没处理好:要么固定了,要么没传,要么传错了。
3. 完整工具类设计与实现
理解了原理,我们开始设计一个健壮、易用的AES工具类。我们的目标是:输入明文和密钥,得到“IV+密文”的Base64字符串;输入这个Base64字符串和密钥,能还原出明文。
3.1 类结构设计
我们将创建一个AesCbcUtil类,它包含以下核心方法:
encrypt(String content, String key): 加密,返回Base64字符串。decrypt(String encryptedStr, String key): 解密,返回原始明文。- 内部辅助方法:
generateIv()(生成IV),getSecretKey(String key)(生成密钥规格)。
同时,我们需要定义一些常量:
ALGORITHM: 算法/模式/填充,即"AES/CBC/PKCS5Padding"。KEY_ALGORITHM: 密钥算法,即"AES"。CHARSET: 字符集,统一使用"UTF-8"。IV_LENGTH: IV的长度,对于AES CBC,固定为16字节。
3.2 密钥处理:从字符串到SecretKey
我们通常希望用一個字符串(比如一个密码)作为密钥。但AES算法需要的密钥是固定长度的二进制数据(128/192/256位)。因此,我们需要一个确定性的方法,将任意字符串转换为符合长度的密钥。
一种常见且安全的方法是使用密钥派生函数,如PBKDF2。但为了简化示例,这里采用一种更直接(但要求密钥字符串本身符合长度要求)的方法:使用字符串的字节数组,并通过MessageDigest(如SHA-256)生成一个固定长度的摘要作为密钥。这样即使原始字符串长度不一,我们也能得到固定长度的密钥材料。
import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; private static SecretKeySpec getSecretKey(final String key) throws Exception { if (key == null || key.trim().isEmpty()) { throw new IllegalArgumentException("密钥不能为空"); } // 使用SHA-256将输入密钥字符串散列成256位(32字节)的数据 MessageDigest sha = MessageDigest.getInstance("SHA-256"); byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] digest = sha.digest(keyBytes); // 取前16字节作为AES-128的密钥。如果需要AES-256,则取全部32字节。 // 注意:使用AES-256需确保JCE无限强度策略文件已安装。 return new SecretKeySpec(digest, 0, 16, "AES"); // AES-128 }实操心得:在生产环境中,对于密钥管理,更推荐的做法是:
- 直接使用一个安全的随机数生成器(如
SecureRandom)生成一个128/256位的二进制密钥,将其用Base64或Hex编码后安全存储。- 或者使用专业的密钥管理服务(KMS)。避免使用简单的字符串密码,尤其是短密码。
3.3 加密过程分步拆解
加密函数encrypt是核心,我们一步步拆解:
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import java.security.SecureRandom; import java.util.Base64; public static String encrypt(String content, String key) throws Exception { // 1. 参数校验 if (content == null || content.trim().isEmpty()) { throw new IllegalArgumentException("加密内容不能为空"); } // 2. 获取密钥规格 SecretKeySpec secretKeySpec = getSecretKey(key); // 3. 生成随机IV(16字节) byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); // 用安全的随机数填充iv数组 IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); // 4. 初始化Cipher为加密模式,传入密钥和IV Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); // 5. 执行加密 byte[] contentBytes = content.getBytes(CHARSET); byte[] encryptedBytes = cipher.doFinal(contentBytes); // 这里已经包含了PKCS5Padding的处理 // 6. 组合IV和密文。IV不需要保密,但必须传给解密方。 byte[] combined = new byte[iv.length + encryptedBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); // 7. 将组合后的二进制数据转换为Base64字符串,便于传输和存储 return Base64.getEncoder().encodeToString(combined); }关键点解析:
SecureRandom:生成IV必须使用密码学安全的随机数生成器,而不是普通的Random类。cipher.doFinal():这个方法一次性完成了加密和填充。我们不需要手动处理PKCS5Padding。System.arraycopy:我们将IV和密文拼接在一起。这是一种常见的约定。你也可以选择其他方式(如将IV进行Base64编码后作为HTTP头传递),但必须保证解密方能拿到相同的IV。
3.4 解密过程分步拆解
解密是加密的逆过程,需要小心地从Base64字符串中分离出IV和密文。
public static String decrypt(String encryptedStr, String key) throws Exception { // 1. 参数校验 if (encryptedStr == null || encryptedStr.trim().isEmpty()) { throw new IllegalArgumentException("待解密字符串不能为空"); } // 2. 获取密钥规格 SecretKeySpec secretKeySpec = getSecretKey(key); // 3. Base64解码,还原出“IV+密文”的二进制组合 byte[] combined = Base64.getDecoder().decode(encryptedStr); // 4. 分离IV和密文。前16字节是IV。 if (combined.length < IV_LENGTH) { throw new IllegalArgumentException("无效的加密数据,长度不足"); } byte[] iv = new byte[IV_LENGTH]; byte[] encryptedBytes = new byte[combined.length - IV_LENGTH]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); // 5. 初始化Cipher为解密模式,传入密钥和IV Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); // 6. 执行解密(同时会自动去除PKCS5Padding) byte[] decryptedBytes = cipher.doFinal(encryptedBytes); // 7. 将解密后的二进制数据按指定字符集转换为字符串 return new String(decryptedBytes, CHARSET); }关键点解析:
- 分离IV和密文是正确解密的前提。这里我们依赖加密时“IV在前,密文在后”的约定。
cipher.doFinal()在解密时,会自动检查并移除PKCS5Padding。如果密文被篡改或密钥错误,填充格式很可能不对,此时会抛出BadPaddingException,这是一个重要的安全特性。
3.5 完整工具类代码
将以上部分整合,并补充必要的常量和导入,就得到了一个完整的工具类。
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; /** * AES CBC PKCS5Padding 加密解密工具类 * 说明:加密结果 = Base64( IV + 密文 )。IV为16字节随机数。 */ public class AesCbcUtil { private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; private static final String KEY_ALGORITHM = "AES"; private static final String CHARSET = "UTF-8"; private static final int IV_LENGTH = 16; // AES块大小,单位字节 /** * 加密 * @param content 明文 * @param key 密钥字符串 * @return Base64编码的字符串,包含IV和密文 */ public static String encrypt(String content, String key) throws Exception { // 参数校验 if (content == null || key == null) { throw new IllegalArgumentException("内容和密钥不能为空"); } // 生成密钥 SecretKeySpec secretKeySpec = getSecretKey(key); // 生成随机IV byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); // 初始化加密器 Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); // 加密 byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); byte[] encryptedBytes = cipher.doFinal(contentBytes); // 组合IV和密文 byte[] combined = new byte[iv.length + encryptedBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); // Base64编码 return Base64.getEncoder().encodeToString(combined); } /** * 解密 * @param encryptedStr 加密后的Base64字符串 * @param key 密钥字符串(需与加密时相同) * @return 解密后的明文 */ public static String decrypt(String encryptedStr, String key) throws Exception { // 参数校验 if (encryptedStr == null || key == null) { throw new IllegalArgumentException("密文和密钥不能为空"); } // 生成密钥 SecretKeySpec secretKeySpec = getSecretKey(key); // Base64解码 byte[] combined = Base64.getDecoder().decode(encryptedStr); // 检查长度 if (combined.length < IV_LENGTH) { throw new IllegalArgumentException("无效的加密数据"); } // 分离IV和密文 byte[] iv = new byte[IV_LENGTH]; byte[] encryptedBytes = new byte[combined.length - IV_LENGTH]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); // 初始化解密器 Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); // 解密 byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } /** * 根据字符串密钥生成SecretKeySpec (AES-128) * 使用SHA-256散列确保密钥长度为32字节,并取前16字节作为128位密钥。 */ private static SecretKeySpec getSecretKey(final String key) throws Exception { if (key == null || key.trim().isEmpty()) { throw new IllegalArgumentException("密钥不能为空或空字符串"); } MessageDigest sha = MessageDigest.getInstance("SHA-256"); byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] digest = sha.digest(keyBytes); // 使用前16字节作为AES-128密钥 return new SecretKeySpec(digest, 0, 16, KEY_ALGORITHM); } // 简单的测试用例 public static void main(String[] args) { try { String originalText = "这是一段需要加密的敏感数据,Hello AES!"; String secretKey = "MySuperSecretKey123"; // 请使用强密钥 System.out.println("原始文本: " + originalText); System.out.println("密钥: " + secretKey); // 加密 String encryptedText = encrypt(originalText, secretKey); System.out.println("\n加密后 (Base64): " + encryptedText); // 每次运行,由于IV不同,这里的加密结果都会不一样 // 解密 String decryptedText = decrypt(encryptedText, secretKey); System.out.println("解密后文本: " + decryptedText); // 验证 System.out.println("\n解密是否成功: " + originalText.equals(decryptedText)); } catch (Exception e) { e.printStackTrace(); } } }运行main方法,你会看到每次加密的结果(Base64字符串)都不同,但解密后都能正确还原原文。这证明了CBC模式随机IV的有效性。
4. 跨平台/跨语言对接的要点
在实际开发中,Java后端经常需要与前端(JavaScript)、移动端(Android/iOS)、或其他语言服务(Python、Go、C#)进行加密解密对接。失败的原因99%在于双方对“约定”的理解不一致。以下是确保成功对接的检查清单:
4.1 必须对齐的八大参数
双方必须就以下八个参数达成完全一致:
| 参数 | 值/说明 | 常见不一致点 |
|---|---|---|
| 1. 算法 | AES | 必须是AES,不是DES、3DES等。 |
| 2. 密钥长度 | 128/192/256位 | 双方使用的密钥实际长度必须一致。Java工具类里我们固定为128位。 |
| 3. 模式 | CBC | 必须是CBC,不是ECB、CFB等。 |
| 4. 填充 | PKCS5Padding (PKCS7) | Java叫PKCS5Padding,其他平台(如JS、Python)可能叫PKCS7。在AES的16字节块下,两者等价。但名称必须确认。 |
| 5. 密钥 | 密钥字符串及生成方式 | 密钥字符串本身、以及如何将其转换为二进制密钥(如本文的SHA-256散列后取前16字节)。这是最容易出错的地方! |
| 6. 初始化向量 | IV的处理方式 | IV必须是16字节随机数。必须约定如何传递IV(本文是拼接在密文前)。 |
| 7. 字符集 | UTF-8 | 明文/密钥字符串转换为字节数组时使用的字符集。必须统一,否则会乱码。 |
| 8. 输出格式 | Base64 | 加密后的二进制数据(IV+密文)通常编码为Base64字符串进行传输。需确认是标准Base64还是URL安全的Base64,是否有换行。 |
4.2 与前端(CryptoJS)对接示例
CryptoJS是前端常用的加密库。以下是如何与上述Java工具类对齐的示例:
前端JavaScript (CryptoJS) 加密:
// 引入CryptoJS库 // <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script> function encryptWithJavaAES(plaintext, keyStr) { // 1. 处理密钥:SHA-256哈希后取前16字节(128位) var keyHash = CryptoJS.SHA256(keyStr); var key = CryptoJS.lib.WordArray.create(keyHash.words.slice(0, 4)); // 128位 = 4个字(32位*4) // 2. 生成随机IV (16字节) var iv = CryptoJS.lib.WordArray.random(16); // 3. 加密选项 var options = { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 // 注意:这里叫Pkcs7,与Java的PKCS5Padding兼容 }; // 4. 加密 var encrypted = CryptoJS.AES.encrypt(plaintext, key, options); // 5. 组合IV和密文:CryptoJS的encrypted对象包含ciphertext和iv // 将IV和密文的WordArray转换为字节数组再拼接 var ivArray = iv.toString(CryptoJS.enc.Base64); // IV的Base64 var ciphertextArray = encrypted.ciphertext.toString(CryptoJS.enc.Base64); // 密文的Base64 // 为了与Java端“IV字节+密文字节”然后整体Base64的格式匹配,我们需要在字节层面拼接 // 更简单可靠的方式:将IV和密文都转换成Latin1字符串(即原始字节)拼接,再整体Base64 var combinedBytes = iv.concat(encrypted.ciphertext); // 直接在WordArray层面拼接 var combinedBase64 = CryptoJS.enc.Base64.stringify(combinedBytes); return combinedBase64; // 这个字符串可以直接传给Java端的decrypt方法 } // 使用示例 var secret = "MySuperSecretKey123"; var text = "这是一段需要加密的敏感数据,Hello AES!"; var encryptedBase64 = encryptWithJavaAES(text, secret); console.log("前端加密结果:", encryptedBase64);关键对齐点:
- 密钥处理:双方都用SHA-256对密钥字符串散列,并取前128位(16字节)。
- IV:前端使用
CryptoJS.lib.WordArray.random(16)生成随机IV。 - 拼接方式:前端使用
iv.concat(encrypted.ciphertext)在二进制层面拼接IV和密文,然后整体进行Base64编码。这与Java工具类中System.arraycopy拼接后再Base64的逻辑完全一致。 - 填充:CryptoJS使用
Pkcs7,与Java的PKCS5Padding在AES下完全兼容。
按照以上方式,前端加密产生的Base64字符串,可以直接用我们Java工具类的decrypt方法成功解密。反之亦然。
注意事项:在实际对接中,务必先进行简单的测试。双方约定一个固定的密钥和明文,分别用各自的代码加密,然后交换密文尝试解密。如果失败,就逐一核对上述八个参数。使用Hex或Base64打印出中间步骤的密钥二进制、IV二进制、加密前的明文字节等,进行对比排查。
5. 常见问题、异常与排查指南
即使代码看起来正确,在实际运行和对接中还是会遇到各种问题。下面是我总结的一些典型异常和排查思路。
5.1 常见异常解析
| 异常信息 | 可能原因 | 排查思路 |
|---|---|---|
javax.crypto.BadPaddingException: Given final block not properly padded | 1.密钥错误:解密用的密钥与加密时不同。 2.IV错误:解密用的IV与加密时不同或未提供。 3.密文被篡改:传输或存储过程中密文损坏。 4.算法/模式/填充不匹配:解密时指定的算法字符串与加密时不一致。 | 1. 确认密钥字符串完全一致(包括大小写、空格)。 2. 确认IV的传递和提取逻辑一致。检查Base64解码和数组分离的代码。 3. 检查密文字符串是否完整,有无被截断或URL编码解码问题。 4. 核对双方 Cipher.getInstance()的参数是否一字不差。 |
java.security.InvalidKeyException: Illegal key size | 尝试使用AES-256加密,但未安装JCE无限强度策略文件。 | 1. 确认你是否使用了256位密钥。我们的工具类固定为128位,通常不会遇到。 2. 如果必须用256位,去Oracle官网下载对应你JDK版本的JCE策略文件,替换 $JAVA_HOME/jre/lib/security/下的两个jar包。 |
java.security.InvalidAlgorithmParameterException: Wrong IV length: must be 16 bytes long | 提供的IV长度不是16字节。 | 1. 检查生成IV的代码,确保生成了16字节。 2. 检查从组合数据中分离IV的代码,偏移量计算是否正确。 |
ArrayIndexOutOfBoundsException在分离IV和密文时 | Base64解码后的数据长度小于IV长度(16字节)。 | 1. 待解密的字符串可能不是有效的Base64格式。 2. 密文可能在传输过程中丢失了部分字符。 |
| 解密后得到乱码 | 字符集不匹配。 | 1. 确认加密和解密时,String.getBytes()和new String()使用的字符集是否都是UTF-8。2. 确认前端或其他系统在将字符串转换为字节数组时也使用了UTF-8。 |
5.2 调试与日志记录建议
在对接调试阶段,加入详细的日志记录至关重要。我建议在工具类中(或调用处)添加以下关键信息的日志:
// 在encrypt方法中 public static String encrypt(String content, String key) throws Exception { // ... 前面的代码 ... SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); System.out.println("[DEBUG-ENCRYPT] 生成IV (Hex): " + bytesToHex(iv)); // 记录IV System.out.println("[DEBUG-ENCRYPT] 密钥字符串: " + key); System.out.println("[DEBUG-ENCRYPT] 密钥字节 (Hex,前16位): " + bytesToHex(secretKeySpec.getEncoded())); System.out.println("[DEBUG-ENCRYPT] 明文字节 (Hex): " + bytesToHex(contentBytes)); System.out.println("[DEBUG-ENCRYPT] 密文字节 (Hex): " + bytesToHex(encryptedBytes)); // ... 后面的代码 ... String result = Base64.getEncoder().encodeToString(combined); System.out.println("[DEBUG-ENCRYPT] 最终输出Base64: " + result); return result; } // 在decrypt方法中 public static String decrypt(String encryptedStr, String key) throws Exception { // ... 前面的代码 ... byte[] combined = Base64.getDecoder().decode(encryptedStr); System.out.println("[DEBUG-DECRYPT] 输入Base64: " + encryptedStr); System.out.println("[DEBUG-DECRYPT] 解码后组合数据长度: " + combined.length); // ... 分离IV和密文 ... System.out.println("[DEBUG-DECRYPT] 提取的IV (Hex): " + bytesToHex(iv)); System.out.println("[DEBUG-DECRYPT] 提取的密文 (Hex): " + bytesToHex(encryptedBytes)); System.out.println("[DEBUG-DECRYPT] 使用的密钥字节 (Hex): " + bytesToHex(secretKeySpec.getEncoded())); // ... 后面的代码 ... } // 辅助方法:字节数组转十六进制字符串 private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); }通过对比双方日志中的IV、密钥二进制(Hex)、加密前的明文二进制(Hex),可以精准定位问题所在。例如,如果双方密钥的Hex值不同,那肯定是密钥处理逻辑不一致。
5.3 安全增强建议
本文提供的工具类已具备基本的安全性(随机IV、CBC模式)。但对于更高安全要求的场景,可以考虑以下增强:
- 密钥管理:绝对不要将密钥硬编码在代码中。使用环境变量、配置中心或专业的密钥管理服务(如HashiCorp Vault, AWS KMS)来存储和获取密钥。
- 密钥派生:对于用户提供的密码,应使用PBKDF2WithHmacSHA256这类慢哈希函数来派生密钥,并加入盐值(Salt),以抵御暴力破解。这比简单的SHA-256哈希更安全。
- 认证加密:CBC模式本身不提供完整性校验。虽然填充错误(BadPadding)能在一定程度上提示数据被篡改,但并非专门用于认证。考虑使用AES-GCM模式,它同时提供加密和认证功能,是更现代的选择。
- IV的存储:本文IV随密文一起存储和传输。确保整个密文(含IV)的完整性,例如在传输时使用HTTPS,存储时考虑签名。
6. 总结与最终建议
走完这一整套流程,你应该不再觉得AES/CBC/PKCS5Padding是什么玄学了。它的核心就是规范和一致。所有的问题都源于对规范理解的偏差或实现时细节的不一致。
回顾一下最关键的行动要点:
- IV是随机的,且必须传递:这是CBC模式正确工作的前提。
- 密钥处理要一致:双方必须用完全相同的方式从密钥字符串生成二进制密钥。
- 对齐所有参数:算法、模式、填充、密钥长度、字符集、输出格式,一个都不能错。
- 善用调试日志:在对接时,将关键中间结果(Hex格式)打印出来对比,是最高效的排查手段。
最后,对于新项目,如果不需要兼容旧系统,我建议直接考虑使用更现代、更简单的加密模式,如AES-GCM。它内置了认证功能,无需单独处理IV的传递(通常将IV作为密文的一部分),API也更简洁。但无论如何,理解CBC模式的工作原理,仍然是掌握对称加密的重要基石。希望这篇超详细的指南,能帮你彻底搞定Java中的AES加密,从此告别“乱配”的烦恼。