☰
前端加解密实战:CryptoJS与Web Crypto API避坑指南
2026/9/26 4:46:55 网站建设 项目流程

做前端时间长了,多少都会碰到 JavaScript 加解密的活儿:登录密码不想明文上传、接口参数怕被篡改、敏感字段想在浏览器里先处理一道再提交……这类需求听起来不复杂,真的动手写起来坑特别多。我从 CryptoJS 一路用到 Web Crypto API,中间踩过字符编码、填充模式、密钥格式、随机数复用一堆坑,后来帮团队整理过一整套从前端到网关的加解密约定。这篇东西就是我实操下来沉淀的思路,既讲清楚前端加解密的边界,也把能直接抄的代码和排查方法给你,适合刚开始接触前端加密的新手,也适合已经在项目里写了但总觉得不踏实的同学。

1. 先搞明白:JavaScript 加解密到底解决什么问题

1.1 前端加解密的边界:能防什么,不能防什么

先说一句可能得罪人的话:很多前端加解密方案,其实是自欺欺人。浏览器里跑的 JavaScript,代码对用户可见,网络请求对抓包工具可见,内存里的数据对调试器可见,甚至密钥就写在 JS 文件里。所谓“前端加密”,在此前提下不可能成为一种真正的安全边界。

真正的安全边界在服务端和传输层。TLS 负责网络传输过程不被窃听,服务端业务逻辑负责认证和授权。前端加解密在整个链路里扮演的角色,是纵深防御中的一环,而不是唯一防线。它能干的事,归纳起来大概是这么几件:减少敏感数据在应用层的明文暴露面、给接口请求加签名防止别有用心的篡改、配合时间戳和随机数降低重放攻击的成功率、满足某些审计场景对字段加密的硬性要求。

所以我对团队的要求一直是:先分清“前端加密”和“后端加密”。后端加密是安全机制,前端加密更像是“提高攻击成本”的辅助手段。如果哪一天你发现自己的加密逻辑已经复杂到影响了开发和排查效率,那大概率不是方案不够强,而是一开始就把安全边界画错了。任何前端能拿到的东西,最终都默认是可以被绕过的,这个心态要摆正。

1.2 抓包场景:明文传输到底有多危险

我见过最典型的反面案例是这样的:登录页面,前端把用户名和密码组装成 JSON,一条 POST 请求直接把明文密码带上去了。开发同学说系统已经上了 HTTPS,所以加密交给 TLS 就够了,这话本身没错,但问题在于 HTTPS 只保证传输管道安全,管道的两头——浏览器页面、被请求打到的服务端日志——都在裸奔。

打开浏览器开发者工具的 Network 面板,密码字段清清楚楚地躺在请求体里。如果某个环节在服务端把请求体打到日志文件,密码就在日志里永久存档;如果前端埋点上报了页面输入事件,输入框内容也可能被第三方脚本拿到。这些都是 TLS 管不到的地方。

有人会说,那我前端先算个 MD5 再传,不就安全了吗?这里必须说清楚:MD5 是哈希算法,不是加密算法,它是单向的,从设计上就不用来保护密码传输。更重要的是,普通哈希对常见口令几乎是透明的,彩虹表一查就出来了,攻击者抓到 MD5 值基本等于抓到密码。退一万步讲,就算你换成 SHA-256,没有随机盐的哈希同样挡不住字典攻击。所以结论很清楚:别指望前端用哈希代替加密,也别把哈希和加密混为一谈。

1.3 加密不解决所有问题,安全靠分层

做企业级项目,最忌讳把全部希望押在某一个单独方案上。安全这件事,从来都是层层叠叠的:传输层有 TLS,应用层有签名、加密、认证,业务层有权限校验、风控策略、操作审计,每一层负责挡住一部分攻击,剩下的交给下一层兜底。

前端加解密的角色,就是应用层这一小块。我一般建议按这个优先级排:先确保全站 HTTPS 和正确的证书配置,再保证服务端对请求做完整校验,然后才轮到考虑 JS 里要不要做签名、要不要加密字段。顺序一旦反了,就会出现团队花了两周做了一个很精妙的加密方案,结果服务端接口连最基本的参数校验都没做,攻击者直接把加密后的密文原样回放,照样能成功。

