做了几年前端和全栈开发只要项目里牵涉到敏感数据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 APIGCM模式。两者并不矛盾很多项目同一个体系里两套都留着CryptoJS来做快速原型Web Crypto API上生产环境。3.2 带项目可跑的引入方式与基础环境假设你决定先用CryptoJS跑通流程最快的引入方式是在浏览器里直接用CDN脚本标签。script srchttps://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把密钥字符串转成WordArrayCryptoJS就不会再做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; }不需要强行把明文提前转成WordArrayCryptoJS.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格式、密文编码全部白纸黑字固定下来前端和后端联调时严格照着执行。加密本身只是一种工具真正容易出问题的地方永远在边界协商上把这些边界提前锁死你就能把精力留给真正有挑战的业务逻辑。