ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端 AES 加解密实战:crypto-js 核心用法与踩坑指南

前端 AES 加解密实战:crypto-js 核心用法与踩坑指南 前端做 AES 加解密绕不开 crypto-js 这个库。我最早接触它是在联调一个移动端 H5 项目的时候后端要求把用户手机号、身份证这类敏感字段加密后再传明文一率不收。当时时间紧、文档少网上搜出来的代码十个有八个是能跑但说不清原理的照抄完心里特别没底。后来我在好几个项目里反反复复用 crypto-js踩了不少坑也彻底把 AES 加解密这块的细节理顺了。这篇东西我不打算写成枯燥的 API 文档就按实际项目里你会遇到的知识点、报错和取舍来聊适合刚接触前端加解密、或者正在跟后端联调加密接口的同学参考。先说清楚一件事crypto-js 是什么、能做什么。它是一个纯 JavaScript 实现的加密算法库支持 AES、DES、TripleDES、Rabbit、RC4、MD5、SHA 系列等常见算法不需要依赖浏览器原生加密接口也不依赖 Node.js 的 crypto 模块所以在浏览器、Node、小程序、甚至一些老旧的 WebView 里都能跑。我们这篇重点讲其中应用最广的 AES 对称加解密同一个密钥前端加密传给后端后端用同一个密钥解密反过来也一样。AES 速度快、密文体积可控、实现简单是目前前后端联调时最常见的选择之一。但有一点我得先给你泼盆冷水前端做 AES 加解密不等于“安全”。密钥一旦写在 JS 里浏览器一打开开发者工具就能被人拿到。所以它真正适合的场景是“防明文传输”和“防低水平的数据泄露”比如抓包时不想让用户手机号直接暴露、数据库落库时不想留明文、或者给接口参数做个基础混淆。真正的高安全场景必须配合 HTTPS、后端密钥管理、签名防篡改等措施一起上。这篇文章后面专门有一节讲安全边界你先有个印象就行。1. 内容整体设计与思路拆解1.1 前端什么时候真的需要 AES 加解密我在社区里经常看到两种极端一种是觉得“前端加密就是脱裤子放屁”另一种是“不管什么数据先加密再说”。实际项目里前端做 AES 加解密通常逃不出这三类需求。第一类是敏感字段的传输加密。用户登录密码、手机号、身份证号、银行卡号这类字段前端拿到之后其实是可以做一层加密再传给后端的。密码加密尤其常见就算后端那边还有 HTTPS多一层加密也能避免密码在浏览器历史、代理日志、运营商缓存这些环节留下明文影子。注意我说的是“避免留明文”不是“防止被黑客破解”这个定位要摆正。第二类是数据落盘保护。如果业务需要在 localStorage、IndexedDB、Cookie 里存一些用户资料、搜索记录、草稿内容全部明文存储确实有点心大。这时候可以用 AES 加密后再存读取时解密至少能做到“本地存储不留明文字段”。当然这个保护强度也受密钥暴露的限制后面我会专门说怎么尽量弱化这个问题。第三类是纯前端产品内部的授权校验。比如某些 H5 页面是给内部人员用的不想让别人看一眼源码、改个参数就绕过前端会约定一个加密规则来生成访问凭据。这类场景本质是“提高破解成本”不是“绝对防盗”但要的就是这个效果。我不建议把整份请求体都加密也不建议把所有接口全部改成加密传输。过度加密会带来几个问题请求调试困难、后端排查问题成本高、性能损耗、以及最尴尬的——一旦密钥泄露前端所有加密形同虚设。我的原则是核验类项目用 HTTPS 做传输安全业务类敏感字段选择性加密内容级保护才考虑整体加密。1.2 为什么选 crypto-js而不是 JSEncrypt 或 Web Crypto API前端能做加解密的不止 crypto-js 一个我列个对比你就明白为什么大多数项目绕不开它了。从面试的角度说这几个库的边界本身就是高频考点。crypto-js 是对称加密JSEncrypt 是非对称加密Web Crypto API 是浏览器原生的加解密接口。三者解决的问题不同不是简单的替代关系。crypto-js 的最大优势是“简单直接”装完就能用浏览器直接引入Node 里也能跑。它支持的算法全面AES、DES、MD5、SHA 全都有而且加解密的写法高度统一。缺点也是明显的它是纯 JS 实现性能不如 Web Crypto API 的原生实现在大量数据加密时会慢一些另外它早期版本更新频率不高一些新算法支持相对滞后。Web Crypto API 是浏览器内置的性能好、安全度高但由于是原生接口API 风格和 crypto-js 差别很大而且必须依赖 window.crypto 环境老版本浏览器和小程序里支持情况不统一。如果做的是纯浏览器端的强安全场景我反而建议优先考虑它。JSEncrypt 主要做 RSA 非对称加密适合加密一些短数据、配合后端做密钥协商不适合做大批量数据传输加密——RSA 加解密速度比 AES 慢不少密文长度也会膨胀。所以现实选择就是要快、要省事、要跨端一致用 crypto-js要极致性能且只跑现代浏览器考虑 Web Crypto API要做非对称密钥交换才上 JSEncrypt。大部分前后端联调场景crypto-js 是最省心的那一个。2. 核心细节解析与实操要点2.1 AES 的分组、密钥、初始化向量和填充一次说清先扫盲几个 AES 最基础但最容易搞混的概念。AES 是一种分组加密算法意思是你不能像加密字符串那样一个字符一个字符地处理而是把数据切成固定大小的块一块一块地加密。AES 的分组长度固定是 128 位也就是 16 个字节。密钥长度有三种128 位16 字节、192 位24 字节、256 位32 字节对应我们常说的 AES-128、AES-192、AES-256。密钥越长加密强度越高但性能也会略降。前后端联调时你不需要自己纠结用哪个后端接口文档里写什么就用什么关键是字节数要对上。初始化向量IVInitialization Vector就更有意思了。同样一段明文、同一个密钥如果每次加密结果都一样攻击者就能通过观察大量密文找到规律。IV 的作用就是给加密过程引入一个随机扰动让相同明文在不同时刻加密出的密文完全不同。IV 的长度固定等于分组长度AES 里就是 16 字节。它不需要保密但必须保证随机性一般跟着密文一起传给后端。填充Padding解决的是“明文长度不是 16 的倍数”的问题。AES 既然是按 16 字节一组加密最后一块不满 16 字节怎么办常见的模式是 PKCS7末尾缺几个字节就补几个字节补的数值就是缺的字节数。比如最后一块只剩 5 个字节那就补 11 个 0x0B。解密的时候按同样的规则去掉填充。再强调一下这是很多新手的理解误区crypto-js 里你写密码的时候通常直接传字符串库内部会用你给的方式去处理这个字符串可能是 UTF-8 编码后直接当 key也可能被当作口令去做密钥派生这两种情况后端拿到的东西天差地别。跨语言联调时 key 和 IV 的处理方式是第一个要确认的点。2.2 分组模式选择CBC、ECB 与 GCMAES 有了分组、密钥、IV、填充之后还要确定把这些东西组合起来的方式也就是“模式”。前端项目里最常见的模式是 CBC 和 ECB偶尔会遇到 GCM。CBCCipher Block Chaining密文分组链接模式会把上一组的密文和这一组的明文混合后再加密这样每一组的密文都依赖之前所有分组即使相同明文块在不同位置密文也不一样。它需要 IV安全强度明显优于 ECB只要是 2020 年之后的项目我基本推荐用 CBC。ECBElectronic Codebook电子密码本模式最简单每个分组独立加密不需要 IV同一明文永远得到同一密文两条相同的数据在密文里一眼就能看出来。这种模式在金融和通信领域已经被广泛弃用了但一些老旧的内部系统里仍然在用它联调时你没法选只能配合。如果真的没有要求不要主动选 ECB。GCMGalois/Counter Mode是一种认证加密模式加密同时输出认证标签能检测密文是否被篡改比 CBC 要安全得多。但麻烦也在这里crypto-js 官方并不原生支持 GCM需要借用第三方扩展或自己实现跨端联调复杂度会明显上升。如果项目对安全要求极高或者后端明确要求 GCM 模式我的建议是考虑用 Web Crypto API 替代 crypto-js。这里给一个实操判断方法后端接口文档里只要提到 AES/CBC/PKCS5Padding那你前端对应的就是 AES-CBC PKCS7 填充如果看到 AES/ECB/PKCS5Padding就是不要 IV 的 ECB 模式如果看到 AES/GCM多半得换个库或者让后端换成 CBC。2.3 crypto-js 的 Parse 与 Stringify 是最大的坑这是我想重点讲的内容。crypto-js 的使用其实分两层一层是算法层也就是 CryptoJS.AES.encrypt 这类方法另一层是编码层决定你传给算法的字符串到底怎么被理解。八成的前端联调失败问题都出在编码层。crypto-js 本身是拿 WordArray 这种数据结构来做运算的一个 WordArray 可以看作是一串 32 位的整数字。你在用一段字符串当 key 或者明文时必须先把字符串转成 WordArray。最常见的转换方式有这么几种CryptoJS.enc.Utf8.parse()、CryptoJS.enc.Hex.parse()、CryptoJS.enc.Base64.parse()。举个例子假如后端给你一个 32 字节的 key这个 key 是一个十六进制字符串比如 “0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef”你如果直接把它当字符串传给 CryptoJS.AES.encryptcrypto-js 会把它当成 64 个字符的 UTF-8 数据正好 64 字节作为 AES-256 密钥显然就错了。正确做法是先用 CryptoJS.enc.Hex.parse() 把它解析成 32 字节的 WordArray。反过来加密结果也是一样。CryptoJS.AES.encrypt 返回的是一个 CipherParams 对象你直接 toString() 得到的是 OpenSSL 格式的密文包含了 salt 和 iv 的信息。但后端那边通常只要纯粹密文的 Base64那你就得用 CryptoJS.enc.Base64.stringify(ciphertext.ciphertext) 去拿纯密文。解密时对称的问题同样存在。你拿到后端返回的 Base64 密文要先 CryptoJS.enc.Base64.parse() 成 WordArray再传给 CryptoJS.AES.decrypt最后把解出来的 WordArray 用 CryptoJS.enc.Utf8.stringify() 转回字符串。我把这些关键转换点列成一个速查表你写代码前先对照一遍操作你手上有的是你要做的转换代码示例构造 key字符串口令按约定转 WordArrayCryptoJS.enc.Utf8.parse(key)构造 key十六进制字符串按十六进制解析CryptoJS.enc.Hex.parse(keyHex)构造 keyBase64 字符串按 Base64 解析CryptoJS.enc.Base64.parse(keyBase64)构造 IV16字节字符串/hex/Base64同上CryptoJS.enc.Utf8.parse(iv)加密结果CipherParams 对象取纯密文转 Base64CryptoJS.enc.Base64.stringify(ciphertext.ciphertext)解密输入Base64 密文解析成 WordArrayCryptoJS.enc.Base64.parse(cipherBase64)解密结果WordArray转成明文字符串CryptoJS.enc.Utf8.stringify(decrypted)这一张表看明白crypto-js 你已经通了八成。后面实战环节我会手把手把这套东西封成一个工具模块。3. 实操过程与核心环节实现3.1 安装与基础封装先说安装。正常的前端工程直接在项目根目录执行npm install crypto-js # 或者 yarn add crypto-js # 或者 pnpm add crypto-js如果你的项目还跑在老的 webpack 环境里可能出现引入报错这个时候用 import CryptoJS from crypto-js 或者 require(crypto-js) 一般都能解决。有人在网上发过“crypto-js is not defined”的报错多半是引入了但没挂到全局对象上或者 script 标签引入了整个 dist 包但是内容和模块化环境冲突了。你按自己的打包工具选一种引入方式就行别在配置文件里乱加全局挂载。接着我给出一个可以直接复制到项目里用的 AES-CBC 加解密工具模块。这个模块是我在真实项目里的封装做了一定简化但核心逻辑没动你只要把 key 和 iv 的取值方式改成你项目里约定的即可。import CryptoJS from crypto-js; // 约定AES-256-CBC密钥为 32 字节IV 为 16 字节 const SECRET_KEY CryptoJS.enc.Utf8.parse(这里是你的32字节密钥字符串); // 注意实际项目中不要写死在前端 const SECRET_IV CryptoJS.enc.Utf8.parse(1234567890abcdef); // 16字节IV /** * 加密方法返回 Base64 字符串 * param {string} plainText 明文 * returns {string} 密文 */ export function encryptAES(plainText) { if (typeof plainText ! string || plainText ) { return ; } const encrypted CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 这里取的是纯密文的 Base64不是包含元数据的 OpenSSL 格式 return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); }再看解密方法/** * 解密方法从 Base64 密文还原明文 * param {string} cipherTextBase64 密文 * returns {string} 明文 */ export function decryptAES(cipherTextBase64) { if (typeof cipherTextBase64 ! string || cipherTextBase64 ) { return ; } const decrypted CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(cipherTextBase64) }, SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return CryptoJS.enc.Utf8.stringify(decrypted).toString(); }这里有几个细节我要重点解释。第一加密的时候我传的明文是 CryptoJS.enc.Utf8.parse(plainText)而不是直接传 plainText。如果你直接传字符串crypto-js 内部也会帮你做 UTF-8 解析但显式转换会让代码意图更清晰也避免后端那边对编码有微妙的差异。第二解密的时候我传给 AES.decrypt 的是一个对象 { ciphertext: 解析出的 WordArray }而不是直接传 Base64 字符串。这样明确告诉 crypto-js 这个就是纯密文而不是 OpenSSL 格式的字符串。这两种写法结果相同但下面的写法排除了“密文里混着 salt/iv”的歧义。第三mode 和 padding 我都显式写出来了没有依赖默认值。这种手法在跨语言联调时能减少很多“我明明写的没错就是不对”的问题。3.2 用 Java 后端对照验证参数前端加解密永远脱离不了后端联调所以我在这一节放一个非常典型的 Java 后端示例方便你对照着看。很多前端同学看到 Java 代码就心理发怵其实你只需要关注它用的算法名、填充方式、密钥和 IV 怎么来的不用看懂整个类。Java 后端常见的 AES-CBC 加密代码是这样的import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AesUtil { private static final String ALGORITHM AES/CBC/PKCS5Padding; private static final String CHARSET UTF-8; public static String encrypt(String plainText, String secretKey, String iv) throws Exception { SecretKeySpec keySpec new SecretKeySpec(secretKey.getBytes(CHARSET), AES); IvParameterSpec ivSpec new IvParameterSpec(iv.getBytes(CHARSET)); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(CHARSET)); return Base64.getEncoder().encodeToString(encrypted); } public static String decrypt(String cipherText, String secretKey, String iv) throws Exception { SecretKeySpec keySpec new SecretKeySpec(secretKey.getBytes(CHARSET), AES); IvParameterSpec ivSpec new IvParameterSpec(iv.getBytes(CHARSET)); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, CHARSET); } }后端这里有几个关键点。密钥和 IV 都是 secretKey.getBytes() 和 iv.getBytes() 转过来的字节数组如果你的前端传递的 key 是 Utf8.parse(32位的字符串)那后端也要用同一个字符串再 getBytes() 才能对上。算法串写的是 AES/CBC/PKCS5Padding对应前端 crypto-js 里的 CBC 模式 Pkcs7 填充。这里有个很多人问的问题PKCS5 和 PKCS7 不是不一样吗对于块大小 16 字节的 AES 来说PKCS5Padding 实际底层用的是 PKCS7 的一模一样的规则所以在 Java 的 AES/CBC 场景里两者等价前端用 Pkcs7 就能对齐。我最早做联调的时候卡在两个地方一个是密文解密出来开头多了一堆乱码后面才正常那是因为前端把包含 salt 的 OpenSSL 格式整体传给了后端而后端按纯密文解密另一个是解密直接报“Given final block not properly padded”这种通常是密钥或 IV 没对上或者密文被截断了。做个简单自测的办法固定一个明文和密钥先用前端加密把结果 Base64 密文交给后端解密后端解出的明文如果和原来一致说明两端参数一致。反过来后端加密出的密文你用前端解密测试。一旦中线对齐整个联调就稳了。3.3 动态 IV 与消息结构设计如果你在公司里负责和多个团队同时联调或者要对接多个后端服务会遇到一个更实际的问题每个接口都要求同一个 IV 吗IV 固定是不是不安全说实话实际项目里很多团队图省事确实把 IV 写死在前端代码里密钥也是固定的。但稍微正规一点的系统都会要求每次加密使用随机 IV并把这个随机 IV 一起传给后端。理由很简单如果 IV 固定“相同明文产生相同密文”的问题就回来了如果对方拿到大量密文分析出原文的一些统计特征会容易很多。前端如果要做随机 IV加密流程就变成生成一个 16 字节的随机字符串作为 IV用这个 IV 去加密明文然后把 IV 和纯密文一起传给后端。传的方式通常是在密文前面拼一段 IV或者干脆放进请求参数里。我推荐后者结构更清晰import CryptoJS from crypto-js; // 生成 16 字节随机 IV返回字符串 export function generateIv() { // 简易随机数方案生产项目建议用 crypto.getRandomValues return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex); } export function encryptAESWithRandomIv(plainText, secretKeyHex) { const key CryptoJS.enc.Hex.parse(secretKeyHex); const iv CryptoJS.lib.WordArray.random(16); const encrypted CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const ivHex iv.toString(CryptoJS.enc.Hex); const cipherTextBase64 CryptoJS.enc.Base64.stringify(encrypted.ciphertext); // 把 IV 和密文都传给后端后端按 base64 密文件 hex IV 解析 return { iv: ivHex, cipherText: cipherTextBase64 }; }这里的核心思想是把“每次加密数据”时把这次用到的随机因素IV显式交给接收方。后端拿到 iv 和 cipherText 之后用同一个密钥、同一个 IV 去解密。注意IV 是不需要加密的它是公开的信息可以被中间人看到这和密钥不是一个层级的安全要求。有一点要提醒你上述方案里我用的是 CryptoJS.lib.WordArray.random(16)它底层用的还是 Math.random 或者浏览器的随机源在密码学意义上不算强随机。如果项目安全评级高建议换成 window.crypto.getRandomValues() 来生成 IV。我这个封装主要是演示思路生产环境要按安全等级来调整。3.4 模块化封装与 TypeScript 支持在真实项目中我一般不会只导出两个裸函数而是会把工具方法封装到一个类或者命名空间下统一管理密钥来源、IV 来源和日志。这里给个稍微工程化一点的思路。比如我会建立一个 src/utils/crypto.tsimport CryptoJS from crypto-js; export interface CryptoOptions { mode?: CBC | ECB; padding?: Pkcs7 | NoPadding; iv?: string; key: string; } export class AesCryptor { private key: CryptoJS.lib.WordArray; private iv?: CryptoJS.lib.WordArray; private mode: any; private padding: any; constructor(options: CryptoOptions) { this.key CryptoJS.enc.Utf8.parse(options.key); if (options.iv) { this.iv CryptoJS.enc.Utf8.parse(options.iv); } this.mode options.mode ECB ? CryptoJS.mode.ECB : CryptoJS.mode.CBC; this.padding options.padding NoPadding ? CryptoJS.pad.NoPadding : CryptoJS.pad.Pkcs7; } encrypt(plainText: string): string { const config: any { mode: this.mode, padding: this.padding }; if (this.iv) { config.iv this.iv; } const encrypted CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), this.key, config); return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); } decrypt(cipherText: string): string { const config: any { mode: this.mode, padding: this.padding }; if (this.iv) { config.iv this.iv; } const decrypted CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(cipherText) }, this.key, config ); return CryptoJS.enc.Utf8.stringify(decrypted); } } export const aesCryptor new AesCryptor({ key: import.meta.env.VITE_AES_KEY || , iv: import.meta.env.VITE_AES_IV || });这个封装有个好处key 和 iv 的取值从环境变量或者配置中心统一管理不散落在业务代码里。虽然前端密钥无论如何都会暴露但至少不会出现在每个页面文件的顶部代码审查的时候也更清晰。TypeScript 方面需要注意 crypto-js 有自己的类型声明一般是内置的如果你的项目报找不到模块执行一下 npm install types/crypto-js 就行。某些版本里 CryptoJS.mode.CBC 的类型推断可能比较弱遇到类型报错可以用 any 处理但别把整段代码都糊上 any否则写成 TS 就没意义了。4. 常见问题与排查技巧实录4.1 解密报 malformed UTF-8 data 的原因与解决这是我在知乎和论坛上被问得最多的一个报错。现象是解密函数不抛异常但最后输出的字符串是空的或者是一堆类似“”的乱码。用 CryptoJS.enc.Utf8.stringify() 转换后如果遇到非法 UTF-8 序列通常就表现为空字符串或乱码。为什么会这样几乎都是因为解出来的字节流本身就不对。可能原因有这个项目用了非 UTF-8 编码的明文比如 GBK或者密文本身不是一个正确的 UTF-8 序列中间被截断或篡改过还有就是传入的 Base64 串不是真正的 AES 密文而是包含了其他格式的数据。我的排查方法很简单。先把密文解密后的 WordArray 转成 Hex 看一眼const parsed CryptoJS.enc.Base64.parse(cipherTextBase64); const decrypted CryptoJS.AES.decrypt({ ciphertext: parsed }, key, config); console.log(decrypted.toString(CryptoJS.enc.Hex));如果 Hex 是一串杂乱但长度正常的字节那说明密钥、IV、填充都大概率没问题只是编码出了问题。比如后端返回的是 GBK 加密、UTF-8 解密那你前端想通过 crypto-js 解出来就得先和后端确认统一编码或者想办法在解密后用 GBK 解码——但 crypto-js 本身不支持 GBK这就需要借助其他解码库了。如果 Hex 里出现大段 0x00 或者反复重复的图案则很可能密钥或 IV 没对齐。4.2 同明文加密出不同密文是 bug 还是特性这个问题经常在测试同学那里被问傻了。测试说“我提交两次一模一样的表单为什么接口里两个加密串长得不一样是不是代码有随机行为”其实只要你的 IV 是随机的或者你用了 CBC 模式且 IV 每次不同那这个现象就是设计如此。如果测试非要两个密文完全一致才能对比那你反而要警惕了——说明你用了 ECB 模式或者 IV 固定这时候相同明文会产生相同密文安全性指标上是减分的。不过有一种情况例外如果后端要求“对签名内容加密”要求每次签名串稳定那你前端就不能在加密环节引入随机 IV否则后端每次验签都失败。这种情况一般建议改用固定 IV 或者干脆用哈希而不是用随机 IV。你需要和后端确认清楚业务对“密文稳定性”有没有明确要求。4.3 加密正常但解密结果和原文明文字节数对不上有个很典型的“隐蔽坑”前端加密明文 A后端解出来变成 A 加了一串尾巴。举个例子明文是“hello”后端解出来是“hello”后面跟着 11 个空格或 11 个 \x0b 类似的控制字符。这种情况几乎都是填充没去掉。Java 的 PKCS5Padding 解密后会自动去填充但某些后端或者某些自定义实现只做了 AES 运算没有做反填充。你要是引入一个工具类之后发现这个问题先别急着改算法直接跟后端确认解密后有没有做 unpad。如果是 Python 的 pycryptodome有些模式需要你手动 unpad如果是 Node 的 crypto 模块默认也会要求你显式处理。前端侧其实很少遇到这个问题因为我们用 CryptoJS.enc.Utf8.stringify() 的时候那些 0x0B 控制字符通常会被转成不可见字符肉眼很难发现。要排查的话可以对解密结果做一次 base64 或 hex 编码看尾部是不是多了几个 0x0B。4.4 常见报错与解决办法对照表我把这些年见过的典型报错和对应解决方法整理成一个速查表你可以直接贴到项目 wiki 里当排障手册。报错或现象根本原因解决办法“Malformed UTF-8 data”解出来的字节不是合法 UTF-8检查密钥、IV、编码是否和后端一致解密结果为空字符串key 或 IV 类型不对或密文解析失败用 Hex 打印解密中间结果尾部多出多余字符\x0b后端未做反填充和后端确认 unpad 逻辑相同明文产生相同密文固定 IV / ECB 模式改成随机 IV CBC除非业务要求稳定密文两次解密结果完全不同密钥字节长度不对确认 AES-128/192/256 对应的字节数密文 Base64 里带加号斜杠请求 URL 解析失败Base64 的 URL 不安全字符用 Base64URL 编码或 encodeURIComponent传输时密文被 URL 编码解码后大改变特殊字符被转义约定统一编码规则crypto-js 引入报错模块化环境兼容问题确认 import 方式与打包配置这些坑我在真实项目里全部踩过一遍。尤其是“尾部多出字节”那次我们前后端排查了一个下午最后发现后端同学用的是 OpenSSL 命令行直接解密根本没走 Java 的 Cipher.doFinalkeystore 也没有自动 unpad。类似的联调问题大多数不是算法本身的问题而是双方对“密文格式、编码方式、填充处理”三个层次的约定不一致。5. 安全边界与进阶建议5.1 前端密钥一定会暴露这是宿命谈到前端加解密绕不开的灵魂拷问就是“密钥放前端安全吗”答案是不安全甚至可以说非常不安全。只要用户在浏览器里运行你的页面他打开开发者工具、在 Sources 里搜索一下你的密钥就暴露了。就算你做了代码压缩混淆在关键词搜索面前也只能拖延几分钟。但是这不代表前端 AES 加解密没有存在价值。安全本来就是分层防御的。你做 AES 加密至少把明文保护起来防止网络抓包、防止服务器日志落地明文、防止数据库里的用户画像字段裸露。攻击者要拿到密钥还得翻你前端代码这本身就是一层成本。我在实际项目里会把前端加解密定位成“第一层防线”配合 HTTPS 使用。HTTPS 解决的是传输过程中被第三方窃听或篡改的问题前端 AES 解决的是应用层数据被采集、日志被日志平台导出、密文可能被静态分析等情况下的明文泄露问题。两层组合覆盖的场景比单独用 HTTPS 更多。5.2 密钥分发和动态密钥交换的工程化思路真正想提高前端加密的安全性核心不是“隐藏密钥”而是“让每次会话的密钥动态化”。常见的做法是采用类似“握手”的流程前端先请求后端获取一个临时密钥这个密钥只在一段时间内有效过期后重新获取。前端拿到这个临时密钥后再用它来做 AES 加密。但前端请求临时密钥这个过程本身也需要保护否则攻击者同样可以模拟。所以更完整的方案是配合非对称加密前端生成一个随机 AES 密钥用后端公钥RSA加密这个 AES 密钥然后传给后端后端用私钥解密出 AES 密钥后续会话都用这个随机 AES 密钥加密通信。这样即使攻击者抓包拿到了加密后的 AES 密钥也无法解开 RSA 密文自然拿不到真正的 AES 密钥。这套“RSA 加密 AES 密钥 AES 加密业务数据”的混合加密方案在前后端联调里偶尔会用到但不是日常标配。它的问题是实现复杂度高、性能开销大、出错排查困难。除非你的项目有明确的合规要求或者安全审计要求否则我建议先用“HTTPS 固定密钥 敏感字段加密 弱随机 IV”把基础打牢再考虑混合加密。5.3 增强安全性的几个低成本手段如果你的项目暂时不需要上混合加密但你又想在现有 crypto-js 方案上稍微提升一点安全性我有几个低成本手段可以分享。第一个是给密钥做混淆。不要在前端代码里明文写“SECRET_KEY abcdef123456”这种东西至少可以把它拆成几段字符串运行时拼接再做一个简单的编码转换。这个手段不能真正防御高手但能阻止绝大多数复制粘贴的“脚本小子”。第二个是敏感数据不落 localStorage。如果你用 AES 加密后把用户手机号、身份证号存到了 localStorage虽然在某种程度上是密文存储但攻击者拿到密钥后照样能解。我的建议是能不在前端存储就尽量不存尤其是身份类信息。真要存也建议优先使用 httpOnly 的 Cookie避免 JS 直接读取。第三个是日志脱敏。前端打印日志、上报监控时要过滤掉加密前的明文。我见过太多项目加密做得好好的结果 console.log 里直接打印了加密前的手机号监控系统一接入等于所有加密全部白做。这是一个常见的低级失误。第四个是定期更新密钥和审查调用权限。如果你的前端项目有多个页面在用同一个 AES 密钥一旦某个页面被恶意注入脚本所有页面都会受影响。这背后其实牵涉到前端供应链安全问题不仅是加解密本身了。5.4 面试中常问的前端加解密衍生问题这个主题是前端面试里出现频率比较高的考点毕竟“前端面试题 2026”这类关键词最近非常火。我总结下面试里常被追问的几个方向你如果是在准备面试可以顺手把这些点都捋一遍。第一个问题通常是“前端为什么要做加密你做过什么加密方案”这个要结合业务场景回答不要只背概念。比如可以说“我们项目里有用户手机号脱敏需求接口传输时用 AES-CBC 加密密钥是后端分发部分接口用 RSA 做密钥协商”这样比背一堆加密算法名更有画面感。第二个问题是“对称加密和非对称加密的区别是什么如何选型”常考点是 AES 对称加密快但密钥分发难RSA 非对称加密安全但速度慢所以才有混合加密。要能说清楚 RSA 加密长度限制和为什么它不适合大批量数据。第三个问题是“AES 加密的 key、iv、mode、padding 分别是什么”这属于基础八股文但很多人答不全。能把 IV 的作用、PKCS7 填充规则、CBC 和 ECB 的差异完整说清楚面试官一般会满意。第四个问题是“前端写的加密是不是安全的密钥暴露了怎么办”考的是工程思维。要表达“前端加密是分层防御的一环不解决安全问题只解决明文泄露问题”这个认知然后能提出 HTTPS、密钥轮换、动态密钥、混合加密等改进方案。这个题答得好不好往往能拉开档次。6. 跨端场景与 CORS/请求传递细节6.1 加密参数放进 GET 请求时的注意事项实际开发里有一次我把加密结果直接拼在 URL 查询参数里去做 GET 请求结果后端一直说解密失败。排查到最后发现 Base64 密文里面有加号“”而 URL 解析的时候把加号解码成了空格。这个问题不算 crypto-js 的问题但特别常见。解决办法有两种一是前端拼 URL 之前先对密文做 encodeURIComponent 编码把加号和斜杠都转义掉二是约定用 Base64URL 格式也就是把 换成 -把 / 换成 _去掉末尾的 。Base64URL 这个格式在很多 RESTful API 签名场景里是标准做法如果你和后端合作频繁建议直接约定用这个。我这里给一段简单的 Base64URL 转换代码export function base64ToBase64URL(base64Str) { return base64Str.replace(/\/g, -).replace(/\//g, _).replace(/$/, ); } export function base64URLToBase64(base64URLStr) { let base64 base64URLStr.replace(/-/g, ).replace(/_/g, /); while (base64.length % 4 ! 0) { base64 ; } return base64; }这样加密后的数据放到 URL 上就不会踩特殊字符的坑。6.2 微前端环境下怎么统一管理加密模块现在不少公司上了微前端架构比如 qiankun 或者 wujie各个子应用都要用到加解密工具。如果每个子应用各自装一份 crypto-js、写一套封装密钥管理就乱了万一某个子应用改了密钥其他人不知道线上联调直接炸。我的建议是单独把一个 shared/crypto 的包抽出来作为公共依赖发布到内部 npm 仓库所有子应用统一引用。密钥从全局配置中心下发子应用启动时读取关键调用加日志。这样至少能做到“同一个业务背后只有一套加密逻辑”排查问题的时候不用在几十个子应用里翻来翻去。当然这样做的代价是公共包的升级会影响所有子应用。所以公共包里面尽量少放业务逻辑只放加密、解密、密钥获取这些基础设施保持接口稳定。6.3 与 Web Worker 结合加密大数据的思路如果你要加密的数据量很大比如上传导出的大文件前端主线程直接跑 crypto-js 的 AES 加密会卡住页面这时候可以考虑把加密逻辑丢到 Web Worker 里。思路很简单主线程把文件的 ArrayBuffer 或 Blob 交给 WorkerWorker 里引入 crypto-js 按块加密加密完成后把结果传回主线程。crypto-js 处理二进制数据时需要把 ArrayBuffer 转成 WordArray处理完再转回来。但 crypto-js 的 WordArray 操作大文件时性能一般如果真到了大文件加密这个级别我更建议用 Web Crypto API 的 SubtleCrypto它对 ArrayBuffer 的原生支持更好性能也明显更强。crypto-js 更适合“小数据量、需要和后端保持同一实现”的场景。大文件加密属于另一个赛道这里不展开只是提醒你选型的时候别搞混。写在最后的个人实操心得回头看前端 AES 加解密这个主题技术栈本身并不复杂复杂的是前后端约定的一致性。我在实际项目里被坑得最多的永远不是算法本身而是 key 的编码方式、IV 是否要带、密文是 Base64 还是 OpenSSL 格式、PKCS5 和 PKCS7 到底怎么对齐——这些全是“人跟人之间沟通的问题”。我做联调的时候现在有一个习惯先自己写一个“十字测试”同一份明文、同一个密钥前端加密后交给后端解后端加密后交给前端解四个方向全部对齐了才开始写业务代码。这个习惯至少帮我省了十几次加班排查的时间。最后分享一个小技巧调试加密代码时别拿中文长文本试先用一个 8 字节以内的英文短字符串比如 “test123”跑通一遍再切换成真实数据。因为英文字符在 UTF-8 下是单字节落进分组里一眼能看出边界在哪儿中文是三个字节一组出错时你很难分辨是编码问题还是分组问题。希望这篇东西能让你少踩一些坑。如果你在联调中也遇到过特别奇葩的加解密问题欢迎在评论区里分享出来我真的很想知道还有哪些坑是我没踩过的。
返回列表