RSA 密码传输加密通用接入方案
@
前言
请各大网友尊重本人原创知识分享,谨记本人博客:南国以南i、微信公众号:白码梦想家

提示:以下是本篇文章正文内容,下面案例可供参考
在登录、注册、忘记密码、修改密码这类场景里,密码字段通常会穿过浏览器、网关、后端服务、日志链路、监控平台和异常平台。即使系统已经启用了 HTTPS,内部链路、代理日志、错误日志里仍然可能留下敏感字段的影子。
重点:RSA 密码传输加密不是替代 HTTPS,而是在 HTTPS 之上,对密码字段再增加一层应用层保护。
这套方案的目标很简单:前端只负责用公钥加密密码,后端只负责用私钥解密密码,原有登录、注册、改密等业务流程尽量不变。
一、介绍
前端调用后端公钥接口,拿到 publicKeyPem、keyId 和一次性 nonce;提交密码接口时,前端使用 RSA 公钥加密密码字段,并把密文和 nonce 一起传给后端。后端通过 AOP 切面在业务方法执行前自动完成 nonce 校验、私钥解密和字段回填,业务代码拿到的仍然是原始明文密码。
前端获取公钥 / keyId / nonce
↓
前端 RSA-OAEP-256 加密 password
↓
提交 RSA:v1:<keyId>:<cipherText> + nonce
↓
后端 AOP 切面拦截
↓
Redis 原子消费 nonce
↓
后端按 keyId 找私钥并解密
↓
DTO 密文字段被替换成明文
↓
原业务逻辑继续执行
推荐:把解密逻辑收敛到 AOP 切面,避免每个 Controller 或 Service 都重复写解密代码。
二、RSA 在本方案中的角色
RSA 是非对称加密算法,核心特点是:公钥可以公开,私钥必须保密。
在密码传输加密中,各字段含义如下:
| 字段 | 作用 |
|---|---|
publicKeyPem |
RSA 公钥,可以返回给前端,用于加密密码 |
privateKeyPem |
RSA 私钥,只能后端保存,用于解密密码 |
keyId |
密钥编号,用于区分当前密钥和历史兼容密钥 |
nonce |
一次性随机值,防止同一段密文被重复提交 |
cacheSeconds |
前端缓存 publicKeyPem/keyId 的时间 |
nonceTtlSeconds |
nonce 在 Redis 中的有效期 |
注意:RSA 密文本身不是“一次性凭证”。如果别人拿到某次请求里的完整密文,理论上仍可以再次提交给同一个接口。真正防重放的是一次性 nonce。
三、密文协议格式
前端提交密码字段时,建议统一使用下面的格式:
RSA:v1:<keyId>:<Base64CipherText>
示例:
RSA:v1:rsa-202608-001:Wm9uZ0hpZGRlbkNpcGhlclRleHQ...
每一段含义:
| 片段 | 含义 |
|---|---|
RSA |
协议标识 |
v1 |
协议版本,方便后续扩展 |
keyId |
使用哪一把公钥加密 |
Base64CipherText |
RSA 加密后的密文字节,再做 Base64 编码 |
提示:nonce 建议作为请求体独立字段传递,不放进密文里。这样后端可以先校验 nonce,再决定是否继续解密。
四、密钥生成与 Apollo 配置
可以使用 Java 离线工具生成 RSA 公私钥。工具类每次运行都会通过 SecureRandom 生成一套新的密钥,所以每次生成的公钥和私钥都不一样。同一次生成的公钥和私钥必须配对使用。
4.1 编译工具类
javac -encoding UTF-8 tools/security/RsaKeyPairGeneratorTool.java -d .codex_tmp/keygen-classes
4.2 执行生成密钥
java -cp .codex_tmp/keygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs/security/rsa-202608-001 --quiet
这里使用 --quiet 是为了避免在控制台打印完整私钥。即使使用 --quiet,工具仍会生成完整的 apollo.properties 文件。
4.3 执行输出示例
RSA key pair generated.
keyId: rsa-202608-001
keySize: 3072
publicKeyPem: rsa-doc-output\rsa-202608-001-public.pem
privateKeyPem: rsa-doc-output\rsa-202608-001-private.pem
apolloProperties: rsa-doc-output\rsa-202608-001-apollo.properties
生成结果通常包括:
rsa-202608-001-public.pem
rsa-202608-001-private.pem
rsa-202608-001-apollo.properties
禁止:不要把私钥文件、完整 Apollo 私钥配置、outputs/security/ 目录提交到 Git 仓库。
4.4 Apollo 配置示例
security.crypto.password.enabled=true
security.crypto.password.mode=compat
security.crypto.password.algorithm=RSA-OAEP-256
security.crypto.password.cacheSeconds=1800
security.crypto.password.nonceTtlSeconds=300
security.crypto.password.maxPlaintextUtf8Bytes=128
security.crypto.password.keyId=rsa-202608-001
security.crypto.password.publicKeyPem=-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A...\n-----END PUBLIC KEY-----
security.crypto.password.privateKeyPem=-----BEGIN PRIVATE KEY-----\n<replace-with-generated-private-key>\n-----END PRIVATE KEY-----
重点:privateKeyPem 只能保存在后端配置中心或 KMS/HSM 中,不能返回给前端,不能写入前端代码,不能出现在接口响应里。
五、后端接入方式
后端建议拆成五个通用组件:
| 组件 | 职责 |
|---|---|
PasswordCryptoProperties |
读取配置中心中的开关、算法、密钥、TTL |
CryptoPublicKeyController |
提供公钥和一次性 nonce |
RsaKeyProvider |
根据 keyId 加载当前私钥或历史私钥 |
CryptoNonceStore |
Redis 存储和原子消费 nonce |
DecryptSensitiveFieldsAspect |
AOP 拦截请求并自动解密敏感字段 |
公钥接口建议返回:
{
"respCode": "20000",
"respMsg": "success",
"data": {
"enabled": true,
"mode": "compat",
"version": "v1",
"algorithm": "RSA-OAEP-256",
"keyId": "rsa-202608-001",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----...",
"cacheSeconds": 1800,
"nonce": "one-time-nonce",
"nonceTtlSeconds": 300,
"serverTime": "2026-08-31T10:00:00.000Z",
"nonceExpiresAt": "2026-08-31T10:05:00.000Z"
}
}
建议:serverTime 和 nonceExpiresAt 使用 UTC ISO 8601,例如 2026-08-31T10:05:00.000Z。前端展示或预判断时更稳定,也能避免时区字符串被误解析。
AOP 注解示例
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptSensitiveFields {
String[] value();
}
业务接口只声明哪些字段需要解密:
@PostMapping("/login")
@DecryptSensitiveFields({"password"})
public Result<?> login(@RequestBody LoginRequest request) {
// 进入业务方法时,request.password 已经被切面还原成明文
return loginService.login(request);
}
修改密码接口可以声明多个字段:
@PostMapping("/password/change")
@DecryptSensitiveFields({"oldPassword", "newPassword"})
public Result<?> changePassword(@RequestBody ChangePasswordRequest request) {
return passwordService.change(request);
}
好处:Controller 和业务 Service 不需要关心 RSA 细节。是否加密、如何解密、nonce 是否过期,都由切面统一处理。
六、后端解密流程
AOP 切面进入业务方法前,建议按下面顺序处理:
1. 判断全局开关 enabled
2. 判断 mode:compat 或 force
3. 解析密文格式 RSA:v1:<keyId>:<cipherText>
4. 从请求体读取 nonce
5. Redis 原子消费 nonce
6. 校验 nonce 中绑定的 keyId 与密文 keyId 一致
7. 按 keyId 找到私钥
8. 使用 RSA-OAEP-256 解密
9. 将 DTO 中的密文字段替换为明文
10. 放行业务方法
Redis 消费 nonce 必须是原子的:
local v = redis.call('GET', KEYS[1])
if v then
redis.call('DEL', KEYS[1])
end
return v
如果 Redis 返回空,说明 nonce 已过期或已经被使用过,建议返回:
{
"respCode": "32007",
"respMsg": "Password encryption nonce expired",
"data": null
}
注意:后端判断 nonce 是否过期依赖 Redis TTL,不依赖前端传入时间,也不依赖用户所在时区。
七、前端接入方式
前端只需要关心三件事:获取公钥、加密字段、提交 nonce。
7.1 获取公钥和 nonce
GET /security/crypto/public-key
前端可以按 cacheSeconds 缓存 publicKeyPem/keyId,但每次提交密码前都应该获取新的 nonce。
重点:cacheSeconds 只能用于缓存公钥和 keyId,不能缓存包含 nonce 的完整响应。
7.2 使用 RSA-OAEP-256 加密
浏览器推荐使用 Web Crypto API:
async function encryptPassword(publicKey, password) {
const encoded = new TextEncoder().encode(password);
const cipherBuffer = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
encoded
);
return btoa(String.fromCharCode(...new Uint8Array(cipherBuffer)));
}
导入公钥时要使用 SHA-256:
const cryptoKey = await crypto.subtle.importKey(
"spki",
publicKeyDer,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
7.3 提交业务接口
{
"loginId": "demo@example.com",
"password": "RSA:v1:rsa-202608-001:Base64CipherText",
"nonce": "one-time-nonce"
}
后端切面解密后,业务层看到的是:
{
"loginId": "demo@example.com",
"password": "plain-password"
}
八、密钥轮换
RSA PEM 本身没有业务有效期。nonceTtlSeconds 也不是密钥有效期,它只是一次性 nonce 的 Redis TTL。
密钥有效期应由运维策略或安全策略定义,例如每 90 天轮换一次:
1. 生成新密钥 rsa-202609-001
2. 将新密钥配置为当前 keyId
3. 将旧密钥 rsa-202608-001 放入 compatibleKeys
4. 等待前端缓存和在途请求自然结束
5. 下线旧密钥
提示:不要用同一个 keyId 覆盖新密钥。否则前端缓存的旧公钥会和后端新私钥不匹配,导致解密失败。
九、联调验收重点
建议至少验证这些场景:
| 场景 | 预期 |
|---|---|
| 正常登录 | 密文提交,后端解密成功 |
| nonce 重复使用 | 返回 32007 |
| nonce 过期 | 返回 32007 |
| keyId 不存在 | 返回密钥不存在或参数非法 |
| 密文格式错误 | 返回参数非法 |
enabled=false |
密文请求被拒绝或按降级策略处理 |
mode=compat |
密文和明文都可过渡 |
mode=force |
明文密码被拒绝 |
最后提醒:日志里不能打印明文密码、私钥、完整密文。排查问题时只打印 keyId、接口路径、字段名和错误码即可。
附录:RSA 密钥生成工具类
下面的 Java 工具类可以直接复制到其它项目中使用。它只依赖 JDK 标准库,默认生成 RSA-3072 密钥,私钥格式为 PKCS8,公钥格式为 X.509,适合后端 Java 解析和前端 Web Crypto 使用。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/**
* Offline RSA key-pair generator for password transport encryption.
*
* Usage:
* javac -encoding UTF-8 tools/security/RsaKeyPairGeneratorTool.java -d .codex_tmp/keygen-classes
* java -cp .codex_tmp/keygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs/security/rsa-202608-001
*/
public final class RsaKeyPairGeneratorTool {
private RsaKeyPairGeneratorTool() {
}
private static final String RSA = "RSA";
private static final int DEFAULT_KEY_SIZE = 3072;
private static final String DEFAULT_OUTPUT_ROOT = "outputs/security";
private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");
public static void main(String[] args) throws Exception {
Options options = Options.parse(args);
if (options.help) {
printUsage();
return;
}
GeneratedRsaKeyPair keyPair = generate(options.keyId, options.keySize);
OutputFiles outputFiles = writeToDirectory(keyPair, options.outDir, options.force);
System.out.println("RSA key pair generated.");
System.out.println("keyId: " + keyPair.getKeyId());
System.out.println("keySize: " + keyPair.getKeySize());
System.out.println("publicKeyPem: " + outputFiles.getPublicPemPath().toAbsolutePath());
System.out.println("privateKeyPem: " + outputFiles.getPrivatePemPath().toAbsolutePath());
System.out.println("apolloProperties: " + outputFiles.getApolloPropertiesPath().toAbsolutePath());
if (!options.quiet) {
System.out.println();
System.out.println("Apollo values:");
System.out.println(toApolloProperties(keyPair));
}
}
public static GeneratedRsaKeyPair generate(String keyId) throws NoSuchAlgorithmException {
return generate(keyId, DEFAULT_KEY_SIZE);
}
public static GeneratedRsaKeyPair generate(String keyId, int keySize) throws NoSuchAlgorithmException {
if (keyId == null || keyId.trim().isEmpty()) {
throw new IllegalArgumentException("keyId cannot be blank");
}
if (keySize < 2048) {
throw new IllegalArgumentException("keySize must be at least 2048");
}
KeyPair keyPair = generateKeyPair(keySize);
String publicPem = toPem("PUBLIC KEY", keyPair.getPublic().getEncoded());
String privatePem = toPem("PRIVATE KEY", keyPair.getPrivate().getEncoded());
return new GeneratedRsaKeyPair(keyId, keySize, publicPem, privatePem);
}
public static OutputFiles writeToDirectory(GeneratedRsaKeyPair keyPair, Path outDir, boolean force) throws IOException {
if (keyPair == null) {
throw new IllegalArgumentException("keyPair cannot be null");
}
if (outDir == null) {
throw new IllegalArgumentException("outDir cannot be null");
}
Files.createDirectories(outDir);
Path publicPemPath = outDir.resolve(keyPair.getKeyId() + "-public.pem");
Path privatePemPath = outDir.resolve(keyPair.getKeyId() + "-private.pem");
Path apolloPath = outDir.resolve(keyPair.getKeyId() + "-apollo.properties");
writeFile(publicPemPath, keyPair.getPublicKeyPem(), force);
writeFile(privatePemPath, keyPair.getPrivateKeyPem(), force);
writeFile(apolloPath, toApolloProperties(keyPair), force);
return new OutputFiles(publicPemPath, privatePemPath, apolloPath);
}
public static String toApolloProperties(GeneratedRsaKeyPair keyPair) {
if (keyPair == null) {
throw new IllegalArgumentException("keyPair cannot be null");
}
StringBuilder builder = new StringBuilder();
builder.append("security.crypto.password.keyId=").append(keyPair.getKeyId()).append('\n');
builder.append("security.crypto.password.publicKeyPem=").append(toApolloValue(keyPair.getPublicKeyPem())).append('\n');
builder.append("security.crypto.password.privateKeyPem=").append(toApolloValue(keyPair.getPrivateKeyPem())).append('\n');
return builder.toString();
}
private static KeyPair generateKeyPair(int keySize) throws NoSuchAlgorithmException {
KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA);
generator.initialize(keySize, new SecureRandom());
return generator.generateKeyPair();
}
private static String toPem(String type, byte[] encoded) {
String base64 = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded);
return "-----BEGIN " + type + "-----\n" + base64 + "\n-----END " + type + "-----\n";
}
private static String toApolloValue(String pem) {
return pem.trim().replace("\r\n", "\n").replace("\n", "\\n");
}
private static void writeFile(Path path, String content, boolean force) throws IOException {
if (Files.exists(path) && !force) {
throw new IllegalStateException("File already exists: " + path.toAbsolutePath()
+ ". Use --force to overwrite.");
}
Files.write(path, content.getBytes(StandardCharsets.UTF_8));
}
private static void printUsage() {
System.out.println("Usage:");
System.out.println(" javac -encoding UTF-8 tools/security/RsaKeyPairGeneratorTool.java -d .codex_tmp/keygen-classes");
System.out.println(" java -cp .codex_tmp/keygen-classes RsaKeyPairGeneratorTool [options]");
System.out.println();
System.out.println("Options:");
System.out.println(" --key-id <value> RSA key id. Default: rsa-yyyyMM-001");
System.out.println(" --key-size <bits> RSA key size. Default: 3072");
System.out.println(" --out-dir <path> Output directory. Default: outputs/security/<key-id>");
System.out.println(" --force Overwrite existing output files");
System.out.println(" --quiet Do not print Apollo values to stdout");
System.out.println(" --help Show this help");
}
public static final class GeneratedRsaKeyPair {
private final String keyId;
private final int keySize;
private final String publicKeyPem;
private final String privateKeyPem;
private GeneratedRsaKeyPair(String keyId, int keySize, String publicKeyPem, String privateKeyPem) {
this.keyId = keyId;
this.keySize = keySize;
this.publicKeyPem = publicKeyPem;
this.privateKeyPem = privateKeyPem;
}
public String getKeyId() {
return keyId;
}
public int getKeySize() {
return keySize;
}
public String getPublicKeyPem() {
return publicKeyPem;
}
public String getPrivateKeyPem() {
return privateKeyPem;
}
}
public static final class OutputFiles {
private final Path publicPemPath;
private final Path privatePemPath;
private final Path apolloPropertiesPath;
private OutputFiles(Path publicPemPath, Path privatePemPath, Path apolloPropertiesPath) {
this.publicPemPath = publicPemPath;
this.privatePemPath = privatePemPath;
this.apolloPropertiesPath = apolloPropertiesPath;
}
public Path getPublicPemPath() {
return publicPemPath;
}
public Path getPrivatePemPath() {
return privatePemPath;
}
public Path getApolloPropertiesPath() {
return apolloPropertiesPath;
}
}
private static class Options {
private String keyId = "rsa-" + LocalDate.now().format(MONTH_FORMATTER) + "-001";
private int keySize = DEFAULT_KEY_SIZE;
private Path outDir;
private boolean force;
private boolean quiet;
private boolean help;
private static Options parse(String[] args) {
Options options = new Options();
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if ("--help".equals(arg) || "-h".equals(arg)) {
options.help = true;
continue;
}
if ("--force".equals(arg)) {
options.force = true;
continue;
}
if ("--quiet".equals(arg)) {
options.quiet = true;
continue;
}
if ("--key-id".equals(arg)) {
options.keyId = requireValue(args, ++i, arg);
continue;
}
if ("--key-size".equals(arg)) {
options.keySize = Integer.parseInt(requireValue(args, ++i, arg));
continue;
}
if ("--out-dir".equals(arg)) {
options.outDir = Paths.get(requireValue(args, ++i, arg));
continue;
}
throw new IllegalArgumentException("Unknown option: " + arg);
}
validate(options);
if (options.outDir == null) {
options.outDir = Paths.get(DEFAULT_OUTPUT_ROOT, options.keyId);
}
return options;
}
private static String requireValue(String[] args, int index, String optionName) {
if (index >= args.length || args[index].startsWith("--")) {
throw new IllegalArgumentException("Missing value for " + optionName);
}
return args[index];
}
private static void validate(Options options) {
if (options.keyId == null || options.keyId.trim().isEmpty()) {
throw new IllegalArgumentException("--key-id cannot be blank");
}
if (options.keySize < 2048) {
throw new IllegalArgumentException("--key-size must be at least 2048");
}
}
}
}
总结
我是南国以南i记录点滴每天成长一点点,学习是永无止境的!转载请附原文链接!!!


浙公网安备 33010602011771号