刚接触前端加解密的人大概率都经历过这样一个瞬间从某个开源项目里复制了一把 JavaScript 加解密代码兴冲冲地塞进自己的登录页结果后端一验发现密文完全对不上。你不是唯一一个。这个领域看起来简单——不就是调几个函数吗真正坑人的恰恰是那些没人写在 README 里的默认参数、编码规则和密钥约定。这篇文章我想把 JavaScript 加解密这条路上的关键节点完整梳理一遍从算法原理到浏览器原生 API再到一套能落进真实业务的企业级安全实践包括我在生产环境中踩过的坑和最终沉淀下来的排错方法。适合刚接手加解密需求的前端同学也适合后端想了解桥接细节的同事。1. 为什么前端加解密总在“做与不做”之间纠结1.1 前端环境决定了“绝对安全”不存在先说一个很多人不爱听但又必须接受的结论前端代码运行在用户的浏览器里本质上所有算法、密钥、逻辑都是可以被逆向的。只要有耐心用户可以通过开发者工具、抓包工具、断点调试把前端所有秘密翻个底朝天。所以“在前端做加密”这件事目标从来不是让黑客无法破解而是解决更具体的现实问题防止数据在传输链路中被人截获、防止接口参数被随意篡改、防止日志里出现明文密码、提高批量抓取的成本。这就好比给日记本加了一把锁但不等于把日记本放进银行保险柜。真正要保证核心资产安全前端加解密必须和服务端校验、HTTPS、权限控制配合起来。你在前端做的每一层加密都是整体安全体系里的一道防线不是唯一的防线更不是终点。基于这个前提设计任何前端加解密方案之前先想清楚一个问题这层加密到底要防谁如果防的是链路窃听那 HTTPS 是第一选择加密属于叠加如果防的是参数被篡改那需要的其实是签名和校验而不只是加密如果防的是数据库泄露后密码被还原那重点在后端口令存储方案前端再怎么加密也帮不上大忙。1.2 哈希、对称加密与非对称加密三件事别混为一谈很多新手把 SHA-256、AES、RSA 放在一起讨论觉得都是“加密”这是最大误区。三类算法的用途完全不同选错了整个方案就废了。哈希如 SHA-256是单向的不可逆不需要密钥。它用来做完整性校验、签名、口令存储但“哈希”不是“加密”因为你没办法把哈希值还原成原文。对称加密如 AES用同一个密钥加密和解密速度快、适合处理大量数据问题是怎么安全地把密钥交给对方。非对称加密如 RSA、ECC有一对密钥公钥加密后私钥才能解密私钥签名后公钥可以验签密钥分发相对方便但性能明显更慢且有单次加密长度限制。实际企业级项目里用得最多的是混合方案用非对称加密完成安全协商用对称加密处理业务数据。RSA 负责把 AES 密钥安全地送到服务端AES 负责把 JSON 请求体快速加密。各干各的活既解决密钥分发问题又绕开 RSA 的性能瓶颈。算法类型是否可逆是否需要密钥典型用途性能SHA-256 哈希不可逆不需要完整性校验、口令摘要极快AES 对称加密可逆需要共享密钥业务数据加密存储和传输快RSA 非对称加密可逆需公钥/私钥对密钥交换、数字签名慢HMAC-SHA256不可逆需要共享密钥带密钥的消息认证快1.3 参数决定了“同样的算法”在不同语言里结果可能完全不同这是我在联调中见过最多的坑。两个人用的都是 AES但一个用 CBC 模式一个用 GCM 模式一个用 16 字节 IV一个用 12 字节 IV一个把结果输出成 Base64另一个输出成 Hex。最后密文长得完全不一样两边都觉得自己没写错。所以谈“加解密”不能只谈算法名字必须把参数约定给全。算法名、密钥长度、工作模式、填充方式、IV 长度、标签长度、字符编码、输出格式这八件事缺一个都可能对不上。我一般推荐优先使用 AES-GCM因为 GCM 是认证加密模式密文自带完整性校验后端拿到后能确认数据没被篡改过比 CBC 要稳健得多。2. JavaScript里常用的加解密API从原生到第三方2.1 浏览器原生Web Crypto API先满足安全上下文现代浏览器其实自带了一套完整的加解密能力就是crypto.subtle。它支持 AES、RSA、ECDSA、HKDF、PBKDF2 等大量算法而且是浏览器原生的经过专门优化和审计比任何第三方纯 JS 库都更可靠。但使用crypto.subtle有一个硬性前提必须运行在安全上下文里。简单来说就是 HTTPS 环境或者本地开发时的localhost。如果你的页面部署在纯 HTTP 域名下crypto.subtle会是undefined直接调用就报错。这个限制是浏览器强制施加的目的是防止加密能力被恶意脚本滥用。之前有人把内网管理后台部署在 HTTP 地址上前端加解密代码写好了联调一测发现crypto.subtle不存在第一反应是浏览器版本太低实际是访问协议不满足条件。crypto.subtle的方法全部返回 Promise这是异步 API。下面是一个比较典型的 AES-GCM 加密片段async function encryptAesGcm(plainText, key) { const iv crypto.getRandomValues(new Uint8Array(12)); const encoded new TextEncoder().encode(plainText); const ciphertext await crypto.subtle.encrypt( { name: AES-GCM, iv, tagLength: 128 }, key, encoded ); return { ciphertext, iv }; }这段代码里有几个细节值得注意。TextEncoder负责把字符串转成 UTF-8 的字节数组这是 AES 加密的基本输入。IV 使用 12 字节是 GCM 的推荐长度每次加密必须重新随机生成。tagLength: 128表示认证标签长度为 128 位这个标签用于校验完整性在后端解密时需要额外传入。解密时对应操作是将密文和 iv 一起交给crypto.subtle.decrypt如果标签校验失败浏览器会抛出一个OperationError。这个错误信息比较笼统真正原因是密文被篡改、密钥不对或者标签缺失。2.2 CryptoJS 还能用吗怎么避开老库的默认大坑CryptoJS 是一个老牌纯 JavaScript 加密库至今还有大量旧项目在用。它的语法直观文档也多但维护频率已经不高而且默认行为非常容易踩坑。如果你打开官方首页会看到一行很简单的写法CryptoJS.AES.encrypt(message, secret key)。看似方便实际上背后做了一堆隐式处理它会自动用 OpenSSL 兼容格式对密码进行 KDF 派生还会在密文头部附带Salted__前缀。这个格式适合 CryptoJS 自己加密自己解密但一旦要和其他语言后端联调双方几乎一定对不上。更稳妥的用法是显式传入十六进制或 Base64 的 key 和 IV并明确指定模式和填充const key CryptoJS.enc.Hex.parse( 0123456789abcdef0123456789abcdef ); const iv CryptoJS.enc.Hex.parse( 00000000000000000000000000000000 ); const encrypted CryptoJS.AES.encrypt(plainText, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); console.log(encrypted.ciphertext.toString(CryptoJS.enc.Base64));这里有一个容易忽略的点当 key 和 iv 使用Hex.parse处理后encrypted.toString()输出的格式和直接用 passphrase 时的 OpenSSL 格式不同。你需要明确到底要的是ciphertext还是整个 OpenSSL 密文。联调时我会直接取encrypted.ciphertext.toString(CryptoJS.enc.Base64)并把 iv 单独传给后端这样最清晰。另外老项目中偶尔能看到使用CryptoJS.MD5、CryptoJS.SHA1做接口签名这在今天的安全标准下已经不够稳妥能换就换成 SHA-256 或 HMAC-SHA256。2.3 Node.js crypto 模块与前端配套后端如果用 Node.js可以直接使用内置的node:crypto模块。它和浏览器 Web Crypto API 不是同一个东西但覆盖面更广支持流式加解密、创建摘要、生成密钥对等。解密前端发来的 AES-GCM 数据时需要注意 GCM 的 auth tag 需要单独保存并传入const crypto require(node:crypto); function decryptAesGcm(ciphertextBase64, ivBase64, tagBase64, key) { const decipher crypto.createDecipheriv( aes-256-gcm, key, Buffer.from(ivBase64, base64) ); const tag Buffer.from(tagBase64, base64); decipher.setAuthTag(tag); const ciphertext Buffer.from(ciphertextBase64, base64); const decrypted Buffer.concat([ decipher.update(ciphertext), decipher.final() ]); return decrypted.toString(utf8); }这段代码里最容易被忽略的是setAuthTag。GCM 加密后得到的认证标签是独立的字节串解密前必须设置。如果前端加密时生成的 tag 没有和后端约定好哪怕密文完全正确后端调用final()时也会直接抛异常。3. 一套可落地的加密通信方案RSAAES混合加密实操3.1 为什么不用RSA直接加密所有数据有人在设计接口加密时想得很简单后端把 RSA 公钥发给前端前端用公钥加密整个请求体后端用私钥解密。听起来完美但实际跑不通。RSA 对单次加密的长度有限制。以常见的 2048 位密钥为例使用 RSA-OAEP 算法时最多能加密大约 190 到 245 字节具体取决于填充算法和摘要长度。我们的登录请求体哪怕很精简通常也会超过这个长度。超长就必须分块加密而每个分块都要做一次慢速的 RSA 运算性能和代码复杂度都会恶化。更好的方式是混合加密。前端每次请求生成一个一次性 AES 密钥和随机 IV用这个 AES 密钥加密完整的 JSON 请求体再用后端公钥对这个 AES 密钥进行 RSA 加密随后把 RSA 密文、AES 密文、IV 和认证标签一起发送给后端。后端先用私钥解密出 AES 密钥再用 AES 密钥解密真正的业务数据。这样既绕开了 RSA 长度限制也保证了每次请求使用不同的会话密钥。3.2 前端完整实现假设后端已经在接口中下发了 PEM 格式的 RSA 公钥前端需要完成这些步骤导入公钥、生成 AES 密钥、生成随机 IV、加密业务数据、RSA 加密 AES 密钥、组装传输对象。先准备一个 ArrayBuffer 和 Base64 互相转换的工具函数function bufferToBase64(buffer) { const bytes new Uint8Array(buffer); let binary ; for (let i 0; i bytes.length; i) { binary String.fromCharCode(bytes[i]); } return window.btoa(binary); } function base64ToBuffer(base64) { const binary window.atob(base64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; }接着实现加密主流程async function buildSecureRequest(payload, serverPublicKeyPem) { const publicKey await crypto.subtle.importKey( spki, base64ToBuffer(serverPublicKeyPem), { name: RSA-OAEP, hash: SHA-256 }, false, [encrypt] ); const aesKey await crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); const iv crypto.getRandomValues(new Uint8Array(12)); const encodedPayload new TextEncoder().encode(JSON.stringify(payload)); const aesCiphertext await crypto.subtle.encrypt( { name: AES-GCM, iv, tagLength: 128 }, aesKey, encodedPayload ); const rawAesKey await crypto.subtle.exportKey(raw, aesKey); const encryptedAesKey await crypto.subtle.encrypt( { name: RSA-OAEP }, publicKey, rawAesKey ); return { encryptedKey: bufferToBase64(encryptedAesKey), ciphertext: bufferToBase64(aesCiphertext), iv: bufferToBase64(iv) }; }这里需要特别说明三点。第一导入公钥时使用了spki格式这是 PEM 公钥去掉头和尾之后的标准 DER 编码功能上等价于 X.509 SubjectPublicKeyInfo。如果后端给的是裸 DER Base64内容结构不同导入后所有加密操作都会报错。第二因为 AES-GCM 的认证标签被直接拼接在密文尾部所以前端返回给后端的ciphertext实际上已经包含了 tag后端需要按“密文 16字节tag”的结构自行切分或者双方约定单独传 tag。但很多后端库会把 tag 和密文拆开处理所以我在实际方案里会把加密后的aesCiphertext做一次切片明确返回ciphertext和tag两个字段。逻辑上多几行代码却能让联调省很多事。第三exportKey(raw, aesKey)导出的就是 32 字节的原始密钥这串字节正是 RSA 要加密的对象不能直接转成字符串否则长度和内容都会变化。3.3 后端解密流程与双方参数约定后端拿到前端传来的encryptedKey、ciphertext、iv之后先用自己的 RSA 私钥解密出 AES 密钥再用这个密钥解密业务数据。Node.js 的流程如下const crypto require(node:crypto); function decryptWithPrivateKey(encryptedKeyBase64, privateKeyPem) { const encryptedKey Buffer.from(encryptedKeyBase64, base64); const aesKey crypto.privateDecrypt( { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: sha256 }, encryptedKey ); return aesKey; }如果解密失败优先检查oaepHash是否和前端导入公钥时指定的哈希一致。前端指定SHA-256后端就必须写oaepHash: sha256。有一类典型的联调事故是两端都声明使用 RSA-OAEP但前端默认 SHA-1后端指定 SHA-256结果互相不认账。参数约定用一张表列清楚最省心参数推荐值说明对称算法AES-GCM认证加密自带完整性校验AES 密钥长度256 位32 字节IV 长度12 字节GCM 推荐长度每请求独立GCM tag128 位16 字节可单独传或拼在密文尾部非对称算法RSA-OAEP推荐 SHA-256公钥格式SPKI / PEM前后端统一下发格式文本编码UTF-8前端用 TextEncoder后端用 utf8这套方案在生产中的定位是“防链路截获、防参数篡改、提高抓包成本”不是“防止前端用户逆向”。用户仍然可以在浏览器里覆盖变量拿到明文但接口链路的安全性确实比裸 JSON 强很多。3.4 公钥下发与防重放设计公钥本身不需要保密但它必须保证是“对的公钥”。如果攻击者把服务端下发的公钥替换成自己的公钥那前端加密的数据就全被攻击者解密了。所以公钥分发通道的安全非常关键。首选的通道是 HTTPS公钥从服务端接口动态获取不要写死在代码里。对于安全要求更高的场景可以再叠加证书指纹校验或者公钥锁定让前端在启动时检查服务端证书指纹是否与预置值一致。另外加密不等于防重放。攻击者不需要知道明文只要把某次请求原样再发一遍就可能造成重复提交。所以正式方案里我会在 payload 中加入timestamp和随机nonce服务端从解密后的 JSON 中取出时间戳做窗口校验通常允许 5 分钟偏差再用 nonce 做短期去重同一个 nonce 只允许出现一次。还可以让服务端记录解密后 payload 中的requestId配合业务幂等逻辑双保险。4. 企业级安全实践中最容易被忽略的坑4.1 模式、填充和编码联调失败的第一大来源我处理过不少“前端能加密但后端解不开”的工单九成问题出在参数没对齐。AES-CBC 模式下 IV 必须是 16 字节AES-GCM 模式下使用 12 字节 IV 是推荐值但很多库也接受其他长度。如果后端硬编码了 16 字节前端传 12 字节解密会直接失败。填充问题同样典型。Java 里的AES/CBC/PKCS5Padding和 PHP 里的AES-128-CBC默认填充方式本质上都是 PKCS#7但因为叫法不同很多人误认为两端填充不兼容白白浪费很长时间去改一个本来不是问题的问题。RSA 那边最常见的是 OAEP 摘要不一致。一个用 SHA-1 一个用 SHA-256密文长度一样但解密时直接抛异常。还有的库默认把密文输出成 Hex前端却按 Base64 解码结果得到一个长度翻倍的字节数组。这几类问题排查看似复杂一旦形成自己的核对清单反而最快。我总是先确认编码再确认 IV 长度再确认算法模式和填充最后才去看密钥。问题典型表现快速检查点Base64 与 Hex 混用解码结果长度异常先统一输出格式AES IV 长度不一致解密报错统一 12 或 16 字节GCM tag 未单独传后端 final 抛异常确认 tag 是否在密文尾部RSA OAEP 哈希不一致私钥解密失败两端统一 sha256PEM 换行丢失导入公钥失败保留 BEGIN/END 之间的换行4.2 密钥安全前端公钥、AES 密钥和随机数前端公钥不是机密但公钥被篡改的后果很严重。所以公钥的下发应该走服务端接口并且接口本身要经过 HTTPS 认证。如果项目里有证书固定需求可以把服务端公钥证书的指纹写入配置文件做一个启动校验校验不通过就拒绝发起加密请求。AES 会话密钥绝对不能写死在代码里不能放在 URL 上更不能在日志里打印。有些技术同学为了排查问题直接把加密后的 payload 和 key 一起 console.log 到控制台然后顺手提交到了代码仓库这是非常危险的。线上日志要默认过滤掉包含password、token、encryptedKey、iv等关键字段的数据只保留必要调用信息。随机数问题是另一个容易被忽视的点。前端生成 IV 和 AES 密钥时必须使用crypto.getRandomValues不能用Math.random。原因是Math.random不是密码学安全的伪随机数生成器它的输出可以被预测如果攻击者拿到了若干随机数样本有可能反推出密钥或 IV。因为 IV 可预测导致的加密强度下降在安全审计里属于高危问题。// 错误示范不可用于密码学场景 const iv Array.from({ length: 12 }, () Math.floor(Math.random() * 256)); // 正确做法 const iv crypto.getRandomValues(new Uint8Array(12));4.3 防重放与过期不能只加密不防重放加密业务数据只能保证内容不可读无法辨别请求是否被原样复制重发。攻击者通过抓包工具拿到一个加密请求后完全可以原封不动地再次发送。如果这个接口是下单、转账、批量操作重放攻击会造成严重问题。所以在企业级方案里我习惯把防重放和前端的身份认证一起考虑。最基础的做法是时间戳加随机数前端在 payload 里带上timestamp和nonce服务端先检查时间戳是否在允许窗口内再把 nonce 写入短期去重集合。这样同一个请求体即使被重放也会因为时间过期或 nonce 重复而被拒绝。更严格的方案是服务端下发现场随机数或者一次性挑战码前端在加密前把它放入 payload服务端再校验挑战码存在且未被使用过。代价是请求链路变长但对高价值接口来说值得。4.4 一个真实事故我把 IV 固定了有一次做内部系统前端加密功能已经联调通过。我在调试阶段为了方便把 IV 固定成了全零字节数组放进代码里等测试没问题后忘记改回来。上线后功能看起来一切正常因为 AES-CBC 模式下固定 IV 并不会导致解密失败只是相同明文会产生相同密文安全性大打折扣。更可怕的情况发生在后来一个项目里我最初也用固定 IV 调 GCM。幸运的是代码评审阶段被同事拦住了。如果 GCM 密钥相同且 IV 固定攻击者获取两份密文后可以分析出大量信息在某些场景下甚至能推导出认证密钥。那次之后我给自己定了一条死规矩所有生成 IV 和密钥的逻辑都必须走统一封装函数禁止在业务代码里手写字节数组。这件事也让我意识到安全代码的“能跑”和“安全”是两件事。很多隐患在功能自测阶段根本发现不了必须靠强制规范、代码评审和自动化检查来兜底。5. 性能、联调和排错遇到“解密失败”我怎么办5.1 前端性能实测有人担心加解密会影响页面性能尤其是混合加密。实测下来在普通开发机上AES-GCM 加密几百字节的 JSON 请求体耗时基本在 1 毫秒以内RSA-2048 用公钥加密一个 32 字节的 AES 密钥大概在 1 到 3 毫秒级别。整个混合加密流程通常不会超过 10 毫秒对接口调用来说几乎可以忽略。真正影响性能的往往是那些看似聪明的优化比如每个字段单独用 RSA 加密一次或者每点击一次按钮就重新生成一套 RSA 公钥。前者会把 RSA 运算次数提升几十倍后者会引入密钥分发时序问题。合理做法是让 AES 会话密钥在合理窗口内复用。比如登录成功后建立一次加密会话在几分钟内用同一个会话密钥加密多笔请求超过窗口后前端重新生成会话密钥并与后端协商。这样减少了 RSA 操作次数也减少了每次请求的密文体积。会话密钥的安全性靠服务端保存一个短期内存映射来保证不需要落库。5.2 “解密失败”的排查顺序前端和后端联调加密接口报错几乎都是“解密失败”。遇到这个错误别急着翻代码我有一套固定的排查顺序先确认两边对密文的编码理解一致。Bit 级最容易出问题的是 Base64 在 URL 传输时被自动替换了字符或者密文中带、/、被某些框架转义导致后端拿到的字符串和前端生成的不一样。此时应统一使用 Base64URL 编码避免字符转义。然后确认 IV 长度。AES-GCM 用 12 字节AES-CBC 用 16 字节。如果前后端代码里一个用了字节数组拼接一个用了字符串会出现隐性长度差异。再检查 GCM 的 tag。前端如果把 tag 和密文直接拼在一起后端拿到后必须按照约定切分成“密文 tag”两个数组。很多解密失败都是因为后端把 tag 当成了密文的一部分一起解密。随后核对 RSA-OAEP 的哈希参数。在双方密钥和密文都正确的情况下这个参数不一致是最难察觉的因为报错只有一句“decrypt failure”。最后看密钥格式。PEM 字符串是否包含完整的-----BEGIN PUBLIC KEY-----头尾是否因为 JSON 序列化丢失了换行符是否被前端误加了解码步骤。密钥格式错误在导入阶段就会报错但报错信息往往指向不明。5.3 用测试向量而不是“黑盒联调”联调加密接口时我强烈建议双方先约一组测试向量而不是各自对着线上数据猜。具体做法是约定一组固定的明文、密钥、IV 和附加数据前端跑一遍生成密文后端跑一遍尝试解密同一份密文。只要测试向量能通过说明算法参数和编码约定全部一致。这组测试向量要覆盖正常明文、空字符串、超长字符串、含中文的字符串以及带特殊符号的字符串。尤其中文内容在 UTF-8 编码下字节长度不可直观判断前端用TextEncoder、后端用Buffer.from(str, utf8)两边处理必须严格一致。我见过一个很典型的反例前端把字符串直接按charCodeAt转成字节数组后端按 UTF-8 解码英文内容完全正常中文内容一多就乱码。如果提前用中文测试向量跑一遍这个问题半小时内就能定位不至于拖到上线前夜才发现。6. 最后的小建议把加解密当成接口协议的一部分写到这里最想强调的一件事是加解密方案不应该是一个藏在代码角落里的临时工具而应该被当作接口协议的一部分来设计。协议意味着要有版本号、有参数约定、有兼容策略。接口从v1升级到v2时如果算法从 AES-CBC 换成了 AES-GCM老客户端必须有能力识别版本并走对应的解密路径否则灰度阶段就会全面报错。我给自己的项目定了一条规则请求头里带encrypt-version字段服务端根据这个字段选择解密方案。线上永远保留上一版本的解密代码平稳运行一段时间后再删除。这样做的代价是代码里会多一些分支但换来的是升级过程中的回退空间。在实际操练中你会发现JavaScript 加解密真正难的从来不是调 API而是把参数约定、密钥生命周期、重放防护、日志脱敏和版本兼容这些“看不见的工程约束”想清楚。把这些约束落实到位比多写几个加密函数重要得多。最后再分享一个我一直在用的习惯每次改动加解密代码都会把测试向量对应的密文固化到测试用例里这样以后任何人改了参数跑一遍测试就能立刻发现协议被破坏不需要等上线后由用户来告诉我们“接口挂了”。
