对称加密原理与实战应用中的常见问题和优化思路

诸葛树灿 安全 阅读 1,455
赞 24 收藏
二维码
手机扫码查看
反馈

对称加密搞了个大乌龙:前端 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),可以粘贴前后端参数实时比对加解密输出,调试时救了我三次命。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论