说到“回放”,很多人以为接口带个签名就是安全了,实际上签名只解决“参数有没有被篡改”的问题,不解决“请求被原样重放”的问题,后者需要时间戳、随机数、甚至幂等机制去配合。这些内容在后面章节会展开,这里先记住一句话:分层防御才是安全,前端加解密只是其中的一块拼图。

2. 基础原理回顾:对称、非对称、哈希与 MAC

2.1 对称加密:为什么 AES-GCM 是当前首选

对称加密的特点是用同一把密钥完成加密和解密,速度快、算法成熟,适合加密大量数据。当前的主流选择是 AES,密钥长度一般用 128 或 256 位。DES、3DES 这类老算法现在不建议再看,密钥长度太短,计算机算力早就把它们打到不值钱了,还在跑这些算法的老系统应当尽快迁移。

AES 是分组密码,加密时把数据分成固定大小的块,然后通过工作模式把块和块之间的关系组织起来。最早见的模式是 ECB,它的问题非常直观:同样的明文块会生成同样的密文块,加密一张图片后,轮廓依然清晰可见,这在语义上是灾难。CBC 模式引入了初始化向量 IV,让同样明文在不同加密里产生不同密文,但它只防篡改不防伪造,历史上还出现过 padding oracle 这类攻击手法。

现在企业项目里我基本只推荐 GCM 模式。GCM 是一种 AEAD 模式,加密的同时会生成一个认证标签,接收方解密时先验标签,标签对不上直接拒绝,这样既拿到机密性,也拿到完整性,一举两得。用 AES-256-GCM,配合随机生成的 12 字节 IV,再加上可选的附加认证数据 AAD,是目前前端能够用到的比较稳妥的组合。后面实战章节我会给出完整代码,这里先把“为什么选它”焊死在脑子里。

2.2 非对称加密:RSA 与 ECC 的取舍

非对称加密使用密钥对:公钥可以公开分发,私钥必须保密。公钥加密的数据只能由私钥解密,反之私钥签名、公钥验签。这套机制解决了对称加密里“密钥怎么安全传递”的难题,所以 HTTPS 握手、数字证书、混合加密方案里到处都是它的身影。

RSA 是其中最普及的算法,但它的缺点是性能差、密文长度有限。以 2048 位 RSA 为例,它最多只能加密大约 245 字节的数据,再多就得自己拆分,非常麻烦。在实际项目里,直接用 RSA 加密业务数据的情况越来越少,更多是用 RSA 加密一把随机生成的 AES 会话密钥,再用 AES 去加密真正的业务数据,这就是常见的“混合加密”。另外,RSA 的填充模式也要留意,OAEP 比 PKCS#1 v1.5 更安全,新系统优先用 RSA-OAEP。

ECC 椭圆曲线算法则是更现代的替代方案,密钥更短、计算更快,例如 P-256、X25519。它特别适合移动端和低性能设备。日常做 Web 开发不一定需要自己实现 ECC,但理解它为什么越来越流行,对方案选型有好处——如果你的项目既要考虑前端性能,又要考虑密钥长度,ECC 通常比 RSA 更省。

2.3 哈希、口令哈希与 HMAC:不是加密的“加密”

哈希算法是单向的,数据进去、摘要出来,但摘要不可能还原成原文。MD5 和 SHA-1 的碰撞攻击已经成熟,不应当再作为安全哈希使用,现在的底线是 SHA-256。可很多人的误区在于:把哈希当加密用,把 SHA-256 当成密码的存储方式。密码存储绝不能直接做普通哈希,因为用户口令的熵值低,攻击者可以跑字典。标准做法是用专门的慢哈希算法,比如 bcrypt、scrypt、argon2,它们自带随机盐、计算强度可调,跑一次要几十毫秒甚至更久,攻击者的成本直线上升。

HMAC 则是另一类东西:带密钥的哈希。同一个消息,没有共享密钥的人就算不出合法的 MAC 值。它的用途是消息认证,也就是“确认数据确实来自持有密钥的一方”。打个比方,哈希是公开的指纹,谁都能计算;HMAC 是双方约定好的盖章指纹,伪造不了。在接口签名、防篡改这类场景中,HMAC-SHA256 是基础。

