Java RSA加解密怎么用才安全
RSA 代码看起来只是生成密钥、调用 Cipher、再做 Base64,但密钥长度、填充模式、明文长度和私钥保存方式任意一处选错,都可能留下安全隐患。这篇从 RSA 的适用边界讲起,用 Java 示例说明 RSA 2048、OAEP SHA-256、X.509/PKCS#8 和混合加密应该怎么落地。
之前写过可逆(对称和非对称)与不可逆加密算法,那篇主要讲不同加密方式各自解决什么问题。
这篇往下落一点,专门看 Java 里 RSA 到底怎么实现。
RSA 的 API 并不复杂:生成密钥对、使用公钥加密、使用私钥解密。
真正容易出问题的地方,反而是那些看起来不起眼的选择:
- 密钥长度是不是太短
- 填充模式是不是还在使用旧方案
- 是否拿 RSA 直接加密一大段配置或 JSON
- Base64 是不是被误当成加密
- 私钥是不是被硬编码进代码或配置文件
- 公钥和私钥的编码格式是不是匹配
旧代码里常见的 1024 位 RSA 和 RSA/ECB/PKCS1Padding,不应该继续作为新项目的默认方案。
本文按当前更稳妥的方向重构示例。
先给结论:RSA 适合加密短数据或保护对称密钥,不适合直接加密大段业务正文;新代码优先使用 RSA 2048 及以上和 OAEP SHA-256,并把私钥放到专门的密钥管理位置。
RSA 解决什么问题
RSA 是非对称加密算法,使用一对密钥:
- 公钥:可以公开
- 私钥:只能由密钥持有方保存
最常见的加密方向是:
发送方拿到公钥
│
▼
使用公钥加密数据
│
▼
只有持有私钥的一方可以解密
签名方向则相反:
发送方使用私钥签名
│
▼
其他人使用公钥验证签名
加密解决的是保密性,签名解决的是来源认证和完整性。
不要把“用私钥加密、公钥解密”简单当成普通加密的反向写法。实际工程中,这通常应该使用签名 API,而不是自己把签名流程写成一套加密流程。
RSA 和对称加密的区别
对称加密只有一把共享密钥,速度快,适合大数据量:
同一把密钥:加密 <-> 解密
RSA 使用公钥和私钥,密钥分发更方便,但计算成本高,而且单次可加密数据长度有限。
RSA:适合密钥交换、短数据、签名
AES-GCM:适合正文、文件和大数据量
真实系统通常使用混合加密:RSA 保护 AES 密钥,AES-GCM 加密正文。
密钥长度和填充模式
不要继续使用 1024 位
旧示例里经常能看到:
keyPairGenerator.initialize(1024);
1024 位 RSA 不适合作为新系统默认配置。
当前更常见的起点是 RSA 2048;如果系统生命周期很长或安全基线要求更高,可以评估 3072 位或更高。
密钥越长,计算成本也会增加,所以最终要结合协议、调用频率和部署环境测试。
PKCS1Padding 和 OAEP
RSA 不能直接把任意明文塞进模数里,还需要填充方案。
旧代码常见:
Cipher.getInstance("RSA/ECB/PKCS1Padding");
对于新设计,更推荐使用 OAEP,并明确哈希算法:
Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
但只看 transformation 名字还不够。不同 JDK 或安全 Provider 对 MGF1 默认哈希的处理可能有差异,生产代码最好显式传入 OAEPParameterSpec。
新项目建议:RSA 2048+ + OAEP + SHA-256
旧 PKCS#1 v1.5:只在兼容既有协议时保留,并完成风险评估
不要把 ECB 理解成 RSA 的分块模式。
Java 的 RSA transformation 习惯上写成 RSA/ECB/...,这里的 ECB 只是 API 命名格式,不代表 RSA 像 AES 那样使用 ECB 分组模式。
生成 RSA 密钥对
下面是一个只负责生成密钥对的示例。
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.util.Base64;
public final class RsaKeys {
private RsaKeys() {
}
public static KeyPair generate() throws Exception {
KeyPairGenerator generator =
KeyPairGenerator.getInstance("RSA");
// 新项目至少从 2048 位开始,具体按安全基线评估
generator.initialize(2048, new SecureRandom());
return generator.generateKeyPair();
}
public static void printForLocalTest(KeyPair keyPair) {
// 只用于本地测试,不要把私钥打印到生产日志
String publicKey = Base64.getEncoder()
.encodeToString(keyPair.getPublic().getEncoded());
String privateKey = Base64.getEncoder()
.encodeToString(keyPair.getPrivate().getEncoded());
System.out.println(publicKey);
System.out.println(privateKey);
}
}
公钥通常编码为 X.509 SubjectPublicKeyInfo。
私钥通常编码为 PKCS#8。
getEncoded() 得到的是 DER 二进制,Base64 只是为了方便放进文本配置或传输,Base64 不是加密。
如果密钥用于生产,不要把 privateKey 写进 Git 仓库、镜像层、普通配置文件或启动日志。
应放到 Secret、KMS、HSM 或受控的密钥管理服务中,并限制读取权限。
加载公钥和私钥
从 Base64 文本恢复密钥时,要使用匹配的 KeySpec:
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public final class RsaKeyLoader {
private RsaKeyLoader() {
}
public static PublicKey loadPublicKey(String encoded)
throws Exception {
byte[] der = Base64.getMimeDecoder()
.decode(encoded);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(new X509EncodedKeySpec(der));
}
public static PrivateKey loadPrivateKey(String encoded)
throws Exception {
byte[] der = Base64.getMimeDecoder()
.decode(encoded);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(new PKCS8EncodedKeySpec(der));
}
}
如果密钥文本来自 PEM 文件,通常还要先去掉头尾标记和空白:
-----BEGIN PUBLIC KEY-----
...Base64...
-----END PUBLIC KEY-----
实际代码需要根据 PEM 类型选择正确的解析方式。
PUBLIC KEY 通常对应 X.509 SubjectPublicKeyInfo,PRIVATE KEY 通常对应 PKCS#8;不要看到都是 Base64 就直接互换。
RSA 短数据加解密
下面给出一个 OAEP SHA-256 的短文本示例。
import javax.crypto.Cipher;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
public final class RsaCrypto {
private static final String TRANSFORMATION =
"RSA/ECB/OAEPPadding";
private static final OAEPParameterSpec OAEP_SHA256 =
new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
private RsaCrypto() {
}
public static String encrypt(String plainText, PublicKey publicKey)
throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256);
byte[] plainBytes = plainText
.getBytes(StandardCharsets.UTF_8);
byte[] encrypted = cipher.doFinal(plainBytes);
return Base64.getEncoder().encodeToString(encrypted);
}
public static String decrypt(String cipherText, PrivateKey privateKey)
throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SHA256);
byte[] encrypted = Base64.getDecoder().decode(cipherText);
byte[] plainBytes = cipher.doFinal(encrypted);
return new String(plainBytes, StandardCharsets.UTF_8);
}
}
这里有三个细节:
- 加密和解密必须使用相同的 OAEP 参数
- 明文和解密结果都明确使用 UTF-8
- Base64 只负责把二进制密文转成文本
如果加密端和解密端的 OAEP 参数不一致,常见结果就是 BadPaddingException 或解密失败。
RSA 明文长度有限
RSA 能加密的最大明文长度和密钥长度、填充方式有关。
对于 RSA 2048 和 OAEP SHA-256,最大长度不是 2048 字节,而大约是:
模数长度字节数 - 2 × 哈希长度 - 2
= 256 - 2 × 32 - 2
= 190 字节
实际长度还要按具体 Provider 和参数计算,不能把某个固定数字写死到所有场景。
因此,配置文件、JSON、文件内容和长文本不应该直接用 RSA 加密。
把明文强行切片后分别 RSA 加密也不是首选方案,容易引入分片顺序、完整性校验、随机性和错误恢复问题。
大数据使用混合加密
处理长文本或文件时,推荐混合加密:
1. 随机生成 AES 会话密钥
2. 使用 AES-GCM 加密真正的业务数据
3. 使用 RSA 公钥加密 AES 会话密钥
4. 保存 RSA 密文、AES 密文、nonce 和必要版本信息
接收方:
1. 使用 RSA 私钥解密 AES 会话密钥
2. 使用 AES-GCM 和 nonce 解密业务数据
3. 校验 GCM 认证标签,失败就拒绝使用明文
整体结构可以表示为:
业务明文 ──AES-GCM──> 数据密文
│
随机 AES key ──RSA 公钥──> key 密文
最终传输:key 密文 + 数据密文 + nonce + 版本信息
AES-GCM 同时提供机密性和完整性校验,比自己使用 AES-CBC 再拼一个哈希更不容易出错。
RSA 只保护短小的 AES key,性能和长度问题就分开解决了。
如果项目已经使用成熟的 JOSE/JWE、云 KMS 或密码库,优先使用标准实现,不要手写一套私有协议。
签名不要当加密用
如果需求是“确认数据是谁发的,并且内容没有被改”,应该使用数字签名。
发送方:私钥签名
接收方:公钥验签
Java 中常见的是 Signature API,而不是把明文用私钥调用 Cipher 加密。
签名通常对摘要签名,不会把完整大文件直接塞进 RSA。
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(data);
byte[] signBytes = signature.sign();
验签方使用公钥:
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(data);
boolean valid = verifier.verify(signBytes);
签名算法、密钥用途和加密算法应该在协议里明确写清楚,不能看到 RSA 就什么都用 RSA。
常见异常怎么排查
InvalidKeySpecException
通常和密钥格式不匹配、Base64 内容损坏、PEM 头尾没有处理、X.509/PKCS#8 用错有关。
排查顺序可以是:
- 确认密钥文本没有多余前缀、截断或错误转义
- 确认公钥使用 X509EncodedKeySpec
- 确认私钥使用 PKCS8EncodedKeySpec
- 确认 Base64 解码器和编码端规则一致
- 不要把公钥当私钥,或把 PKCS#1 私钥直接当 PKCS#8 读取
BadPaddingException
常见原因包括:
- 加密和解密使用了不同填充模式
- OAEP 的摘要或 MGF1 参数不一致
- 使用了错误的私钥
- 密文被截断或 Base64 内容被改动
- 把签名数据当成加密数据解密
不要为了“让它解密成功”直接换成更弱的填充模式。
先确认协议双方的算法参数和密钥用途。
数据过长
RSA 加密长文本时,常见异常是输入数据过长。
正确方向不是随意调整字符串或强行扩大 RSA 密钥,而是改用混合加密,或者只用 RSA 包装一个短密钥。
密钥保存和轮换
安全实现不只在 Cipher 代码里。
私钥保存方式同样重要:
- 不要写死在 Java 源码里
- 不要提交到 Git 仓库
- 不要打印到启动日志或异常日志
- 不要和业务配置一起明文分发
- 生产环境使用 Secret、KMS、HSM 或专用密钥服务
- 建立密钥版本和轮换策略
- 解密时根据密钥版本选择对应私钥
如果必须从环境变量读取,也要注意环境变量可能被诊断工具、进程信息或部署日志暴露。
更稳妥的架构是:应用只在需要时向密钥服务请求解密能力,不让长期私钥散落在每台应用机器上。
小结
Java RSA 加解密最重要的不是记住几段 API,而是先把算法边界想清楚:
- 新项目优先 RSA 2048 及以上,具体按安全基线评估
- 新协议优先 OAEP,并明确 SHA-256 等参数
- 公钥通常使用 X.509,私钥通常使用 PKCS#8
- Base64 是编码,不是加密
- RSA 只适合短数据或保护对称密钥,不适合直接加密大文件和长 JSON
- 大数据使用 AES-GCM 加密正文,再用 RSA 包装 AES key
- 身份认证和完整性校验使用签名,不要把签名和加密混用
- 私钥不进源码、不进日志、不进仓库,并且要支持撤销和轮换
一句话总结:RSA 的 API 很短,安全协议却很长;密钥长度、填充、数据长度、密钥保存和轮换,任何一环都不能只凭旧代码照抄。
如果只是学习 API,可以运行短文本示例;如果要保护真实配置或文件,先确认项目是否应该使用成熟的混合加密或 KMS 方案。
