ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端敏感数据加密实战:AES-JS/CryptoJS 参数选型与前后端联调避坑指南

前端敏感数据加密实战:AES-JS/CryptoJS 参数选型与前后端联调避坑指南

AES-JS 这个名字,玩前端的老哥们应该不陌生——它就是那个让 JavaScript 世界里能直接做 AES 加解密的纯 JS 实现。我最早接触它,是给某电商项目做用户敏感字段脱敏:登录密码、手机号、身份证这一类信息,在前端表单里明文放着总让人觉得不踏实,后端接口又不可能为每个小项目单独改造上线。于是我用 AES-JS 在浏览器端先把数据加密,再走常规接口提交,后端拿到密文后统一解密入库,一条链路就给敏感数据加了把锁。

说实话,这个库能做的不只是“加密一段字符串”。它解决的是前端数据裸奔的普遍焦虑:localStorage 里存了 token、草稿箱里有未提交的订单、配置文件里躺着数据库地址——这些东西只要落盘就是明文,一台被木马光顾过的电脑或一个浏览器插件就能把它们读走。AES-JS 的价值,就是把这些本该见不得光的数据在浏览器内存里就转成密文,让即使有人打开 DevTools 也看不到明文。这篇文章我把密钥选择、IV 处理、模式选型、前后端联调这些坑一次性捋清楚,适合刚上手加密的前端开发、写过几个接口想加强安全性的全栈工程师,以及所有被“数据裸奔恐慌”困扰的个人项目维护者。

1. 先搞清楚边界:AES-JS 到底守住哪道门

1.1 一句话认识 AES-JS

AES-JS 是 AES(Advanced Encryption Standard,高级加密标准)在 JavaScript 世界的落地实现。AES 本身是取代 DES 的对称加密算法,密钥长度支持 128、192、256 三种,按目前公开的密码学分析,AES-128 的暴力破解在可预见的时间尺度内都不可行,所以“高级”指的是算法强度等级,不是“高级玩法”那种高级。

对称加密就一个特点:加密解密用同一把钥匙。这跟非对称加密(公钥加密私钥解密)正好相反。所以 AES-JS 的使用逻辑从来不是“自己一个人偷偷加密就完事”,而是“有密钥的双方共用一把钥匙互通密文”。小到两个页面之间传个敏感字符串,大到前端加密后端解密的完整链路,本质上都是这把钥匙在流动。

我见过不少刚接触加密的开发者犯过一个认知错误:以为浏览器端加密了,网络传输就安全了。这是不对的。AES 加密的是“数据内容”,让数据即使被截获也读不出原意;而 HTTPS 保护的是“传输通道”,防止数据在链路上被完整截获。两者根本不在一个层面,不能互相替代。

1.2 三条真实使用场景

那 AES-JS 到底适合干什么?基于我的实际项目经验,大概三件事最有价值。

第一件,登录和支付类表单的敏感字段前置加密。表单里的密码、银行卡号、手机号,在点击提交按钮之前先用 JS 加密成密文,再作为请求参数发出。后端拿到密文后解密出明文再做业务校验。这样即使有人用抓包工具盯着接口看,看到的也只是一串密文,而不是用户密码本身。不过要强调,这是一种“纵深防御”手段,绝不能因为有了前端加密就不上 HTTPS——因为防截获靠的还是传输层。

第二件,浏览器本地缓存保护。localStorage、sessionStorage、IndexedDB 里的数据都是明文落盘的。比如有些单页应用把用户信息、权限标识、购物车快照直接塞进 localStorage,token 有效期还设得很长。一旦本机染上恶意插件或被人直接翻浏览器缓存文件,这些数据等于白送。用 AES-JS 把值加密后再 setItem,取用时再解密,成本很低,但能把“裸奔”变成“有壳”。

第三件,配置文件与导出文件的保护。有些企业项目会通过前端导出包含敏感信息的 Excel 或 CSV,或者在前端代码里内置一些外部系统的访问密钥、API 地址。这些场景下用 AES-JS 在文件生成或存储环节加密一层,虽然不是绝对安全,但能让偶然拿到文件的人无法直接使用数据。

1.3 为什么选 AES-JS,而不是其他方案

