news 2026/10/22 10:30:54

前端RSA加密实战:从Web Crypto API到jsencrypt的踩坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端RSA加密实战:从Web Crypto API到jsencrypt的踩坑指南

前阵子接了个管理后台的改造需求,后端同事在接口文档里补了一行备注:登录密码字段请使用RSA加密后再传输。于是我把“JS实现RSA加密”这条老路重新走了一遍,从Web Crypto API到jsencrypt,从密钥格式到填充模式,中间踩了几个坑,也顺手把一些平时容易忽略的细节重新梳理了一遍。

这篇不打算讲高深的理论,只以一个实际项目的节奏来走:前端RSA加密到底解决什么问题、方案怎么选、代码怎么写,以及跨端联调时最容易出的几个错。适合正在做Web登录加密、接口敏感字段防护,或者被前后端RSA加解密“对不上”折磨过的朋友。

1. 先想清楚边界再动手:前端RSA加密解决什么问题

1.1 典型的业务场景

做Web端开发时,提到RSA加密,大概率逃不出这几个场景:

  • 登录密码加密。输入框里的密码不直接以明文提交,先在前端用公钥加密,后端拿私钥解密后再走后续校验流程。
  • 敏感字段防日志泄漏。很多框架和网关在打印请求参数时会顺带把业务字段打出来,如果接口里直接传明文手机号、身份证号,日志一旦泄出去就是批量泄露。RSA加密能保证原始数据在到达业务代码之前,日志里记录的是一串无意义的密文。
  • 防止调试工具直接看到明文。现在浏览器DevTools、抓包工具都很方便,有人会直接在Network面板里看请求体,加密后至少把门槛抬高了一点。

这里要先泼一盆冷水:前端加密不是银弹。一个真正较真的攻击者完全可以改你前端的JS代码、Hook浏览器内存,甚至直接把解密后的数据在内存里捞出来。所以RSA前端加密解决的是“降低风险”,而不是“杜绝偷看”。这个边界在自己心里要清楚,跟产品和技术对齐的时候也要讲明白,否则容易产生不切实际的预期。

1.2 方案选型:原生Web Crypto API还是第三方库

接到需求后第一个问题不是写代码,而是选方案。市面常见的做法无非三种:浏览器原生Web Crypto API、老牌库jsencrypt、以及功能更全的node-forge。

方案优点缺点适用场景
Web Crypto API浏览器内置、零依赖、支持RSA-OAEP、异步不阻塞只支持现代浏览器,需要HTTPS或localhost环境,IE和旧内核基本没戏新项目、内部系统、对安全性有要求的场景
jsencrypt简单粗暴、同步API、兼容老浏览器、体积小多年没怎么更新,默认PKCS#1 v1.5填充,不支持OAEP老项目改造、要兼容旧环境的快速落地
node-forge功能全面、能生成PEM、能做格式转换,浏览器和Node都能用API风格偏老,打包体积偏大需要在浏览器里生成密钥对、做密钥格式转换的场景

我自己的选型原则很简单:如果项目跑在现代浏览器上,优先用Web Crypto API,因为它是官方标准,安全性和后续维护都有保障;如果必须兼容老环境或者只是想几分钟内跑通一个Demo,那就用jsencrypt;一旦遇到密钥格式转换这类需求,我会顺手把node-forge拉进来当“瑞士军刀”。

2. 密钥长度、填充模式与密钥格式:RSA绕不开的三个基础概念

2.1 单次加密的数据上限是怎么算出来的

RSA不是谁都能一口气加密任意长度的数据,单次能加密的明文长度,取决于两件事:密钥长度和填充模式。

先说密钥长度。RSA的模数长度习惯用bit表示,比如1024位、2048位。换算成字节,1024位就是128字节,2048位就是256字节。这里的“字节数”就是单次加密的容量上限基数,但这不是全部,因为填充算法会占掉一部分空间。

