
1. 项目缘起为什么前端要自己去实现RSA加密先说结论HTTPS已经是事实标配但很多业务场景里前端依然需要单独做一层RSA加密。举几个我实际遇到过的场景。第一个是登录密码的传输很多系统的登录接口走的是HTTP内网网关或者虽然有HTTPS但证书链本身有历史遗留问题。这种情况下如果前端直接拿明文密码往上传风险不小。第二个场景是第三方对接对方接口要求客户端提供公钥加密后的RSA密文比如一些银行对接、政务平台对接密钥体系是人家定死的你必须按对方的规则来。第三个场景是防抓包移动端或Web端提交的敏感数据即使有HTTPS如果设备本身被安装了抓包工具加了RSA这一层抓到的也只是密文解密成本会高出很多。那有人会问既然有HTTPS为什么还要多此一举我个人理解是这样的HTTPS解决的是传输链路安全RSA解决的是数据本身的不可读性。两者不是替代关系而是叠加关系。尤其当你无法完全掌控服务器端和客户端之间的每一跳网络设备时应用层加密就是最后一道防线。这个项目我会围绕JavaScript实现RSA加密和解密来做一次完整的落地记录。会涉及浏览器端、Node.js端两种环境包含密钥生成、公钥加密、私钥解密、跨语言对接、长度限制、填充模式等核心环节。适合前端开发、全栈开发、以及对接口安全有要求的测试或运维同学参考。我不会只贴代码更多会讲清楚每一步为什么要这么做以及我踩过哪些坑。2. 方案选型原生Web Crypto API还是第三方库2.1 原生Web Crypto API是首选先说说技术选型。现代浏览器和Node.js原生都内置了Web Crypto API也就是crypto.subtle这一套。它的最大优势是底层实现由浏览器或操作系统提供性能稳定不依赖任何第三方包而且密钥不会暴露到JavaScript堆内存中安全级别更高。用原生API实现RSA加解密核心操作是这几个// 生成RSA密钥对 const keyPair await crypto.subtle.generateKey( { name: RSA-OAEP, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: SHA-256, }, true, [encrypt, decrypt] );modulusLength: 2048是当前最稳妥的密钥长度安全性够性能也OK。1024位在2024年已经明确不安全不建议使用。4096位则意味着更长的加解密耗时实际业务中大多是2048位。hash: SHA-256这个参数很多人会忽略。RSA-OAEP填充算法内部需要一个哈希函数来生成掩码这里选用SHA-256兼容性比SHA-1更好也符合当前安全基线。2.2 第三方库的适用场景那第三方库还有存在的必要吗有。Web Crypto API虽然强大但有一个明显痛点它只支持RSA-OAEP和RSA-PSS这两种填充模式。RSA-PSS是签名用的加解密只能用RSA-OAEP。但很多老系统和第三方接口用的是PKCS#1 v1.5填充也就是传统意义上的RSA/ECB/PKCS1Padding。遇到这种情况我们就需要node-forge或jsencrypt来处理。另外一个场景是兼容性。虽然crypto.subtle在主流浏览器上普及率已经很高但如果你需要兼容一些老旧内核的WebView原生API可能不可用这时候第三方库反而是救命稻草。2.3 选型对比维度Web Crypto APInode-forgejsencrypt依赖无需引入NPM包需引入NPM包填充模式RSA-OAEPOAEP PKCS#1 v1.5主要是PKCS#1 v1.5密钥格式SPKI/PKCS#8支持PKCS#1、PKCS#8等多种格式主要PKCS#1性能原生实现性能最好纯JS性能中等纯JS体量更小适用场景浏览器端、Node.js端标准环境需要兼容老接口、复杂密钥格式快速实现、前端表单加密我的建议是大部分新项目直接用Web Crypto API就够了不需要引入任何第三方依赖。只有在对接老系统、对方强制要求PKCS#1 v1.5填充、或者密钥格式比较特殊时才考虑引入node-forge。jsencrypt适合快速搞定一个简单的公钥加密功能但它的实现相对老旧功能也比较单一。这个项目中我主要用Web Crypto API来演示同时会给出node-forge的对照代码方便你遇到老接口时能切换。3. 密钥格式详解不要输在起跑线上3.1 SPKI、PKCS#8、PKCS#1是什么RSA加解密过程中最容易出错的地方就是密钥格式。很多人从后端拿到一串公钥字符串直接丢给加密库结果报错完全摸不着头脑。先说清楚三个标准SPKISubjectPublicKeyInfoX.509证书体系中的公钥格式也是Web Crypto API导出公钥时的默认格式。它包含算法标识、参数、RSA公钥本体是一棵完整的ASN.1结构树。PKCS#8私钥的标准化封装格式同样包含算法信息Web Crypto API导出私钥时默认使用它。PKCS#1这是RSA密钥最初的定义格式只包含RSA的数学参数本身没有算法标识。老系统的Java、PHP代码经常输出这种格式。打个比方SPKI像是一个带标签的快递盒上面写着这是RSA-OAEP的公钥参数是SHA-256PKCS#1则像一个光溜溜的U盘里面只有数据本身你要知道它是什么格式才能正确读取。在后端接口中最常见的公钥字符串格式是PKCS#1的PEM文本以-----BEGIN RSA PUBLIC KEY-----开头。而Web Crypto API导出的是SPKI格式以-----BEGIN PUBLIC KEY-----开头。这两者不能直接互换使用导入的时候必须指定正确的格式或者做转换。3.2 导出、转换与传递用Web Crypto API生成密钥对后我们会把公钥发给后端自己保存私钥用于解密。公钥默认导出的是ArrayBuffer我们需要转成Base64字符串方便传输// 导出公钥为SPKI格式的Base64字符串 async function exportPublicKeyBase64(keyPair) { const spkiBuffer await crypto.subtle.exportKey(spki, keyPair.publicKey); return arrayBufferToBase64(spkiBuffer); } // ArrayBuffer转Base64这里用分片编码避免大字符串溢出 function arrayBufferToBase64(buffer) { const bytes new Uint8Array(buffer); let binary ; for (let i 0; i bytes.length; i) { binary String.fromCharCode(bytes[i]); } return btoa(binary); }如果你需要PKCS#1格式的公钥给Java后端用最简单的方式是用node-forge做转换。我在这里也踩过一次坑后面会展开说。私钥的导出方式和公钥类似只是格式是PKCS#8const privateKeyBuffer await crypto.subtle.exportKey(pkcs8, keyPair.privateKey);需要特别注意私钥字符串绝对不能通过日志、接口响应等途径暴露出去。私钥永远只应该保存在服务端的安全存储中或者是客户端的本地安全存储里。前端持有私钥的场景通常只出现在端到端加密这样的特殊业务中。4. 核心实现浏览器端的RSA加密与解密全流程4.1 完整代码生成密钥对、加密、解密下面是一套可以在现代浏览器中直接运行的完整实现。这段代码解决的是最典型的需求前端生成密钥对把公钥交给服务端服务端用公钥加密数据前端用私钥解密。class RSAHelper { constructor() { this.keyPair null; } // 初始化生成RSA-OAEP密钥对 async init() { this.keyPair await crypto.subtle.generateKey( { name: RSA-OAEP, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: SHA-256, }, true, [encrypt, decrypt] ); } // 公钥Base64字符串SPKI格式 async getPublicKey() { const spki await crypto.subtle.exportKey(spki, this.keyPair.publicKey); return arrayBufferToBase64(spki); } // 私钥Base64字符串PKCS#8格式 async getPrivateKey() { const pkcs8 await crypto.subtle.exportKey(pkcs8, this.keyPair.privateKey); return arrayBufferToBase64(pkcs8); } // 使用别人的公钥加密 static async encryptWithPublicKey(publicKeyBase64, plainText) { const publicKey await crypto.subtle.importKey( spki, base64ToArrayBuffer(publicKeyBase64), { name: RSA-OAEP, hash: SHA-256, }, false, [encrypt] ); const encoded new TextEncoder().encode(plainText); const encryptedBuffer await crypto.subtle.encrypt( { name: RSA-OAEP, }, publicKey, encoded ); return arrayBufferToBase64(encryptedBuffer); } // 使用自己的私钥解密 static async decryptWithPrivateKey(privateKeyBase64, cipherBase64) { const privateKey await crypto.subtle.importKey( pkcs8, base64ToArrayBuffer(privateKeyBase64), { name: RSA-OAEP, hash: SHA-256, }, false, [decrypt] ); const decryptedBuffer await crypto.subtle.decrypt( { name: RSA-OAEP, }, privateKey, base64ToArrayBuffer(cipherBase64) ); return new TextDecoder().decode(decryptedBuffer); } }配套的Base64转换函数需要两个方向都写完整我在真实项目里发现很多人只写了arrayBufferToBase64忘了base64ToArrayBuffer也需要处理二进制数据结果解密时拿到的是乱码或者直接报错。// Base64字符串转ArrayBuffer function base64ToArrayBuffer(base64String) { const binaryString atob(base64String); const bytes new Uint8Array(binaryString.length); for (let i 0; i binaryString.length; i) { bytes[i] binaryString.charCodeAt(i); } return bytes.buffer; }4.2 使用说明上面的代码中init()方法在页面加载时调用一次即可。生成密钥对之后把公钥字符串发给后端后端用它加密返回数据。解密时调用decryptWithPrivateKey传入你保存的私钥和密文就能得到明文。需要注意私钥保存后不要直接塞到localStorage里如果一定要在客户端保存建议配合IndexedDB加权限隔离或者用Web Crypto API的crypto.subtle.wrapKey先包装一层。关于TextEncoder和TextDecoderRSA加密操作的对象是字节序列不是字符串。直接用JSON.stringify后的字符串去加密是没问题的但解密后要记得用JSON.parse还原或者统一走TextEncoder/TextDecoder处理避免中文乱码和特殊字符错乱。我在实际调试中遇到过这样的情况加密正常后端能解密但是解密出来中文是寰堝ソ这种乱码。排查后发现问题出在后端把字符串按UTF-8编码时前端传过去的密文是Base64字符串后端没有先解码成字节就直接做了解密。这类问题不是RSA本身的问题而是编码链路没对齐。4.3 填充模式与最大明文长度这一点必须单独拿出来讲因为它经常成为生产事故的根源。RSA-OAEP加密时2048位密钥对应256字节的密文块明文长度上限是最大明文长度 密钥字节数 - 2 * 哈希长度 - 2当hash选择SHA-256时哈希长度是32字节所以计算公式为256 - 2 * 32 - 2 190字节也就是说在RSA-OAEP SHA-256 2048位密钥的组合下单次最多只能加密190字节的明文。如果明文超过这个长度crypto.subtle.encrypt直接会抛DataError异常。这是很多人第一次接触RSA时最容易忽略的点。RSA本身不是用来加密大体积数据的它适合加密对称密钥、小字段、短文本。如果你要加密的内容超过190字节常规做法是走混合加密用RSA加密一个AES对称密钥再用AES去加密实际数据。后面第6节我会给出一个完整的混合加密例子那是实际项目中更通用的方案。5. 服务端对接跨语言兼容的实战要点5.1 和Node.js后端对接最常见的场景是前端调用后端接口后端返回一个RSA公钥前端用这个公钥加密请求体中的某个字段。Node.js端可以使用crypto内置模块完成解密不需要额外安装东西const crypto require(crypto); function decryptWithPrivateKey(privateKeyPem, encryptedBase64) { // 注意这里默认前端传入的密文是Base64格式 const buffer Buffer.from(encryptedBase64, base64); const decrypted crypto.privateDecrypt( { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: sha256, }, buffer ); return decrypted.toString(utf8); }关键点在于oaepHash: sha256必须和前端生成密钥时指定的hash: SHA-256一致。如果前端用SHA-1后端用SHA-256或者反过来解密必然报错。5.2 和Java后端对接Java端常见写法是Cipher cipher Cipher.getInstance(RSA/ECB/OAEPWithSHA-256AndMGF1Padding); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] plainBytes cipher.doFinal(Base64.getDecoder().decode(encryptedBase64));这里的OAEPWithSHA-256AndMGF1Padding对应前端的RSA-OAEP SHA-256但Java默认的MGF1摘要可能也是SHA-256有些版本默认MGF1用的SHA-1这会导致前端加密、后端解密时出现javax.crypto.BadPaddingException。遇到这种情况需要显式指定MGF1参数或者前端调整Hash算法。我在实际对接中踩过一个特别典型的坑Java后端返回的公钥是PKCS#1格式前端importKey时指定了spki结果直接报错。后来我把前端的导入格式改成pkcs1但Web Crypto API原生并不支持直接导入PKCS#1格式必须先用node-forge转成SPKI。下面是一段node-forge的转换示例const forge require(node-forge); // 将PKCS#1 PEM转换为SPKI PEM function pkcs1ToSpki(pkcs1Pem) { const publicKey forge.pki.publicKeyFromPem(pkcs1Pem); const asn1 forge.pki.publicKeyToAsn1(publicKey); const spkiPem forge.pki.publicKeyToPem( forge.pki.publicKeyFromAsn1(asn1) ); return spkiPem; }记住一个原则不同语言、不同库之间互调密钥格式和填充模式必须两边对齐。上线前先打印日志确认两边的密钥格式字符串和算法参数能省掉大量排查时间。5.3 常见错误速查对接过程中如果报错可以从这张表快速定位问题报错信息可能原因解决方向TypeError: Cannot read properties of undefined (reading encrypt)浏览器环境不支持crypto.subtle检查协议是否为HTTPS或localhost检查浏览器版本DOMException: The operation is for an incompatible object导入密钥时指定的算法参数与密钥实际生成参数不一致统一指定相同的hash算法Error: Error during decryption填充模式不匹配确认OAEP与PKCS#1 v1.5的一致性javax.crypto.BadPaddingExceptionJava解密时MGF1摘要算法与前端不一致显式设置MGF1参数或调整前端hashDataError明文长度超过190字节改用混合加密或分段加密还有一个容易被忽略的问题Base64字符串中如果混入了换行符atob会解析失败。很多PEM格式的密钥在传输过程中会被自动换行前端拿到的字符串里可能带了\n最好先做一次replace(/\r?\n/g, )再处理。6. 混合加密实践RSA加密长文本的通用方案6.1 为什么必须用混合加密前面提到RSA的明文长度上限是190字节这在真实业务中远远不够。一个JSON请求体比如{ userId: 10001, timestamp: 1700000000000, data: { name: 张三, phone: 13800138000, remark: 这是一段比较长的备注信息 } }转成字符串后很容易超过190字节。如果用RSA直接加密分分钟报错。解决思路是混合加密用AES加密实际内容再用RSA加密AES的密钥。这个方案在HTTPS中也是这么干的握手时用非对称算法协商对称密钥之后所有数据走对称加密。6.2 前端混合加密完整代码// 生成AES密钥并用RSA公钥加密 async function hybridEncrypt(rsaPublicKeyBase64, plainText) { // 1. 生成AES-GCM密钥 const aesKey await crypto.subtle.generateKey( { name: AES-GCM, length: 256, }, true, [encrypt, decrypt] ); // 2. 生成IV const iv crypto.getRandomValues(new Uint8Array(12)); // 3. AES-GCM加密明文 const encodedData new TextEncoder().encode(plainText); const encryptedData await crypto.subtle.encrypt( { name: AES-GCM, iv: iv, }, aesKey, encodedData ); // 4. 导出AES密钥并使用RSA公钥加密 const rawAesKey await crypto.subtle.exportKey(raw, aesKey); const publicKey await crypto.subtle.importKey( spki, base64ToArrayBuffer(rsaPublicKeyBase64), { name: RSA-OAEP, hash: SHA-256, }, false, [encrypt] ); const encryptedAesKey await crypto.subtle.encrypt( { name: RSA-OAEP, }, publicKey, rawAesKey ); // 5. 打包返回加密后的AES密钥 IV 密文 return { encryptedAesKey: arrayBufferToBase64(encryptedAesKey), iv: arrayBufferToBase64(iv.buffer), encryptedData: arrayBufferToBase64(encryptedData), }; }6.3 后端解密对应的Node.js实现const crypto require(crypto); function hybridDecrypt(privateKeyPem, payload) { const { encryptedAesKey, iv, encryptedData } payload; // 1. 用RSA私钥解密出AES密钥 const aesKeyBuffer crypto.privateDecrypt( { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: sha256, }, Buffer.from(encryptedAesKey, base64) ); // 2. 用AES-GCM解密数据 const decipher crypto.createDecipheriv( aes-256-gcm, aesKeyBuffer, Buffer.from(iv, base64) ); // 这里注意GCM模式需要处理auth tag const encryptedBuffer Buffer.from(encryptedData, base64); const authTag encryptedBuffer.subarray(encryptedBuffer.length - 16); const dataBuffer encryptedBuffer.subarray(0, encryptedBuffer.length - 16); decipher.setAuthTag(authTag); const decrypted Buffer.concat([ decipher.update(dataBuffer), decipher.final(), ]); return decrypted.toString(utf8); }这段代码有几个细节值得注意。AES-GCM的认证标签默认是16字节前端crypto.subtle.encrypt返回的密文中已经包含了认证标签所以后端需要从密文末尾拆出tag再调用setAuthTag。如果不设置解密时会直接抛错提示Unsupported state or unable to authenticate data。混合加密的好处一是支持任意长度的数据二是因为数据主体走的是AES对称加密加解密性能远高于纯RSA。思路和应用层如何实现都建立在这套方案上。6.4 什么时候不需要混合加密如果你的数据量很小比如就是加密个验证码、手机号、身份证号长度不超过190字节直接用RSA就够了不需要引入AES那套复杂度。业务简单性也是工程价值的一部分。我见过不少项目为了看起来安全把混合加密写上去了结果增加了前后端联调成本却没有带来本质收益。密钥管理、密钥轮换、审计日志这些没跟上方案再复杂也是白搭。7. 安全底线与常见坑现场排查实录7.1 再强调一遍RSA加密不等于绝对安全分享一段我经历过的真实排查过程。有一个系统前端用RSA公钥加密登录密码后端用私钥解密看起来没啥问题。但后来安全团队测试发现只要抓包拿到密文直接重放到接口上服务端照样解密成功登录照样通过。问题出在哪缺少防重放机制。RSA加密解决的是数据传输过程中的不可读性但密文本身可以被录制、复制、重放。这就像你写了一封加密信但信被偷看后小偷可以把信原封不动再交一次。真正安全的接口必须有时间戳、随机数、序列号之类的机制结合验证。我建议在前端加密时把当前时间戳和随机字符串拼进明文服务端解密后校验时间戳的时效性比如允许5分钟内有效同时维护一个已使用随机数列表来防重放。7.2 密钥管理的前端注意事项另一个高频问题是密钥的存储安全。前端浏览器环境中任何保存在localStorage、sessionStorage中的数据都面临XSS攻击的威胁。如果攻击者能在页面注入脚本他就能直接读取私钥字符串。我在项目中的做法是前端私钥只在本次会话中保留在内存变量里不写入localStorage。如果一定要持久化使用IndexedDB并配合CSP内容安全策略降低XSS风险。加密私钥时用crypto.subtle.wrapKey配合另一个密钥来包裹私钥。7.3 一次完整的联调排查记录有一次我负责的项目里前端在Chrome上加密正常但测试同学在某个国产浏览器的极速模式下解密失败。打开控制台发现crypto.subtle是undefined说明内核比较旧。当时的处理方案是在初始化时做一个特性检测if (window.crypto window.crypto.subtle) { // 使用Web Crypto API } else { // 降级到node-forge }后来项目组要求兼容的浏览器列表里移除了那个老内核版本才彻底砍掉了降级逻辑。但这里也说明一个经验产品规划阶段就要明确兼容目标技术方案要和兼容矩阵对齐。7.4 实用检查清单我把自己每次上线的自检项整理成了一张清单供参考[ ] 密钥长度是否不低于2048位[ ] 前后端hash算法是否完全一致SHA-256[ ] 公钥导入格式是否与后端导出一致SPKI还是PKCS#1[ ] 加密数据的长度是否在190字节以内如果超出是否已走混合加密[ ] Base64字符串是否处理过换行符[ ] 是否加入了时间戳和随机数防重放[ ] 私钥是否未写入localStorage[ ] 是否做了crypto.subtle特性检测[ ] 是否处理过中文编码TextEncoder/TextDecoder[ ] 是否确认服务端日志不会打印私钥和明文8. 个人实操心得在这个项目的整个过程中我最大的体会是RSA加解密的代码本身并不复杂真正的复杂度在于格式对齐、参数对齐和场景判断。crypto.subtle.generateKey、encrypt、decrypt这些API用起来很快但如果你不去理解SPKI和PKCS#1的区别不理解为什么RSA-OAEP限制了明文长度不理解为什么Java后端的MGF1参数会导致解密失败那么代码写得再顺手联调时也会被各种诡异的报错折腾到怀疑人生。最后再分享一个小技巧。在联调阶段我习惯在前端和后端各写一个自测用例前端用公钥加密一段固定字符串解密后对拍后端用私钥解密前端生成的密文再对拍明文。两边能对上同一个值接口联调才真正有把握。这个习惯帮我节省了大量时间也避免了上线后才发现两边算法不一致的尴尬。