如果你在前端项目里搜过“加密插件”这类词,大概率会撞上jsencrypt.js。这玩意不新,但直到今天,很多系统的登录接口、敏感字段提交,用的都还是它。原因很简单:RSA 非对称加密里,在一堆可用方案中,它属于“开箱即用、不管老的还是新的浏览器都能跑”的那一类。我见过不少人第一次用它写登录加密,结果后端一解密就乱码,或者长一点的文本直接返回 false,然后愣是不知道问题出在哪。
这篇文章我不打算从源码逐行讲算法,而是以一个实际用过的过来人身份,把 jsencrypt 的选型理由、密钥格式、跨语言联调、长文本处理、以及那些文档里不会写的坑一次性聊清楚。适合刚接手含加密需求的前端项目、或者前后端都在摸索 RSA 联调的读者。
1. 为什么前端项目几乎都会用到 jsencrypt
1.1 它到底解决什么问题
前端加密最典型的场景是登录。一个账号密码输入框,用户点完登录,数据直接通过POST提交给后端。如果后端日志、网关、数据库里保存了明文的密码或身份证号,你可以想象一旦日志泄露会是什么后果。于是大家想到在浏览器端就把密码加密,让后端收到的是密文,后端再用私钥解开。
这时候 RSA 就被自然引出来了。RSA 是典型的非对称加密,加密用公钥,解密用私钥。公钥可以大大方方放在前端代码里,私钥留在后端服务器。别人拿到公钥也没用,因为只能加密、不能解密。这个特性让前端加密的“门槛”降低了很多,你不需要设计一套密钥同步机制,也不用担心中间人把加密方法逆向后偷走解密钥匙。
jsencrypt 解决的正是这个场景:它把 RSA 加密封装成了浏览器可用的 JavaScript 库,你只需要引入文件、设置公钥、调一个encrypt方法,就能把字符串加密成一段 Base64 密文。后端只要用标准 RSA 解密工具,就能还原出原文。它还顺带支持私钥签名、公钥验签,所以不仅在登录场景,在一些需要防篡改的接口报文里也经常出场。
1.2 RSA 密码学的入门理解
想把 jsencrypt 用明白,不需要啃完 RSA 的数学证明,但至少得理解两个核心概念:公钥加密、私钥解密,以及私钥签名、公钥验签。
你可以把 RSA 想成一个带锁的快递箱:锁是公钥,钥匙是私钥。任何人都能用这个锁把东西锁进箱子里(公钥加密),但只有拿着钥匙的人才能打开(私钥解密)。反过来,如果一个人手上有钥匙,他在箱子上贴了个自己的封印(私钥签名),其他人用锁就能确认这个封印确实是持钥匙的人贴的(公钥验签)。
这种设计解决了一个很关键的问题:不用提前在通信双方之间“同步密钥”。对称加密(比如 AES)需要双方都知道同一个密钥,传输密钥本身就有风险;RSA 则不同,公钥本来就是公开的,私钥只保存在服务器端,这样整个系统的密钥分发压力小了很多。jsencrypt 库里,encrypt方法对应公钥加密,decrypt方法对应私钥解密,sign和verify对应签名验签,思路非常直观。
1.3 为什么是 jsencrypt 而不是其他方案
前端做 RSA 加密,方案其实不少。原生 Web Crypto API 现在浏览器都支持,node-forge 功能更全面,crypto-js 也常被拿来用。但 jsencrypt 的优势在于三点:第一,老项目兼容性好,对低版本浏览器的支持比原生 API 省心;第二,接口极简,几行代码就能跑通,适合业务开发而不是学术研究;第三,社区存量极大,后端用什么语言解密都有现成资料,这点对跨团队联调特别重要。
如果你是全新项目,我建议顺便看看原生 Web Crypto API,毕竟浏览器原生性能好,也不怕依赖库出问题。但如果你的项目里已经有一堆老代码,或者你们后端长期用一段复制的 jsencrypt 解密逻辑,那最省事的办法就是继续在 jsencrypt 上做。毕竟对一个业务系统来说,稳定和团队熟悉度往往比框架先进重要得多。我也见过有人把 Web Crypto API 和 jsencrypt 混着用,结果公钥格式互相不认,调试到半夜才回退。所以我的建议是:团队统一一种方案,不要左右横跳。
2. 密钥生成与格式,第一个大坑
2.1 用 openssl 一分钟生成密钥对
先把工具链准备好。如果你电脑上有 OpenSSL,直接用命令生成即可:
openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem第一条命令生成 2048 位的 RSA 私钥,第二条命令从私钥里提取公钥。最终你会得到两个文件:private.pem和public.pem。用文本编辑器打开,它们长得像下面这样:
-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcw... -----END PRIVATE KEY----------BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----很多初学者会把这整段带BEGIN和END的字符串直接贴进前端代码,这没问题。jsencrypt 的setPublicKey方法能识别这里面的 Base64 内容并解析出公钥。但踩坑的地方也经常出现在这里:不同工具生成的公钥头部可能不一样,而后端拿到的私钥格式也可能存在微妙的解析差异。
2.2 公钥格式差异
稍微留意过 PEM 文件的人会发现,公钥有两种常见开头:
-----BEGIN PUBLIC KEY-----:这是 PKCS#8 格式-----BEGIN RSA PUBLIC KEY-----:这是 PKCS#1 格式
OpenSSL 1.1 以上版本用rsa -pubout生成的默认是 PKCS#8 格式。但有些老系统、Java 代码、或者部分在线工具生成的可能是 PKCS#1 格式。jsencrypt 新版本对这两种格式做了兼容,理论上都能解析。但我依然建议统一使用 PKCS#8 格式,因为它的兼容面更广,各种语言的标准库默认支持的也是这一种。
如果你手里已经有了一个 PKCS#1 格式的公钥,拿它加密一直失败,可以用下面命令切换:
openssl rsa -RSAPublicKey_in -in rsa_pub_pkcs1.pem -pubout -out public_pkcs8.pem私钥这边也要注意。BEGIN RSA PRIVATE KEY是传统 PKCS#1 私钥,BEGIN PRIVATE KEY是 PKCS#8 私钥。jsencrypt 对这两种也基本都支持,但后端代码如果用 Java 标准库,是能直接吃 PKCS#8 的;如果拿到的是 PKCS#1,需要转一下:
openssl pkcs8 -topk8 -nocrypt -in private_pkcs1.pem -out private_pkcs8.pem2.3 密钥长度的取舍
密钥长度直接影响加密能力。jsencrypt 默认建议 2048 位,这是目前在安全性和性能之间比较平衡的选择。1024 位的密钥已经被认为不够安全,建议直接放弃;4096 位虽然更难破解,但密钥更长、运算更慢,在浏览器端有明显的耗时增长。
还有一点很实用:RSA 加密的密文长度和密钥长度是强相关的。2048 位密钥加密后的密文 Base64 字符串长度基本固定在 344 个字符左右。如果你发现加密结果长度是动态变化的,多半不是标准 RSA,而是做了什么额外处理。这个特征在后端判断“是不是标准 RSA”时很有用。
每个长度能加密的明文上限,也跟密钥位数直接挂钩,后面我会专门讲。这里先记住结论:实际项目用 2048 位就够了,并且不要随便把私钥传到前端代码里,哪怕只是测试也不建议。公钥泄露没问题,私钥一旦落到别人手里,整个加密就成了摆设。
3. 加密、解密、签名的完整实操
3.1 引入与初始化
jsencrypt 的引入方式很简单,可以直接在 HTML 里引脚本:
<script src="https://cdn.jsdelivr.net/npm/jsencrypt@3.3.2/bin/jsencrypt.min.js"></script>也可以走 npm:
npm install jsencrypt前端代码里最基础的用法是这样:
import JSEncrypt from 'jsencrypt'; const publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----`; const crypt = new JSEncrypt(); crypt.setPublicKey(publicKey); const encrypted = crypt.encrypt('hello jsencrypt'); console.log(encrypted); // Base64 密文如果你喜欢一次性构造完,也可以这样写:
const crypt = new JSEncrypt({ default_key: publicKey }); const encrypted = crypt.encrypt('hello');注意encrypt方法返回的是 Base64 字符串。如果失败,返回的可能是false或者空字符串,false在 JSON 序列化后甚至会变成"false"这种字符串,后端收到会非常莫名其妙。所以调用之后最好判断一下。
3.2 后端联调:跨语言解密不踩雷
前端加密不是自己加密完事,后端得能解密才叫联调成功。这里最容易出的问题有三个:密文编码、填充方式、密钥格式。
jsencrypt 加密后的密文默认是 Base64 编码,后端拿到后要先Base64Decode再解密,而不是直接拿字符串丢给 RSA 解密函数。如果后端犯了这个错,一定会报“数据格式不正确”。
填充方式上,jsencrypt 使用标准的 RSA_PKCS1_PADDING,也就是 PKCS#1 v1.5 填充。后端的解密库设置的时候必须指定成一样的,否则解密出来的数据会不对。拿 Java 来说,标准写法是Cipher.getInstance("RSA/ECB/PKCS1Padding"),千万不能写成OAEPPadding。Node.js 里则要明确设置:
const crypto = require('crypto'); const fs = require('fs'); const privateKey = fs.readFileSync('./private.pem', 'utf8'); const ciphertext = '前端传过来的Base64密文'; const decrypted = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(ciphertext, 'base64') ); console.log(decrypted.toString('utf8')); // 原文Python 后端用rsa库解密时,同样要保证padding是 PKCS1v15,不能用 OAEP。很多报错“RSA_padding_check_PKCS1_type_2 error”,本质就是填充方式不统一。
3.3 不只是加密,还能签名验签
RSA 不止能做加密,还能做签名。签名的作用是证明“这段数据确实是拥有私钥的一方发出来的”,同时保证数据没有被篡改。在业务里,最常见的用法是:后端签名返回数据,前端验签确认来源可信;或者前端给关键参数签名,后端验证完整性。
jsencrypt 的签名方法长这样:
// 引入 CryptoJS 用于摘要算法 const CryptoJS = require('crypto-js'); const privateKey = `-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----`; const signer = new JSEncrypt(); signer.setPrivateKey(privateKey); const signature = signer.sign('待签名内容', CryptoJS.SHA256, 'sha256');验签对应:
const verifier = new JSEncrypt(); verifier.setPublicKey(publicKey); const isValid = verifier.verify('待签名内容', signature, CryptoJS.SHA256);这里有一个容易被忽略的点:签名前要保证前后两端对“待签名内容”的定义完全一致,包括拼接顺序、字段名、换行符,多一个空格验签都会失败。我见过一个项目,前端签名时用的是对象序列化后的字符串,后端验签时又自己重新拼了一遍字段,结果总是对不上,最后才发现是字段顺序问题。签名这种事,约定清楚比什么都重要。
4. 两个高频翻车点:长文本和中文乱码
4.1 一次最多能加密多少字
很多人第一次用 jsencrypt,加密一个很短的密码没问题,一加密长文本,encrypt直接返回 false,或者返回一段解密后完全不认识的内容。原因很简单:RSA 对明文长度有严格限制。
对于 PKCS#1 v1.5 填充,理论最大明文长度为:
maxBytes = keySize / 8 - 112048 位密钥,也就是 2048 / 8 - 11 = 245 字节。中文在 UTF-8 编码下通常占 3 字节,所以大约只能加密 81 个汉字。如果明文里还有英文、数字、符号,还要具体按字节算。超过这个长度,RSA 就无能为力了。
所以在设计接口时,先想清楚这个字段要传多长的数据。登录密码那几十个字节完全没压力;但如果直接拿它去加密一笔几百字的备注文本,妥妥会踩坑。
4.2 长文本解决方案:AES+RSA 混合加密
先看一段用于生成随机 AES 密钥并加密的代码结构:
import CryptoJS from 'crypto-js'; import JSEncrypt from 'jsencrypt'; // 1. 生成随机 AES 密钥(16 字节 -> 128 位) const aesKey = CryptoJS.lib.WordArray.random(16).toString(); // 比如 "a1b2c3d4e5f6a7b8" // 2. 用 AES 加密长文本 const encryptedData = CryptoJS.AES.encrypt(longText, aesKey, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }).toString(); // 3. 用 RSA 加密 AES 密钥 const rsa = new JSEncrypt(); rsa.setPublicKey(publicKey); const encryptedKey = rsa.encrypt(aesKey); // 4. 同时传给后端 const payload = { encryptedData: encryptedData, encryptedKey: encryptedKey };后端 Node.js 解密:
const crypto = require('crypto'); const privateKey = fs.readFileSync('./private.pem', 'utf8'); // 1. RSA 解密出 AES 密钥 const aesKey = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(payload.encryptedKey, 'base64') ).toString('utf8'); // 2. AES 解密出真正的数据 const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null); decipher.setAutoPadding(true); let decrypted = Buffer.concat([ decipher.update(Buffer.from(payload.encryptedData, 'base64')), decipher.final() ]).toString('utf8');这里为了示例清晰使用了 ECB 模式,但它不是最推荐的生产模式,优先级上 CBC/GCM 会更安全。不过无论如何,思路是一致的:RSA 只负责加密一把临时生成的对称密钥,真正的大数据量交给 AES。这样既绕开了 RSA 的长度限制,又不需要在后端持久化任何对称密钥。每次请求生成新的 AES 密钥,安全性也说得通。
如果你不想自己管理 AES 密钥,还有另一个思路:把长文本在前端分片,每片用同一个公钥加密,后端按顺序解。但分片加密会让密文长度暴涨、还要协调切片顺序和边界,相比 AES+RSA 混合方案并不划算。我在实际项目里基本只推荐混合加密。
4.3 中文乱码和兼容性问题
中文乱码是 jsencrypt 老版本的一大痛点。老版本加密时对中文字符串的处理存在编码问题,加密完传给后端,解密出来是一团乱码,或者后端解密直接报错。解决方法是升级到 jsencrypt 3.x 版本,并且前后端都统一用 UTF-8 编码。
如果你无法升级版本,也可以在加密前手动转一次编码:
const encrypted = crypt.encrypt(unescape(encodeURIComponent(str)));后端再逆操作:decodeURIComponent(escape(decrypted))。这是一种兼容老环境的土办法,能跑,但不优雅。有条件还是直接升级依赖,新版对 Unicode 的支持已经好很多了。
另外一个常见兼容坑是公钥字符串里的换行。复制 PEM 公钥时,如果格式化工具把换行去掉了,比如变成一整行纯 Base64,jsencrypt 可能解析失败。稳妥的办法是保留原始 PEM 格式,让代码里直接使用带换行符的字符串模板。如果你从环境变量里读公钥,记得检查字符串里有没有\n的转义问题。
5. 常见问题速查与排查实录
5.1 一张表解决 90% 的报错
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 加密返回 false 或为空 | 公钥格式不对、密钥与长度不匹配、明文过长 | 检查公钥首尾标记,减少明文长度,改用混合加密方案 |
| 密文长度莫名其妙变化 | 使用了非标准 RSA 实现 | 确认加密方法是否来自 jsencrypt,查看密文是否为固定长度 |
| 后端解密报 PKCS1_type_2 error | 填充方式不一致、密文被截断、公私钥不匹配 | 统一使用 RSA_PKCS1_PADDING,检查密文完整性,核对密钥对 |
| 中文解密乱码 | 版本过老、前后端编码不一致 | 升级 jsencrypt 3.x,统一 UTF-8,必要时用转码兼容 |
| 签名验证失败 | 待签名内容格式不一致,签名参数顺序不同 | 前后端固化签名串格式,统一字段顺序和拼接规则 |
| setPublicKey 后仍然加密失败 | 公钥字符串被去掉换行或丢失部分字符 | 检查 PEM 字符串完整性,保留换行符,不要截取过长前缀 |
这些内容看起来是零散问题,实际上全都指向一个核心:RSA 是一个格式极其敏感的工具,参与方少了任何一个约定,都会出岔子。不要觉得后端能自适应,前后端必须提前对好密钥格式、编码和填充方式。
5.2 一次线上问题的定位过程
有一个项目我记得很清楚,前端用 jsencrypt 加密登录密码,后端是 Java 接口,上线前联调一切正常,上线后用户反馈“某些账号登录报错”。我打开页面一看,encrypt返回了 false,说明问题出在前端。
排查步骤大致如下:
- 在控制台手动调用一次
crypt.setPublicKey(publicKey)和crypt.encrypt('test'),发现仍然是 false,说明不是输入数据问题。 - 打印 publicKey 变量,发现字符串开头是
-----BEGIN PUBLIC KEY-----,但结尾被截断成了-----END PUBLI,明显是配置中心下发密钥时把换行符丢了,导致字符串被截断。 - 修复配置中心转义,把
\n改成真实换行,再测,加密恢复正常。
这个问题的教训不是 jsencrypt 自身,而是密钥在系统间流转的过程中容易被“熵减”。公钥从后端到配置中心、再到前端环境变量,中间任何一步在换行、缩进、转义上做了文章,加密就挂。以后再遇到加密失败,第一件事就是打印公钥字符串,确认它从头到尾完整无损。
6. 顺带聊聊“猪圈密码”的热点
最近“猪圈密码在线加密解密”这个词搜的人不少,正好跟加密解密话题高度相关,我也简单说两句。
猪圈密码(Pigpen Cipher)是一种非常古老的替换密码,核心做法是把字母映射到一组网格和交叉符号里。比如 A 映射成┌┐这种格子形状,I 就是中间的格子,J-R 放进 X 形图案里,S-Z 放进十字形图案里。在线工具网站上,你输入一段英文,它马上能给你输出一堆像“小方块”“拐角线”组合出来的符号,看着很神秘,其实就是一张映射表在来回替换。
这类密码和 RSA 有本质区别:替换密码的密钥空间很小,几十种可能就枚举完了,而且英文文本里字母频率分布明显,哪怕没有密钥,靠统计也能猜个七八成。所以猪圈密码只能用在游戏谜题、寻宝活动、桌面彩蛋这种娱乐场景,没有任何真实的保密能力。如果哪天你在项目里看到有人用这种逻辑做安全校验,还是劝他尽早换掉。
我倒是觉得这类古典密码在技术上有个潜在用途:做前端彩蛋或者活动文案的“隐藏关卡”。比如在页面上放一段看似乱码的符号,用户解码成功后获得一个优惠码,这种轻量玩法不需要引入重量级加密,反而能提升趣味性。现代安全场景该用 RSA、AES 还是用它们,没必要用古典密码硬扛。
总结的话我不写了,最后说点我个人的习惯。前端加密做久了,我现在对 RSA 的态度是:它有用,但别神化。它解决的是“传输过程中不裸奔”的问题,替代不了 HTTPS,也不能弥补后端的权限漏洞。我一般把公钥放在前端一份,私钥只在后端保留,所有涉及签名的内容前后端严格统一串格式。能用 AES 加密大数据量,就别死磕 RSA 的长度。每次接新项目,先把密钥格式和填充方式定好,再开写代码,可以省一整个加班的调试时间。