还有一件事必须强调:整个加解密体系都建立在随机数之上。IV 要用随机数,salt 要用随机数,nonce 要用随机数。JavaScript 的 Math.random 绝不能用在这些地方,因为它不满足密码学安全随机数的要求。浏览器里要用 crypto.getRandomValues,Node.js 里要用 crypto.randomBytes,这是底线,没有商量的余地。

3. 工具与库选型:CryptoJS、Web Crypto API 怎么挑

3.1 环境决定第一选择:浏览器、Node.js 还是老系统

选库之前先回答一个问题:代码到底跑在什么环境?现代浏览器已经内置 Web Crypto API,也就是 window.crypto.subtle,提供 AES、RSA、ECDSA、HMAC、SHA 等能力,由浏览器原生实现,性能和对安全随机数的支持都很可靠。Node.js 从 15 版本开始也支持 globalThis.crypto 的 WebCrypto 接口,后端代码可以保持和前端一致的写法。

CryptoJS 则是一个纯 JavaScript 实现的加密库,老项目里非常常见。它最大的优势是兼容老浏览器、接入简单,但性能不如原生实现,而且它很多默认行为在跨语言联调时特别容易出问题,这点后面细说。如果你的项目没有历史包袱,我强烈建议优先用 Web Crypto API;如果服务端开发团队语言多样,也可以统一约定 Node.js 的 crypto 模块或 WebCrypto 接口,争取一套逻辑复用。

有个细节要注意:Web Crypto API 要求运行在安全上下文里,具体来说就是 HTTPS 页面或者 localhost。如果开发时用 http 加 IP 访问,或者直接用 file:// 打开,会出现“crypto.subtle is undefined”这类报错,这不是代码的问题,而是环境不支持。遇到这种情况,先检查页面协议再查代码。

3.2 CryptoJS 的正确打开方式:别只抄表面代码

CryptoJS 网上教程很多,但大多数教程里那种最简单的写法,恰恰是跨语言联调的大坑。最典型的是这种代码:

// 反面示例:直接把一个普通字符串当密钥 const encrypted = CryptoJS.AES.encrypt('hello', 'my-secret-key').toString();

这句代码看起来漂亮,实际上 CryptoJS 内部会用 EVP_BytesToKey,拿 MD5 去派生真正的密钥,并且会随机生成一段 salt 拼进密文里。结果就是这段密文只能由 CryptoJS 自己解,后端用 OpenSSL、Java、Go 的解密代码去解,经常对不上。问题的根源是“你以为自己用的是 AES,实际用的是一套带自定义 KDF 的封装格式”。

正确做法是显式指定密钥、IV、模式和填充,让每一份参数都可控。下面是我生产环境里一直在用的写法:

// 前台加密:AES-256-CBC,密钥和 IV 都用 Base64 传入 function aesEncrypt(plainText, keyBase64, ivBase64) { const key = CryptoJS.enc.Base64.parse(keyBase64); const iv = CryptoJS.enc.Base64.parse(ivBase64); const encrypted = CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(CryptoJS.enc.Base64); } // 对应解密 function aesDecrypt(cipherBase64, keyBase64, ivBase64) { const key = CryptoJS.enc.Base64.parse(keyBase64); const iv = CryptoJS.enc.Base64.parse(ivBase64); const bytes = CryptoJS.AES.decrypt(cipherBase64, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return bytes.toString(CryptoJS.enc.Utf8); }

这里有个容易踩的细节:如果前端传的是普通字符串密钥,需要先用 CryptoJS.enc.Utf8.parse 把字符串转成 WordArray,再作为 key 传入,而不是直接把字符串丢进去。另外,同一个错误在不同开发者手里的表现还不一样,有的后端用 OpenSSL 解不开,有的解出来是乱码,总之先统一“密钥用什么编码、IV 放哪里、密文输出什么格式”,再去联调。

3.3 Web Crypto API 实操:AES-GCM 完整代码

如果你在做一个新项目,我建议直接用 Web Crypto API。它的写法是异步的,所有操作返回 Promise,而且密钥是 CryptoKey 对象,不能用 JSON.stringify 去打印,也不太容易被人误存到日志里。这里是一份可以直接跑通 AES-GCM 加密解密的参考代码:

