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 性能考量与最佳实践

  1. Cipher对象复用: Cipher.getInstance() 是一个比较耗时的操作,因为它需要加载类、验证转换字符串。在需要频繁加解密的场景(如Web请求中对每个字段加解密),应该将初始化好的Cipher对象缓存起来(例如使用ThreadLocal),避免重复创建。
  2. 选择适合的模式: 如果加密大量数据(如文件),且不需要CBC模式的安全性增益(例如已在其他层面保证了数据模式安全),可以考虑使用CTR模式以获得并行加密的性能优势。
  3. 密钥长度: 128位密钥在可预见的未来都是安全的。使用256位密钥会略微增加计算开销,但安全性提升在当前技术背景下并不显著。除非有合规性要求(如某些金融标准),否则128位是性价比最高的选择。
  4. 异常处理: 加解密操作可能抛出多种异常,如 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位密钥,这对绝大多数应用已足够安全。

我个人在实际项目中的几点心得:

  1. 密钥管理是核心 :不要将密钥写在代码里。我通常的做法是,在应用启动时从环境变量或中心配置服务读取一个“密钥加密密钥”(KEK),然后用这个KEK去解密存储在安全配置文件中的“数据加密密钥”(DEK)。这样即使配置文件泄露,攻击者没有KEK也无法解密DEK。
  2. 为加密数据添加“版本”或“上下文”信息 :在存储或传输加密数据时,可以在密文前附加一个简短的头部,例如 v1:CBC: ,其中 v1 表示算法版本, CBC 表示模式。这样未来如果需要升级加密算法(比如从AES-128-CBC升级到AES-256-GCM),可以平滑过渡,系统能够根据头部信息选择对应的解密方法。
  3. 考虑使用认证加密 :上述的CBC模式只提供了保密性,没有提供完整性(数据是否被篡改)。更现代的做法是使用GCM(Galois/Counter Mode)这样的认证加密模式,它同时提供保密性和完整性。Java中可以通过 "AES/GCM/NoPadding" 来使用。这通常是比CBC更推荐的选择,但实现上稍微复杂一点,需要处理认证标签(Tag)。
  4. 单元测试至关重要 :为你的加密工具类编写全面的单元测试,包括:使用固定密钥和IV验证加解密可逆;模拟与已知第三方输出结果的对比;测试对异常输入(空值、错误长度密钥)的处理。这能极大避免线上故障。

最后,记住加密只是安全链条中的一环。安全的系统设计、严格的访问控制、及时的漏洞修复同样重要。希望这篇长文能帮你彻底搞懂Java中的AES加解密,在项目中游刃有余地应用它。如果在实践中遇到新的问题,多从模式、填充、编码、密钥/IV这几个核心维度去排查,大部分问题都能迎刃而解。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