首页 / 资讯中心 / 文章详情

JS混合加密:RSA管钥匙AES管数据,前端安全加固实战

JS混合加密:RSA管钥匙AES管数据,前端安全加固实战 ★ FEATURED ARTICLE
昨天有位做金融H5的朋友来找我聊说他们部门在做安全加固要求所有关键接口的报文在应用层再加一层加密不能只靠HTTPS。他问我前端跑的都是JS加密逻辑全在用户手里这不是脱裤子放屁吗这句话听着糙但确实是很多人的第一反应。先说结论前端加密防不住真正的高手但它的目标从来就不是防高手而是把数据的明文暴露面缩到最小让批量抓包、日志泄露、调试代理这些中低危风险全部失效。金融数据一旦出事就是大事所以成本高一点没关系能挡住绝大多数人就行是这个行业的常态。我给他梳理了一套方案也就是标题里说的JS混合加密核心一句话RSA管钥匙AES管数据。应用层报文加密用非对称算法解决密钥怎么安全传给对方的问题再让对称算法去加密真正的业务数据。这篇文章就把这套方案的原理、代码、坑全部拆开讲一遍适合正在给Web应用做加密方案的同路人。1. 混合加密的核心思路与选型逻辑1.1 为什么非对称加密不能单独扛下所有非对称加密RSA有一个硬伤加密数据长度上限很低。拿最常用的2048位RSA来说PKCS#1 v1.5填充模式下单次最多只能加密245字节换成OAEP填充模式还要再减去头长度大概只有190字节出头。可是金融业务报文哪次不是几百字节起步登录接口要传账号、密码、设备指纹、客户端版本随便一拼就是几百字节RSA直接扛不住。就算报文能硬塞进去RSA的加解密性能也让人头疼。浏览器端每次做一次2048位RSA-OAEP加密耗时在几十毫秒量级而AES加密同样大小的数据算上初始化时间也就在微秒级。接口每调一次就要等几十毫秒高频业务的性能直接被拖垮。更深一层的原因是非对称加密的设计目标就不是加密大数据而是解决密钥如何在不安全信道上安全传递。这个使命本身没有问题但把它当成通用加密工具来用就是用错了场景。就像用保险箱去锁一张纸条可以但你想用保险箱去运一卡车货那就离谱了。1.2 对称加密的效率优势与密钥分发的死结对称加密AES这边是另一个极端加解密速度快、支持任意长度的数据流、算法成熟金融行业的落库加密、批量文件加密基本都是AES。但它的致命痛点是密钥分发——AES加解密用的是同一个密钥这个密钥怎么安全地从服务端交给客户端最省事的做法是直接在前端写死一把AES密钥。很多做过外包项目的朋友应该见过这种骚操作CryptoJS.AES.encrypt(password, FirmSecretKey2024)密钥明文躺在代码里抓包看一眼就能提出来这跟没加密没任何区别。就算你混淆一下、拆成几段拼接也只是增加了读取成本本质上还是在裸奔。密钥不写死那就得想别的办法分发。国密领域的SM4、TLS里的对称套件全都面临同一个问题需要一条安全信道来协商密钥。于是RSA的价值就出来了——用它来加密AES密钥这个密钥每次请求都重新生成、用完即扔即使某个请求的AES密钥被破解了也不会影响其他请求。用生活化的话说RSA负责把车钥匙锁进保险箱送过去AES负责开着这辆车运输货物。1.3 算法与参数选型AES-GCM、RSA-OAEP 这些名词该怎么理解选型不是拍脑袋每一步都有依据。AES这边我强烈推荐GCM模式。CTR模式的加密速度快、支持并行计算但CTR本身不带完整性校验密文被篡改后解密方根本察觉不到。GCM模式在CTR基础上增加了GMAC认证标签解密时会校验这个标签数据被改动一下解密立刻失败。金融场景里完整性和机密性同等重要一个能悄悄篡改报文里转账金额的系统加密做得再强也没用。GCM模式还有一个好处加密时自动把认证标签拼在密文尾部使用方不需要自己维护MAC逻辑代码实现简洁。唯一要注意的是IV参数GCM模式下IV是12字节96位不像CBC模式的16字节。IV必须每次随机生成绝对不能固定否则相同密钥下两次加密使用了相同IV等于把明文信息泄露给了攻击者。RSA这边填充模式优先选OAEP不要用PKCS#1 v1.5。OAEP有随机化填充同样的明文每次加密得到的密文都不一样能抵抗选择明文攻击。配套的哈希算法在浏览器端和服务端要统一我推荐都指定SHA-256。密钥长度2048位起步低于这个级别的RSA在行业里已经不被信任了。如果项目有国密合规要求把RSA换成SM2、AES换成SM4链路逻辑完全一样只是算法库和参数名不同。做过国密改造的朋友应该能直接对上号。2. 金融案例链路设计从密钥生成到解密落库2.1 完整的加密链路长什么样我用一个登录接口来做案例分析。假设前端需要上送账号、密码、设备ID最简且安全的链路是分五步第一步前端生成临时的AES-256-GCM密钥这个密钥只服务于当前这一次请求。第二步前端用AES密钥加密登录报文得到密文。第三步前端用服务端下发的RSA公钥加密AES密钥的原始字节。第四步前端把RSA密文、AES密文、IV、时间戳一起放进请求体发给服务端。第五步服务端先用RSA私钥解出AES密钥再用AES密钥解出业务报文完成登录。这个链路里有个容易被忽略的细节AES密钥为什么要导出原始字节再去RSA加密因为Web Crypto API生成AES密钥时返回的是一个CryptoKey对象这个对象不能直接跨算法使用。需要先exportKey(raw)拿到原始字节数组RSA-OAEP才能加密它。服务端拿到原始字节后再反方向创建AES解密器这个过程是零损失的。请求体格式我习惯这样设计{ encKey: base64编码的RSA加密后的AES密钥, data: base64编码的AES-GCM密文含认证标签, iv: base64编码的12字节随机数, ts: 1735540000000 }encKey和data看起来都是base64字符串但解开后的用途完全不同调试时一定要分清楚。2.2 密钥对生成与公钥下发RSA密钥对的生成用openssl一行命令搞定openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -pubout -in private_key.pem -out public_key.pem私钥文件只存在服务端且生产环境不建议直接藏在代码里或环境变量里而是放到密钥管理系统KMS里接口运行时动态获取。公钥文件可以打进前端发布包也可以启动时拉取一次并做版本管理。注意公钥要带版本号因为后续密钥轮换时旧版本的前端还在线上跑老接口要能兼容新旧两把公钥。PEM本身是文本格式前端需要把它转成浏览器能识别的ArrayBuffer。这里有一个很常见的坑PEM头尾的BEGIN PUBLIC KEY、换行符、空格都必须先剔除再走base64解码否则Web Crypto API会直接报DataError。2.3 前端实现Web Crypto API 版本现在浏览器端的原生能力足够强直接用crypto.subtle就能实现全套逻辑不需要引crypto-js那种重量级库。贴一段我整理过的可用代码async function encryptPayload(payload, publicKeyPem) { // 1. 生成临时AES-256-GCM密钥 const aesKey await crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); // 2. 导出AES密钥的原始字节 const rawAesKey await crypto.subtle.exportKey(raw, aesKey); // 3. 导入RSA公钥 const publicKey await crypto.subtle.importKey( spki, pemToArrayBuffer(publicKeyPem), { name: RSA-OAEP, hash: SHA-256 }, false, [encrypt] ); // 4. 用RSA-OAEP加密AES密钥 const encryptedAesKey new Uint8Array( await crypto.subtle.encrypt( { name: RSA-OAEP }, publicKey, rawAesKey ) ); // 5. 生成12字节随机IV const iv crypto.getRandomValues(new Uint8Array(12)); // 6. AES-GCM加密业务数据认证标签自动追加到密文尾部 const dataBytes new TextEncoder().encode(JSON.stringify(payload)); const ciphertext new Uint8Array( await crypto.subtle.encrypt( { name: AES-GCM, iv }, aesKey, dataBytes ) ); // 7. 组装请求体 return { encKey: arrayBufferToBase64(encryptedAesKey.buffer), data: arrayBufferToBase64(ciphertext.buffer), iv: arrayBufferToBase64(iv.buffer), ts: Date.now() }; } // PEM转ArrayBuffer注意清理头尾标记与空白 function pemToArrayBuffer(pem) { const body pem .replace(-----BEGIN PUBLIC KEY-----, ) .replace(-----END PUBLIC KEY-----, ) .replace(/\s/g, ); const binary atob(body); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; } function arrayBufferToBase64(buffer) { let binary ; const bytes new Uint8Array(buffer); for (let i 0; i bytes.byteLength; i) { binary String.fromCharCode(bytes[i]); } return btoa(binary); }使用的时候const body await encryptPayload( { account, password, deviceId }, PUBLIC_KEY_PEM ); const resp await fetch(/api/v1/auth/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) });这段代码有几个点值得单独说。generateKey里的true参数表示密钥可导出因为我们要拿它的原始字节去做RSA加密如果不需要导出这里就该写false这是一个安全与需求之间的取舍。crypto.getRandomValues是浏览器唯一推荐使用的随机数接口千万不能自己用Math.random拼IV那是拿安全开玩笑。TextEncoder把JSON字符串转成UTF-8字节服务端接收后也要按UTF-8解码编码不一致是中文字段乱码的根源。2.4 服务端实现Node.js crypto 版本服务端我用Node.js的crypto模块来解密逻辑和前面对称对应const crypto require(crypto); const express require(express); const app express(); app.use(express.json()); app.post(/api/v1/auth/login, (req, res) { const { encKey, data, iv, ts } req.body; // 时间戳防重放5分钟窗口 const now Date.now(); if (Math.abs(now - ts) 5 * 60 * 1000) { return res.status(403).json({ code: 403001, msg: 请求已过期 }); } let aesKey; try { // RSA私钥解密出AES密钥 aesKey crypto.privateDecrypt( { key: PRIVATE_KEY_PEM, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: sha256 }, Buffer.from(encKey, base64) ); } catch (e) { return res.status(400).json({ code: 400001, msg: encKey解析失败 }); } let plaintext; try { // AES-GCM解密注意认证标签在密文尾部 const encrypted Buffer.from(data, base64); const tag encrypted.subarray(encrypted.length - 16); const ciphertext encrypted.subarray(0, encrypted.length - 16); const decipher crypto.createDecipheriv( aes-256-gcm, aesKey, Buffer.from(iv, base64) ); decipher.setAuthTag(tag); plaintext Buffer.concat([ decipher.update(ciphertext), decipher.final() ]).toString(utf8); } catch (e) { return res.status(400).json({ code: 400002, msg: 数据解密失败可能被篡改 }); } const payload JSON.parse(plaintext); // 继续业务逻辑校验账号密码、生成token... });服务端最容易踩的坑就是GCM的认证标签。浏览器端AES-GCM加密返回的密文中尾部16字节是GMAC认证标签。服务端必须先把这16字节切出来通过setAuthTag设置进去再执行final()。如果你直接把整个密文丢给update再finalNode.js会报Unsupported state or unable to authenticate data。反过来如果你在本地用Node.js做AES-GCM加密一定要记得把标签拼到密文后面再发给前端两边约定俗成保持一致。另一个关键点是oaepHash: sha256。Node.js的privateDecrypt如果传入OAEP填充默认哈希是SHA-1。前端importKey时如果指定了SHA-256服务端也必须明确指出来否则解密会报padding相关错误。这属于前后端算法参数失配的典型问题稍后会专门讲。3. 防重放、防篡改与身份校验的配套设计3.1 时间戳窗口与防重放混合加密解决了报文不能被别人看懂的问题但解决不了别人把请求原样再发一遍的问题。攻击者可以不去破解你的加密直接把抓到的一包有效密文重新提交一遍这在业内叫重放攻击。金融场景里重放一个转账请求、重放一个登录请求后果都很严重。最简单的缓解手段就是时间戳窗口。前端在请求体里带上ts字段服务端收到后先判断时间差超过5分钟直接拒绝。如果配合高频交易的严格风控这个窗口还能缩到30秒。时间戳窗口的局限性在于5分钟内截获的请求依然可以重放。更严格的做法是服务端维护一个已消费请求ID列表每次请求带一个随机UUID服务端检查这个UUID是否出现过出现过就直接拒绝。配合Redis的过期时间可以做到细粒度的防重放。我通常在登录、支付这类敏感接口上做UUID防重放普通查询只做时间戳窗口。3.2 完整性校验GCM标签与HMAC签名前面提到AES-GCM自带认证标签这已经保证了密文在传输过程中不会被篡改。但有一个边界情况要说明GCM的认证逻辑是先加密后认证的AEAD结构它保护的是加密后的数据以及附加信息AAD不是原始明文。所以任何人对密文做任何改动服务端都会解密失败。有些团队会在混合加密的基础上再叠加一层HMAC签名将请求体的关键字段如ts、encKey、业务数据用HMAC-SHA256签名服务端验签。为什么有这个需求因为GCM认证只能证明密文没被改过不能证明这个请求确实是你这个客户端发的。HMAC密钥如果由服务端在用户登录后动态下发并且绑定会话就能提供一层真正的身份证明。从成本收益看我建议金融敏感接口至少要做到AES-GCM完整性 时间戳防重放 token/session身份校验。HMAC签名属于加分项适合对安全性要求极高的核心交易链路。3.3 密钥轮换与性能取舍密钥不能一封到底。RSA私钥如果泄露历史流量全部能被解密所以要定期轮换。轮换的策略是新密钥立即生效旧密钥保留一段宽限期等存量客户端更新完毕后旧私钥再下线。公钥在客户端可以先下发当前版本号每次请求带上ver字段服务端按版本号选择对应的私钥解密。这个设计不复杂但能避免一把钥匙走天下的灾难。性能上RSA加解密的消耗集中在密钥长度上。2048位RSA在浏览器端单次加密大约消耗20到80毫秒在服务端解密的耗时也在这个量级。而AES-256-GCM的加解密速度达到每秒数百MB报文加密本身的成本几乎可以忽略。所以整套方案的性能瓶颈在RSA这一步对登录、下单这类低频接口完全够用如果是高频查询接口可以考虑增加一个会话周期的AES密钥缓存比如登录时下发一个有效期30分钟的会话密钥后续请求用会话密钥加密RSA只在会话建立时调用。4. 实战中踩过的坑与排查实录4.1 RSA-OAEP哈希算法不一致前端死活解不开这是前后端联调时最典型的报错。前端在crypto.subtle.importKey里指定hash: SHA-256服务端的privateDecrypt却只写了padding: RSA_PKCS1_OAEP_PADDING没写oaepHash于是Node.js默认按SHA-1处理。结果是前端加密正常、请求也能发出去但服务端一解密就报错日志里全是EVP_DecryptFinal_ex: bad decrypt。排查思路很直接先确认两端OAEP的哈希参数完全一致。Web Crypto API里RSA-OAEP如果没显式指定hash默认是SHA-1和Node.js的默认行为一致但只要任何一端显式改成SHA-256另一端就必须改。随手拈来的经验加解密联调前把两端的算法参数逐项写在同一张表里比对比反复猜日志高效得多。4.2 AES-GCM认证失败一改数据就崩有次联调时前端改了请求里一个小字段服务端立刻报Unsupported state or unable to authenticate data前端很委屈说我只是加了个字段啊。其实这正是GCM在起作用任何比特的变化都逃不过GMAC校验。问题在于有些同事会把setAuthTag的调用给漏掉。浏览器端返回的密文尾部自带16字节标签服务端如果不切出来、不设置Node.js会报错。我见过的最隐蔽版本是用Buffer.from(data, base64)后没区分密文与标签直接丢给update结果final()永远报错。代码里先把尾部16字节切出来再用subarray取密文主体这一步一定不能省。4.3 PEM密钥与Base64编码的坑前端AES密文、RSA密文、IV都以base64字符串传输但三者的解码方式各不相同。RSA密文被服务端用Buffer.from(encKey, base64)解码后是256字节IV是12字节AES密文长度取决于明文长度加上16字节标签。如果前端在arrayBufferToBase64里把数组拼错了最常见的就是ArrayBuffer到二进制字符串的转换中把字节截断或重复了。调试时建议先在前端把三种base64串各打印一遍看一眼长度是否符合预期。PEM转ArrayBuffer那块记得atob只接受纯base64字符。如果你在PEM里保留了换行符和-----BEGIN PUBLIC KEY-----标记atob可能不报错但importKey会报DataError。我的习惯是在转换函数里用两个replace把标记清掉再用一个replace(/\s/g, )把所有空白字符全干掉一步到位。4.4 常见问题排查速查表症状可能原因定位与解法服务端解密encKey报bad decryptRSA-OAEP的oaepHash不一致两端统一SHA-256并显式声明前端importKey报DataErrorPEM格式处理不干净去掉头尾标记和所有空白再atob服务端报unable to authenticate dataGCM认证标签未分离或数据被篡改切出尾部16字节setAuthTag后再final请求被拒但日志无解密错误时间戳窗口过期检查客户端与服务端的系统时间偏差适当放宽窗口密文长度忽长忽短base64编码拼接错误检查arrayBufferToBase64的字节循环是否完整同一请求重放两次都成功缺少防重放机制加时间戳窗口 请求ID消费记录4.5 一个容易忽视的边界服务器与客户端的时钟偏差时间戳防重放依赖两端的时钟同步。如果服务端部署在云上运行时间很久没做NTP同步时钟偏了几分钟就会把真实用户请求全部误判为过期。我处理过一个线上事故客户端明明刚发出的请求服务端却报请求已过期查了半天发现是宿主机时钟慢了4分钟。排查建议是服务端做好时间同步监控窗口尽量用Math.abs处理双向偏差别只校验客户端时间不能早于服务器时间否则会把时钟略微快一点的用户全部拒掉。写在最后的一点体会前端混合加密这套方案我前前后后在多个项目里落地过。最大的体会是不要把前端加密理解成坚不可摧的盾牌它本质上是把攻击成本抬高。真正的高手有无数办法绕过去比如直接Hook浏览器上下文、修改你的JS逻辑、模拟你的加密函数。但80%的批量攻击者不会这么做他们只抓明文包、写脚本、碰运气而这套加密足以让他们知难而退。还有一个心得加密方案一定要让前后端参数保持一致最好沉淀成一份接口文档里面写清楚算法名称、密钥长度、填充模式、哈希算法、IV长度、标签位置。每次联调遇到问题先对文档再查日志能节省大量时间。如果未来要做国密改造这份文档也能直接指导替换算法链路完全复用。技术永远在往前跑但安全设计的核心逻辑没变过分层防御、限制暴露、提高成本。JS混合加密只是这棵技能树上的一个分支把原理吃透后面碰到任何语言的加密需求你都能举一反三。
阅读完成 · 觉得有帮助?
咨询建站