// 生成 AES-GCM 256 位密钥 async function generateAesGcmKey() { return crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } // 加密:返回由 IV 和密文拼接的结果 async function aesGcmEncrypt(key, plainText, iv, aad) { const encoder = new TextEncoder(); const cipherBuffer = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv, additionalData: aad }, key, encoder.encode(plainText) ); return bufferToBase64(cipherBuffer); } // 解密 async function aesGcmDecrypt(key, iv, cipherBase64, aad) { const decoder = new TextDecoder(); const cipherBytes = base64ToUint8Array(cipherBase64); const plainBuffer = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv, additionalData: aad }, key, cipherBytes ); return decoder.decode(plainBuffer); } // 工具函数: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 btoa(binary); } // 工具函数:Base64 转 Uint8Array function base64ToUint8Array(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; }

注意这里我对 IV 的处理比较保守,没有把 IV 直接拼进密文。实际项目里为了便捷,通常把 iv 和密文拼接在一起传输,但拼接顺序必须固定、写进文档。GCM 模式本身默认会在加密结果尾部带上认证标签,所以解密方只要参数一致,标签校验是自动完成的,不需要额外操作。

3.4 按场景选库:RSA、哈希、口令哈希的匹配表

下面是我在项目里常用的选型思路,罗列出来供你参考。没有一个库是万能的,也没必要为了一个 AES 功能引入 500KB 的全家桶。

场景推荐工具注意事项
浏览器 AES/RSA/HMACWeb Crypto API要求 HTTPS 或 localhost
Node.js 服务端加解密node:crypto / webcrypto优先原生实现,性能好
老项目快速接 RSAjsencrypt默认 PKCS#1 v1.5,不支持 OAEP
复杂证书、TLS 操作node-forge功能全但体积大,按需引入
浏览器端口令哈希bcryptjs慢哈希,可用 Worker 防卡顿
纯摘要计算Web Crypto subtl.digest别再用 MD5/SHA-1

实际开发中我见过最痛的问题,是项目为了一个 RSA 加密引入一个大而全的库,结果和另一个依赖冲突,构建体积暴涨。做选型时建议先看自己到底需要哪几种算法,再反向决定引什么库,能用原生 Web Crypto 解决的绝不额外引包。

4. 典型实战场景:登录密码、接口签名与密文传输

4.1 登录密码到底要不要前端“加密”

每次聊到密码处理,我都会先给团队吃一颗定心丸:只要是标准 HTTPS 环境,用户名密码明文提交、服务端用 bcrypt 加盐慢哈希存储,这就是全世界最常见的做法,也是安全上站得住脚的方案。不要因为谁说了句“前端密码必须加密”就慌着写一堆自定义逻辑,反而增加了出漏洞的概率。

如果产品确实有降低日志暴露面的需求,可以再叠加一层“挑战响应”。流程是这样的:用户提交前,前端先从服务端接口拿到一个随机 nonce,然后用 SHA-256 或者 PBKDF2 对 password 加 nonce 做一次派生计算,把派生结果而不是原始密码传上去,服务端拿到后用同样的 nonce 重算一遍再校验。这样网络层和日志层都不出现原始密码明文,代价是接口多了一次请求、服务端多一次计算。

但必须说清楚:这个方案不能替代服务端慢哈希存储,也不能替代 HTTPS。nonce 对攻击者是可见的,它防的不是重放,而是“密码明文出现在日志里”。真正的密码安全,最终还是要靠服务端在拿到密码后做慢哈希、加盐、限制失败次数这些动作。如果你看到有人在前端用固定 salt 把密码哈希后再提交,那只说明这个人没搞懂 salt 的意义——salt 公开没关系,但固定的 salt 让彩虹表攻击依旧可行,毫无增益。

4.2 接口防篡改与防重放:HMAC 签名 + 时间戳 + nonce

接口签名是我在企业项目里用得最频繁的一项能力。典型场景是 H5 页面调用业务接口时,请求参数可能被中间层篡改,或者被攻击者抓包后重放。签名的基本思路是:把业务参数、时间戳、随机数按既定规则拼接成一个字符串,用双方共享的密钥做 HMAC-SHA256,得到签名串,放到请求头里。服务端拿到请求后,把同样参数拼出来,重算一次签名,比对成功才放行。

