做了几年前端和全栈开发,只要项目里牵涉到敏感数据,AES加密基本是绕不开的名字。AES也就是高级加密标准,一套对称加密算法,加密解密用同一把密钥,速度快、实现简单,所以无论是登录密码、接口报文还是本地缓存,很多团队的第一反应就是它。这篇文章不打算讲那些晦涩的数学证明,就聚焦在JavaScript里怎么落地一套真正能跑的AES加密。如果你刚好是前端、全栈,或者正在为前后端加密联调头疼,这篇可以从选库、写代码、踩坑排查几个角度帮你把路上的大泥坑提前填平。
1. 为什么在JavaScript里选AES加密
1.1 对称加密为什么是业务场景的主力
对称加密和非对称加密这对兄弟,非对称加密像挂号信,安全性高但是慢,适合加密手机号、银行卡这种小字段;而对称加密更像一把钥匙开一把锁,加密方和解密方共用同一把密钥,在数据量大、通信频繁的场景里性能优势非常明显。
日常业务里的大部分加解密需求,本质是“数据在传输或存储过程中被别人看到会出事”。个人信息、订单详情、聊天记录、凭据令牌,这些都是对称加密的典型应用对象。AES作为现代对称加密事实标准,已经在硬件层面获得大量支持,很多CPU都有专属指令集加速。换句话说,同样一段逻辑,AES跑起来性能非常理想,这也是它在Web领域里被广泛接受的关键原因。
1.2 前端哪些场景适合用AES
很多刚接触加密的开发者会觉得“前端做加密没意义”,但实际上前端加密重点不是防御有国家级别攻击者的系统,而是防止明文在日志、数据库、中间环节被扒走,或者避免接口参数裸奔。
我在实际项目里见过并且自己用过的场景大致是这几类:
- 登录或注册时,对密码字段做加密后再提交接口,避免前端日志、网络抓包里直接暴露明文。
- 用户个人页面的敏感缓存,比如身份信息快照、会员密钥,加密后写入localStorage,防止别人打开DevTools就能直接读。
- 前后端接口报文加密,前端把整个请求体加密后再传给后端,服务端拿到后再解密,平台内部即使有人抓包也看不到业务参数内容。
- 配合二维码、小程序场景传递敏感参数时,先用AES加密,再压缩或编码生成链接。
不过这里要泼一盆冷水:AES加密解决的是“别人看不到明文”的问题,解决不了“系统被攻破后密钥也丢了”的问题。整个通信链路里,HTTPS仍然是最基础的传输层防线,AES通常是纵深防御里非常重要的一层,但一定不是唯一一层。想清楚这一点,再看后面加解密落地的细节,心态就稳很多。
2. 动手前先搞懂的4个关键参数
在JavaScript里调一个加密API看似只有几行代码,真正坑人的其实都在参数里。如果你只是复制粘贴别人代码,多半会在联调阶段被加解密结果不一致磨到怀疑人生。动手之前,我建议先把下面这几个参数彻底搞明白。
2.1 密钥长度:AES-128、192、256怎么选
AES分三个版本,密钥长度分别为128位、192位、256位,也分别对应16字节、24字节、32字节的密钥。位数越大密钥越长,暴力破解的难度指数级上升,实际性能差异对普通业务几乎可以忽略。
聊到这部分,我总是会直接提醒团队里的新人:密钥长度和安全性的关系,不要只看数字大小。AES-128本身至今也还没有被实际破解的先例,很多机构和合规标准之所以强制要求AES-256,是因为密钥长度更长、后量子时代理论上更稳妥,同时也为了统一规范。
落地时经常出现的问题是,前后端说好了用AES-256,结果一方密钥字符串长度只有16个字符,另一方代码里写的是AES-128,两边都在加密,但是互相解不开。所以在你写任何代码之前,先确定你手里密钥的真实长度到底是多少。一个约定的建议是:统一用32字节长度的字符串,或者直接用32字节的字节数组,让AES-256成为唯一选项,减少分歧。
2.2 分组模式:ECB、CBC、CTR、GCM的区别
AES本质是分组密码,它把明文切分成固定长度的分组,再逐块加密。问题在于,如果每块都独立加密,相同明文就会产生相同密文,这种模式叫ECB。ECB模式安全性不足,因为有经验的攻击者可以根据密文块重复性反推明文结构,正规系统里早就不推荐使用了。
实际项目里最常用的是CBC模式,它引入了一个初始化向量IV,让每个明文分组先和前一个密文分组做XOR,然后才进行加密,这样相同明文在不同IV下会得到完全不同的密文,而且块与块之间相互关联。CBC关键词就是IV,只要IV随机变化,相同的密文结构就会被完全打散。
还有一个方向是CTR模式,它把AES当成一个流密码来用,通过加密计数器生成密钥流,再和明文做XOR。CTR支持并行计算,性能相当好,但CTR同样不提供完整性保护。
近年来越来越多项目在推GCM模式。GCM把加密和消息认证打包在一起,既保证机密性,又提供防篡改能力,是目前安全性和性能平衡得最好的模式之一。浏览器原生Web Crypto API对GCM支持已经很成熟,如果是新项目,我非常建议优先考虑GCM方案。
下面这个表是我自己团队里用的模式速查:
| 模式 | 是否推荐 | 是否需要IV | 是否防篡改 | 典型应用场景 |
|---|---|---|---|---|
| ECB | 不推荐 | 不需要 | 否 | 仅兼容老旧系统 |
| CBC | 推荐 | 需要 | 否 | 大多数业务,最简单通用 |
| CTR | 推荐 | 需要 | 否 | 需要并行、效率优先的场景 |
| GCM | 强烈推荐 | 需要 | 是 | 新项目、高安全要求 |
2.3 填充方式为什么不能随便选
分组密码有个天然限制:必须按块处理,AES一个分组固定是16字节。你输入“hello”这种长度不是16倍数时,得先填充到整块才能加密。
常见的填充方式是PKCS7,规则很直白:缺几个字节,就在末尾补几个值为N的字节。比如缺5个字节就填充5个0x05,解密时看最后一字节是几,就删掉几个填充字节。即使明文长度刚好是16的整数倍,PKCS7也依然会补满一个完整分组,目的就是让解密方永远能识别是否有填充。
Java后端有个经典历史坑:命名叫PKCS5Padding,实际实现就是PKCS7,因为AES的块长是16字节,PKCS5原本只适用于8字节块。所以前端用Pkcs7,后端写PKCS5Padding,两边能正常对齐。别被名字骗了,它们在这里是同一件事。
填充一旦不一致,解密时通常会报“解密后填充无效”或“数据不是块大小的整数倍”。这类报错听起来吓人,实际只是你和对接方选的不同填充算法,或者数据压根没加密成功。
2.4 编码格式决定你能不能正确解密
编码问题是最常被忽略的隐藏杀手。同一个字符串“张三”,在UTF-8下是6个字节,在GBK下是4个字节。如果加解密双方没有统一字符编码,最后解出来的要么乱码,要么报错。
JavaScript里的字符串一开始就是Unicode形态,通常用UTF-8编码成字节数组是默认选择。加密前的明文,用CryptoJS.enc.Utf8.parse把字符串转成密码对象才能参与运算;解密后的结果,也一定要再用CryptoJS.enc.Utf8转回字符串。
密文在传输和存储时,最常见的是Base64和Hex两种编码。Base64能把二进制转成英文字母、数字和+/=组成的文本,方便放在JSON或URL里(但要注意是否做URL转义)。Hex则把每个字节表示成两位十六进制字符,一眼看过去很规整。CryptoJS默认输出的密文串是OpenSSL格式的Base64字符串,很多新手直接把这个字符串拿去对接Java或PHP,结果解析失败。最稳妥的方案是:统一密文格式为纯Base64,并且自己管理IV的编码和传输方式,这样对接任何语言都不容易产生歧义。
3. 选库与工程准备
3.1 老牌库CryptoJS和原生Web Crypto API怎么选
JavaScript生态里最常用的AES加密方案有两个方向:一个是老牌开源库CryptoJS,另一个是浏览器原生自带的Web Crypto API。
CryptoJS的最大优势是简单直接,API对新手非常友好,在浏览器和Node.js里都能跑,文档和示例也最多。很多历史项目、低版本浏览器项目、临时脚本,用CryptoJS可以很快跑通。它的缺点也一直被人诟病:项目维护节奏比较慢,安全审计报告不算多,一些新算法和模式支持不如原生API及时。
Web Crypto API是浏览器内置标准,通过window.crypto.subtle调用,安全性更好,随机数来源也更可靠。它支持AES-CBC、AES-CTR、AES-GCM这些主流模式,而且GCM是官方主推方案。缺点是对新手不太友好,全部异步操作,返回值是Promise,代码写起来要层层await,老浏览器支持也不够好。
我的建议很务实:如果你只是做内部工具、自己可控的Node脚本、或者兼容老旧浏览器,选CryptoJS完全没毛病;但如果是新项目、长期维护、对安全合规有要求,优先考虑Web Crypto API+GCM模式。两者并不矛盾,很多项目同一个体系里两套都留着,CryptoJS来做快速原型,Web Crypto API上生产环境。
3.2 带项目可跑的引入方式与基础环境
假设你决定先用CryptoJS跑通流程,最快的引入方式是在浏览器里直接用CDN脚本标签。
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>如果你用的是现代前端工程,推荐走包管理器:
npm install crypto-js然后在模块里按需引入:
import CryptoJS from 'crypto-js';Node.js的CommonJS写法则这样:
const CryptoJS = require('crypto-js');至于Web Crypto API,不需要安装任何依赖,直接在浏览器控制台就能用,但要确保运行环境支持crypto.subtle。注意一个坑:window.crypto.subtle只在安全上下文里可用,也就是HTTPS或localhost环境下才能访问,普通的http页面拿不到。
工程准备阶段还有一个我自己习惯做的事:确认你到底在哪个环境跑代码。很多人在浏览器里调试得好好的,写了个自动化脚本放到Node里跑,结果发现window不存在,或者crypto对象来源变了。Node 15以上版本原生支持globalThis.crypto,但兼容性细节仍然不一样,所以过早地均匀混合两套环境,只会给自己埋雷。
4. 从零手写一套AES加解密实现
这章是全文的重点,我会把一套可以直接用的AES-CBC加解密代码拆开揉碎,每一步都告诉你为什么这么写。
4.1 先写加密:CBC加Pkcs7的标准写法
我强烈建议密钥和IV都用WordArray对象,而不是直接用字符串。为什么?CryptoJS里如果直接把字符串当密钥传进去,默认会走OpenSSL的KDF逻辑,也就是用随机盐和迭代次数派生真正的密钥,这样加密结果里会带一段Salted__开头的内容,跟很多后端标准AES实现根本对不上。用CryptoJS.enc.Utf8.parse把密钥字符串转成WordArray,CryptoJS就不会再做KDF,而是直接拿这串字节作为密钥,这才是和其他语言对齐的最稳方式。
下面是加密函数:
import CryptoJS from 'crypto-js'; // 密钥统一用32字节字符串,对应AES-256 const key = CryptoJS.enc.Utf8.parse('0123456789abcdef0123456789abcdef'); // 生成16字节随机IV,每次加密都不一样 const iv = CryptoJS.lib.WordArray.random(16); function encryptByAES(plainText) { const encrypted = CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 把iv和密文用冒号拼在一起返回,方便解密时取用 const ivStr = CryptoJS.enc.Base64.stringify(iv); const cipherStr = encrypted.ciphertext.toString(CryptoJS.enc.Base64); return ivStr + ':' + cipherStr; }不需要强行把明文提前转成WordArray,CryptoJS.AES.encrypt第一参数可以直接收字符串,内部默认按UTF-8编码处理。也就是说我这段代码把编码、模式、填充的处理全部显式写清楚了,后续不管谁接手,看到这段代码就知道这套方案长什么样。
这里最让我想强调的其实是IV的作用。IV不需要保密,但是必须每次都随机,同一个密钥下如果IV重复,那么相同明文就会被加密成相同密文,CBC的混淆效果就直接失效了。每次加密都调用WordArray.random(16),这是成本最低又最保险的做法。
4.2 再写解密:注意CipherParams而不是直接传字符串
解密时最常见的错误,是把上面拼接出来的iv:cipher整个字符串直接扔给CryptoJS.AES.decrypt,结果要么拿到一堆空对象,要么解出乱码。因为解密API只认CipherParams对象或者OpenSSL格式文本,不会自己帮你拆分IV和密文。
正确的做法是我直接用冒号拆开:
function decryptByAES(encryptedText) { // 拆开iv和密文 const parts = encryptedText.split(':'); const iv = CryptoJS.enc.Base64.parse(parts[0]); const ciphertext = CryptoJS.enc.Base64.parse(parts[1]); // 组装成CipherParams对象 const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: ciphertext }); const decrypted = CryptoJS.AES.decrypt(cipherParams, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 把解密结果从WordArray转回字符串 return decrypted.toString(CryptoJS.enc.Utf8); }解密结果必须先调用toString(CryptoJS.enc.Utf8),否则你得到的是WordArray对象,直接拿去展示或传给后端,会看到一堆二进制乱码。这一步是几乎所有新手都会卡住的地方。
如果解密后得到空字符串,或者抛异常提示“填充无效”,优先检查这几个地方:密钥用的WordArray还是字符串明文?密文是否真的Base64解析成功了?IV是不是和加密时完全一致?顺序检查,能排查掉九成的问题。
4.3 加解密联调:前后端字节序和编码要统一
前端JavaScript的AES实现,和Java、Go、Python这些后端语言的AES实现,采用的算法标准是同一个,本应该能互通,但实际联调中最容易挂的就是编码和格式不匹配。我见过无数次前端输出一串Base64,后端用Java解密报错,原因就是前端实际用了CryptoJS的OpenSSL KDF格式,而后端用的是标准CBC加密——两者怎么可能解得开。
这里给一个Node.js原生crypto配合解密的示例,展示后端如何解密我前面前端加密出来的内容:
const crypto = require('crypto'); const algorithm = 'aes-256-cbc'; const key = Buffer.from('0123456789abcdef0123456789abcdef', 'utf8'); const iv = Buffer.from(ivBase64, 'base64'); const cipherBuffer = Buffer.from(cipherBase64, 'base64'); const decipher = crypto.createDecipheriv(algorithm, key, iv); let decrypted = decipher.update(cipherBuffer, 'base64', 'utf8'); decrypted += decipher.final('utf8'); console.log(decrypted);Java后端如果用AES/CBC/PKCS5Padding,和前端这套是能直接互通的,原因前面说过,Java里的PKCS5Padding在这里实际就是PKCS7。Go后端用aes.NewCipher配cipher.NewCBCDecrypter,注意填充处理方式保持一致即可。
关键点在于前后端必须统一以下四个约定:
- 密钥的编码格式,UTF-8字符串还是Hex字符串。
- 密钥字节长度,必须对应AES-128或AES-256。
- 密文的编码格式,我建议统一Base64。
- IV的传递方式,我建议统一
IV:密文的拼接格式,并用Base64表示IV。
只要这四点对齐,任何语言任何一个标准库都能跟你的JavaScript加密实现互相解密。
5. 真实项目里的安全细节与踩坑记录
代码能跑通只是第一步,拿到生产环境里,问题才真正开始。
5.1 密钥怎么管理,硬编码在前端等于没加密
AES是对称加密,密钥如果直接写在浏览器前端代码里,任何人打开控制台都能看到这份密钥。密钥泄露之后,你的加密只是给服务器日志和数据库里多存了一串能解回明文的字符串,安全能力基本作废。
很多教程为了演示方便,直接把密钥写在JavaScript代码里,这是业务逻辑之外的另外一个故事,正式项目绝对不能这么干。常见的替代方案是:
- 密钥由后端下发给前端,只在当前会话中使用,不写到前端代码里。
- 前端拿到的密钥只保存在内存变量里,不放进localStorage。
- 服务端通过网关或参数协商,动态生成一次性会话密钥,用完即弃。
就算用了后期方案,也要清醒:Web端永远不存在绝对安全的密钥,因为运行代码的设备不在你手里。我们能做的是尽量缩短密钥生命周期、减少密钥泄露后的影响范围、避免把明文数据永久落在前端存储里。
5.2 CBC不防篡改,要用GCM或HMAC补上
CBC模式只保证机密性,不保证完整性。攻击者即使不知道密钥,也可以通过修改密文块来篡改解密后的明文,这种攻击手法叫做比特翻转。对很多业务来说,单纯加密远远不够。
如果你坚持用CBC,就必须用HMAC对密文做校验。最简做法是把IV和密文拼接成字符串,计算一个带密钥的HMAC摘要,传输和存储时把这个摘要一起带上,解密前先校验摘要是否一致,不一致就直接丢弃。
const hmacKey = CryptoJS.enc.Utf8.parse('another-secret-key-here'); const message = ivStr + ':' + cipherStr; const hmac = CryptoJS.HmacSHA256(message, hmacKey).toString();对更好的方案,直接用GCM模式。GCM在加密的同时生成认证标签,接收方在解密时验证标签,既防窥探又防篡改,一步到位。Web Crypto API就支持GCM,这也是我推荐新项目优先考虑它的重要原因。
5.3 那些文档里不会写的常见大坑
说几个我在实际项目中真实踩过、也帮别人排查过无数次的坑。
第一,密文Base64里的+/=字符在URL里非常危险。+在URL query里会被解析成空格,导致签名校验永远失败。我在一个项目里就因为这个+的问题,前端调通后一上生产环境就偶发解密失败,最后才发现是URL编码问题。解决方式很简单,传URL之前用encodeURIComponent包一层,后端取参数后再用对应方式解码。
第二,CryptoJS的字符串密钥KDF陷阱。使用CryptoJS.AES.encrypt('hello', 'mykey')这种写法,代码看起来很简单,但它内部会生成随机盐、迭代1000次、输出OpenSSL格式加密串。如果你拿这个结果去对接Java或Go标准AES,一定失败。我之前已经在第4章强调过,这里换一个角度再提醒:凡是看到加密结果以U2FsdGVkX1开头,基本就是命中了这个陷阱。
第三,WordArray和字符串之间频繁转换容易丢数据。有些开发者习惯先把加密结果转成Hex,随后又用CryptoJS.enc.Hex.parse转回来,一旦中间某个环节把前导0截断,密文长度就变了,解密必然失败。我的习惯是能全程使用Base64就不要反复转两次。
第四,跨终端联调时,不要用“看起来一样”的密钥。比如后端从环境变量里读取的密钥可能带了换行符,前端字符串里没有这个换行符,两边密钥字节不一样,加密结果必然无法互通。遇到这类问题时,直接把两边的密钥用Hex打印出来逐字节比对是最快的定位手段。
6. 调试排查手记
把调好的代码复制到新环境里通常会突然失灵,这时候一套清晰的排查流程能帮你省掉几个小时的瞎试。
6.1 典型的报错和现象速查
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 解密后乱码 | 密钥、IV或明文编码不一致 | 统一所有字符串为UTF-8,检查密钥字节长度 |
| 解密抛“padding error” | 填充模式不匹配,或密钥格式不一致 | 确认双方都使用PKCS7/PKCS5,密钥统一WordArray或Hex |
| 解密结果为空字符串 | 密文没有正确转成CipherParams对象 | 检查IV和密文拆分方式,确认用Base64.parse而不是直接传字符串 |
| 加密结果以U2FsdGVkX1开头 | 字符串密钥触发了OpenSSL KDF | 把密钥用CryptoJS.enc.Utf8.parse转成WordArray |
密文里+和/丢失 | 未做URL编码,被浏览器或后端解析掉 | 对密文整体用encodeURIComponent编码 |
| 用了Web Crypto API却拿不到crypto.subtle | 普通http页面不在安全上下文 | 切到HTTPS或localhost环境调试 |
6.2 现场排查的顺序与技巧
我自己遇到加解密问题时,不会一上来就试各种代码写法,而是按照固定顺序缩小范围。
第一步,先固定变量。把IV固定成一个写死的值,例如全部设为0000000000000000,并且明文也固定成短字符串,先排除随机因素。如果固定IV下加解密能通,说明整体算法流程没问题,问题大概率出在随机IV的传输或解析上。
第二步,打印密钥长度。这一步非常重要,很多人只看到密钥字符串一样就以为万事大吉,实际上两端字符串编码不同,字节数也对不上。用Buffer.byteLength(key, 'utf8')或key.toString().length打印出来看一眼,能解决掉一半联调问题。
第三步,拆开对拼。如果前端自己加密再自己解密都失败,问题显然出在你代码内部的数据转换流程,沿着“明文 -> WordArray -> AES -> Base64 -> 解析 -> 解密 -> 明文”这个路径一步步打印中间态,很快能定位是哪一环丢数据。
第四步,做跨语言解密时,保留最小用例。把前端生成的iv:密文复制出来,不带任何随机参数,用稳定的样例在后端跑解密。后端能把固定样例解开,再上生产动态数据就会从容很多。
调试过程中还有一个小技巧:不要把密钥和IV写在输出日志里。日志是敏感数据泄露的高发区,加密调试时为了排查问题可以临时打印,但上线前一定要清理干净。我在一次上线评审里就用这个理由拦下过一份差点带着密钥进日志的代码。
回头看AES加密在JavaScript里的实现,把参数定清楚、把编码捋顺、把密钥生命周期想明白,这三件事做好,基本就不会再被解密问题折腾。我现在的习惯是每个新项目的第一天,先把加密参数模板写进接口文档:密钥长度、模式、填充、IV格式、密文编码,全部白纸黑字固定下来,前端和后端联调时严格照着执行。加密本身只是一种工具,真正容易出问题的地方永远在边界协商上,把这些边界提前锁死,你就能把精力留给真正有挑战的业务逻辑。