选型这个问题,我经历过挺多纠结。前端做加密,其实有四条路可走:自己手写算法、用浏览器原生的 WebCrypto API、用 aes-js 这种纯 JS 库、或者用功能更全的封装库(比如社区里最常见的 CryptoJS)。

自己手写算法这条路首先排除。AES 的 S 盒、轮常量、列混合变换,每一个环节都经过极其严格的设计和安全性审查,业余选手自己写能跑通就很不错了,还想保证没有侧信道漏洞?不如直接去抢银行更省事。

然后是 WebCrypto API。这是浏览器私生子,所有主流现代浏览器都支持,性能是原生 C++ 级的,还支持 AES-GCM 这种带认证加密的模式。但它有两个让人头疼的问题:第一,API 设计很繁琐,生成密钥、导入密钥、加解密、导出密钥,每一步都要写好几行异步代码;第二,它只在安全上下文里可用,部分企业内网环境、低版本浏览器和 WebView 场景都不支持。我做过一个要兼容老旧收银机的项目,WebCrypto 直接被排除了。

AES-JS 和 CryptoJS 这类纯 JS 库的优势在哪?零依赖、随处可跑、接口简单,字符串一把梭进去就能出密文。CryptoJS 用的人尤其多,因为它顺手封装了 WordArray、Base64、Hex 等编码转换,写起来非常舒服。而 aes-js 则更轻量、更底层,适合需要自己控制字节流细节的高级用法。对大多数项目来说,我会优先建议从 CryptoJS 这种封装库入手,因为它更贴近业务,代码量最少;等真正有性能苛求或字节级控制需求时再切 aes-js。

用个生活类比:AES-JS 和 CryptoJS 就像一把开箱即用的标准保险箱,说明书写得很清楚,按钮一按就锁上;WebCrypto 是给你一堆钢板焊条让你自己焊一口保险柜,能焊出个好东西,但大多数人的焊工都用不上;手写算法则相当于想自己发明一套锁芯——听着很硬核,实际上等于把自己的钱从一个坑搬进另一个坑。

2. 避坑前置课:密钥、模式、IV、Padding 四个词必须吃透

很多人上来就抄网上的代码,跑通就以为会了,然后上线后踩坑踩到怀疑人生。加密这件事,不懂底层参数就像不懂交通规则就上路,出事只是时间问题。所以这节先把四个最核心的概念讲透。

2.1 密钥长度与口令派生

AES 支持三种密钥长度:128、192、256 位,对应字节数就是 16、24、32 字节。

很多人的直觉是“越长越安全”,所以闭眼选 256。但实际上 AES-128 的暴力破解难度已经远超任何现实攻击能力,128 位密钥意味着 2 的 128 次方种可能,用全球所有算力加起来穷举也是天文数字级别。选 256 唯一的收获是心理安慰,代价是性能更慢(轮数从 10 轮增加到 14 轮)以及密钥管理的字节数更多更容易出错。对绝大多数业务场景,AES-128 完全够用。

真正的危险不是密钥太短,而是密钥太弱。有的同学直接把"123456"这种字符串当 key 传进去,或者把"password"这种词 hash 一下当密钥——这类密钥空间太小,密文稍微多点就能被字典攻击打穿。正确的做法是:要么用密码学安全的随机数生成 16 字节随机字节当密钥;要么从一个高强度口令通过 PBKDF2/scrypt 这类密钥派生函数生成密钥。

举个例子,用口令派生密钥:

// 这是概念示例,实际投产建议用专门库做PBKDF2 // 口令:MyStr0ng!Passphrase // 盐:随机生成,至少16字节 // 迭代次数:至少10万次 // 输出:16字节的AES-128密钥

注意 PBKDF2 的“盐”和“迭代次数”也是参数,盐要随机且每次不同,迭代次数影响暴力破解成本,太低会被秒破,太高则浏览器端卡顿明显,我实测下来 10 万次在主流 PC 上大概耗时几十到两百毫秒,是体验和安全的较好平衡点。

2.2 加密模式怎么选:CBC 还是 CTR

同一个 AES 加密算法,可以套不同的“工作模式”。模式决定了分组密码如何加密多于一组的数据。主流的模式无非 ECB、CBC、CTR、GCM 这么几个,但它们的差别非常大。

ECB(电子密码本模式)是最好理解也最不应该用的模式:每组明文独立加密,同样的明文组永远得到同样的密文组。这会产生模式泄漏,比如一张头像图片的密文,仍然能看到原图的轮廓。任何正经项目都不建议 ECB。

