RSA 密码传输加密通用接入方案

@


前言

请各大网友尊重本人原创知识分享,谨记本人博客:南国以南i微信公众号:白码梦想家
关注

提示:以下是本篇文章正文内容,下面案例可供参考

在登录、注册、忘记密码、修改密码这类场景里,密码字段通常会穿过浏览器、网关、后端服务、日志链路、监控平台和异常平台。即使系统已经启用了 HTTPS,内部链路、代理日志、错误日志里仍然可能留下敏感字段的影子。

重点:RSA 密码传输加密不是替代 HTTPS,而是在 HTTPS 之上,对密码字段再增加一层应用层保护。

这套方案的目标很简单:前端只负责用公钥加密密码,后端只负责用私钥解密密码,原有登录、注册、改密等业务流程尽量不变。


一、介绍

前端调用后端公钥接口,拿到 publicKeyPemkeyId 和一次性 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"
  }
}

建议:serverTimenonceExpiresAt 使用 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记录点滴每天成长一点点,学习是永无止境的!转载请附原文链接!!!
关注

posted @ 2026-09-01 09:55  南国以南i  阅读(128)  评论(0)    收藏  举报