计算公式很简单:

  • PKCS#1 v1.5填充:maxLen = k - 11
  • OAEP填充:maxLen = k - 2 - 2 * hashLen

其中k是模数字节数,hashLen是摘要长度。比如SHA-1摘要20字节,SHA-256摘要32字节。

实际数字最直观:

密钥长度填充模式单次加密明文上限
1024位PKCS#1 v1.5117字节
2048位PKCS#1 v1.5245字节
2048位RSA-OAEP, SHA-256190字节
2048位RSA-OAEP, SHA-1214字节

很多老博客里写的“最多117字节”,其实是1024位密钥配合PKCS#1 v1.5填充的结论。现在主流都是2048位起步,对应上限是245字节。但你真要在生产环境加密超过这个长度的数据,不应该去算能塞几段,而是应该重新考虑架构——RSA本来就不是用来加密大块数据的。

注意:如果加密内容超过上限,jsencrypt的encrypt方法会直接返回false,Web Crypto API的encrypt会直接抛异常。不是“默默截断”,而是直接失败,这个表现反而能帮你尽早发现问题。

2.2 填充模式为什么重要

曾经有人问过我一个很尖锐的问题:RSA公钥加密不就是为了让同一份明文每次加密结果都不同吗?为什么我前后加密两次,结果一样?

这里必须澄清:RSA本身是确定性的,同样的输入、同样的密钥,算出来的密文理论上是一样的。但这样有一个安全隐患——攻击者如果能看到两次密文相同,就能判断“你这次提交的内容和上次一样”,这在很多业务场景下是可以被利用的。所以填充模式的作用不只是凑长度,它还会引入随机数,让每次加密的结果都不同。

目前主流填充模式就两种:

  • PKCS#1 v1.5:历史最悠久,jsencrypt默认用的就是它,兼容性好,但安全强度不如OAEP。
  • OAEP(Optimal Asymmetric Encryption Padding):更安全的填充方式,Web Crypto API默认支持,跨端使用时需要注意哈希算法的匹配。

跨端联调时,填充模式不一致是报错率最高的原因之一。前端用了OAEP,后端却是PKCS#1,解密时十有八九会抛BadPaddingException或类似错误。这在下文会有专门的排查表格。

2.3 PEM、DER、SPKI、PKCS#1/PKCS#8命名纠偏

很多前后端对不上,不是算法问题,而是密钥格式没对齐。这里有必要把几个容易混淆的术语理顺。

  • DER:二进制格式,就是密钥的原始字节流。
  • PEM:把DER做了Base64编码,再在开头和结尾加上标记行,比如-----BEGIN PUBLIC KEY-----。这是互联网上最常见的传输格式。
  • PKCS#1:RSA专用格式。公钥的标记是-----BEGIN RSA PUBLIC KEY-----,私钥是-----BEGIN RSA PRIVATE KEY-----。
  • PKCS#8:通用格式,可以容纳多种非对称算法。公钥标记是-----BEGIN PUBLIC KEY-----,私钥是-----BEGIN PRIVATE KEY-----。
  • SPKI:全称SubjectPublicKeyInfo,是PKCS#8体系里的公钥定义,也就是BEGIN PUBLIC KEY对应的内部结构。
  • JWK:JSON格式的密钥表示,Web Crypto API可以直接导入导出。

不同语言、不同库对格式的默认偏好不一样。比如很多在线工具生成的是PKCS#1格式,但Web Crypto API的importKey只认SPKI(也就是BEGIN PUBLIC KEY),jsencrypt两边都能读。遇到“公钥解析失败”先别怀疑算法,看一眼PEM的头行是什么,往往问题就清楚了。

3. Web Crypto API原生实现:RSA-OAEP加解密全流程

3.1 生成密钥对并导出为PEM

先写一个完整的Demo,前端自己生成一对RSA密钥,导出的公钥可以发给后端,私钥在演示里用来本地解密验证。

