Java AES加密解密实战:从原理到代码实现与常见问题解决
1. 项目概述与核心价值
在Java开发中,数据安全是一个绕不开的话题。无论是用户密码、敏感配置信息,还是需要在网络上传输的业务数据,加密都是保护它们的第一道防线。AES(Advanced Encryption Standard,高级加密标准)作为目前全球广泛使用的对称加密算法,因其安全性高、性能好,成为了Java开发者工具箱里的标配。但很多朋友在初次接触时,往往会卡在一些细节上:为什么我的加密结果和别人不一样?为什么从数据库解密出来一堆乱码?CBC模式和ECB模式到底该用哪个?IV(初始化向量)又是什么?
这篇文章,我就结合自己多年在金融和互联网项目中的实战经验,把Java AES加密和解密从原理到代码实现,掰开揉碎了讲清楚。我会重点解释那些官方文档里一笔带过,但实际开发中却至关重要的“坑点”,比如不同工作模式和填充方式的区别、密钥和IV的生成与管理、以及与MySQL等数据库的加解密互通。无论你是正在处理一个需要加密存储用户信息的Web项目,还是在设计一个安全的API接口,这篇文章提供的代码和思路都能让你直接“抄作业”,避开我当年踩过的那些坑。
2. AES加密核心原理与模式选择
在动手写代码之前,我们必须先理解AES是怎么工作的,以及几个关键概念的选择会如何影响我们最终的代码实现。这就像做菜,你得先知道火候和调料的作用,才能炒出一盘好菜。
2.1 对称加密与AES算法简述
AES是一种对称加密算法。所谓“对称”,就是加密和解密使用同一把钥匙(密钥)。这把钥匙的长度可以是128位、192位或256位。位数越长,安全性越高,但计算开销也略大。对于绝大多数应用场景,128位(即16个字节)的密钥长度已经足够安全,它也是目前最常用的选择。
AES加密的过程,可以想象成一个非常复杂的“搅拌机”。它把固定大小的“数据块”(128位,即16字节)和“密钥”放进去,经过多轮的替换、移位、列混合等操作(这些操作统称为轮函数),最终输出一个同样大小的“密文块”。解密则是这个过程的逆运算。如果原始数据(明文)超过16字节,就需要把它切成多个16字节的块,逐个处理,这就是“分组加密”。如何切分、如何处理块与块之间的关系,就引出了不同的“工作模式”。
2.2 五种工作模式深度解析与选型指南
工作模式决定了AES这个“搅拌机”如何处理多个数据块。选错了模式,可能会导致安全性问题或功能缺陷。
ECB模式 (Electronic Codebook): 这是最简单粗暴的模式。每个明文块独立地用同一个密钥加密,相同的明文块必然产生相同的密文块。你可以把它想象成用同一本密码本(Codebook)逐字翻译一篇文章。它的优点是简单、速度快,并且每个块的加密可以并行进行。但致命缺点是,如果原文中有大量重复的段落(比如一张图片的纯色背景),那么密文中也会出现明显的重复图案,这为攻击者提供了分析突破口。 因此,在需要保护数据模式的场景下(如图像、有固定结构的数据),绝对不要使用ECB模式。
CBC模式 (Cipher Block Chaining): 这是目前最常用、也最推荐的模式。它引入了一个“初始化向量”(IV)。加密第一个块时,先将IV与第一个明文块进行异或(XOR)操作,然后再用密钥加密。加密第二个块时,则将第一个块的 密文 作为新的“IV”,与第二个明文块异或后再加密,以此类推。这就好比做菜时,炒完第一盘菜后,锅底留的底油(相当于上一块的密文)会影响到下一盘菜的味道。这样一来,即使两个明文块完全相同,加密后的密文块也完全不同,完美解决了ECB的模式泄露问题。解密时过程相反。CBC模式的缺点是加密过程无法并行(因为需要前一个密文块),但解密过程可以并行。
CFB模式 & OFB模式 (Cipher/Output FeedBack): 这两种模式都将分组密码转换为一种“流密码”。它们不是直接加密明文,而是先用密钥加密一个“状态值”(初始为IV),生成一个密钥流,然后用这个密钥流与明文进行异或得到密文。CFB模式用密文反馈来更新状态,OFB模式用加密后的输出反馈来更新状态。它们的特点是可以处理任意长度的数据,不需要填充,特别适合实时流数据传输(如音视频流)。但同样,它们的加密过程也无法并行。
CTR模式 (CounTeR): CTR模式也可以看作是一种流密码。它使用一个计数器(Counter)与密钥加密后生成密钥流,再与明文异或。计数器的值通常每次加1。它的巨大优势在于, 加密和解密都可以完全并行化 ,因为每个块的密钥流生成只依赖于计数器的值,而不依赖于其他块。这使得CTR模式在需要高性能加解密的场景下(如加密大文件)非常有优势。
模式选择总结:
- 通用场景,数据安全优先:选择 CBC 模式。 这是平衡了安全性和实现复杂度的最佳选择,也是大多数库和协议的默认或推荐模式。
- 需要加密流数据或数据长度不固定:考虑 CFB 或 OFB 模式。
- 追求极致加解密性能,且能保证计数器唯一性:选择 CTR 模式。
- 除非有特殊兼容性要求,否则避免使用 ECB 模式。
2.3 填充方案(Padding)详解
AES一次处理一个16字节的块。如果明文长度不是16字节的整数倍,最后一个块就需要“填充”到16字节。常见的填充方案有:
- PKCS5Padding / PKCS7Padding: 这是最常用的填充方式。假设最后一个块还差N个字节,那么就填充N个值为N的字节。例如,差5个字节,就填充
0x05 0x05 0x05 0x05 0x05。解密后,读取最后一个字节的值,就知道需要移除多少填充字节。Java中通常用PKCS5Padding,但实际上它处理的是16字节块,应称为PKCS7Padding,不过JDK内部做了兼容。 - NoPadding: 不进行填充。这就要求调用者必须确保明文长度是16字节的整数倍,否则会抛出异常。
- ISO10126Padding: 与PKCS5类似,但除了最后一个字节是填充长度外,其余填充字节是随机数,安全性稍好。
在CBC模式下,即使选择了 NoPadding ,如果数据不是16字节的倍数,JDK的Cipher实现通常也会在内部用0补全以满足异或运算,但解密后这些补全的0也会被保留,需要开发者手动截断。因此, 对于通用文本加密,强烈建议使用 PKCS5Padding ,省心省力。
3. Java AES加解密代码实战与避坑指南
理论讲完,我们进入实战环节。这里我会给出两套最常用、也最容易出错的代码实现,并附上详细的注释和避坑说明。
3.1 基础工具类:AES-128-ECB 模式实现
ECB模式虽然不安全,但因其简单,常在一些对安全性要求不高或需要与某些旧系统(如特定版本的MySQL AES_ENCRYPT 函数)兼容的场景下使用。下面的工具类实现了与MySQL AES_ENCRYPT / AES_DECRYPT 函数兼容的ECB模式加解密。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64; // 使用JDK8+的标准Base64
/**
* AES-128-ECB 加解密工具类
* 注意:ECB模式不安全,仅用于演示或与特定系统(如旧版MySQL AES函数)兼容。
* 密钥必须为16字节(128位)。
*/
public class AesEcbUtil {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding"; // 算法/模式/填充
/**
* ECB模式加密
* @param plainText 明文
* @param secretKey 密钥,必须为16个字符(128位)
* @return Base64编码的密文
*/
public static String encrypt(String plainText, String secretKey) throws Exception {
// 1. 参数校验
if (secretKey == null || secretKey.length() != 16) {
throw new IllegalArgumentException("密钥必须为非空且长度为16位");
}
// 2. 生成密钥规范
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes("UTF-8"), ALGORITHM);
// 3. 初始化Cipher为加密模式
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
// 4. 执行加密
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes("UTF-8"));
// 5. 使用Base64编码,便于传输和存储(避免乱码)
return Base64.getEncoder().encodeToString(encryptedBytes);
}
/**
* ECB模式解密
* @param encryptedText Base64编码的密文
* @param secretKey 密钥,必须为16个字符(128位)
* @return 解密后的明文
*/
public static String decrypt(String encryptedText, String secretKey) throws Exception {
// 1. 参数校验
if (secretKey == null || secretKey.length() != 16) {
throw new IllegalArgumentException("密钥必须为非空且长度为16位");
}
// 2. 生成密钥规范
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes("UTF-8"), ALGORITHM);
// 3. 初始化Cipher为解密模式
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec);
// 4. Base64解码
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedText);
// 5. 执行解密
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
// 6. 返回字符串
return new String(decryptedBytes, "UTF-8");
}
public static void main(String[] args) throws Exception {
String secretKey = "1234567890123456"; // 16位密钥
String originalText = "Hello, AES-ECB!";
System.out.println("原文: " + originalText);
String encryptedText = encrypt(originalText, secretKey);
System.out.println("加密后 (Base64): " + encryptedText);
String decryptedText = decrypt(encryptedText, secretKey);
System.out.println("解密后: " + decryptedText);
}
}
避坑点1:字符编码一致性 注意代码中多次出现的
"UTF-8"。getBytes()和new String()时必须明确指定字符编码,否则在不同操作系统(如Windows默认GBK,Linux默认UTF-8)上运行时,同样的字符串会得到不同的字节数组,导致“密钥错误”或解密后乱码。这是新手最容易踩的坑之一。
避坑点2:Base64编码的必要性 加密后的字节数组是二进制数据,可能包含不可打印字符。直接转换成String会丢失信息或产生乱码。使用Base64编码将其转换为纯文本字符串,便于在JSON、XML、URL或数据库中安全存储和传输。JDK8及以上推荐使用
java.util.Base64,替代旧的sun.misc.BASE64Encoder。
3.2 推荐方案:AES-128-CBC 模式实现(更安全)
对于绝大多数需要安全加密的场景,我们都应该使用CBC模式。它需要一个额外的参数:初始化向量(IV)。
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES-128-CBC 加解密工具类(推荐)
* 使用更安全的CBC模式,需要密钥和初始化向量(IV)。
* 密钥和IV必须为16字节(128位)。
* 注意:IV不需要保密,但必须不可预测,且每次加密最好使用不同的IV。
*/
public class AesCbcUtil {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
private static final int KEY_SIZE = 16; // 128位 = 16字节
/**
* 生成一个随机的初始化向量(IV)
* @return 16字节的随机IV
*/
public static byte[] generateIv() {
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv); // 使用密码学安全的随机数生成器
return iv;
}
/**
* CBC模式加密
* @param plainText 明文
* @param secretKey 密钥,16字节
* @param iv 初始化向量,16字节
* @return Base64编码的密文。实际应用中,IV需要和密文一起存储/传输。
*/
public static String encrypt(String plainText, byte[] secretKey, byte[] iv) throws Exception {
// 参数校验
if (secretKey.length != KEY_SIZE || iv.length != KEY_SIZE) {
throw new IllegalArgumentException("密钥和IV长度必须为16字节");
}
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey, ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encryptedBytes);
}
/**
* CBC模式解密
* @param encryptedText Base64编码的密文
* @param secretKey 密钥,16字节
* @param iv 初始化向量,必须与加密时使用的IV相同
* @return 解密后的明文
*/
public static String decrypt(String encryptedText, byte[] secretKey, byte[] iv) throws Exception {
// 参数校验
if (secretKey.length != KEY_SIZE || iv.length != KEY_SIZE) {
throw new IllegalArgumentException("密钥和IV长度必须为16字节");
}
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey, ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedText);
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
return new String(decryptedBytes, "UTF-8");
}
/**
* 一个更易用的加密方法,内部生成随机IV并拼接在密文前
* 格式: Base64(IV) + ":" + Base64(CipherText)
* @param plainText 明文
* @param secretKey 密钥
* @return 拼接后的字符串
*/
public static String encryptWithIv(String plainText, byte[] secretKey) throws Exception {
byte[] iv = generateIv();
String cipherText = encrypt(plainText, secretKey, iv);
String ivBase64 = Base64.getEncoder().encodeToString(iv);
return ivBase64 + ":" + cipherText; // 用分隔符连接
}
/**
* 解密 encryptWithIv 方法加密的数据
* @param combinedText encryptWithIv 返回的字符串
* @param secretKey 密钥
* @return 明文
*/
public static String decryptWithIv(String combinedText, byte[] secretKey) throws Exception {
String[] parts = combinedText.split(":", 2);
if (parts.length != 2) {
throw new IllegalArgumentException("无效的加密数据格式");
}
byte[] iv = Base64.getDecoder().decode(parts[0]);
String cipherText = parts[1];
return decrypt(cipherText, secretKey, iv);
}
public static void main(String[] args) throws Exception {
// 密钥应该从安全的配置源获取,这里仅为演示
String keyStr = "MySuperSecretKey!"; // 16个字符
byte[] secretKey = keyStr.getBytes("UTF-8");
String originalText = "这是一段需要安全加密的敏感数据。";
System.out.println("原文: " + originalText);
// 方法一:自己管理IV
byte[] iv = generateIv();
String encryptedText1 = encrypt(originalText, secretKey, iv);
System.out.println("加密后 (CBC,自带IV): " + encryptedText1);
String decryptedText1 = decrypt(encryptedText1, secretKey, iv);
System.out.println("解密后: " + decryptedText1);
System.out.println("---");
// 方法二:使用封装方法,IV与密文一起返回
String combinedResult = encryptWithIv(originalText, secretKey);
System.out.println("加密后 (IV+密文组合): " + combinedResult);
String decryptedText2 = decryptWithIv(combinedResult, secretKey);
System.out.println("解密后: " + decryptedText2);
}
}
核心要点1:IV的作用与管理 IV的作用是确保即使相同的明文、相同的密钥,每次加密也会产生不同的密文,从而隐藏明文模式。 IV不需要保密,但必须不可预测(即随机生成),且对于同一密钥,每次加密都应使用不同的IV。 常见的做法是使用
SecureRandom生成IV,然后将其与密文一起存储或传输(如拼接在密文前)。解密时,再从组合字符串中分离出IV使用。绝对不要使用固定IV或全零IV,那会严重削弱CBC模式的安全性。
核心要点2:密钥的安全存储 代码中硬编码密钥是极不安全的。在实际项目中,密钥应该存储在环境变量、专用的密钥管理系统(如HashiCorp Vault、AWS KMS)或经过严格访问控制的配置文件中。密钥是加密体系的根本,一旦泄露,所有加密数据都可能暴露。
4. 高级话题:密钥生成、数据库互通与性能优化
掌握了基础实现后,我们来看看在实际工程中会遇到的一些进阶问题。
4.1 安全的密钥生成与管理
我们不应该用人脑来想一个密钥。Java提供了 KeyGenerator 类来生成密码学意义上安全的随机密钥。
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
public class KeyGenDemo {
public static void main(String[] args) throws NoSuchAlgorithmException {
// 1. 获取AES密钥生成器实例
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
// 2. 初始化密钥生成器,指定密钥长度(128, 192, 256)
keyGen.init(128); // 也可以是 192 或 256
// 3. 生成密钥
SecretKey secretKey = keyGen.generateKey();
// 4. 获取密钥的字节数组
byte[] keyBytes = secretKey.getEncoded();
// 5. 可以转换为Base64字符串方便存储(但存储本身需安全!)
String base64Key = Base64.getEncoder().encodeToString(keyBytes);
System.out.println("生成的密钥 (Base64): " + base64Key);
System.out.println("密钥长度 (字节): " + keyBytes.length);
// 后续可以将 base64Key 安全地存储起来,使用时再解码回 byte[]
// byte[] loadedKey = Base64.getDecoder().decode(base64Key);
// SecretKeySpec keySpec = new SecretKeySpec(loadedKey, "AES");
}
}
注意: 生成的密钥是随机的二进制数据。将其转换为Base64字符串只是为了便于在文本配置中表示。 这个字符串本身就和密钥一样敏感,必须妥善保管。
4.2 与MySQL数据库的AES函数互通
很多业务场景下,我们希望在Java端加密数据,存入数据库,然后既能用Java解密,也能直接用SQL语句在数据库层面进行查询和解密。MySQL提供了 AES_ENCRYPT() 和 AES_DECRYPT() 函数。
关键点在于:MySQL的AES函数默认使用ECB模式,且填充方式比较特殊。 它并不是标准的PKCS5/PKCS7填充。为了让Java与MySQL互通,我们需要在Java端模拟MySQL的填充行为。
经过测试,MySQL 5.7及以上版本的 AES_ENCRYPT 函数,在数据不是16字节倍数时,使用的是“用0x00字节填充到16的倍数”的方式。同时,它默认使用ECB模式。因此,对应的Java代码需要做如下调整:
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class MysqlCompatibleAESUtil {
/**
* 模拟 MySQL AES_ENCRYPT 函数
* 算法:AES/ECB/NoPadding,但需手动进行零填充
*/
public static String encryptLikeMysql(String plainText, String secretKey) throws Exception {
if (secretKey.length() != 16) {
throw new IllegalArgumentException("MySQL AES key must be 16 bytes");
}
byte[] keyBytes = secretKey.getBytes("UTF-8");
byte[] plainBytes = plainText.getBytes("UTF-8");
// 手动进行零填充
int blockSize = 16;
int paddedLength = ((plainBytes.length + blockSize - 1) / blockSize) * blockSize;
byte[] paddedPlainBytes = new byte[paddedLength];
System.arraycopy(plainBytes, 0, paddedPlainBytes, 0, plainBytes.length);
// 剩余部分已经是0 (Java数组初始化默认值为0)
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); // 注意是NoPadding
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encryptedBytes = cipher.doFinal(paddedPlainBytes);
// MySQL的AES_ENCRYPT返回的是二进制,我们这里用Base64表示
// 实际存储到MySQL BLOB字段时,可以直接存字节数组
return Base64.getEncoder().encodeToString(encryptedBytes);
}
/**
* 模拟 MySQL AES_DECRYPT 函数
*/
public static String decryptLikeMysql(String base64CipherText, String secretKey) throws Exception {
byte[] keyBytes = secretKey.getBytes("UTF-8");
byte[] encryptedBytes = Base64.getDecoder().decode(base64CipherText);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decryptedPaddedBytes = cipher.doFinal(encryptedBytes);
// 去除末尾的零填充。找到最后一个非零字节的位置。
int idx = decryptedPaddedBytes.length;
while (idx > 0 && decryptedPaddedBytes[idx - 1] == 0) {
idx--;
}
byte[] decryptedBytes = new byte[idx];
System.arraycopy(decryptedPaddedBytes, 0, decryptedBytes, 0, idx);
return new String(decryptedBytes, "UTF-8");
}
public static void main(String[] args) throws Exception {
String key = "1234567890123456";
String text = "Hello MySQL AES";
String encrypted = encryptLikeMysql(text, key);
System.out.println("Java加密 (模拟MySQL): " + encrypted);
// 对应的MySQL查询应该是:
// SELECT TO_BASE64(AES_ENCRYPT('Hello MySQL AES', '1234567890123456'));
// 两者输出的Base64字符串应该是一致的。
String decrypted = decryptLikeMysql(encrypted, key);
System.out.println("Java解密: " + decrypted);
}
}
重要提示: 这种零填充方式存在歧义。如果明文本身末尾就有合法的0x00字节,解密时将无法区分哪些是填充、哪些是原始数据。因此, 仅在与MySQL AES函数互通的特定场景下使用此方法 。在新的跨系统设计中,更推荐使用标准的PKCS5Padding和CBC模式,并在各端(Java、数据库、其他语言)使用统一的、标准的加密库来实现。
4.3 性能考量与最佳实践
- Cipher对象复用:
Cipher.getInstance()是一个比较耗时的操作,因为它需要加载类、验证转换字符串。在需要频繁加解密的场景(如Web请求中对每个字段加解密),应该将初始化好的Cipher对象缓存起来(例如使用ThreadLocal),避免重复创建。 - 选择适合的模式: 如果加密大量数据(如文件),且不需要CBC模式的安全性增益(例如已在其他层面保证了数据模式安全),可以考虑使用CTR模式以获得并行加密的性能优势。
- 密钥长度: 128位密钥在可预见的未来都是安全的。使用256位密钥会略微增加计算开销,但安全性提升在当前技术背景下并不显著。除非有合规性要求(如某些金融标准),否则128位是性价比最高的选择。
- 异常处理: 加解密操作可能抛出多种异常,如
BadPaddingException(通常意味着密钥或IV错误)、IllegalBlockSizeException(数据长度不对)等。在生产代码中,应该捕获这些异常并转换为业务友好的错误信息,而不是直接将栈轨迹抛给用户。
5. 常见问题排查与实战技巧
在实际开发中,你几乎一定会遇到下面这些问题。我把它们和解决方案整理成了表格,方便你快速查阅。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
解密时抛出 javax.crypto.BadPaddingException: Given final block not properly padded |
1. 密钥错误 :加密和解密使用的密钥不一致。 2. IV错误 (CBC模式):加密和解密使用的IV不一致。 3. 数据被篡改 :密文在传输或存储过程中发生了改变。 4. 填充方式不匹配 :加密用PKCS5,解密用NoPadding,或与MySQL等外部系统填充方式不一致。 |
1. 核对密钥 :确保加解密双方的密钥字符串完全一致,包括大小写和空格。使用调试工具打印密钥的字节数组或Base64编码进行比对。 2. 核对IV :确保CBC模式下,解密时使用的IV与加密时生成的IV完全相同。检查IV的存储和传输逻辑。 3. 检查数据完整性 :确保密文没有被截断或修改。可以考虑在加密后对密文计算MAC(消息认证码)或使用AEAD模式(如GCM)。 4. 统一填充方案 :确保加解密双方使用相同的填充方案。与外部系统交互时,务必查明对方使用的具体填充规则。 |
| 解密后得到乱码 | 1. 字符编码不一致 :加密时用UTF-8,解密时用GBK,或反之。 2. Base64编解码问题 :加密后未做Base64编码直接转字符串,或解密前未正确Base64解码。 3. 数据截断 :密文在传输过程中可能被不恰当地处理(如存入数据库VARCHAR字段时被截断)。 |
1. 强制指定编码 :在所有 getBytes() 和 new String() 的地方显式指定 "UTF-8" 。 2. 验证Base64 :确保加密后一定经过Base64编码再输出;解密前一定先对输入字符串进行Base64解码。可以使用在线的Base64编解码工具验证中间结果。 3. 使用二进制字段 :将密文(Base64字符串或字节数组)存入数据库时,使用 BLOB 、 VARBINARY 或 TEXT / LONGTEXT 字段,避免使用 VARCHAR (可能因字符集转换出问题)。 |
| 加密后的结果每次都不一样(CBC模式) | 这是正常且正确的行为! 如果使用了随机生成的IV,即使相同的明文和密钥,每次加密结果也会不同。这正是CBC模式安全性的体现。只要解密时使用对应的IV即可。 | 无需处理。确保将IV随密文一起保存和传输。 |
| 加密后的结果每次都不一样(ECB模式) | 不正常! ECB模式是确定性加密,相同输入必然产生相同输出。如果结果不同,说明密钥或明文在每次加密前发生了变化(例如,字符串包含了时间戳等可变部分)。 | 检查加密函数的输入参数(明文和密钥)是否每次调用都完全一致。 |
| 与第三方(如PHP、Python)加解密结果不一致 | 1. 模式/填充不匹配 :双方使用的AES模式(ECB/CBC等)或填充方案(PKCS5/NoPadding等)不同。 2. 密钥/IV处理不同 :密钥或IV的字符串到字节数组的转换方式不同(如编码差异)。 3. Base64差异 :可能存在URL安全的Base64、标准Base64、是否添加换行等差异。 |
1. 对齐算法字符串 :这是最关键的一步。确保双方使用的算法标识字符串完全一致。例如,Java的 "AES/CBC/PKCS5Padding" 对应OpenSSL的 aes-128-cbc 和PKCS7填充(与PKCS5兼容)。 2. 统一编码 :约定双方都使用UTF-8编码将字符串转换为字节。 3. 统一Base64 :约定使用标准Base64,不换行。可以使用一个简单的字符串(如 "test" )进行双向加解密联调。 |
InvalidKeyException: Illegal key size |
这是因为使用了超过128位的密钥(如192或256位),而Java运行环境受限于默认的JCE策略文件。 | 1. 方案一(推荐) :下载并安装Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files,覆盖JRE的 lib/security 目录下的对应jar包。 2. 方案二 :使用128位密钥,这对绝大多数应用已足够安全。 |
我个人在实际项目中的几点心得:
- 密钥管理是核心 :不要将密钥写在代码里。我通常的做法是,在应用启动时从环境变量或中心配置服务读取一个“密钥加密密钥”(KEK),然后用这个KEK去解密存储在安全配置文件中的“数据加密密钥”(DEK)。这样即使配置文件泄露,攻击者没有KEK也无法解密DEK。
- 为加密数据添加“版本”或“上下文”信息 :在存储或传输加密数据时,可以在密文前附加一个简短的头部,例如
v1:CBC:,其中v1表示算法版本,CBC表示模式。这样未来如果需要升级加密算法(比如从AES-128-CBC升级到AES-256-GCM),可以平滑过渡,系统能够根据头部信息选择对应的解密方法。 - 考虑使用认证加密 :上述的CBC模式只提供了保密性,没有提供完整性(数据是否被篡改)。更现代的做法是使用GCM(Galois/Counter Mode)这样的认证加密模式,它同时提供保密性和完整性。Java中可以通过
"AES/GCM/NoPadding"来使用。这通常是比CBC更推荐的选择,但实现上稍微复杂一点,需要处理认证标签(Tag)。 - 单元测试至关重要 :为你的加密工具类编写全面的单元测试,包括:使用固定密钥和IV验证加解密可逆;模拟与已知第三方输出结果的对比;测试对异常输入(空值、错误长度密钥)的处理。这能极大避免线上故障。
最后,记住加密只是安全链条中的一环。安全的系统设计、严格的访问控制、及时的漏洞修复同样重要。希望这篇长文能帮你彻底搞懂Java中的AES加解密,在项目中游刃有余地应用它。如果在实践中遇到新的问题,多从模式、填充、编码、密钥/IV这几个核心维度去排查,大部分问题都能迎刃而解。
更多推荐

所有评论(0)