签名串的构造必须严格统一。我习惯先把参数按 key 排序,过滤掉空值,然后逐项做 URL 编码,最后拼上时间戳和 nonce:

function buildSignMessage(params, timestamp, nonce) { const keys = Object.keys(params).sort(); const parts = keys .filter((key) => params[key] !== undefined && params[key] !== null) .map((key) => `${key}=${encodeURIComponent(params[key])}`); return `${parts.join('&')}&timestamp=${timestamp}&nonce=${nonce}`; } // 使用 Web Crypto 计算 HMAC-SHA256 async function signMessage(message, secretBase64) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', base64ToUint8Array(secretBase64), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); return Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }

服务端要做三件事。第一,校验时间戳,比如 |timestamp - now| 超过 5 分钟直接拒绝;第二,用 Redis 或者数据库记录 nonce,同一个 nonce 用两次就不放行;第三,重算签名并做常量时间比较,防止时序攻击。只有这三个条件同时满足,请求才被视为合法。

为什么必须同时有时间戳和 nonce?因为时间戳把重放窗口压缩到了几分钟,nonce 保证同一窗口内同一请求不能被第二次使用。这两个缺失任何一个,签名方案都会漏。比如只有签名没有时间戳,攻击者可以一年后原样重放;只有时间戳没有 nonce,攻击者可以在窗口内重复重放,如果接口是下单、转账一类的操作,影响就很明显。

4.3 敏感字段加密:一次性会话密钥的混合加密方案

有些业务场景里,特定字段必须做应用层加密,比如手机号、身份证号、银行卡号,这类要求多来自合规审计。最稳妥的前端方案不是我前端写死一把 AES 密钥然后给后端对暗号,而是“后端下发 RSA 公钥 + 前端生成一次性 AES 密钥”的混合加密。

流程是这样:

  1. 后端生成 RSA 密钥对,私钥保存在服务端或云 KMS,公钥通过 HTTPS 接口下发给前端。
  2. 前端随机生成一把 AES-GCM 256 位密钥,这一把密钥只在当前请求里有效。
  3. 前端用这把 AES 密钥加密手机号等敏感字段,得到密文。
  4. 前端用后端公钥对这把 AES 密钥做 RSA-OAEP 加密,得到密钥密文。
  5. 前端把 IV、字段密文、密钥密文一起提交给后端。
  6. 后端先用 RSA 私钥解出 AES 密钥,再用 AES 密钥解出业务字段。

用这样的方式,真正的业务密钥没有在网络上明文传输过,每次请求都用新密钥,旧的密钥没法解密其他请求的数据,私钥始终没有离开服务端。下面是核心代码:

