为什么 JS 的 AES 加密每次得到的结果都不一样,并且无法在后端被 Java 正常解密呢?
·
前言:从钓鱼钓出问题说起
大家好,我是老张,今年 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));
// 总是解密失败!
核心问题有两个:
- 为什么每次加密结果都不一样?
- 为什么 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"
}
五、常见错误排查清单
✅ 检查项:
-
密钥长度
- 16 字节 = 128 位
- 24 字节 = 192 位
- 32 字节 = 256 位
- 前后端必须完全一致
-
模式一致
- 都用 CBC 模式
- 都用 PKCS5 或 PKCS7 填充
-
编码统一
- 都转成 Hex 或 Base64 传输
- 都用 UTF-8 编码文本
-
IV 处理
- IV 是随机的,每次加密不一样是正常的
- IV 要一起传给后端(不能丢弃)
- 或者前后端都固定同一个 IV(不推荐,但简单)
-
大小写一致
- 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 年代码,见过太多加密问题。记住三点:密钥要安全、模式要统一、测试要充分。”
我的建议:
- 不要自己发明加密协议:用成熟的库(CryptoJS、Javax Crypt)
- 前后端都要写测试用例:确保加密解密能对上
- 生产环境用 HTTPS:加密是最后一道防线,不是全部
- 定期更新密钥:至少每年换一次
- 敏感数据双重加密:密码用 bcrypt,其他用 AES
结语:从问题中学习
上周小刘按照这个方法改完代码,终于成功了。他说:“张叔,原来问题不在代码本身,而在细节!”
是啊,编程就是细节的功夫。AES 加密看似简单,其实有很多坑。希望这篇文章能帮到正在遇到同样问题的你。
记住:
- 每次加密结果不一样是正常的(因为 IV)
- 前后端必须用完全一致的密钥和模式
- IV 要一起传给后端,或者都固定同一个
有问题欢迎评论区交流,或者来我家钓鱼喝茶,边喝边聊!
更多推荐

所有评论(0)