CBC(密码分组链接模式)是实际中最通用的选择。它把每一组明文与前一组密文做异或后再加密,所以同样的明文,只要每组之前的链接过程不同,最终密文也会不同。代价就是需要一个 16 字节的 IV 作为“初始链接值”,而且无法并行加密长数据。

CTR(计数器模式)则是把 AES 变成一个流密码:用一个递增计数器生成密钥流,然后与明文异或得到密文。它支持随机访问,可以任意定位解密某一段数据,适合加密超大文件或需要流式处理的场景。代价是如果计数器重复使用了同一个 IV,那加密结果会直接暴露重叠部分的明文关系——这是流密码的宿命。

GCM 则是带认证的模式,它除了加密,还能同时计算认证标签,加密和防篡改一起搞定。但需要用标准实现,比如本机几乎没有靠谱的 GCM 封装,而 WebCrypto 原生支持 GCM。

给一张实用对照表:

模式需要IV是否防篡改典型场景推荐指数
ECB否否演练/教学不推荐
CBC是否通用数据传输优先推荐
CTR是否大文件/流式处理视场景推荐
GCM是是现代新项目有条件则最优

实际业务中我的默认选型是 CBC,因为后端几乎所有语言的标准库都支持,联调最省心。如果整个技术栈是 2020 年以后新起的,且运行环境确认支持 WebCrypto,优先 GCM。

2.3 IV 和 Padding:最容易被忽略的两个翻车点

IV 全称 Initialization Vector,初始化向量。在 CBC 模式下,IV 就是“第 0 组密文”,用于让加密结果随机化。这里的坑有三个。

第一,IV 必须是 16 字节(对应 AES 块大小),少一字节都不行。很多人把"123456"这种短字符串当 IV 传进去,运行时不报错但加密结果错乱,最坑的是表面看不出问题,直到后端解不开才发现。

第二,IV 每次加密都必须随机生成,不能固定。把 IV 写死在代码里的后果是:同一密钥下的同一明文,永远得到同一密文,密文的重复模式会成为攻击线索。正确的做法是加密时随机生成 IV,然后把 IV 和密文一起传给对方。注意 IV 本身不需要保密——它就像盐一样,公开了也不影响安全性,但你绝不能重复使用。

第三,如果你在前后端联调时把 IV 写死成某个固定字符串来方便调试,上线前必须改成随机方案。我接过一个救火任务,对方前端把 IV 写死成"0000000000000000",用了一整年,直到某次安全扫描才暴露。这种事在真实项目里发生得比想象中多。

Padding,中文叫填充,解决的是一个数学问题:AES 是按 16 字节一组的,可你明文不见得刚好是 16 的倍数。比如"hello"是 5 字节,差 11 字节才能凑满一组,多余的字节用什么填?最通用的方案是 PKCS7:缺多少字节就填多少字节,而且每个填充字节的值都等于缺失数量。缺 1 字节就填 1 个0x01,缺 11 字节就填 11 个0x0B。解密后按最后一位的值把填充去掉,完美还原。

这里的经典翻车点:Java 里对外接口写的往往是PKCS5Padding,而 JavaScript 里叫PKCS7。很多人看到名字不一样就以为模式不匹配,抓耳挠腮半天。实际上 Java 的 PKCS5Padding 在某些场景下和 PKCS7 的填充规则完全一致——尤其 AES 固定 16 字节块时,PKCS5 只是名字沿用旧算法的叫法,本质执行的就是 PKCS7 的规则。所以前端用 PKCS7,后端用 PKCS5Padding,是可以联调通的。

3. 完整实操:从安装到前后端互通

这节我们进入正题——把整个链路跑通。我用目前社区主流的 CryptoJS 为例演示,同时也提一下轻量的 aes-js 怎么选。

3.1 安装与引入

先装包:

npm install crypto-js

或者如果你倾向轻量、底层控制力更强的 aes-js:

npm install aes-js

两者的差异值得多说两句。CryptoJS 是一个封装非常完整的库,自带 WordArray、BitArray、Base64、Hex、Utf8 等编码转换工具,写业务代码非常顺手,我做的多数项目都用它。aes-js 则更纯粹,它只做 AES 核心运算,输入输出都是 Uint8Array,不管编码转换,好处是包体积小、逻辑透明,适合有性能洁癖或者需要精确控制字节流的人。

