昨天有位做金融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再final,Node.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 decrypt | RSA-OAEP的oaepHash不一致 | 两端统一SHA-256,并显式声明 |
| 前端importKey报DataError | PEM格式处理不干净 | 去掉头尾标记和所有空白再atob |
| 服务端报unable to authenticate data | GCM认证标签未分离或数据被篡改 | 切出尾部16字节,setAuthTag后再final |
| 请求被拒但日志无解密错误 | 时间戳窗口过期 | 检查客户端与服务端的系统时间偏差,适当放宽窗口 |
| 密文长度忽长忽短 | base64编码拼接错误 | 检查arrayBufferToBase64的字节循环是否完整 |
| 同一请求重放两次都成功 | 缺少防重放机制 | 加时间戳窗口 + 请求ID消费记录 |
4.5 一个容易忽视的边界:服务器与客户端的时钟偏差
时间戳防重放依赖两端的时钟同步。如果服务端部署在云上,运行时间很久没做NTP同步,时钟偏了几分钟,就会把真实用户请求全部误判为过期。我处理过一个线上事故:客户端明明刚发出的请求,服务端却报"请求已过期",查了半天发现是宿主机时钟慢了4分钟。排查建议是服务端做好时间同步监控,窗口尽量用Math.abs处理双向偏差,别只校验"客户端时间不能早于服务器时间",否则会把时钟略微快一点的用户全部拒掉。
写在最后的一点体会
前端混合加密这套方案,我前前后后在多个项目里落地过。最大的体会是:不要把"前端加密"理解成坚不可摧的盾牌,它本质上是把攻击成本抬高。真正的高手有无数办法绕过去,比如直接Hook浏览器上下文、修改你的JS逻辑、模拟你的加密函数。但80%的批量攻击者不会这么做,他们只抓明文包、写脚本、碰运气,而这套加密足以让他们知难而退。
还有一个心得:加密方案一定要让前后端参数保持一致,最好沉淀成一份接口文档,里面写清楚算法名称、密钥长度、填充模式、哈希算法、IV长度、标签位置。每次联调遇到问题,先对文档,再查日志,能节省大量时间。如果未来要做国密改造,这份文档也能直接指导替换算法,链路完全复用。
技术永远在往前跑,但安全设计的核心逻辑没变过:分层防御、限制暴露、提高成本。JS混合加密只是这棵技能树上的一个分支,把原理吃透,后面碰到任何语言的加密需求,你都能举一反三。