1. 前后端加密为什么动不动就对不上
提到前后端加密一致性,我第一反应就是:坑,而且是大坑。你很可能经历过这种场景:前端用 crypto-js 给字段做了 AES 加密,后端怎么解都解不出来;或者后端加完密,前端拿到手直接乱码。再一排查,密钥一致、AES 模式也调成一样的了,可结果就是不对。这种问题不解决,接口联调能卡上好几天。
所谓 AES 全链路加密方案,不是指某种高深算法,而是把前端加密、网络传输、后端解密整条链路里每一个会影响结果的细节全部约定清楚。AES 本身是公开标准,正常情况下加出来的密文理应一样,真正导致不一致的几乎都是实现参数、编码方式和格式约定。这篇文章我就从实际联调中遇到的“加密不一致”问题讲起,聊清楚 crypto-js 做 AES 时那些容易踩的坑,并给出一套前后端都能直接照抄的联调规范。适合被加密不一致折磨过的前端,也适合想为接口加一层保护的后端同学。
1.1 一次让我印象深刻的联调事故
先还原一下我遇到过的场景:某内部管理系统,前端用 crypto-js,后端是 Java。甲方提了个要求,登录密码和手机号不允许明文出现在请求日志里。前端同事很快写好了加密逻辑:
const encrypted = CryptoJS.AES.encrypt('123456', 'mySecretKey').toString();后端同学拿到这段逻辑也很快写了一版 Java 解密:
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec("mySecretKey".getBytes(), "AES"));结果呢?前端说加密没问题,后端说解密报 "Given final block not properly padded"。两边都没说错,但合在一起就是跑不通。
问题出在哪?我逐个点核对:
- 前端没有显式指定 IV,crypto-js 在 CBC 模式下会生成一个随机 IV 并拼在密文头里;后端却以为 IV 没有。
- 前端用的 key 是字符串,长度 10 字节,不满足 AES-128 的 16 字节要求,crypto-js 会把这串字符当“口令”做密钥派生,而不是直接当字节密钥;Java 却把这串字符直接按字节塞给 SecretKeySpec,长度都不够,直接抛异常。
- 前端输出的密文是一个带特殊头部的 Base64 字符串,后端却按普通 Base64 解,解出来和原密文完全对不上。
是不是很眼熟?单独看每个问题都不难,但全部叠在一起,联调时间就这么被消耗掉了。这个问题的根源不是 AES 本身,而是“全链路各方对 AES 的隐含细节没有达成共识”。
1.2 AES 加密链路里的五个“隐形开关”
AES 加解密看似一句话搞定,实际上背后至少藏着五个会严重影响结果的开关,任何一个开关不对,解密结果就是乱码或者直接报错。
我用一张表把这五个开关列出来:
| 开关 | 典型取值 | 不一致时会怎样 |
|---|---|---|
| 密钥长度 | 128 / 192 / 256 位 | 密钥字节数不对,后端直接抛 Illegal key size 或 key length 异常 |
| 分组模式 | CBC / ECB / GCM 等 | 密文结构完全不同,解出来的内容大概率是乱码 |
| 填充方式 | Pkcs7 / ZeroPadding | 填充字节不一样,解密时会出现 padding 校验失败 |
| IV 向量 | CBC 模式必须 16 字节 | IV 不一致时,第一个分组解密会乱码,后续分组全会乱 |
| 编码格式 | Hex / Base64 / UTF-8 | 密文拿到手后解析规则不对,可能解出来是一串乱码字符串 |
为什么这几项最容易出问题?因为很多封装好的工具类隐藏了这些细节,前端调用一句AES.encrypt,后端调用工具类一句aesDecrypt,看似都完成了,实际上两个工具类默认参数不同,就裂开了。比如同样是 CBC 模式,有的工具默认 PKCS7 填充,有的默认 ZeroPadding;同样是传密钥,有的要求传 UTF-8 字符串,有的要求传 Hex 编码的字节串。
所以做全链路加密,第一步不是写代码,而是把上面这张表当成“合同”一样,前后端先同步好每一项的值。我后面会给出可以直接用的约定表。
1.3 最容易被忽略的格式问题:密文容器和口令派生
很多前端拿到 crypto-js 的加密结果后,第一眼看到是长这样:
U2FsdGVkX1+...一串很长的Base64注意这个头部U2FsdGVkX1,它看起来像普通 Base64,实际上有特殊含义。crypto-js 在检测到你传入的 key 是普通字符串时,会默认认为你在使用“口令”,于是它会用 OpenSSL 风格的密钥派生方式,把口令扩展成真正的 AES 密钥,还会生成一个随机盐值混进密文里,最后输出的 Base64 字符串要先解释成“Salted__ + salt + 加密数据”三段。
这种格式如果拿到后端去解,后端除非也用完全相同的口令派生逻辑,否则基本不可能解出来。而 Java、Node、Python 各自实现这种派生逻辑时,参数细节很容易对不上,比如迭代次数、摘要算法、盐值长度,只要有一点不同,结果就是错的。
正确的做法很简单:不要给 crypto-js 传字符串 key,而是传明确解析后的字节序列。这样它就不会走口令派生,而是直接把你给的一组字节当成 AES 密钥。这是我在前面案例中定位到的核心原因之二。
2. 先把 crypto-js 的用法掰开揉碎
网上很多 cpy 下来的 crypto-js 示例都是最简单的写法:一个字符串 key 直接丢进去。这种写法演示可以用,真到了前后端联调环节,就是事故源头。我们先把 crypto-js 的正确用法弄清楚。
2.1 加解密 API 的完整正确姿势
先给出一段我认为可以直接作为基础模板的前端代码,使用的是 AES-256 / CBC / Pkcs7,密钥和 IV 都用 Hex 字符串显式传入。
import CryptoJS from 'crypto-js'; // 密钥:64位十六进制字符串,对应32字节;IV:32位十六进制字符串,对应16字节 const KEY_HEX = '你的64位hex密钥在这里'; const IV_HEX = '你的32位hex IV在这里'; const key = CryptoJS.enc.Hex.parse(KEY_HEX); const iv = CryptoJS.enc.Hex.parse(IV_HEX); function encryptText(plainText) { const encrypted = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return encrypted.ciphertext.toString(CryptoJS.enc.Hex); } function decryptText(cipherHex) { const decrypted = CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Hex.parse(cipherHex) }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); }这里有几个特别容易被忽视的点,我一个个说清楚。
第一,key不能直接写字符串,必须用CryptoJS.enc.Hex.parse(...)转成 WordArray。WordArray 你可以理解成 crypto-js 内部表示二进制数据的字节数组。只有让它明确知道“这 64 个 hex 字符就是 32 个字节的密钥”,它才不会去做口令派生。
第二,加密时为什么要写CryptoJS.enc.Utf8.parse(plainText)?虽然 crypto-js 内部也会处理字符串,但显式指定 UTF-8 编码可以避免项目中存在非 UTF-8 编码时出现歧义。后端收到明文后通常都是用 UTF-8 去解码,前端这一步最好也强制统一。
第三,encrypted这个对象不能直接拿来当字符串用。CryptoJS.AES.encrypt返回的是一个 CipherParams 对象,它包含ciphertext、key、iv、mode、padding等属性。直接对它toString(),crypto-js 会按带 Salt 的格式输出一段 Base64;如果你只是想要纯密文,必须取encrypted.ciphertext,再把它转成 Hex 或 Base64。
第四,解密时推荐传对象{ ciphertext: CryptoJS.enc.Hex.parse(cipherHex) },而不是直接把 hex 字符串塞进去。显式声明“我传过来的就是纯密文,不是带 Salt 的格式”,crypto-js 就会按你给的数据直接解密,不会再去解析什么 Salt 结构。
2.2 密钥、IV 怎么生成更省心
密钥和 IV 不能随便敲键盘上的字母,需要用真随机数生成。最简单的办法是用 Node 自带能力,一行命令生成一个 32 字节密钥和一个 16 字节 IV:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"生成的 64 位 hex 和 32 位 hex,分别写到前后端配置文件里。注意这个密钥对加解密的不管是哪一端都不能再变,否则历史密文会全部解不开。IV 相对特殊一点,后面我们再讨论固定和随机两种策略。
生成之后建议在配置旁加一行注释,说明这个 hex 字符串对应的字节长度。很多人之所以填错,就是因为分不清“64 个 hex 字符是 32 字节,32 个 hex 字符是 16 字节”这层关系。密钥长度影响 AES 版本,IV 长度影响 CBC 模式能否初始化成功,这两点必须做静态检查。
2.3 为什么我坚持用 Hex 传密文,而不是 Base64
这个问题我犹豫过很久,最后还是统一用 Hex。原因很简单:Base64 编码后的字符串可能包含+、/、=这些特殊字符。如果你的密文要放在 URL 参数里,+会被理解成空格,/可能会被识别成路径分隔符,=也可能引发参数解析问题。虽然可以通过 URLEncode 处理,但多一层处理就多一个出错点。
Hex 只有0-9a-f,没有任何歧义,JSON、URL、数据库字符串字段里都能直接存放。它的缺点是长度比 Base64 多一倍,但对于登录密码、手机号、身份证号这类短字段来说,这点体积完全可以忽略。
如果你加密的是大字段,比如整段 JSON 甚至文件内容,我建议你还是使用 Base64 再配合 URLEncode,或者干脆换分段加密方案。普通接口字段加密,Hex 是最省心的。
3. 一套能直接照抄的前后端 AES 联调规范
下面是我在多个项目里沉淀下来的一套约定,直接拿去用就好。这套约定已经考虑了 crypto-js 行为、Java 默认行为和 Polyfill 兼容性,目标是让前后端各自写代码时不再需要互相猜。
3.1 前后端协商好的参数约定表
在写第一行代码之前,先把这张表发给前端和后端各一