前端页面引入 CryptoJS 时,浏览器环境直接用:

<script src="https://cdn.example.com/crypto-js.min.js"></script> <script> // 全局出现 CryptoJS 对象 </script>

如果是模块化项目,直接import CryptoJS from 'crypto-js'就好。这里不赘述具体 CDN 地址,因为每个项目的内网源和版本策略不同,按你项目里的统一依赖管理习惯来。

3.2 最小可运行示例:字符串加解密闭环

先来一段完整可跑的代码,我们逐行拆:

const CryptoJS = require('crypto-js'); // 注意:这是演示用的固定密钥/IV,真实项目绝不能这样写死! const key = CryptoJS.enc.Utf8.parse('0123456789abcdef'); // 16字节,对应AES-128 const iv = CryptoJS.enc.Utf8.parse('1234567887654321'); // 16字节,对应CBC模式 function encryptByAES(text) { return CryptoJS.AES.encrypt(text, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); } function decryptByAES(ciphertext) { const bytes = CryptoJS.AES.decrypt(ciphertext, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return bytes.toString(CryptoJS.enc.Utf8); } const plain = 'hello aes-js, this is a secret.'; const enc = encryptByAES(plain); console.log('密文:', enc); console.log('解密:', decryptByAES(enc));

关键参数逐项解释:

key和iv都用了CryptoJS.enc.Utf8.parse()把字符串转成 WordArray。0123456789abcdef正好 16 个 ASCII 字符,等于 16 字节,这是 AES-128 的正确字节数。很多人直接在encrypt时传一个字符串当 key,CryptoJS 内部会做一些兼容处理,但输出格式和字节数非常不透明,强烈建议统一用 parse 明确转字节。

mode指定 CBC,padding指定 Pkcs7,这两个参数要和后端严丝合缝地对齐,一个字母都不能错。

encrypt返回的是一个 CipherParams 对象,我调用了.toString()让它变成字符串。这里有个隐蔽的大坑:CryptoJS 的.toString()默认转成 OpenSSL 兼容格式——一个加了Salted__头的 Base64 字符串。这玩意如果你直接丢给后端用自己的标准库解密,大概率会炸。很多前后端联调失败的案例,根子都在这里。

解决办法是要明确指定序列化格式。如果你和后端约定用纯 Base64 密文,就改成:

const encrypted = CryptoJS.AES.encrypt(text, key, options); const ciphertextBase64 = CryptoJS.enc.Base64.stringify(encrypted.ciphertext); // 这样拿到的就是不带Saltted__头的纯密文

如果约定用 Hex,就换成CryptoJS.enc.Hex.stringify(encrypted.ciphertext)。

解密时也有对应操作。CryptoJS.AES.decrypt接收的第一个参数可以是字符串,但这个字符串的格式必须和加密时的输出格式对应上。你要是把不带Salted__头的纯 Base64 直接塞进 decrypt,它用 OpenSSL 格式解析就会出错。所以最稳妥的写法是——解密时先明确把密文转成CipherParams对象再喂给解密函数。比如:

function decryptByAES(ciphertextBase64) { const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(ciphertextBase64) }); const bytes = CryptoJS.AES.decrypt(cipherParams, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return bytes.toString(CryptoJS.enc.Utf8); }

这套写法看起来啰嗦,但它把“密文格式”这个变量完全钉死了——无论谁给你传什么格式,都按约定好的 Base64 去解析。我在项目里遇到过好多次,同事把老代码里的.toString()结果拿到新函数里解密,报错Malformed UTF-8 data,一查就是格式不匹配。这个写法能根治。

3.3 进阶:JSON 数据脱敏与本地缓存加密

字符串加解密只是热身,实际业务里我们更多要处理对象数据。比如要把一个用户信息对象加密后存到 localStorage,解密后还原成对象:

