最近帮一个朋友排查接口联调问题,场景很典型:PHP 后端用 AES 把数据加密后返回,前端 JavaScript 拿到密文怎么解都不对,不是直接报错就是解出来一片乱码。这类 “PHP 后端 AES 加密、前端解密” 的需求在实际项目里出现的频率比想象中高得多——用户信息回显、订单数据脱敏、临时票据传递、敏感配置下发——但真正动手做的时候才会发现,跨语言加解密最难的不是算法本身,而是两端参数对齐。
这篇文章我会把整个链路完整拆开讲:AES 的密钥、IV、填充、模式这些参数到底怎么配,PHP 端用 openssl_encrypt 怎么写,前端用 crypto-js 怎么写,以及我实际踩过、帮别人排查过的各种坑。不管你是刚接手前后端分离项目的全栈,还是只负责其中一端的前端或后端同学,这篇文章都可以直接作为操作手册来用。
1. 先搞清楚需求场景和方案选型
1.1 为什么非要在前端解密
很多第一次接到这个需求的同学会本能地反问:数据在后端加密,传给前端又解密,那加密的意义是什么?浏览器里跑着 JS,密钥终究要暴露,这不是脱裤子放屁吗?
有这个疑问很正常,但真实业务场景里,“后端加密、前端解密”解决的问题并不是对抗用户,而是对抗链路中的被动窃听和日志泄露。比如一个用户中心接口,返回的是手机号、邮箱、身份证号这类敏感字段。如果后端直接返回明文,那么从服务器到浏览器这条链路上,任何一层代理日志、网关访问日志、浏览器插件、抓包工具都能直接看到完整明文。把接口响应体加密成一段密文,至少能让敏感数据在传输链路上不以明文形态出现,就算日志把响应体整个打出来,也得拿到密钥才能还原。
另外还有一种很实际的用法:防止明文数据被浏览器缓存。有些运营后台会把接口响应原样缓存到本地存储或者浏览器的磁盘缓存里,如果响应体是密文,缓存文件里就只是一堆无意义的字符。解密动作只发生在内存中,页面关闭即消失,这对敏感数据的保护是有实际价值的。
当然必须承认,这个方案防不住“用户主动调试前端代码”这种情况,因为密钥最终一定会在浏览器端出现。所以它的定位是纵深防御的一环,而不是唯一的安全手段。真正的高敏数据,该走后端解密、权限管控还得走,不能把宝全押在前端解密上。
1.2 算法选型:为什么 AES 是默认答案
对称加密算法里,AES 是当前应用最广、生态最成熟的选择。它被纳入各类标准体系多年,硬件和软件层面都有大量优化,PHP 的 openssl 扩展和 JavaScript 的各类加密库都对它完美支持。相比之下,DES 和 3DES 已经属于淘汰边缘,性能和安全性都不占优;非对称加密 RSA、ECC 虽然能解决密钥分发问题,但加解密速度慢一个数量级,不适合直接加密大段业务数据,更适合用来做密钥交换。
AES 本身有三个常见变体:AES-128、AES-192、AES-256。区别只在密钥长度和加密轮数,前端的实现方式完全一样。考虑到 PHP 和 JS 两端都要对齐,我一般直接选 AES-256,密钥长度 32 字节,安全性余量更足,性能差距在业务数据量级下基本感觉不到。
真正需要认真选的是工作模式。AES 的电码本模式(ECB)简单但不安全:相同的明文块会加密出相同的密文块,容易被猜出模式、被重放攻击。密码分组链接模式(CBC)是最常用的替代方案,每个明文块先和上一个密文块异或再加密,相同的明文在不同位置会产生不同密文,安全性明显更好。再往上还有伽罗瓦计数器模式(GCM),它不仅能加密,还内置了完整性校验标签,能防止密文被篡改。我用一张表把三个模式的关键差异列出来:
| 对比项 | AES-ECB | AES-CBC | AES-GCM |
|---|---|---|---|
| 密钥长度 | 16/24/32字节 | 16/24/32字节 | 16/24/32字节 |
| 是否需要 IV | 不需要 | 必须,16字节 | 必须,推荐12字节 |
| 完整性校验 | 无 | 无 | 内置认证标签 |
| PHP 支持 | openssl_encrypt | openssl_encrypt | openssl_encrypt(需取 tag) |
| 前端 crypto-js | 支持 | 支持 | 不支持 |
| 前端 Web Crypto API | 支持 | 支持 | 支持 |
| 安全性定位 | 不推荐 | 可用 | 更强 |
实践结论是:如果你的前端用的是 crypto-js 这个库,那 CBC 模式是你最稳妥的选择。crypto-js 对 GCM 的支持非常有限,硬要用 GCM 就得换 Web Crypto API,兼容性和代码复杂度都要重新考虑。如果项目对完整性校验有硬性要求,或者专门为现代浏览器开发,那 GCM 更合适。但就“PHP 加密、前端解密”这个通用需求来说,AES-256-CBC 是兼容性和安全性平衡最好的默认答案。
1.3 前端解密两条路线:crypto-js 与 Web Crypto API
前端做 AES 解密有两条主流路线。
一条是第三方库 crypto-js。它的最大优势是简单直接,封装了常用的加密算法,AES、DES、SHA 系列都有,接口风格对后端转前端的同学很友好,而且天然支持我们后续要讲的 CipherParams 参数对象。缺点是需要额外引入依赖,会增加打包体积,但按现在的网络环境和构建工具来看,这点体积完全可以接受。npm 安装一行命令,CDN 引入也只需要一行 script 标签,是我在大多数项目里的首选。
另一条是浏览器原生支持的 Web Crypto API。它最大的优势是不需要任何依赖,而且由于是底层原生实现,性能和安全性理论上更好,还支持 AES-GCM 这种带认证的模式。缺点是 API 设计偏底层,使用起来比较啰嗦:需要先 importKey 导入密钥、再创建 ArrayBuffer、再调用 decrypt 方法,全程操作的是 TypedArray 而不是普通字符串,调试成本明显更高。另外它要求页面环境必须是 secure context,也就是 HTTPS 或 localhost,这一点在本地开发时偶尔会坑人。
两条路线不冲突。我做选型时的判断标准很简单:项目是传统 jQuery 时代的老页面,或者需要兼容老浏览器,用 crypto-js;项目是现代前端工程、对安全等级要求高、确定跑在 HTTPS 环境下,可以考虑 Web Crypto API + GCM。如果只是要把“PHP AES 加密、前端解密”快速落地,我建议你先无脑选 crypto-js,把链路跑通再说。
2. 把 AES 参数对齐这件事彻底讲透
2.1 密钥、IV、填充、模式,一个都不能少
跨语言加解密之所以经常对不上,原因就是 AES 的运算结果由多个参数共同决定,任何一边差一个细节,出来的就是完全不同的结果。
首先是密钥,也就是 Key。AES-256 要求密钥长度固定为 32 字节。注意是“字节”,不是“字符”。如果密钥是英文字母和数字组成的字符串,一个字符通常对应一个字节,所以 32 个 ASCII 字符正好是 32 字节。但如果密钥里混入了中文,情况就变了,一个中文字符在 UTF-8 编码下占 3 个字节,所以“32 个中文字符”实际上远超 32 字节,Key 就会被截断或报错。最稳妥的做法是密钥固定用 32 位十六进制字符串或 32 个 ASCII 字符,并在两端约定好使用 UTF-8 编码解析。
其次是 IV,初始化向量。CBC 模式要求一个 16 字节(128 位)的随机向量。它的作用是让同样的明文在不同次加密时产生不同的密文,即使内容和密钥完全相同。很多初学者第一次做 CBC 加密时,会用固定的一串 0 或者简单写个 1234567890abcdef 当 IV,这在安全性上是禁忌。IV 必须在每次加密时随机生成,并要求不可预测。
然后是填充。AES 是分组密码,每组固定 16 字节,当明文长度不是 16 的倍数时,最后一块就需要填充。最常见的填充方式是 PKCS7,也就是缺多少字节就补多少个值为缺少数量的字节。PHP 的 openssl_encrypt 在 CBC 模式下默认就是 PKCS7,crypto-js 默认也是 Pkcs7,这一对默认刚好能对齐,不用额外处理。
最后是输出编码。AES 加密出来的是二进制数据,不能直接塞进 JSON 传输,所以 PHP 端一般都转成 Base64。前端拿到的是 Base64 字符串,正确的做法是先把 Base64 解码成字节数组或 WordArray,再参与解密运算,而不是把 Base64 字符串当密码口令直接丢进去。这一个细节衍生出的坑最多,后面问题排查部分我会展开讲。
2.2 PHP 端 openssl_encrypt 的几个关键细节
PHP 做 AES 加密的核心函数是 openssl_encrypt,它本身封装得很好,典型调用方式如下:
$key = '1234567890abcdef1234567890abcdef'; // 32字节,对应 AES-256 $iv = random_bytes(16); // 16字节随机 IV $ciphertext = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );需要注意几个容易踩的点。
第一,算法名不能写错。写 'AES-256-CBC' 时,openssl 会检查传入的 $key 长度,必须是 32 字节,不够会直接返回 false。如果你写 'AES-128-CBC',那密钥必须是 16 字节。这个可以先用 strlen 测试,不要等到联调才排查。
第二,第四个参数,也就是 options,我建议一直用 OPENSSL_RAW_DATA。这样 openssl_encrypt 返回的是原始二进制密文,方便我们后续自己拼接 IV 和密文,然后统一用 base64_encode 输出。如果你用默认的 0,函数返回的是 Base64 编码后的字符串,也不是不行,但后续处理逻辑会绕,而且容易和 JS 端的 Base64 解码步骤搞混。
第三,IV 不能写死。代码里每次加密都应该用 random_bytes(16) 新生成一个 IV,然后把 IV 和密文一起发给前端。经典错误是后端固定一个 IV,这样同一个用户在不同时间请求同一个数据时,密文永远一样,攻击者可以根据密文变化推断业务逻辑,加密效果大打折扣。
第四,PHP 7.1 之后 openssl_encrypt 的参数校验变得更严格,如果密钥长度不对或者内部初始化失败,会返回 false 而不是抛异常。所以调用之后做一个 false 判断、抛出业务异常,是非常值得的防御性写法。
2.3 前端 crypto-js 是怎么工作的,和 PHP 哪里容易对上/对不上
crypto-js 的 AES 解密接口长这样:
const decrypted = CryptoJS.AES.decrypt( ciphertext, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } );这里最容易出问题的是第一个参数 ciphertext 的格式,以及第二个参数 key 的格式。
crypto-js 内部有一套自己的数据类型叫 WordArray,可以简单理解成“带字节语义的数组”。所有加密、解密操作,本质上是把字符串或字节流转成 WordArray,运算之后再转回需要的格式。PHP 传过来的 Base64 密文,必须先用 CryptoJS.enc.Base64.parse() 转成 WordArray,再作为解密输入。有人直接写成:
CryptoJS.AES.decrypt(base64Ciphertext, key, ...)crypto-js 并不会把它当成“Base64 密文”来处理,而是会用 OpenSSL 兼容格式去解析这个字符串——它会尝试读取前 8 个字节是否是 “Salted__” 头,然后用 EVP_BytesToKey 算法从密码口令派生密钥。结果就是你传的明明是密文,却被当成加密文件格式解析,解密结果必然是错的,或者直接抛出 “Malformed UTF-8 data” 异常。
key 参数也一样。PHP 用的是原始 32 字节密钥,JS 端如果直接把密钥字符串填进去,crypto-js 会把它当作“口令”,然后用 OpenSSL 的密钥派生算法生成一个完全不同的 AES 密钥。两边密钥不一致,解密自然失败。正确做法是:
const key = CryptoJS.enc.Utf8.parse('1234567890abcdef1234567890abcdef');这样 key 才真正变成 32 字节的 WordArray,和 PHP 的 $key 完全对应。
3. PHP 加密 + JS 解密的完整实现
3.1 PHP 端:加密并输出规范 JSON
实操环节,我给你一个可以直接抄的 PHP 加密服务类。这个类把 key 通过构造函数注入,提供加密方法,返回一个包含 iv 和 data 两个字段的数组,两个字段都做 Base64 编码。前端拿到这个 JSON 就能完成解密,不需要关心加密细节。
<?php class AesCbcService { private string $key; public function __construct(string $key) { // AES-256-CBC 要求 32 字节密钥 if (strlen($key) !== 32) { throw new InvalidArgumentException('Key must be 32 bytes for AES-256-CBC'); } $this->key = $key; } public function encrypt(string $plaintext): array { $iv = random_bytes(16); $ciphertext = openssl_encrypt( $plaintext, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA, $iv ); if ($ciphertext === false) { throw new RuntimeException('AES encrypt failed'); } return [ 'iv' => base64_encode($iv), 'data' => base64_encode($ciphertext), ]; } }调用方式就更简单了。比如用户信息接口:
$service = new AesCbcService(getenv('APP_DATA_KEY')); $payload = json_encode([ 'mobile' => '13800000000', 'email' => 'user@example.com', 'level' => 3, ], JSON_UNESCAPED_UNICODE); $result = $service->encrypt($payload); // 输出形如 {"iv":"xxxx","data":"xxxx"} 的 JSON 响应 echo json_encode($result, JSON_UNESCAPED_UNICODE);穿透之后的 JSON 响应自然包含 iv 和 data 两个字段。注意我加了 JSON_UNESCAPED_UNICODE,目的是让 JSON 里的中文不要变成 \uXXXX 的转义序列,因为我们要整体加密后再传给前端,转义会增加不必要的体积,也可能在极端情况下干扰前后端对字符串的解析。
再补充一个处理长文本的细节。AES-CBC 对明文长度没有上限,加密大段 JSON 完全没问题,但 Base64 编码会让体积膨胀约 33%。如果一个响应体原本是 3KB,加密后大约 4KB。对正常业务接口来说完全可以接受,但如果你把整个大报表或文件内容塞进去加密,就要考虑传输和前端内存的代价。建议加密对象是结构精简的 JSON 字符串,而不是体积很大的文件块。
3.2 JavaScript 端:用 crypto-js 还原明文
前端部分我直接用 crypto-js 来写。先安装依赖:
npm install crypto-js解密函数如下,我加了详细注释:
import CryptoJS from 'crypto-js'; /** * 解密 PHP 端 AES-256-CBC 加密的数据 * @param {string} keyString 32字节密钥字符串 * @param {{iv: string, data: string}} payload 接口返回的加密对象 * @returns {string} 解密后的明文 */ function aesDecrypt(keyString, payload) { // 1. 密钥必须解析成 WordArray,不能直接传字符串 const key = CryptoJS.enc.Utf8.parse(keyString); // 2. 分别把 Base64 的 iv 和密文还原成 WordArray const iv = CryptoJS.enc.Base64.parse(payload.iv); const ciphertext = CryptoJS.enc.Base64.parse(payload.data); // 3. 注意这里传的是 CipherParams 对象,不是字符串 const decrypted = CryptoJS.AES.decrypt( { ciphertext: ciphertext }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); // 4. 把解密结果转成 UTF-8 字符串 return decrypted.toString(CryptoJS.enc.Utf8); }使用示例:
const payload = await fetch('/api/user/info').then(res => res.json()); const key = '1234567890abcdef1234567890abcdef'; const plaintext = aesDecrypt(key, payload); const userInfo = JSON.parse(plaintext); console.log(userInfo.mobile); // 13800000000整个过程的核心思想就一句话:把 PHP 传过来的 Base64 字符串先解码成 WordArray,再通过 CipherParams 对象把它交给 AES.decrypt。你不要让 crypto-js 帮你猜格式,直接告诉它 iv 是什么、密文是什么、密钥是什么,它就不会跑偏。
3.3 实战场景:一个接口从加密到解密的全流程
为了让你更好理解,我模拟一个完整的“后端加密用户信息、前端解密后渲染页面”的场景。
假定后端有一个接口/api/user/info,它内部从数据库查出用户信息,构造成关联数组,然后转成 JSON、加密、返回。前端页面加载时调用这个接口,拿到 JSON 字符串后解密并渲染到页面上。
这个场景下有几个工程化的问题值得注意。
第一,接口设计上,我建议把 iv 和 data 放在一个对象里,不要拼接成一个字符串再传。因为拼接成字符串就需要额外约定分隔规则,比如“前 16 字节是 IV,后面是密文”,这种做法不是不行,但解析逻辑多一层,别人接手时也容易犯错。直接用两个字段,语义清晰。
第二,密钥传参。开发阶段把密钥写在前端常量里没问题,方便调试。但上了生产环境,不要把这个常量写死在前端源码仓库里。常见的过渡方案是:用户登录成功后,后端通过另一个专门接口下发本次会话的临时密钥,前端保存在内存变量中,页面刷新后重新获取。这个方案并不能做到绝对防调试,但至少密钥不会静态出现在打包产物里,能过滤掉相当一部分“直接翻源码找密钥”的低成本攻击。
第三,正则层面做个版本字段。我的习惯是在响应 JSON 里加一个 algo 字段,比如 “aes-256-cbc-v1”,前端拿到后根据版本走不同的解密逻辑。这样密钥轮换、算法升级时,老数据还能被旧逻辑解密,新数据走新逻辑,不会因为一次性升级导致所有在线用户解密失败。
3.4 生产环境的密钥管理与版本兼容
讲到这里,多说几句生产环境的事。
密钥管理是这类方案最容易被人喷的地方。AES 对称加密的核心前提是密钥保密,但前端解密这个需求天然意味着密钥要在浏览器端出现。这不是 PHP 或 JS 的问题,而是场景本身的约束。所以这种方案只适用于“防链路窃听、防日志泄露”级别的数据保护,不能用来保护那些需要强认证、强授权的核心资产。
密钥轮换的实际做法,我建议在密钥载体里带上版本。比如后端生成一个密钥版本号,查询密钥时返回一个 JSON:
{ "key": "1234567890abcdef1234567890abcdef", "version": "v1", "expires_at": 1700000000 }前端把 version 明文记录下来,解密时用 version 对应的密钥。如果密钥过期,后端可以返回新的密钥,但注意要有一个平滑过渡期:短时间内新旧密钥同时有效,已经下发的页面还能用旧密钥解密正在展示的数据,新请求再用新密钥,避免大面积解密失败。
另外强烈建议敏感接口全部启用 HTTPS。AES 加密只能保证数据在应用层是密文,如果传输层没加密,攻击者照样能篡改或进行中间人攻击。HTTPS 和 AES 加密是两层不同的防线,缺一不可。
最后,日志安全也要管。后端日志里不要打印解密后的明文,更不要打印加密用的 key。我见过不止一次事故:后端为了排查问题,在日志里把整个响应体打了出来,结果加密接口形同虚设。基于此,建议日志框架里对包含加密数据的字段做脱敏处理,或者在返回接口响应前把日志级别调到不输出 body。
4. 我踩过的坑:常见问题与排查方法
4.1 解密出来全是乱码,大概率是密钥没对齐
乱码是跨语言加解密最常见的症状,出现频率最高。表现为解密过程不报错,toString 也能转,但输出是类似于“��d��”这种无意义的字符。
排查路径非常固定。第一步,确认密钥长度。PHP 端 AES-256-CBC 要求 32 字节,前端 CryptoJS.enc.Utf8.parse(keyString) 之后,可以用 key.words 的字节数来验证。第二步,确认两端密钥内容完全一致,包括编码。拷贝过程中多了个空格、少了个字符,都会导致乱码。第三步,确认 IV 一致。如果 PHP 返回的 iv 和前端解析的 iv 不一致,解出来的也是一堆乱码。第四步,检查明文是不是非 UTF-8 字符,比如 PHP 端用了 GBK 编码输出,而 JS 端强制转 UTF-8,也会乱码。但这类情况在统一使用 UTF-8 的现代项目中很少见。
我自己的经验是,遇到乱码先别急着看代码逻辑,先用一个“密钥 + IV + 已知明文”的用例做两端自测。PHP 端自己加密再解密,JS 端拿同一段密文解密,哪边能解出原始明文哪边有问题,二分开来,排查效率高很多。
4.2 crypto-js 报 Malformed UTF-8 data,不是数据坏了
这条报错我帮人排查过太多次了。很多同学一看到 Malformed UTF-8 data 就以为是后端加密出来的数据有问题,但实际上几乎都是密钥用法不对。
前面 2.3 节里讲过,crypto-js 的 AES.decrypt 第一参数如果给字符串,第二参数也给字符串,它就会走 OpenSSL 兼容模式,对字符串口令做 EVP_BytesToKey 密钥派生。这个过程产生的密钥和 PHP 的原始 32 字节 key 完全不同,解密结果自然不是合法 UTF-8 字符串,于是抛出这个异常。
解决办法就两条。第一,密文用 Base64 解码成 WordArray 后包进 CipherParams 对象;第二,密钥用 Utf8.parse 转成 WordArray。两条都做到,这个错误就不会出现。如果做完之后仍然报同样错误,那就检查解密后是不是调用了 toString(CryptoJS.enc.Utf8),这个转换是必需的,它负责把解密后的 WordArray 恢复成普通字符串。
隐藏比较深的一种情况是:明文里包含合法的二进制数据或压缩包,比如你加密的是 gzip 压缩后的字节流而不是 JSON 字符串,那解密结果就不是合法 UTF-8。这种场景下,UTF-8 转换本身就无意义,你应该直接操作 WordArray 或转成 ArrayBuffer,而不是强制 toString。
4.3 为什么每次加密密文不同,但解密反而失败
这是一个初看很反常的问题。CBC 模式下,由于每次加密都会生成新的随机 IV,所以同样的明文加密两次得到的密文是不同的,这本身是正确行为。前端解密时必须使用“那一次加密时”的 IV,而不是一个固定的或者从别处抄来的 IV。
我见过的一种错误写法是:后端返回的密文每次都在变,但前端代码里把 iv 写死成了某一个固定值。这样解密偶尔能成功——因为某次加密恰好用了和你写死 IV 一样的随机值,概率上是 1 除以 2 的 128 次方,约等于零——绝大多数情况下解出来都是乱码或直接报错。
正确的设计是:每次加密时生成一个随机 IV,把它和密文一起返回给前端,前端解密时用同一个 IV。所以上一节里我强调接口必须返回两字段,iv 和 data 都要有,就是这个原因。
另外,千万不要自己尝试拼接 IV 和密文字符串来减少传输字段,除非你明确知道固定分隔或固定长度规则,否则会给后面维护的人埋雷。我实测下来最省心的方案永远是把 iv 字段单独传,清晰且不易出错。
4.4 一个小教训:前端密钥硬编码只是看起来省事
有一类问题不是报错,而是安全隐患。前端把密钥直接写在 JS 代码里,比如:
const AES_KEY = '1234567890abcdef1234567890abcdef';这在开发阶段最省事,但打包到生产环境后,任何人打开浏览器按 F12,在源码里搜索 “1234567890abcdef”,都能直接看到密钥。只要密钥泄露,所谓的加密就失去了意义。
我的建议是至少做到两步。第一步,把密钥从代码中抽到运行时配置里,通过接口下发。第二步,密钥定期轮换,并且下发时绑定用户登录态,一个用户拿到的是一个独立的会话密钥,而不是所有用户共用一个全局密钥。
这样做仍然有局限性,因为前端调试者完全可以在内存中拿到密钥。所以还是回到前面那个观点:这个方案是纵深防御的一环,目的是提高攻击成本,而不是一劳永逸。你在写代码时始终要意识到这一点,不要过度依赖它。
4.5 常见问题速查表
为了方便你直接排查,我把上面的经验整理成一个速查表。
| 症状 | 可能原因 | 检查点与解法 |
|---|---|---|
| 解密结果为乱码 | 密钥、IV 不一致或编码错误 | 逐项核对 PHP 与 JS 的 key、iv、UTF-8 编码 |
| 报 Malformed UTF-8 data | crypto-js 将密文当 OpenSSL 格式解析 | 密文先用 Base64 解成 WordArray,包进 CipherParams |
| 报 key 长度相关错误 | AES-256 密钥不是 32 字节 | strlen 检查 PHP 的 key,确保是 32 个字符 |
| 解密结果空字符串 | 明文本身就是空串,或密文解析为空 | 打印 payload.data 长度,检查 Base64 是否被 JSON 转义 |
| 解密偶发失败 | 前端写死 IV | 随机 IV 必须与密文一起返回,每次解密用当次 IV |
| 接口响应被日志泄露 | 日志打印了明文或密文+密钥 | 敏感接口日志脱敏,不打印响应体,不打印 key |
| 打包产物泄露密钥 | 前端硬编码密钥 | 密钥运行时下发,绑定用户登录态,定期轮换 |
这七类问题覆盖了我实际遇到过的绝大多数情况。如果你还碰到其他诡异现象,建议再检查一遍两端 CryptoJS 和 PHP openssl 的版本——虽然这两者实现的 AES 标准是稳定的,但少数时候不同版本的默认行为会带来坑。
最终的一点个人经验
这类跨语言加解密方案,我在实际项目中做过很多次,最深的体会是:先固定参数,再做联调。所谓固定参数,就是把密钥、IV 长度、算法模式、填充方式、输出编码这五个要素先写成一张参数表,发给前后端双方确认,而不是双方各自写代码,最后联调的时候互相报错。
我的习惯流程是:先写一个 PHP 脚本,用固定 key 加密一段中文测试文本,再在 PHP 里解密验证;然后把同一段测试文本用 JS 端加密、JS 端解密验证;最后把 PHP 加密结果丢给 JS 解密,把 JS 加密结果丢给 PHP 解密。四个步骤全部通过,再接入业务接口。这个流程看起来多花几分钟,实际能省下你数小时的联调时间。
再分享一个小的扩展思路:如果未来项目对完整性校验有要求,可以把算法从 AES-256-CBC 升级到 AES-256-GCM,PHP 端用 openssl_encrypt 的第五个参数接收 tag,前端改用 Web Crypto API 解密。切换时记得在响应 JSON 里保持 iv、data、tag 三个字段分开传输,结构清晰就不会乱。