const keyPair = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: 'SHA-256' }, true, // extractable,true表示允许导出密钥,生产环境要谨慎 ['encrypt', 'decrypt'] );

这里publicExponent是公钥指数,业界通常用65537,也就是十六进制的0x010001,安全性好且加密效率高。modulusLength用2048位是当前比较稳妥的底线,1024位已经不被推荐。

生成之后,密钥还不能直接拿来给别人用,要先导出成PEM格式。公钥导出用spki:

const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey); const publicPem = arrayBufferToPem(spki, 'PUBLIC KEY');

对应的arrayBufferToPem转换函数,注意不要用简单的String.fromCharCode(...new Uint8Array(buf))展开大数组,稍微大一点就可能爆栈。用循环分片更稳妥:

function arrayBufferToPem(buffer, label) { const bytes = new Uint8Array(buffer); let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); } const base64 = btoa(binary); const lines = base64.match(/.{1,64}/g).join('\n'); return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`; }

每行64个字符是PEM的标准排版,不是必须的,但绝大多数工具都按这个习惯来,保持这个格式能少踩很多兼容性的坑。

3.2 公钥加密文本

拿到了PEM公钥,下一步就是导入并加密。导入用importKey,格式指定spki:

async function encryptWithPublicKey(publicPem, plainText) { const publicKey = await crypto.subtle.importKey( 'spki', pemToArrayBuffer(publicPem), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const encrypted = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, new TextEncoder().encode(plainText) ); return arrayBufferToBase64(encrypted); }

pemToArrayBuffer是arrayBufferToPem的逆操作,去掉头尾标记行,把Base64解码回二进制:

function pemToArrayBuffer(pem) { const base64 = pem .replace(/-----BEGIN [^-]+-----/, '') .replace(/-----END [^-]+-----/, '') .replace(/\s+/g, ''); 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; }

这里有个容易忽略的细节:encrypt的入参接受BufferSource,TextEncoder().encode()返回的Uint8Array可以直接传,不用再手动转一次ArrayBuffer。

3.3 私钥解密还原明文

解密是加密的逆过程,改用pkcs8导入私钥,使用decrypt方法:

async function decryptWithPrivateKey(privatePem, base64CipherText) { const privateKey = await crypto.subtle.importKey( 'pkcs8', pemToArrayBuffer(privatePem), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'] ); const decrypted = await crypto.subtle.decrypt( { name: 'RSA-OAEP' }, privateKey, base64ToArrayBuffer(base64CipherText) ); return new TextDecoder().decode(decrypted); }

这个Demo跑通了,你就能直观感受RSA加解密的完整链路。但我要强调一句:这个Demo里的私钥导出只是为了教学验证,真实项目里千万不要这样设计。

3.4 一个关键设计原则:前端不需要私钥

如果你只是要让“前端加密,后端解密”,那前端根本不需要生成密钥对。正确做法是:

  • 后端生成RSA密钥对,自己保管私钥;
  • 公钥通过配置接口或配置文件下发给前端;
  • 前端只导入公钥,执行encrypt;
  • 后端用私钥decrypt。

根本不需要generateKey。如果前端自己生成密钥对再把私钥留在浏览器里,等于把保险柜钥匙放在保险柜旁边。浏览器环境不可信,任何放在前端的私钥都只是“看起来保密”。

还要提一句extractable参数。如果某个场景确实需要在前端生成密钥对(比如纯离线工具),私钥的extractable要设为false,这样浏览器层面就禁止导出私钥。另外要注意,即使设了false,解密操作本身还是能执行的,安全性主要依赖浏览器的实现。

4. jsencrypt快速落地:老库也有它不可替代的价值

4.1 安装与基础用法

如果你要兼容IE/旧浏览器,或者项目里就想赶紧跑通一个加密字段,jsencrypt依然是很多老项目的默认选择。安装很简单:

npm install jsencrypt

使用方式也很直接:

import { JSEncrypt } from 'jsencrypt'; const encryptor = new JSEncrypt(); encryptor.setPublicKey(`-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----`); const encrypted = encryptor.encrypt('hello world'); // encrypted 是 Base64 编码的密文

就这么简单,同步方法,拿到密文就可以放进请求体。如果你需要私钥解密来测试,同样只需要setPrivateKey然后调用decrypt:

encryptor.setPrivateKey(`-----BEGIN PRIVATE KEY-----...`); const decrypted = encryptor.decrypt(encrypted);

jsencrypt内部对PEM头行的兼容性做得不错,PKCS#1和PKCS#8格式都能处理,这也是它至今还有人用的原因之一。

4.2 中文乱码的经典解法

用jsencrypt加密中文,是踩坑重灾区。直接调用encrypt('你好世界'),有时返回的是false,有时后端解出来是一堆乱码。这其实不是加密算法的锅,而是字符编码的锅。

JS里的字符串是UTF-16编码,RSA底层处理的是字节数组。jsencrypt内部对UTF-8的处理并不彻底,当原文里包含非拉丁字符时,转换过程就可能出问题。

我验过多次,最省心的做法是:加密前先把原文用encodeURIComponent转一遍,转义后的字符串全部是ASCII安全字符,每个字符一个字节,不会再产生编码歧义。后端拿到解密结果后,再用对应的URL解码还原。

const encrypted = encryptor.encrypt(encodeURIComponent('你好世界'));

在后端对应的是:

// 先RSA解密得到 "你好世界" 的 encodeURIComponent 形式 String encoded = rsaDecrypt(cipherText); String original = java.net.URLDecoder.decode(encoded, "UTF-8");

PHP那边对应的是rawurldecode。核心思路是:让明文在加密前先变成“纯字节友好”的形态,把编码争议挡在加密之外。

4.3 单次加密上限与长文本策略

jsencrypt的encrypt方法不会自动分段,明文超过上限直接返回false。所以在加密较长的文本之前,要先根据密钥长度确定分段大小。

以2048位密钥、PKCS#1 v1.5填充为例,上限是245字节。如果原文经过encodeURIComponent转义,中文字符会变成%XX格式,每个字符占3个字节,所以245字节大约能容纳80个左右的中文字符。你先测试确认,别凭感觉。

分段加密的思路是:按上限切块,逐块加密,用分隔符拼接,后端逐段解密再拼回去。注意选择的分隔符不能出现在Base64密文中,我用的是竖线|,Base64字符集里不包含它,安全。

function encryptLongText(encryptor, text, chunkSize = 245) { // 先转义再分段,避免中文编码问题 const input = encodeURIComponent(text); const chunks = []; for (let i = 0; i < input.length; i += chunkSize) { const piece = input.slice(i, i + chunkSize); const encryptedPiece = encryptor.encrypt(piece); if (encryptedPiece === false) { throw new Error('RSA encrypt failed at chunk ' + i); } chunks.push(encryptedPiece); } return chunks.join('|'); }

后端收到后按|拆分,每段独立解密,然后拼接,最后decodeURIComponent还原原文。

但这里我必须说句实在话:如果一个字段长到需要分段加密,那大概率不应该继续用RSA硬扛。面对超过1KB甚至更大的数据,行业标准做法是“混合加密”:用AES对称加密算法加密业务数据,再用RSA公钥去加密这个随机生成的AES密钥。RSA管密钥传输,AES管数据加密,两者配合,既安全又快,这才是大数据的正解。

4.4 混合加密的简单思路

如果你决定用混合加密,流程大致是这样的:

  • 前端生成一个16字节的随机AES密钥;
  • 用AES-GCM模式加密业务数据,得到密文和初始化向量iv;
  • 用后端下发的RSA公钥加密这个AES密钥;
  • 把iv、密文、加密后的AES密钥一起发给后端;
  • 后端先用RSA私钥解密得到AES密钥,再用AES密钥解密密文。

前端只要引入一个支持AES的库或者用Web Crypto API就行,后端处理起来也是常规操作。这种方案避免了RSA长度限制,加密性能也好很多,实际项目里我遇到超过200字节的敏感字段,基本都是这个思路。

5. 跨端联调问题排查手册:那些报了无数次的错

5.1 填充模式不匹配导致的解密失败

这是前后端联调时最经典的问题。比如前端用的是Web Crypto API的RSA-OAEP,后端Java还在用默认的RSA/ECB/PKCS1Padding,解密时直接抛异常。反过来也一样。

我整理了一张对应表,前端和后端对齐时照着配:

前端实现后端Java对应配置
jsencrypt默认encrypt()RSA/ECB/PKCS1Padding
Web Crypto API RSA-OAEP + SHA-256RSA/ECB/OAEPWithSHA-256AndMGF1Padding
Web Crypto API RSA-OAEP + SHA-1RSA/ECB/OAEPWithSHA-1AndMGF1Padding
node-forge RSA-OAEP + SHA-256同上需要指定OAEP with SHA-256

注意OAEP模式下还要确认MGF1的摘要算法是否一致。有些实现在配置里只写了OAEP,但MGF1用的还是默认的SHA-1,两边算法对不上一样会解密失败。

排查手法也很简单:解密报错后,先别急着翻代码,前端确认一下自己用的填充模式是什么,后端确认一下Cipher.getInstance里的算法串是什么,八成问题在这一步就解决了。

5.2 公钥私钥格式不匹配

另一种高频报错是“公钥解析失败”“无效的密钥”。常见的场景是:某在线工具生成了一对RSA密钥,头行是-----BEGIN RSA PUBLIC KEY-----(PKCS#1格式),结果前端用Web Crypto API的spki导入时直接报错,因为importKey只认BEGIN PUBLIC KEY(SPKI)。

解决办法有两个:

  • 在Node环境里用node-forge或openssl命令转换成SPKI格式。
  • 或者改代码,用PKCS#1解析器处理(Web Crypto API原生不支持PKCS#1公钥,需要先转换)。

命令行转换最直接:

# 将PKCS#1公钥转为SPKI(BEGIN PUBLIC KEY) openssl rsa -RSAPublicKey_in -in rsa_pub.pem -out pub_spki.pem

私钥如果也不匹配,常见场景是PKCS#1私钥转PKCS#8:

# 将传统格式私钥转成PKCS#8(BEGIN PRIVATE KEY) openssl pkcs8 -topk8 -inform PEM -in private.pem -outform PEM -nocrypt

在写代码之前先把密钥格式统一,能省下一整天的联调时间。

5.3 同一明文每次加密结果不一样

这个问题经常让不熟悉RSA的同事虚惊一场:前端同一个密码,两次调用encrypt,产出的密文完全不同,是不是出bug了?

不是。这正是填充模式里引入随机数的正常效果。RSA填充会在加密前给明文加上随机序列,所以即使明文相同,经过填充后的块也不同,最终密文自然不同。

那后端怎么判断密码对不对?当然不是比对密文,而是解密后比对明文。如果后端逻辑里写的是“判断两次密文是否相等”,那才是真的需要改。

5.4 安全细节与注意事项

最后把几个实际操作中容易忽略的安全问题集中说一遍,都是踩过坑才记住的:

  • 私钥绝不能出现在前端代码、前端仓库、前端网络请求里。只要私钥出过浏览器,就当它已经泄露。
  • 密文和密钥不要进日志。很多人调试时顺手把请求参数打出来,生产环境忘了删,等于加密白做了。
  • 前端加密不能替代HTTPS。HTTPS加密的是传输信道,前端RSA加密是在应用层做了一层额外保护,两者是互补关系,不是替代关系。
  • 公钥的完整性也要保护。如果攻击者能篡改下发的公钥,把你手里的真公钥换成他自己的,那加密就失去了意义。公钥下发的通道要有鉴权和完整性校验。
  • 密钥要定期轮换。RSA密钥不像密码一样天天换,但一年甚至半年轮换一次是合理的运维习惯。设计接口时最好把密钥版本做成可配置的,轮换时不用发版。

我个人在实际操作中的体会是:jsencrypt适合快速做通一个联调,但正式项目里我更愿意用Web Crypto API加上严格的密钥管理,哪怕前期代码多一点,后续维护省心得多。还有一个长期受用的小习惯——在本地准备一个node-forge写的小脚本,专门做PEM格式转换和密钥生成,遇到联调格式问题的时候,30秒就能定位是PKCS#1还是SPKI,不用每次翻文档。RSA加密本身不是万能药,传输安全始终是一套体系,但把这套基础功打扎实,遇到实际问题时你会从容很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 10:30:33

大厂校招全解析:机考准备、面试攻防与避坑指南

每年一到三四月份&#xff0c;我的私信里就会被同一类问题塞满&#xff1a;“校招机考到底考什么&#xff0c;刷多少题才够&#xff1f;”“技术面为什么挂得莫名其妙&#xff1f;”“同样是投研发岗&#xff0c;为什么别人被捞了&#xff0c;我连简历筛选都没过&#xff1f;”…

作者头像 李华
网站建设 2026/10/22 10:30:23

Oracle数据库的“王者重负”:成本、复杂度与迁移决策全解析

1. 写在最前面&#xff1a;我对“王者”与“重负”的理解我入行那几年&#xff0c;Oracle 几乎就是高端数据库的代名词。谈到核心交易系统、金融账务、大型 ERP&#xff0c;大家默认就是“上 Oracle”&#xff0c;没有什么可讨论的。那会儿它确实是当之无愧的王者&#xff1a;功…

作者头像 李华
网站建设 2026/10/22 10:30:09

数位之和真题详解:从整除取余到循环累加,避开二级考试丢分坑

每次带备考班的同学刷题&#xff0c;碰到“数位之和”这道二级真题&#xff0c;我都能预见两种反应&#xff1a;刚入门的小朋友觉得太简单&#xff0c;三分钟写完提交&#xff0c;结果因为小细节扣了分&#xff1b;另一部分同学则是被“数位”两个字绕晕&#xff0c;上来就问“…

作者头像 李华
网站建设 2026/10/22 10:28:10

anydoc:Word/PPT/Excel/PDF统一转Markdown的文档转换方案

1. 办公文档格式割裂的真实困境1.1 为什么我们需要一个统一的文档转换方案但凡在职场里摸爬滚打过几年的人&#xff0c;大概率都经历过这样的场景&#xff1a;手头有一份几十页的Word技术方案&#xff0c;需要把里面的内容搬到内部知识库里&#xff1b;或者收到一份Excel数据报…

作者头像 李华
网站建设 2026/10/22 10:24:13

Spring MVC单元测试实战:MockMvc与Mockito高效测试Controller与Service层

1. 为什么要给 Spring MVC 写单元测试&#xff08;项目背后的核心思路&#xff09;先说结论&#xff1a;Spring MVC 的单元测试&#xff0c;难的不是写代码&#xff0c;而是搞清楚你到底在测什么。是测 Controller 的路由映射&#xff1f;还是测 Service 层的业务逻辑&#xff…

作者头像 李华
网站建设 2026/10/22 10:23:49

RoboMaster嵌入式视觉链路:从蓝色灯条检测到CV1串口通信

1. 项目概述&#xff1a;一条视觉链路的诞生&#xff0c;不是炫技&#xff0c;是工程落地的硬功夫RoboMaster 视觉招新考核&#xff0c;表面看是一场面向高校学生的选拔测试&#xff0c;实则是一次浓缩版的工业级视觉系统实战沙盘。它不考背诵公式&#xff0c;不考调参玄学&…

作者头像 李华