async function importPublicKey(spkiPem) { const body = spkiPem .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', '') .replace(/\s+/g, ''); const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0)); return crypto.subtle.importKey( 'spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); } async function encryptSensitiveData(plainText, spkiPem, aadText) { const encoder = new TextEncoder(); // 1. 生成一次性 AES-GCM 密钥 const aesKey = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt'] ); // 2. 随机 IV,12 字节 const iv = crypto.getRandomValues(new Uint8Array(12)); // 3. 用 AES 加密业务字段,AAD 可以绑定用户ID、接口路径 const cipherBuffer = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: encoder.encode(aadText) }, aesKey, encoder.encode(plainText) ); // 4. 导出 AES 原始密钥,用 RSA 公钥包装 const rawAesKey = await crypto.subtle.exportKey('raw', aesKey); const publicKey = await importPublicKey(spkiPem); const wrappedKey = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, rawAesKey ); return { version: 1, iv: bufferToBase64(iv), cipher: bufferToBase64(cipherBuffer), wrappedKey: bufferToBase64(wrappedKey) }; }

这里面值得多说一句的是 additionalData(AAD)的用法。AAD 是不加密的,但在认证范围内,任何一位 AAD 变动都会导致认证失败。把用户 ID 或者接口路径放进 AAD,相当于把密文和上下文绑定起来,攻击者把 A 接口的密文搬到 B 接口的时候,认证直接失败,这是一个很实用但很多人不知道的细节。

4.4 H5 文件处理与多端互通:图片、上传与桥接参数

H5 页面里做文件加密,比如身份证照片、合同附件的安全上传,核心思路是把文件读成 ArrayBuffer,然后用 AES-GCM 加密,再封装成 Blob 上传。文件往往比较大,加密性能和内存回收需要提前评估。我建议在 Web Worker 里做加密,避免阻塞 UI 线程;加密完成之后要用 URL.createObjectURL 生成临时链接给页面预览,预览用完立刻 revokeObjectURL,否则在 iOS Safari 上内存占用会慢慢爬上去。

还有一类场景是 H5 与原生应用互相调用,比如 JS 调起原生 SDK,或者原生 WebView 向 JS 传数据。这种 Bridge 通信里传的几乎都是字符串,加解密产生的二进制数据必须转成 Base64 再传。我见过好几个线上问题,都是因为原生端拿到字符串做了截断或替换特殊字符,密文到服务端就解不开了。跨端的加解密参数必须在开发第一天就固定下来,包括密钥格式、IV 长度、密文编码、签名串拼接顺序,最好沉淀成文档让三端都对着一张表开发。

移动端的网络环境也比较复杂,弱网下请求重试会带来一个隐含问题:前端每次重试时如果重新生成随机密钥,原始请求和重试请求的密文可能对不上,服务端幂等设计时要兼容这一点。我的建议是重试时复用同一份加密参数,或者干脆把业务幂等键放到签名串里,由服务端做去重。

5. 企业级落地:密钥管理、防重放与安全评审

5.1 密钥管理:前端不应该持有“真密钥”

业界对密钥管理的原则是:密钥分级、最小权限、动态轮换。落到前端场景,核心结论就是——不要让前端拿到任何长期有效的业务密钥。前端拿到的应该是短期会话密钥,或者只是 RSA/ECC 公钥。即使公钥本身不怕被看见,也要通过正规接口分发,而不是写死在 JS 包里。因为写死在代码里的公钥一旦需要轮换,就要发版;动态下发的公钥可以随时换,不用等用户升级页面。

我见过一个比较务实的做法,是在密文前面加版本号。比如上传的密文格式统一为 version + keyVersion + iv + ciphertext,服务端看到 version 和 keyVersion 后,从自己的密钥库里找到对应版本号的密钥去解密。这样线上可以同时跑两套密钥,新请求全部用 v2,失败率极低之后再停掉 v1,轮换期间不影响用户。这个思路虽然多写几行解析代码,但能解决“密钥轮换必须停机”的尴尬。

日志里不要打印密钥,这是老生常谈但总有人犯。尤其在前端,加密代码里顺手 console.log 一个 iv、一个 rawKey,页面一跑,控制台全裸奔。这类代码评审时必须抓出来。

5.2 防重放与幂等:前端如何配合服务端做闭环

前面讲的签名加时间戳加 nonce,属于应用层的防重放常用组合。但如果你面对的是支付、下单这类高价值操作,还需要进一步叠加幂等机制。简单说,就是前端每次发起操作时生成一个 requestId,连同签名一起提交;服务端用一个 Redis 的 SETNX 把 requestId 存下来,处理成功后返回,下次遇到同一个 requestId 直接拒绝或者返回上一次的响应结果。

前端生成 requestId 要用 crypto.randomUUID(),而不是 Math.random 拼时间戳,否则并发情况下极易撞车。浏览器兼容性方面,现代浏览器和 Node.js 16+ 基本都支持,老浏览器可以用 crypto.getRandomValues 手搓一个 UUID v4。

还有一层是风控。前端配合做设备指纹、操作埋点、触发验证码,都是把“异常”信号反馈给服务端。但前端能做的只是传递信号,最终判断必须在服务端统一完成,前端任何“我觉得没问题就放行”的逻辑都不该存在。

5.3 JS 与原生/服务端互通时的编码和版本兼容

一个加解密方案,最怕的不是算法选错,而是各端之间“约定”不统一。有一次项目里前端用 CryptoJS 加密,后端用 Go 解密,怎么都解不开,最后发现前端加密时用了非常规的盐拼接格式,Go 侧标准库根本不认。这种问题一般在联调阶段爆炸,如果发现得晚,会导致上线前返工。

跨端协作时至少要定下这些内容:算法(AES-256-GCM)、密钥编码(Base64)、IV 长度(12 字节)、密文编码(Base64)、AAD 内容(哪些字段参与)、时间戳格式(毫秒还是秒)、签名字符串排序规则。这些全部写进接口文档,并且放到仓库里的共享常量文件中,让三端共同引用。

老技术栈的兼容也容易踩坑。比如早期一些用 ASP.NET WebForms 写的后端,Java 和 .NET 对 RSA 密钥格式的处理不同,前端拿到的公钥在 jsencrypt 里能导入,到了 Web Crypto 里就得做 SPKI 和 PKCS#1 的格式转换;Java 默认用 PKCS1Padding,Node 的 RSA-OAEP 却要明确指定 SHA-256。这类坑不是算法问题,是生态差异问题,多预留联调时间,别在最后一天才开始对接。

5.4 上线前的安全自查清单

下面这份清单是我在每次涉及加解密的前端改动发布前都会过的,你可以直接拿去用:

检查项通过标准
传输层全站 HTTPS,无 http 明文接口
弱算法无 DES、3DES、MD5、SHA-1 用于安全场景
密钥安全前端不硬编码长期密钥,私钥只存在服务端/KMS
随机数IV、nonce 使用 crypto.getRandomValues,无 Math.random
日志代码和日志中不出现明文密钥、完整敏感字段
服务端校验服务端对签名、密文、nonce 做独立校验,不依赖前端
测试向量三端共享同一组固定测试向量,有回归用例
依赖安全npm audit 或等效检查无高危漏洞
错误处理解密失败时不暴露堆栈,统一返回模糊错误

如果你的团队没有专职安全人员,建议把这张表贴在需求评审文档里,由项目负责人逐项打勾。前端同学容易只盯着自己那一亩三分地,觉得“我这边加密了就行”,实际上攻击者的路径往往是跨层拼接的,只有把整条链路都过一遍,方案才算闭环。

6. 常见问题与排查技巧实录

6.1 编码问题:密文对不上盘的罪魁祸首

在加解密联调里,十个报错里至少有七个是编码问题。同样的二进制数据,用来转字符串,就有 UTF-8、Base64、Hex、Latin1 一堆选择;同样叫 Base64,前端脚本里处理换行符的方式和后端还不一样。最崩溃的排查体验是:加密端和解密端代码看着都对,就是结果对不上,这时候不要怀疑算法,先检查每一行编码转换。

我调试时的固定动作,是把每个环节的数据形式打出来:明文是字符串、UTF-8 编码后是字节数组、AES 加密后是二进制 Buffer、Base64 输出后是字符串。中间任何一步转错字节序,后面就全废了。

另外,浏览器里的 atob/btoa 对中文和超长内容不友好,建议先转成字节数组再操作,不要直接拿中文字符串去 btoa。如果遇到crypto.subtle抛 DOMException,第一步检查页面是不是 HTTPS,第二步检查 key 的用法是否正确,比如签名操作是否给了解密用途的密钥,这两个原因占了绝大多数。

6.2 IV、nonce、AAD 的雷区

IV 的雷区永远是“重复使用”。CBC 模式下 IV 重复会让相同明文产生相同密文,泄露模式信息;GCM 模式下 nonce 重复直接威胁到密钥安全,这是密码学上特别严重的问题。所以 IV 必须每次随机生成,长度按算法要求来,GCM 推荐 12 字节。生成 IV 的代码要放在加密之前,一行都不能少。

密文传输时的字段拼接顺序也要统一。有人喜欢把 iv 拼在密文前面,有人喜欢把 AAD 放后面,服务端解析时必须知道确切的位置。我建议在传递结构里用明确的字段名,而不是简单地把 iv 和密文拼成一个大字符串,这样至少可读性好、不容易分割错。前端解密报“解密失败”时,先检查 iv 是否每天都重新生成,再检查 AAD 与加密时是否完全一致,经常就是差一个字符。

GCM 模式下认证标签默认是 128 位,Web Crypto API 在这个值是固定的,但某些语言库允许配置更短的标签长度。跨端对接时如果发现后端一直提示认证失败,建议把标签长度明确写到文档里,免得两边理解不一致。

6.3 RSA 公钥、填充与密钥格式的兼容问题

RSA 前端实践里最典型的坑有两个:公钥格式和填充模式。PEM 公钥可能是-----BEGIN PUBLIC KEY-----(SPKI 格式),也可能是-----BEGIN RSA PUBLIC KEY-----(PKCS#1 格式),不能用同一套解析逻辑去处理。Web Crypto 的 importKey 默认要求 SPKI,而 java 的老接口可能生成 PKCS#1,这时前端要先做格式转换或者在服务端统一输出格式。

jsencrypt 默认用的是 RSA PKCS#1 v1.5 填充,Java 服务端调用RSA/ECB/PKCS1Padding能对上,但如果你切换到 Web Crypto 的 RSA-OAEP,Java 侧也得同步改成 OAEP 并指定摘要算法。这种不匹配不会报编译错误,只会在解密时抛异常,排查起来非常耗时间。

在浏览器里调试这种问题时,我习惯把按钮事件写得干净一点。有些老页面喜欢给链接写javascript:void(0)占位,点击后什么也不做,这在排查加解密报错时特别容易误导人——你会怀疑是不是 JS 没执行、事件没绑定,其实代码根本没跑。建议调试时用显式的事件监听并在回调里打日志,报错定位快很多;生产环境不要依赖这种伪链接做交互。

6.4 测试向量:最容易被忽略的回归保障

加解密代码最怕“升级之后就坏了”。CryptoJS 同一个版本在不同浏览器上行为有差异,Node 升级大版本后底层 OpenSSL 也可能变化,如果没有回归用例,你可能压根不知道它们变了。所以我强烈建议每个涉及加解密的项目都维护一份独立于实现的测试向量 JSON 文件,里面写死一组明文、密钥、IV、AAD、预期密文和预期签名,前端、后端、App 共同引用同一份文件。

{ "algorithm": "AES-256-GCM", "keyBase64": "9Kj3sE...", "ivBase64": "5y4dGt...", "aad": "userId=1001&path=/api/order", "plainText": "13800138000", "cipherBase64": "7Gp2vK...", "authTagBase64": "4HnqF9..." }

前端测试可以很简单,用 Jest 或者 Node 断言跑一遍“加密后解密是否回到原文”和“固定输入是否得到固定输出”。后端也做同样的单测。只要测试向量能对得上,版本升级就不会引起恐慌;对不上,说明某个环境的行为变了,这时候还来得及查,比线上解不开好得多。

6.5 常见错误速查表

表现常见原因处理思路
解密出来是乱码明文编码不一致,常见中文 UTF-8 问题加密前统一 TextEncoder/UTF-8
后端解不开 CryptoJS 密文CryptoJS 默认 KDF 和盐格式非常规显式传 key/iv,不使用默认封装
浏览器报 crypto.subtle undefined页面非 HTTPS 或 file:// 环境切换到 HTTPS/localhost
RSA 解密失败填充模式不一致统一 PKCS1 或 OAEP,并固定摘要
same IV 导致密文相同IV 复用或写死crypto.getRandomValues 每次生成
接口重放成功无时间戳或 nonce 校验加窗口校验和 nonce 去重
大文件加密卡顿主线程阻塞放到 Web Worker 中加密

最后说一个我自己的固定习惯。每接一个涉及加解密的项目,我都会在仓库里放一份 test-vectors.json,把算法、模式、密钥、IV、AAD、明文、密文、签名串全部固定下来,前端、后端、App 三端都引用同一份测试向量。有一次升级核心加密库,就是因为这份文件提前逮住了 CryptoJS 和服务端 OpenSSL 对 salt 处理不一致的问题,硬生生把一次线上事故变成了测试阶段的一行失败日志。加解密这件事,技术本身并不神秘,真正决定项目上线后稳不稳的,往往是那些写进文档、写进测试用例的“约定”。如果你正在设计新项目,我建议先把 HTTPS、服务端校验和审计日志做好,再考虑要不要在 JS 里加一层应用层加密——不要为了加密而加密,更不要在你也不清楚它到底防什么的时候,把一个自己都解释不清的方案推上线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询