前言:从钓鱼钓出问题说起

大家好,我是老张,今年 65 了,在石家庄退休了。年轻时在软件公司干了 40 年代码,现在退休了,家里养花、钓鱼、偶尔修修电子玩意儿。上周,邻居小刘找我帮忙:“张叔,我做个微信小程序,用 AES 加密传数据给 Java 后端,老是解密失败,怎么回事?”

我琢磨了一周,发现问题出在很多程序员都没注意的地方。今天把经验整理出来,希望能帮到像小刘这样的年轻人。


一、问题现象:加密结果每次都不同

小刘遇到的情况:

// JavaScript 前端
const encrypted1 = AES.encrypt('hello', 'key');
const encrypted2 = AES.encrypt('hello', 'key');

console.log(encrypted1); // U2FsdGVkX1+abc123...
console.log(encrypted2); // U2FsdGVkX1+xyz789...  // 不一样!

后端 Java 解密:

// Java 后端
SecretKeySpec key = new SecretKeySpec("key".getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted));
// 总是解密失败!

核心问题有两个:

  1. 为什么每次加密结果都不一样?
  2. 为什么 Java 后端解密不了?

二、原因分析:AES 加密的三个关键点

1. 随机 IV(初始化向量)

根本原因:

// 正确做法:每次生成随机 IV
const iv = CryptoJS.lib.WordArray.random(16);
const encrypted = CryptoJS.AES.encrypt(plaintext, key, {iv: iv});

为什么要 IV?

  • 防止相同明文生成相同密文
  • 提高安全性(否则攻击者可以发现规律)
  • 每次加密必须用新的随机 IV

问题所在:
如果每次加密生成的 IV 不一样,那么密文自然也不一样。但这不是错误,而是正常的安全机制


2. 密钥格式不对

JavaScript 的密钥:

// 很多开发者直接这样写
const key = CryptoJS.enc.Utf8.parse("password123");  // 只是字符串

Java 的密钥:

// Java 需要的是 16/24/32 字节的密钥
byte[] keyBytes = "password123".getBytes();  // 长度可能不对!

问题:

  • JS 可能用字符串生成密钥
  • Java 可能直接字节数组
  • 生成的密钥字节不一样!

解决方案:

// JS 端:统一用 Hex 或 Base64 密钥
const key = CryptoJS.enc.Hex.parse("30313233343536373839616263646566"); // 32 字符 = 16 字节

// Java 端:同样用 Hex
byte[] keyBytes = DatatypeConverter.parseHexBinary("30313233343536373839616263646566");

3. 填充模式不一致

常见加密模式:

AES/ECB/PKCS5Padding  // ECB 模式
AES/CBC/PKCS5Padding  // CBC 模式(需要 IV)
AES/CTR/PKCS5Padding  // CTR 模式

JavaScript 的默认:

// CryptoJS 默认用 CBC 模式 + PKCS7 填充
CryptoJS.AES.encrypt(plaintext, key)

Java 的默认:

Cipher.getInstance("AES")  // 默认 AES/ECB/PKCS5Padding

问题:
两者默认模式可能不一样!


三、完整解决方案

前端 JavaScript(CryptoJS)

// 1. 引入 CryptoJS
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>

// 2. 定义密钥和 IV(必须固定)
const KEY = CryptoJS.enc.Utf8.parse("0123456789abcdef");  // 16 字节
const IV = CryptoJS.enc.Utf8.parse("fedcba9876543210");    // 16 字节

// 3. 加密函数
function encrypt(plaintext) {
    const srcs = CryptoJS.enc.Utf8.parse(plaintext);
    const encrypted = CryptoJS.AES.encrypt(srcs, KEY, {
        iv: IV,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    });
    return encrypted.ciphertext.toString().toUpperCase();  // 转大写 Hex
}

// 4. 加密结果包含 IV 信息
function encryptWithIV(plaintext) {
    const iv = CryptoJS.lib.WordArray.random(16);
    const srcs = CryptoJS.enc.Utf8.parse(plaintext);
    const encrypted = CryptoJS.AES.encrypt(srcs, KEY, {
        iv: iv,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    });
    
    // 返回:密文 + IV(一起传给后端)
    return {
        ciphertext: encrypted.ciphertext.toString().toUpperCase(),
        iv: iv.toString().toUpperCase()
    };
}

