简介:面向Web前后端开发者的RSA加解密实战源码包,针对浏览器环境与Java服务端之间敏感数据传输的安全需求,提供一套可直接运行的jsencrypt+Java RSA解密示例。资源共4个文件,涵盖前端加密所需的jsencrypt.min.js、jquery.min.js、演示交互页面index.html,以及后端负责私钥解密与Base64处理的RSAUtils.java工具类;压缩包仅54KB,结构轻量,便于快速移植到现有登录或表单提交场景。目前已有3166人学习,适合刚接触非对称加密的前端工程师或全栈开发者。通过阅读源码,可以理清RSA公钥加密、私钥解密的核心流程,掌握JSEncrypt对象初始化与公钥设置方法、Java端PKCS8私钥解析及Cipher解密调用方式,同时理解PEM格式密钥与Base64明文/密文的配合要点,为项目中的密码、身份证号等敏感字段加密提供完整实现参考。
1. 为什么前端要用 jsencrypt 加密,后端要用 Java RSA 解密
打开一套内部系统的登录页,用浏览器开发者工具抓一下请求,一眼就能看到密码明文躺在请求体里。很多团队以为上了 HTTPS 就万事大吉,但内网系统、测试环境、网关日志、运维抓包,任何一个环节都可能把请求体完整暴露出去。jsencrypt 加密 + Java RSA 解密这套组合,解决的就是“浏览器这一侧先把明文变成只有后端私钥能解开的密文”这个问题。
方案本身不复杂:前端只持有公钥,用 jsencrypt 对密码、手机号这类短字段做 RSA 加密,得到一段 Base64 密文;后端用 Java 标准库里的 Cipher 配合私钥解密还原。适合任何 Java 后端 + 浏览器前端的登录、支付、敏感字段回传场景,不需要引入重量级密码学中间件。接下来我把密钥生成、前端加密、后端解密的完整源码链路拆开讲,会把踩过的坑一并说清。
2. 先搞懂这套加解密链路的三个关键件:密钥格式、块长度与填充模式
把链路在脑子里过一遍:前端持有 RSA 公钥,用 jsencrypt 把明文变成 Base64 字符串;后端持有 RSA 私钥,用 Java 的 Cipher 把密文还原成 UTF-8 字符串。看起来就两步,实际翻车点几乎全集中在密钥格式和长度限制上。
2.1 密钥格式:PEM 头尾、PKCS#1 与 PKCS#8 的取舍
RSA 私钥在存储和传输时常见两种编码。PKCS#1 的私钥文件头是-----BEGIN RSA PRIVATE KEY-----,PKCS#8 是-----BEGIN PRIVATE KEY-----。公钥则通常用 SubjectPublicKeyInfo 编码,文件头是-----BEGIN PUBLIC KEY-----,前端 jsencrypt 认这种格式。
Java 原生的PKCS8EncodedKeySpec只认 PKCS#8 私钥,不信 PKCS#1。你用openssl genrsa默认生成的是 PKCS#1,直接拿来喂给 Java 会抛InvalidKeySpecException。这不是玄学,是格式没对上。我一般统一到 PKCS#8,公钥统一用带PUBLIC KEY头的那份。
| 密钥类型 | PEM 文件头 | Java 原生加载 | jsencrypt 识别 |
|---|---|---|---|
| 公钥 | BEGIN PUBLIC KEY | 支持 | 支持 |
| 私钥 PKCS#1 | BEGIN RSA PRIVATE KEY | 不支持,需转换 | 不涉及 |
| 私钥 PKCS#8 | BEGIN PRIVATE KEY | 支持 | 不涉及 |
jsencrypt 的setPublicKey既接受带头尾的完整 PEM 字符串,也接受去掉头尾后的纯 Base64,两种写法后面会各给一个示例。私钥始终只留在后端,前端不接触私钥。
提示:私钥一旦泄露,相当于整套加密失效。别把私钥写进前端代码、Git 仓库或日志。
2.2 块长度:为什么 jsencrypt 加密长文本会失败
RSA 不能一次加密任意长的数据。2048 位密钥时,加密块上限是 2048 / 8 = 256 字节,PKCS#1 v1.5 填充固定占 11 字节,所以单次最多加密 245 字节明文。1024 位密钥则是 1024 / 8 - 11 = 117 字节。
jsencrypt 原版的encrypt()方法只处理单块,超过上限就失败或返回 false。我见过有人拿它加密一整个 JSON 对象,结果密文始终生成不出来,反复排查后才发现是长度超限。正确的做法是只加密密码、手机号、身份证号这类短字段,或者做前后端配合的分段加密。分段方案放在最后一章讲,先记住:短字段不要超过 245 字节。
2.3 生成一套可用密钥对:openssl 与 Java 两种方式
用 openssl 生成并转换成 Java 能读的格式,这是最常用的做法:
openssl genrsa -out private_pkcs1.pem 2048 openssl pkcs8 -topk8 -nocrypt -in private_pkcs1.pem -out private_pkcs8.pem openssl rsa -in private_pkcs1.pem -pubout -out public.pem第一条命令生成 PKCS#1 私钥,长度 2048 位;第二条把它转成 PKCS#8,-nocrypt表示不额外加密私钥文件,方便 Java 直接加载;第三条从私钥导出公钥。转换后private_pkcs8.pem给 Java 用,public.pem给前端。模长我建议直接用 2048,1024 位在多数安全策略里已经过不了审,而且加密上限只有 117 字节,更容易触发长度问题。
如果不方便用命令,Java 自己也能生成密钥对:
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); KeyPair keyPair = generator.generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); String publicKeyBase64 = Base64.getEncoder().encodeToString(publicKey.getEncoded()); String privateKeyBase64 = Base64.getEncoder().encodeToString(privateKey.getEncoded());getEncoded()返回的就是 PKCS#8 标准的私钥字节,Java 自己生成自己解,最不容易出错。生产环境里私钥我会放到配置中心或环境变量,读取出来先 trim 再去掉换行,避免 PEM 字符串里残留空白字符导致解码失败。
3. 前端用 jsencrypt 加密:最小可运行代码与参数说明
3.1 初始化与设置公钥的两种写法
引入 jsencrypt 后,常规写法是new一个实例再setPublicKey。公钥可以直接传带 PEM 头的完整字符串:
import JSEncrypt from 'jsencrypt' const encryptor = new JSEncrypt() encryptor.setPublicKey(`-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----`) const ciphertext = encryptor.encrypt('loginPassword123')如果后端接口返回的是纯 Base64,省去头尾同样合法:
const encryptor = new JSEncrypt() encryptor.setPublicKey('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...') const ciphertext = encryptor.encrypt('loginPassword123')第二种写法省掉了后端处理 PEM 头的步骤,我接口里返回给前端的公钥字段就是纯 Base64。encrypt()返回 false 表示加密失败,多数情况是公钥换行没处理好或明文长度超限。如果是没用构建工具的老页面,直接script标签引入 jsencrypt 的压缩文件,全局变量是window.JSEncrypt,用法不变。
3.2 把加密结果组装进请求体
实际项目里我不会加密整个表单,只加密敏感字段。用原生 fetch 写一个登录示例:
async function login(username, plainPassword, publicKey) { const encryptor = new JSEncrypt() encryptor.setPublicKey(publicKey) const encryptedPassword = encryptor.encrypt(plainPassword) if (!encryptedPassword) { throw new Error('密码加密失败,请检查公钥与明文长度') } const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, encryptedPassword: encryptedPassword }) }) return response.json() }这个函数里用户名可以明文传,密码密文是 Base64 字符串,放在 JSON body 里不会丢字符。需要注意 Base64 包含+、/、=这些符号,如果走 GET 拼 URL 必须encodeURIComponent,POST 放 body 则不用。前端加密用的是 PKCS#1 v1.5 填充,这就是为什么后端解密时必须用同一个填充模式,不能一边是 PKCS1Padding 一边是 OAEP。
4. 后端用 Java RSA 解密:接口接收与私钥匹配的完整实现
4.1 从 PEM 读取私钥:统一处理 PKCS#8 的加载方法
后端拿到的是 PKCS#8 PEM 私钥。常见做法是把 PEM 内容配置到 application.yml 或环境变量,读取后去掉头尾和空白字符,再 Base64 解码成字节数组:
public static PrivateKey loadPrivateKey(String privateKeyPem) throws Exception { String content = privateKeyPem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(content); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePrivate(spec); }去空白用replaceAll("\\s", "")而不是只替换空格,因为 PEM 复制到配置文件里时可能带换行和制表符。如果私钥头是RSA PRIVATE KEY,说明拿到的是 PKCS#1,这段代码会抛InvalidKeySpecException,先把私钥按上一章的 openssl 命令转一遍再加载。
4.2 解密接口:Cipher 初始化、Base64 解码与 UTF-8 还原
解密的核心方法如下,代码里的注释标出了每一步不能省的动作:
public static String decrypt(String ciphertext, String privateKeyPem) throws Exception { PrivateKey privateKey = loadPrivateKey(privateKeyPem); Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(decryptedBytes, StandardCharsets.UTF_8); }Cipher.getInstance("RSA/ECB/PKCS1Padding")把算法、模式、填充写完整。虽然只写"RSA"时默认值正好是这一串,但显式写清楚能防止未来有人改动默认实现导致线上解密失败。Base64 解码必须放在doFinal之前,前端传来的 Base64 字符串不能直接转 byte[]。最后用 UTF-8 还原明文,如果这里用系统默认字符集,中文很容易变成乱码。
Controller 层示例:
@PostMapping("/api/login") public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest request) { try { String password = RSAUtil.decrypt(request.getEncryptedPassword(), privateKeyPem); // 校验用户名密码,返回结果 return ResponseEntity.ok(Map.of("code", 0, "msg", "登录成功")); } catch (BadPaddingException e) { return ResponseEntity.status(400).body(Map.of("code", 401, "msg", "密文校验失败")); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("code", 500, "msg", "解密异常")); } }BadPaddingException单独 catch 出来,它基本就是密钥不匹配或密文被改动的信号。接口响应不要返回堆栈细节给前端,日志里记录完整异常供排查即可。另外,encryptedPassword这个字段名在前后端要完全一致,我见过前端传password、后端收encryptedPassword,结果密文一直为空的情况。
5. 加解密实战避坑:现象、原因、解决
5.1 前端生成的密文后端解不开:密钥、编码与传输三层检查
现象:前端加密正常,后端doFinal抛BadPaddingException,或者解出来是一串看不懂的乱码。
原因一,公钥和私钥不是同一对。常见于本地调试时前后端各生成了一套密钥,合起来自然不匹配。解决起来很简单,确认前端配置的公钥与后端持有的私钥来自同一个 KeyPair。
原因二,Base64 在传输过程中被改写。GET 方式拼 URL 时+会被解释成空格,/可能被转义,后端解码得到的字节已经变了。解决:POST 请求把密文放在 body 里,或前端先encodeURIComponent,后端先解码再 Base64。
原因三,明文在加密前带了不可见字符。前端trim()一下再加密,后端拿到密文后也不要再做多余的去空格处理,避免两端各 trim 一次导致密文不一致。
5.2 密钥加载失败与中文乱码的排查路径
现象:Java 加载私钥报InvalidKeySpecException;前端加密的中文解出来是乱码。
原因:私钥是 PKCS#1 格式,Java 原生只认 PKCS#8。乱码则是前端 jsencrypt 把字符串按 UTF-8 编码成字节,而 Java 端用了 GBK 或系统默认字符集去还原。
解决:私钥统一转成 PKCS#8,用上一章的转换命令一次到位;乱码统一在 Java 端写new String(decryptedBytes, StandardCharsets.UTF_8),前端页面字符集也固定 UTF-8。不要在加密前手动做escape或encodeURIComponent,否则解密后还要手动还原一次,很容易做重。
5.3 换库、换填充、换密钥长度时的兼容坑
现象:从网上复制一段示例代码,前端换了 jsencrypt 版本,后端解密突然失败。
原因:jsencrypt 默认走 PKCS#1 v1.5 填充,但网上部分改版代码把加密逻辑改成了 OAEP,后端Cipher模式没有同步改。另一个常见场景是 2048 位密钥下前端恰好加密了一段超过 245 字节的文本,超限失败。
解决:两端锁死同一个加密策略,密钥长度和填充模式写进接口文档。Java 端用"RSA/ECB/PKCS1Padding",jsencrypt 保持默认。升级库版本前,先用固定的测试字符串跑一遍完整加解密链路,不要直接上生产。
5.4 加密接口突然变慢或密文异常长
现象:接口耗时从几十毫秒涨到几百毫秒,后端接收到的密文长度远超 256 字节。
原因:前端把整个 JSON 塞进 RSA 加密,或者同一份数据被反复加密。RSA 加密速度本来就不快,数据一长耗时线性上涨。解决:确认加密内容确实是短敏感字段;如果一定要加密长数据,先压缩再分段,或者改用“对称加密正文 + RSA 加密对称密钥”的方案。后者后端先解出临时密钥,再用它解正文,这才是大数据量下的正确姿势。
6. 进阶:长文本分段加密、OAEP 升级与密钥轮换技巧
6.1 让 jsencrypt 支持分段加密长文本
jsencrypt 原版不支持分段,可以自己补一个分段逻辑。核心是按 UTF-8 字节宽度切分明文,保证每一段不超过 245 字节,且不把多字节字符切在中间:
function utf8ByteLength(char) { const code = char.codePointAt(0) if (code <= 0x7f) return 1 if (code <= 0x7ff) return 2 if (code <= 0xffff) return 3 return 4 } export function rsaEncryptLong(plainText, publicKey, maxBytes = 240) { const encryptor = new JSEncrypt() encryptor.setPublicKey(publicKey) const chunks = [] let current = '' let currentBytes = 0 for (const char of plainText) { const charBytes = utf8ByteLength(char) if (currentBytes + charBytes > maxBytes) { const encrypted = encryptor.encrypt(current) if (!encrypted) throw new Error('分段加密失败') chunks.push(encrypted) current = char currentBytes = charBytes } else { current += char currentBytes += charBytes } } if (current) { const encrypted = encryptor.encrypt(current) if (!encrypted) throw new Error('分段加密失败') chunks.push(encrypted) } return chunks.join('|') }maxBytes设 240 而不是 245,是给填充和边界留余量。for...of按字符码点迭代,codePointAt能正确处理中文和 emoji。后端按|拆开逐段解密再拼接:
public static String decryptLong(String ciphertextContent, String privateKeyPem) throws Exception { String[] parts = ciphertextContent.split("\\|"); StringJoiner joiner = new StringJoiner(""); for (String part : parts) { joiner.add(decrypt(part, privateKeyPem)); } return joiner.toString(); }分段逻辑只在确实需要加密长文本时才加,登录密码这类短字段直接用单段方案即可,多一层分段就多一分出错空间。
6.2 OAEP 升级与密钥轮换:安全要求高时怎么改
如果安全规范要求用 OAEP,jsencrypt 原生不直接支持,前端要换支持 OAEP 的加密库,后端改成Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")。这个改动涉及两端,必须同步发版,否则新密文用旧逻辑解不出来。密钥轮换的稳妥做法是接口里带一个keyId,前端提交密文时把keyId一起带上,后端维护keyId到私钥的映射,按keyId选私钥解密。这样旧密钥到期时可以平滑切换,不用停机。
我自己的习惯是私钥一律放配置中心,发布时只改配置不改代码,而且每套环境用独立的密钥对,避免测试环境泄露拖累生产。这套“前端 jsencrypt 加密、后端 Java RSA 解密”的链路,把密钥格式、填充模式和长度限制这三件事钉死,后面基本不会再翻车。我在项目里吃过几次密钥格式和填充不匹配的亏,后来把全套逻辑沉淀成模板,团队照着做就再没出过事。希望帮到你。
本文还有配套的精品资源,点击获取