对称加密原理与实战应用中的常见问题和优化思路
对称加密搞了个大乌龙:前端 AES 加密后后端死活解不开
今天上线前联调,前端用 CryptoJS 加密了一段用户 token 传给后端,结果 Java 后端一直报 javax.crypto.BadPaddingException: Given final block not properly padded。我盯着控制台看了五分钟,第一反应是:这破错误是不是又在骗我?
后来发现真不是它骗我——是我自己把 AES 的模式、填充、IV 全配错了,还硬扛着试了半小时“改后端”,差点让同事以为我们 Java 组的 Cipher 初始化逻辑有 bug(对不起,已买奶茶道歉)。
这里我踩了个坑:**CryptoJS 默认用的是 OpenSSL 兼容格式,不是标准 AES 参数组合**。它会自动把 key 和 iv 做一次 PBKDF2 衍生,再拼上 salt,最后 base64 编码整个密文(含 salt)。而我们后端直愣愣地拿原始密钥去解,当然失败。
折腾了半天发现,根本不是加解密算法不一致,而是“两边根本没在说同一种语言”。CryptoJS 的 AES.encrypt(data, key) 看似简单,实则暗藏玄机——它默认走的是 CryptoJS.enc.Utf8 + mode: CBC + padding: Pkcs7 + iv: 随机生成 + salt: 随机生成 + key derivation: OpenSSL EVP_BytesToKey 这一套“全家桶”。而 Java 默认的 SecretKeySpec 是纯裸 key,零推导、零 salt、零 IV 复用保护。
所以第一个解决思路就是:别用 CryptoJS 的“便捷封装”,手动拆开,把参数全显式写死。
核心代码就这几行
我最终方案是:前端固定 IV(16 字节),用固定 salt(为了开发调试先写死,上线换随机),key 用 SHA256 摘要后取前 32 字节(适配 AES-256),所有参数明文传递,不做任何隐式衍生。这样后端才能稳稳接住。
下面是我在项目里实际跑通的完整前端加密逻辑(基于 CryptoJS 4.2.0):
import CryptoJS from 'crypto-js';
// 注意:生产环境必须用随机 IV,这里写死只为演示一致性
const FIXED_IV = CryptoJS.enc.Utf8.parse('1234567890123456'); // 16 bytes
const FIXED_SALT = CryptoJS.enc.Utf8.parse('saltyboi12345678'); // 16 bytes,仅调试用
function encryptAES(data, password) {
// 用 password + salt 推导出 32 字节 key(AES-256)
const key = CryptoJS.PBKDF2(password, FIXED_SALT, {
keySize: 256 / 32,
iterations: 1000,
hasher: CryptoJS.algo.SHA256
});
const encrypted = CryptoJS.AES.encrypt(data, key, {
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
iv: FIXED_IV
});
// 返回 base64 密文(不含 salt/iv),后端只收这个
return encrypted.toString();
}
function decryptAES(encryptedBase64, password) {
const key = CryptoJS.PBKDF2(password, FIXED_SALT, {
keySize: 256 / 32,
iterations: 1000,
hasher: CryptoJS.algo.SHA256
});
const decrypted = CryptoJS.AES.decrypt(encryptedBase64, key, {
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
iv: FIXED_IV
});
return decrypted.toString(CryptoJS.enc.Utf8);
}
// 使用示例
const token = 'user:123456:exp:1718832000';
const encrypted = encryptAES(token, 'my-super-secret-key-2024');
console.log('encrypted:', encrypted); // 比如 "U2FsdGVkX1+..."
后端 Java 对应的解密逻辑(Spring Boot)大概长这样:
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.util.Base64;
public class AesUtils {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final byte[] FIXED_IV = "1234567890123456".getBytes(StandardCharsets.UTF_8);
private static final byte[] FIXED_SALT = "saltyboi12345678".getBytes(StandardCharsets.UTF_8);
public static String decrypt(String encryptedBase64, String password) throws Exception {
byte[] key = deriveKey(password, FIXED_SALT);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(FIXED_IV));
byte[] decoded = Base64.getDecoder().decode(encryptedBase64);
byte[] decrypted = cipher.doFinal(decoded);
return new String(decrypted, StandardCharsets.UTF_8);
}
private static byte[] deriveKey(String password, byte[] salt) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(password.getBytes(StandardCharsets.UTF_8));
md.update(salt);
return md.digest();
// 注意:CryptoJS 的 PBKDF2 是迭代 1000 次,这里简化为单次 SHA256 + salt。
// 如果要完全兼容 CryptoJS 的 EVP_BytesToKey,得手写那个逻辑(太重,我们选了轻量方案)
}
}
这里注意我踩过好几次坑:
- IV 必须严格 16 字节,少一个字节就会 throw
InvalidAlgorithmParameterException,CryptoJS 不报错但结果错; - padding 名称不一致:CryptoJS 叫
Pkcs7,Java 叫PKCS5Padding,其实是同一个东西,别被名字唬住; - 密钥长度必须匹配:AES-128 要 16 字节 key,AES-256 要 32 字节,我一开始用 SHA256 输出 32 字节,但没注意 CryptoJS 的
keySize: 256/32是指“位数除以 8”,写成keySize: 32就错了(应该写32或直接256/32,它认); - base64 解码时别漏掉 +/ 字符:CryptoJS 默认用标准 base64,Java 的
Base64.getDecoder()也是标准的,但如果用错了 decoder(比如 URL-safe 版),就会解出乱码。
改完之后,前后端终于能握手成功了。不过还有个小问题没彻底解决:目前 salt 是写死的,意味着相同密码 + 相同明文永远产出相同密文——虽然 IV 固定也削弱了语义安全性,但现阶段够用(登录态 token 是短期有效的,且走 HTTPS)。等下次迭代,我会把 salt 和 IV 都随请求随机生成,然后一起 base64 传给后端(比如拼成 iv.b64 + ':' + salt.b64 + ':' + ciphertext.b64),后端再拆开用。
另外提一嘴,有人问为啥不用 Web Crypto API?我也试了,subtle.encrypt() 确实更标准,但兼容性在老安卓 WebView 上还是有点悬(尤其 Android 7-8),而且 CryptoJS 在我们存量项目里已经深度耦合,临时切 API 成本太高。所以这次选择“修好旧轮子”,而不是换新引擎。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案——比如用 Web Crypto + fallback polyfill,或者更安全的 key 衍生策略(Argon2?),欢迎评论区交流。顺带一提,jztheme.com 上有个小工具页(/tools/aes-debug),可以粘贴前后端参数实时比对加解密输出,调试时救了我三次命。

暂无评论