后端 Java

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class AESUtil {
    
    // 1. 定义密钥和 IV(必须和前端一致)
    private static final String KEY_STRING = "0123456789abcdef";
    private static final String IV_STRING = "fedcba9876543210";
    
    private static final SecretKeySpec KEY_SPEC;
    private static final IvParameterSpec IV_SPEC;
    
    static {
        try {
            KEY_SPEC = new SecretKeySpec(
                KEY_STRING.getBytes("UTF-8"), 
                "AES"
            );
            IV_SPEC = new IvParameterSpec(
                IV_STRING.getBytes("UTF-8")
            );
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    // 2. 解密函数
    public static String decrypt(String ciphertextHex) throws Exception {
        // 将 Hex 转字节
        byte[] encryptedBytes = hexStringToByteArray(ciphertextHex);
        
        // 创建 Cipher
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, KEY_SPEC, IV_SPEC);
        
        // 解密
        byte[] decrypted = cipher.doFinal(encryptedBytes);
        return new String(decrypted, "UTF-8");
    }
    
    // 3. Hex 转字节辅助方法
    private static byte[] hexStringToByteArray(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                                 + Character.digit(hex.charAt(i+1), 16));
        }
        return data;
    }
    
    // 测试
    public static void main(String[] args) throws Exception {
        String ciphertext = "8F3A2B1C...";  // 从前端传来的密文
        String decrypted = decrypt(ciphertext);
        System.out.println("解密结果:" + decrypted);
    }
}

四、完整测试代码

前端测试

// 测试加密
const result = encryptWithIV("hello world");
console.log("密文:", result.ciphertext);
console.log("IV:", result.iv);

// 发送到大后端
fetch('/api/encrypt', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(result)
});

后端测试

// 接收并解密
@PostMapping("/encrypt")
public String decrypt(@RequestBody AESRequest request) throws Exception {
    String ciphertext = request.getCiphertext();
    String iv = request.getIv();  // 如果 IV 单独传
    
    String decrypted = AESUtil.decrypt(ciphertext);
    return decrypted;  // "hello world"
}

五、常见错误排查清单

✅ 检查项:

  1. 密钥长度

    • 16 字节 = 128 位
    • 24 字节 = 192 位
    • 32 字节 = 256 位
    • 前后端必须完全一致
  2. 模式一致

    • 都用 CBC 模式
    • 都用 PKCS5 或 PKCS7 填充
  3. 编码统一

    • 都转成 Hex 或 Base64 传输
    • 都用 UTF-8 编码文本
  4. IV 处理

    • IV 是随机的,每次加密不一样是正常的
    • IV 要一起传给后端(不能丢弃)
    • 或者前后端都固定同一个 IV(不推荐,但简单)
  5. 大小写一致

    • Hex 编码注意大小写
    • CryptoJS 默认大写,Java 要匹配

❌ 常见问题:

问题 原因 解决方案
密文每次都变 随机 IV 正常现象 不需要解决,这是安全的
Java 解密报错 密钥长度/模式不对 检查 KEY_SPEC 和 Cipher 参数
解密结果乱码 编码不一致 统一用 UTF-8
部分字节解密失败 填充模式不匹配 都用 PKCS5Padding
密文太长 包含 IV 的编码 检查传输数据格式

六、进阶:更安全的做法

1. 使用随机 IV 并单独传输

function encryptSecure(plaintext) {
    const iv = CryptoJS.lib.WordArray.random(16);
    const key = CryptoJS.enc.Utf8.parse("your-secret-key-1234");
    
    const encrypted = CryptoJS.AES.encrypt(plaintext, key, {
        iv: iv,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    });
    
    // 返回格式:{ iv: hex, ciphertext: hex }
    return {
        iv: iv.toString().toUpperCase(),
        ciphertext: encrypted.ciphertext.toString().toUpperCase(),
        tag: encrypted.sigBytes  // 如果需要认证
    };
}

2. 使用 AES-GCM 模式(推荐)

// CryptoJS 支持 GCM 模式(更现代、更安全)
const encrypted = CryptoJS.AES.encrypt(plaintext, key, {
    mode: CryptoJS.mode.GCM,
    iv: CryptoJS.lib.WordArray.random(12),
    length: 128
});

GCM 模式比 CBC 更安全,自动提供认证,不需要手动处理填充。


七、老程序员的建议

“我干了 40 年代码,见过太多加密问题。记住三点:密钥要安全、模式要统一、测试要充分。”

我的建议:

  1. 不要自己发明加密协议:用成熟的库(CryptoJS、Javax Crypt)
  2. 前后端都要写测试用例:确保加密解密能对上
  3. 生产环境用 HTTPS:加密是最后一道防线,不是全部
  4. 定期更新密钥:至少每年换一次
  5. 敏感数据双重加密:密码用 bcrypt,其他用 AES

结语:从问题中学习

上周小刘按照这个方法改完代码,终于成功了。他说:“张叔,原来问题不在代码本身,而在细节!”

是啊,编程就是细节的功夫。AES 加密看似简单,其实有很多坑。希望这篇文章能帮到正在遇到同样问题的你。

记住:

  • 每次加密结果不一样是正常的(因为 IV)
  • 前后端必须用完全一致的密钥和模式
  • IV 要一起传给后端,或者都固定同一个

有问题欢迎评论区交流,或者来我家钓鱼喝茶,边喝边聊!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