简介:一套前后端配合使用的RSA加密解密示例代码,面向Web开发者,解决浏览器端敏感信息在提交前加密、服务端安全还原的实际问题。示例基于非对称加密体系,前端借助jsencrypt库获取公钥并完成加密,后端通过Java内置安全组件解析私钥后执行解密,覆盖从密钥解析、Base64编码处理到Cipher填充方式的完整调用链,适合正在实现登录、注册、支付验签等数据保护场景的读者参考。压缩包共4个文件,整体54KB,包含两个JavaScript文件、一个HTML页面和一个Java类文件;jsencrypt和jquery脚本负责前端加密逻辑与页面交互,HTML提供演示输入界面,RSAUtils负责接收密文并还原明文,结构清晰,便于直接查看或修改测试。目前已有3166人浏览学习。通过这套示例可以快速得到一个可运行的加解密对照实现,直观看到公钥加密、私钥解密和两端参数保持一致时的完整效果,也能作为后续对接真实后台接口、替换正式密钥的基础模板。
1. 前后端RSA加密落地:不是所有“加密了”都能被Java解出来
很多项目在联调阶段都会遇到同一个问题:前端用 jsencrypt 加密后把密文发给后端,Java 端一执行doFinal就抛BadPaddingException,更早的可能在加载私钥阶段就报InvalidKeySpecException。这套“前端 jsencrypt、后端 RSA 解密”的源码,核心就是解决从密钥生成、格式转换、Base64 传输到填充模式对齐这一整条链路。它适合正在做 Web 登录、支付回调参数加密或者接口防抓包的前后端开发,也适合想把 RSA 加密从“能跑通”做到“知道为什么能跑通”的从业者。我拆过这套资源,下面按我实际复现的顺序把每一层说清楚。
2. 密钥对生成与格式选型:PKCS#1、PKCS#8与Base64这三关先过掉
2.1 用OpenSSL生成RSA密钥对:私钥格式决定后端能不能加载
RSA 非对称加密的密钥对有两种常见来源:一是代码里用KeyPairGenerator直接生成,二是用 OpenSSL 命令行生成 PEM 文件。项目中拿到手的基本都是后者,因为密钥要交给前后端两个团队,PEM 文件比内存对象更好分发。
用 OpenSSL 生成密钥对的标准做法如下:
# 生成2048位RSA私钥,OpenSSL 3.0 默认输出 PKCS#8 格式 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private_key.pem # 从私钥中导出对应公钥 openssl rsa -in private_key.pem -pubout -out public_key.pem第一行命令生成的是 PKCS#8 格式的私钥,文件开头是-----BEGIN PRIVATE KEY-----。第二行从私钥导出公钥,公钥格式固定为 X.509 SubjectPublicKeyInfo,文件开头是-----BEGIN PUBLIC KEY-----。
这里有个很容易翻车的细节:很多旧教程用openssl genrsa -out private.pem 2048生成,这种命令输出的是 PKCS#1 格式,文件开头是-----BEGIN RSA PRIVATE KEY-----。PKCS#1 和 PKCS#8 都能做 RSA 解密,但 Java 的PKCS8EncodedKeySpec只认 PKCS#8。所以如果你拿到手的资源里私钥是BEGIN RSA PRIVATE KEY开头,后端直接用PKCS8EncodedKeySpec加载必然失败。解决方案是用openssl pkcs8 -topk8 -nocrypt -in old_private.pem -out new_private.pem转换一次。
注意:私钥文件绝不能出现在前端代码仓库里,前端只需要公钥。
2.2 公钥给前端、私钥给后端:格式与编码约定
密钥分发时,前后端约定的不是密钥内容,而是“传输格式”。前端 jsencrypt 的setPublicKey方法既能接收带-----BEGIN PUBLIC KEY-----头尾的完整 PEM,也能接收去掉头尾、去掉换行的纯 Base64 字符串。后端 Java 读取公钥用X509EncodedKeySpec,读取私钥用PKCS8EncodedKeySpec,这两者需要的 Base64 内容恰好都对应“去掉头尾标记后的那部分”。
我建议在项目里做一层统一约定:后端下发公钥时,直接下发去掉头尾、去掉换行的纯 Base64 字符串;前端收到后原样塞给jsencrypt。密文传输同理,前端把 jsencrypt 加密后输出的 Base64 字符串直接放到请求体 JSON 字段里,不要拼到 URL 查询参数上,避免+号被解析成空格导致密文损坏。
这套源码里最常见的用法,其实就是以下三个步骤的串联:前端用公钥字符串加密明文,得到一个 Base64 密文;后端用私钥字符串解密,还原明文;中间不经过任何二次编码。一旦有人在这条链路上额外加了 URLEncoder、JSON 转义或者去掉了 Base64 里的某些字符,就会踩到后面第 5 章的坑。
2.3 密钥长度与填充算法的选型理由
这套资源默认使用 2048 位 RSA、RSA/ECB/PKCS1Padding。为什么不是 1024?因为 1024 位 RSA 在目前的算力下已经不够安全,很多安全评审直接不允许。为什么不是 4096?一方面是 jsencrypt 加密性能下降明显,另一方面是密文长度随之增加,前端传参和日志存储都不划算。2048 是安全性和性能的平衡点。
填充算法必须明确:RSA 加密本身支持的填充模式包括PKCS1Padding、OAEPPadding和NoPadding。jsencrypt 底层用的是 PKCS#1 v1.5 填充,对应 Java 端的Cipher.getInstance("RSA/ECB/PKCS1Padding")。如果你在 Java 端写成OAEPPadding,前端加密、后端解密时会直接抛BadPaddingException。这一点在第三节、第四节会分别展开。
3. 前端jsencrypt加密:公钥格式、字符编码与长度限制
3.1 JSEncrypt实例化与setPublicKey:两种传法都能用但要防换行
jsencrypt 的 API 非常简单,核心就两个方法:setPublicKey和encrypt。但简单不等于不会出错,我见过最多的问题出在公钥字符串本身。
import JSEncrypt from 'jsencrypt' export function encryptByRSA(plainText, publicKey) { const encryptor = new JSEncrypt() // 去掉头尾标记和所有空白字符,避免换行符影响内部解析 const cleanKey = publicKey .replace(/-----BEGIN PUBLIC KEY-----/, '') .replace(/-----END PUBLIC KEY-----/, '') .replace(/\s+/g, '') encryptor.setPublicKey(cleanKey) const encrypted = encryptor.encrypt(plainText) if (encrypted === false) { throw new Error('RSA加密失败,请检查公钥格式或明文长度') } return encrypted }上面代码里的cleanKey处理很关键。jsencrypt 内部虽能识别 PEM 头尾,但如果后端下发的公钥字符串里有\n或\r,不同版本的 jsencrypt 解析表现不一致,有的能正常处理,有的会返回false。稳妥的做法就是统一清洗成无头尾、无换行的纯 Base64。你可以把 cleanKey 打印出来看一眼,它应该是一段连续的字母数字和+/字符。
setPublicKey还有一个隐藏行为:它在内部会把密钥先解码成字节数组再包装成 RSA 公钥对象。如果公钥不是合法 Base64,这里不会立即报错,而是等到encrypt时才返回false。所以排查问题时,先确认公钥字符串本身能够被atob正常解码,这一步能排除一半的格式问题。
3.2 encrypt返回值判定:false不是加密结果而是失败信号
jsencrypt 的encrypt方法在成功时返回 Base64 字符串,失败时返回布尔值false。很多刚接触的人拿到false会当成合法密文传给后端,后端 Base64 解码直接抛IllegalArgumentException。
什么情况下会返回false?常见原因有两个:公钥解析失败,或者明文长度超过上限。RSA 2048 位 + PKCS1 填充时,单次加密的最大明文长度是 117 字节。注意是字节不是字符。中文字符在 UTF-8 编码下占 3 字节,所以一个 40 个汉字的字符串就已经接近上限,英文字符则可以到 117 个。jsencrypt 内部对超长文本不会自动分段,它会直接返回false。
因此前端加密前最好先判断文本长度。一般做法是统一转成 UTF-8 字节后再数长度:
function utf8ByteLength(str) { return new Blob([str]).size } if (utf8ByteLength(plainText) > 100) { // 留出余量,避免加密假死 throw new Error('明文过长,请使用分段加密或改用混合加密方案') }判断用> 100而不是> 117,因为 jsencrypt 内部还会对明文字节做一次额外处理,留出余量能减少边界问题。
3.3 密文编码与传输:Base64 字符串直接进 JSON 字段
加密成功后返回的密文是 Base64 编码,里面可能包含+、/、=三种特殊字符。如果密文放在 HTTP 请求体 JSON 里,这些字符不需要额外处理;如果放在 URL 查询参数里,+会被解释成空格,造成密文污染。这套资源的做法是统一走 JSON 请求体,前端代码里不做任何 URL 编码。
后端拿到密文字段后,直接用Base64.getDecoder().decode(cipherText)解码。如果你在 URL 模式里踩了坑,会在第 5 章看到具体现象和解决办法。
4. 后端Java解密:Cipher填充模式与私钥加载的细节
4.1 私钥读取:清洗PEM头尾后用PKCS8EncodedKeySpec加载
后端拿到私钥字符串后,第一步不是直接解码,而是先清洗。PEM 文件的头尾标记和换行符不属于 Base64 内容,如果带着它们去解码会抛出IllegalArgumentException: Illegal base64 character。
import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import javax.crypto.Cipher; public class RsaDecryptor { private final PrivateKey privateKey; public RsaDecryptor(String privateKeyPem) throws Exception { // 兼容两种情况:PKCS#8 和 PKCS#1 的头部标记不同 String key = privateKeyPem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("-----BEGIN RSA PRIVATE KEY-----", "") .replace("-----END RSA PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(key); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); this.privateKey = factory.generatePrivate(spec); } }这里要重点说明:如果私钥是 PKCS#1 格式(BEGIN RSA PRIVATE KEY),上述清洗后得到的 Base64 字节仍然不是 PKCS#8 结构,PKCS8EncodedKeySpec加载时依然会抛InvalidKeySpecException。代码里同时替换四种头尾标记只能解决“带着标记解码”的问题,不能解决“PKCS#1 转 PKCS#8”的问题。后者必须在密钥生成阶段处理,也就是第 2 章里说的openssl pkcs8 -topk8 -nocrypt转换。
4.2 Cipher选型与doFinal解密:填充模式必须和前端对齐
解密核心代码就是Cipher的四步操作:初始化、传入密文、解密、转字符串。填充模式必须写完整,不要只写Cipher.getInstance("RSA"),因为不同 JDK 版本对默认填充算法的解释不完全一致,显式写出RSA/ECB/PKCS1Padding最稳。
public String decrypt(String cipherText) throws Exception { Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] plainBytes = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(plainBytes, StandardCharsets.UTF_8); }cipher.init的第一个参数固定用Cipher.DECRYPT_MODE,第二个参数是刚才加载的PrivateKey。doFinal接收的是密文的字节数组,所以必须先对前端传来的 Base64 字符串做解码。这里有一个常见认知误区:doFinal不是直接吃 Base64 字符串,也不是直接吃密文字符串,而是吃“Base64 解码后的原始密文二进制”。顺序搞反了就会报IllegalArgumentException: Input byte array has wrong 4-byte ending unit。
解密后的plainBytes是 UTF-8 编码的明文,用new String(... , StandardCharsets.UTF_8)还原。字符集必须和前端的TextEncoder或Blob统计规则一致,否则中文会变成乱码。
4.3 完整解密工具类:从密文到明文的边界处理
把上面两部分拼起来就是一个可直接使用的解密工具类。业务代码调用时只需要一行:
String plainText = new RsaDecryptor(privateKeyPem).decrypt(cipherText);这个工具类没有做分段解密,因为它默认前端严格按照 117 字节上限加密。如果业务确实有大字段要传,不要试图用同一个 RSA 密钥去解超长密文,正确做法是在后端提供专门的分段解密接口,或者让前端改用 AES 对称加密正文、RSA 只加密 AES 密钥。混合加密方案在本资源里没有展开,但第 6 章会给出验证思路。
提示:工具类里的异常不要吞掉。
doFinal抛出的BadPaddingException是最关键的排错线索,吞掉后只返回null会让前端排查问题毫无头绪。
5. 常见故障排查:五个案例把前端加密到后端解密这条路铺平
5.1 前端encrypt返回false:公钥被换行和头尾标记干扰
现象:前端调用encryptor.encrypt(plainText)得到的不是 Base64 字符串,而是false。后端还没有参与就已经断了。
原因:公钥字符串里有\n或\r,jsencrypt 内部解析 PEM 时对换行符处理不一致;或者公钥本身被截断,Base64 长度不是 2048 位 RSA 应有的长度。
解决:按第 3 章的方式清洗公钥,去掉头尾标记和所有空白。再用atob(cleanKey)手动解码一次,能解码说明 Base64 合法;解码后字节长度应为 294 字节(2048 位 RSA 公钥的 DER 编码),多一位少一位都有问题。
5.2 后端报InvalidKeySpecException:私钥是PKCS#1格式
现象:Java 端在factory.generatePrivate(spec)一行报InvalidKeySpecException: java.security.spec.InvalidKeySpecException: java.security.InvalidKeyException: IOException: algid parse error, not a sequence。
原因:私钥文件是openssl genrsa生成的 PKCS#1 格式,文件头是-----BEGIN RSA PRIVATE KEY-----。PKCS8EncodedKeySpec只能解析 PKCS#8 结构。
解决:用如下命令转换格式:
openssl pkcs8 -topk8 -nocrypt -in rsa_private_key.pem -out pkcs8_private_key.pem转换后再看文件头,确认变成-----BEGIN PRIVATE KEY-----。如果项目里同时存在两种格式的私钥,建议在配置里直接固化一个环境变量区分,避免每次联调都猜。
5.3 解密报BadPaddingException:密文被URL编码污染
现象:后端cipher.doFinal(...)抛javax.crypto.BadPaddingException: Decryption error。前端确认加密成功,密文确实是 Base64 字符串。
原因:密文曾经出现在 URL 查询参数里,Base64 里的+被 URL 解析成空格,后端拿到密文后 Base64 解码结果已经不是原来的二进制。
解决:前后端约定密文只走请求体 JSON 字段。如果历史接口已经用了 URL 参数,前端发送前先encodeURIComponent(cipherText),后端接收后先URLDecoder.decode(cipherText, "UTF-8")再解码 Base64。新代码一律避开 URL 传参。
5.4 中文乱码:UTF-8在两端没有对齐
现象:解密成功,但中文变成???或乱码字符,英文字符正常。
原因:jsencrypt 加密时对文本执行的是 UTF-8 编码,后端解码时用了平台默认字符集。服务器如果是 GBK 环境,new String(plainBytes)就会按 GBK 解码,中文自然错乱。
解决:后端强制指定new String(plainBytes, StandardCharsets.UTF_8)。同时前端统计明文长度时也用 UTF-8 字节数,两边基于同一套字符集计算才不会产生边界误差。
5.5 一会成功一会失败:公钥私钥不匹配
现象:同一套前后端代码,有时候解密正常,有时候报BadPaddingException,重启后情况随机变化。
原因:常见于密钥被缓存或配置在多个地方。前端用的公钥来自 A 配置文件,后端用的私钥来自 B 配置文件,两者不是一对。由于 RSA 加密结果每次不同(PKCS#1 填充含随机数),所以只有“恰好”用匹配密钥时才成功,概率性通过。
解决:拿后端的私钥重新导出公钥,和前端配置对比是否一致。用下面的命令从私钥导出公钥,再对比两者 Base64 是否相同:
openssl rsa -in private_key.pem -pubout | openssl md5 openssl md5 < public_key.pem6. 进阶验证:手工生成密钥对、抓包校验与加签防篡改
解密链路跑通之后,我建议你再多做两步,一步是绕过前端直接用命令行验证密钥对,一步是在接口里加验签。
用 OpenSSL 命令行验证后端私钥能否解开前端密文,是最快的排错手段。把 jsencrypt 加密得到的 Base64 密文写进文件,再用私钥解密:
echo -n "前端加密后的Base64密文" | base64 -d > cipher.bin openssl pkeyutl -decrypt -in cipher.bin -inkey private_key.pem -pkeyopt rsa_padding_mode:pkcs1如果命令行能解出明文,说明前端、公钥、私钥这条链路本身没问题,问题出在 Java 代码或传输环节。这一步我每次联调都会先做,它能节省大量两边互相推诿的时间。
第二件事是防重放。RSA 加密本身有一个特性:相同明文用相同公钥加密,每次生成的密文都不一样,因为 PKCS#1 填充包含随机字节。这个特性让攻击者无法通过“重放上一次密文”绕过校验,但如果攻击者直接重放整个请求,后端无法判断请求是不是当前会话发起的。常见做法是在明文里嵌入时间戳和随机数,后端解密后检查时间戳差值是否在允许范围内。这个逻辑可以写在解密工具类里,在返回明文前先提取时间戳校验。
第三件事是加签。RSA 加密保护的是“明文不被看见”,但保护不了“明文被篡改后传给后端”。更稳妥的做法是后端用SHA256withRSA对收到的密文做验签,或者前端对明文计算签名后连同密文一起提交。不过要注意,加签验签需要第二对密钥,公钥和私钥的角色与加密相反:签名用私钥,验签用公钥。
有段时间我负责的接口频繁被测试工具刷请求,我就把时间戳和签名都补齐了。从那以后,我每次做 RSA 联调都强制先走一遍命令行验证,再进入代码调试,这个习惯帮我跳过了大量“看起来是代码问题、其实是密钥问题”的排查,希望帮到你。
本文还有配套的精品资源,点击获取