function getRandomIv() { // 16字节随机IV,每次加密都不同 const words = CryptoJS.lib.WordArray.random(16); return words; } function encryptObject(obj) { const iv = getRandomIv(); const json = JSON.stringify(obj); const encrypted = CryptoJS.AES.encrypt(json, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 把iv和密文拼在一起,方便解密时取出 return { iv: CryptoJS.enc.Base64.stringify(iv), data: CryptoJS.enc.Base64.stringify(encrypted.ciphertext) }; } function decryptObject(payload) { const iv = CryptoJS.enc.Base64.parse(payload.iv); const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(payload.data) }); const bytes = CryptoJS.AES.decrypt(cipherParams, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const json = bytes.toString(CryptoJS.enc.Utf8); return JSON.parse(json); } // 使用 const userInfo = { name: '某开发者', phone: '13800000000', level: 2 }; const safe = encryptObject(userInfo); localStorage.setItem('user_profile', JSON.stringify(safe)); // 读取 const stored = JSON.parse(localStorage.getItem('user_profile')); const info = decryptObject(stored); console.log(info.name);

这段代码用了随机 IV,并把 IV 和密文一起存起来。这个模式一定要记牢:IV 随机生成,但随密文一起传递。解密时没有 IV 就解不出,有了 IV 才能完整还原。本地存储时 IV 明放没关系,它不泄露任何明文信息。

另外补充一个性能建议:不要把所有 localStorage 数据都加密。有些高频读写的数据项(比如主题色、折叠状态)加密后每次读都要做一次 AES 运算,反而拖慢页面。我给同事的建议是:只加密真正敏感的内容,比如 token、用户画像、通讯录快照;其他体验类数据保持明文即可。加密是手段,不是装饰,别为了“看起来安全”把性能毁了。

3.4 与后端联调的完整流程

前端加密最终要落地的关键动作,是和后端把“字节层面的约定”全部对齐。我整理了个对照表,按这个表逐项核对,联调基本一次过:

约定项前端行为后端行为
密钥16字节UTF8字符串与前端同一字符串,转16字节
密钥编码Utf8.parsegetBytes("UTF-8")
模式CBCCBC
填充Pkcs7PKCS5Padding(实为同规则)
IV策略随机生成,附在密文前从请求中取前16字节作为IV
密文编码纯Base64(不带密文头)Base64解码后直接AES解密
结果编码Utf8字符串/JSON字符串与前端保持一致

后端伪代码示例(这里用 Java 风格示意,具体语言同理):

// 请求体里 data = base64(iv + ciphertext) byte[] body = Base64.decode(req.get("data")); byte[] iv = Arrays.copyOfRange(body, 0, 16); byte[] ciphertext = Arrays.copyOfRange(body, 16, body.length); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes("UTF-8"), "AES"); cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv)); byte[] plain = cipher.doFinal(ciphertext); String json = new String(plain, "UTF-8");

我在联调时踩过的最典型的坑就是:前端把Salted__的 OpenSSL 格式密文发给后端,后端 Java 直接按标准格式解,抛BadPaddingException。排查了两天,最后把密文格式改纯 Base64 就通了。所以强烈建议,前端加密输出统一用encrypted.ciphertext对应的 Base64/Hex,而不是默认的.toString()。

4. 让“高级”名副其实:密钥管理与防篡改设计

标题敢叫“高级加密”,到这个程度才算开始高级。前面的代码能跑通只是基础,真正的安全水平取决于密钥怎么管理、数据怎么防篡改。这一节全是实战里总结出来的硬骨头。

4.1 密钥从哪来:别把钥匙放在保险箱旁边

前端代码本身就是公开的,你把 AES 密钥硬编码在 JS 里,等于把保险箱打开,把钥匙贴在箱门旁边。攻击者只要用浏览器开发者工具看一眼源码,你的加密就形同虚设。所以密钥管理是前端加密的第一条命。

工业界最常用也最务实的方案是:动态下发密钥。具体流程是这样的:用户登录成功后,后端生成一个随机会话密钥(AES-128 密钥),用 HTTPS 安全地传给前端;前端把它保存在内存变量里(不要存 localStorage,存了又会裸奔);后续所有加解密都用这个会话密钥;会话过期或退出登录,密钥随之销毁。这样即使有人翻源码,看到的也只有“从接口取密钥”的逻辑,拿不到密钥本身。

更进阶一点的方案叫混合加密:前端随机生成 AES 密钥,用后端提供的 RSA 公钥把这个 AES 密钥加密后传给后端;后端用 RSA 私钥解密拿到 AES 密钥,之后的业务数据都用 AES 密钥加解密。RSA 加密慢,但只加密一个密钥没问题;AES 加密快,负责海量业务数据。这套方案的好处是 AES 密钥不用经过传输链路下发,而是由前端生成、后端解密获取,杜绝了“下发途中被截获密钥”的可能。

再强调一次:登录接口本身必须走 HTTPS,因为密钥传输的通道如果不安全,整个加密体系就没意义了。前端加密是纵深防御的一层,不是替代 HTTPS 的遮羞布。

4.2 加密不等于防篡改:把 HMAC 加上去

有一个概念经常被混淆:加密解决的是机密性问题,也就是“别人看不懂”;而完整性校验解决的是另一个问题——“别人有没有改过我的数据”。AES-CBC 模式下,攻击者即使不知道密钥,也可以通过翻转某些密文位来影响解密后的明文(这叫位翻转攻击)。所以如果你的场景是接口参数防篡改,光加密不够,还需要加消息认证码。

最务实的做法是 HMAC(基于哈希的消息认证码)。用一个只有前后端共享的密钥,对明文或密文计算 HMAC 值,附在请求里一起发送;接收方自己也算一遍,比对一致才接受。只要攻击者拿不到 HMAC 密钥,就无法伪造合法数据。

代码示例:

const hmacKey = CryptoJS.enc.Utf8.parse('another-secret-key'); // 对明文计算HMAC const sign = CryptoJS.HmacSHA256(jsonStr, hmacKey).toString(CryptoJS.enc.Hex); // 请求参数:data=密文&sign=签名

注意 HMAC 密钥和 AES 密钥要用两个不同的密钥。如果同一个密钥既做加密又做认证,在某些场景下会存在安全问题;实践中分开管理,成本极低,收益明确。如果项目已经用了 WebCrypto 的 GCM 模式,那 GCM 自带认证标签,等于加密和防篡改一起完成了,不需要单独算 HMAC。

4.3 安全红线清单

这些年做安全升级,我给自己列过一张红线清单,每次 Code Review 看到这些苗头都会直接打回。分享出来,你可以拿它当自查表:

  1. 前端代码里硬编码 AES 密钥或 IV——直接打死,密钥必须动态获取或协商生成。
  2. 所有加密都用同一个固定 IV——至少要在每个会话或每条记录里随机生成。
  3. 使用 ECB 模式加密业务数据——明文模式会泄漏,业务数据一律 CBC 或更高。
  4. 明文和密文一起打在日志里——日志系统被拖库后,密文的保密性全废。
  5. 用前端加密替代 HTTPS——两者是不同层面的安全,必须同时存在。
  6. 密钥存放在 localStorage 或任何可持久化存储里——密钥应该活在内存,不该落盘。

这些红线不是理论推导,每条我都见过真实翻车现场。最讽刺的是第 5 条,有团队觉得“都加密了,上不上 HTTPS 无所谓”,结果加密的数据包还是在链路上被完整截获,加密算法再强也没能挡住中间人把改过的密文转发给后端。传输层安全必须靠 HTTPS,这个真没得商量。

5. 高频问题与排障实录

这一节算是硬核经验合集。我整理了近两年在做加密联调、技术咨询时碰到的高频问题,每条都标注了根因和解法。

5.1 后端 Java/C#/Go 解不开前端数据

这是最高频的问题,没有之一。表象是前端加密明明成功,后端一解密就抛异常或出来乱码。根因百分之九十都在下面这五个点:

可能原因检查方式解决方案
密钥编码不一致前端用Utf8.parse,后端用getBytes("GBK")或指定字符集不同统一UTF-8
密文带了Salted__头检查Base64前几位是否是"U2FsdGVkX1"用enc.Base64.stringify(ciphertext)拿纯密文
模式不一致前端CBC,后端默认ECB,或相反显式指定CBC并确认
Padding不匹配前端Pkcs7,后端NoPadding或PKCS1统一为PKCS7/PKCS5Padding
IV没有传给后端前端随机IV,后端却写死把IV编入请求体并让后端取用

第一个和第三个是我遇到最多的。有一个项目,前端工程师和后端工程师分别照着两份完全不同的教程写加密逻辑,前端用 ECB 模式,后端用 CBC 模式,而且两边都不知道对方用了什么模式。联调了两周没通,最后拉了个对子逐项对才发现模式不一样。所以我一直建议,前后端联调前要先拉一个“加密参数确认单”,把密钥、IV 策略、模式、Padding、编码格式列成表格,邮件或文档形式确认好再动手写代码。磨刀不误砍柴工。

5.2 报错“Malformed UTF-8 data”或“Unexpected token”怎么办

这类报错在解密时才常见。很多人的第一反应是“密钥错了”,但其实往往是密文格式处理不对。

CryptoJS 的AES.decrypt(ciphertext, key)里,传入的ciphertext如果是字符串,会被默认当作 OpenSSL 格式(带Salted__头的 Base64)去解析。如果你的密文是纯 Base64 或 Hex,解析就会失败。正确做法就是我前面写过的:用CipherParams.create先把密文转成明确的二进制对象,再喂给解密函数。这样绕开了 CryptoJS 默认格式的陷阱。

还有一种情况是搞混了 Base64 和 Hex。有人加密时用CryptoJS.enc.Hex.stringify()输出十六进制字符串,解密时却用CryptoJS.enc.Base64.parse()去解析——这就变成了“用 A 编码再用 B 解码”,结果必然乱码。记住:parse 和 stringify 是一对,什么格式出去的,就什么格式回来。

5.3 解密结果乱码且长度不对

这个问题的根源几乎都是密钥或 IV 的字节数不对。

AES-128 要求密钥恰好 16 字节。如果你在Utf8.parse时传入的是一个 15 字节或 18 字节的字符串,CryptoJS 不会报错——它只把能用的部分拿来用,剩下的直接忽略,导致前后端实际用的密钥根本不是同一把。这种 bug 极其隐蔽,因为明文短时可能恰好能解出来,一旦明文变长,结果就彻底乱了。

更离谱的是有人直接在encrypt函数里传字符串当密钥。CryptoJS 对字符串密钥有一套内部处理逻辑(它会把密码通过哈希派生真实密钥),如果你前端这么写而后端没用同样的派生逻辑,神仙来了也解不开。稳妥的做法是:密钥和 IV 都明确用parse转字节,并通过代码断言来防呆:

const key = CryptoJS.enc.Utf8.parse('0123456789abcdef'); if (key.sigBytes !== 16) { throw new Error('AES-128密钥必须是16字节,当前是' + key.sigBytes); }

这样万一有人后来改动了密钥字符串,联调时能第一时间炸出问题,而不是在线上环境里解出一堆乱码再排查。

5.4 性能与包体积的真实体感

最后说点实践层面容易忽略的:性能和包体积。

CryptoJS 完整包编译后大约 100KB 左右,对现代前端项目来说不算大,但如果你只用了 AES 一家的功能,可以把引入方式改成按需加载,避免把所有算法打进主包。例如:

import AES from 'crypto-js/aes'; import modeCBC from 'crypto-js/mode-cbc'; import padPkcs7 from 'crypto-js/pad-pkcs7'; import encUtf8 from 'crypto-js/enc-utf8';

这样最终打包体积能明显降下来,实测能从 100KB 级别降到 40KB 上下。

至于性能,我实测过在主流桌面浏览器上用 AES-128-CBC 加密一个 1MB 的字符串,耗时大概在几十到一百毫秒这个量级,几乎无感;但在低端手机上,反复调用加密会带来可感知的卡顿,尤其是加密大 JSON 或高频轮询的场景。优化办法就三条:能不加密就不加密、把加密内容控制在最小范围、V8 引擎解析慢就缓存密文避免重复加密。

aes-js的包体积会更小一些,因为它不做编码转换,纯核心算法约几十KB。但代价是你得自己手工处理字节流、补零、拼接 IV 等细节。如果项目对包体积有洁癖,且你有信心驾驭底层字节操作,aes-js值得考虑;否则我建议用 CryptoJS 或类似封装库,省心优先。

再分享一个我个人的排障心法:遇到加密解密问题,第一件事不是看代码,而是先拉一张“参数对照表”贴在会议室白板上——密钥值、密钥编码、模式、Padding、IV 策略、密文格式,逐项跟对方核对。所有加密联调的疑难杂症,百分之九十九都能在这张表里找到凶手。我后来每个加密项目开工前,都会先发一版这样的表格给后端同事,确认签字后再写代码。这个习惯替我节省了无数加班时间,真的可以试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进