[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"consumer-news-detail-881":3,"consumer-news-interaction-881":41,"consumer-news-related-881":44},{"detail":4,"item":36},{"card":5,"schemaVersion":23,"fields":24,"content":30},{"id":6,"kind":7,"targetType":8,"targetId":9,"subtype":7,"typeLabel":10,"title":11,"subtitle":12,"summary":13,"coverUrl":14,"badgeText":15,"href":16,"sourceName":12,"meta":17,"metrics":20,"tags":21,"resolved":22},"NEWS_ARTICLE:881","news","NEWS_ARTICLE",881,"资讯","RSA 密码传输加密通用接入方案","博客园","@目录前言一、介绍二、RSA 在本方案中的角色三、密文协议格式四、密钥生成与 Apollo 配置4.1 编译工具类4.2 执行生成密钥4.3 执行输出示例4.4 Apollo 配置示例五、后端接入方式AOP 注解示例六、后端解密流程七、前端接入方式7.1 获取公钥和 nonce7.2 使用 RSA-","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F1867541\u002F202608\u002F1867541-20260831165110853-162420906.png","","\u002Fnews\u002F881",[18,19],"2026","软件开发",{},[19],true,"consumer-content-detail-v1",{"sourceName":12,"authorName":25,"categoryName":19,"summary":13,"description":13,"publishTime":26,"updateTime":27,"sourceUrl":28,"language":29},"南国以南i","2026-09-01T09:56","2026-09-02T20:37:52","https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418","中文",{"format":31,"policy":32,"normalized":22,"html":33,"text":34,"wordCount":35,"hasBody":22},"HTML","NEWS_CONTENT_V1","\u003Cp>@\u003C\u002Fp>\n\u003Cdiv>\n \u003Cdiv>\n  目录\n \u003C\u002Fdiv>\n \u003Cul>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#前言\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">前言\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#一介绍\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">一、介绍\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#二rsa-在本方案中的角色\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">二、RSA 在本方案中的角色\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#三密文协议格式\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">三、密文协议格式\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#四密钥生成与-apollo-配置\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">四、密钥生成与 Apollo 配置\u003C\u002Fa>\n   \u003Cul>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#41-编译工具类\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">4.1 编译工具类\u003C\u002Fa>\u003C\u002Fli>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#42-执行生成密钥\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">4.2 执行生成密钥\u003C\u002Fa>\u003C\u002Fli>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#43--执行输出示例\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">4.3 执行输出示例\u003C\u002Fa>\u003C\u002Fli>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#44--apollo-配置示例\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">4.4 Apollo 配置示例\u003C\u002Fa>\u003C\u002Fli>\n   \u003C\u002Ful>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#五后端接入方式\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">五、后端接入方式\u003C\u002Fa>\n   \u003Cul>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#aop-注解示例\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">AOP 注解示例\u003C\u002Fa>\u003C\u002Fli>\n   \u003C\u002Ful>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#六后端解密流程\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">六、后端解密流程\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#七前端接入方式\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">七、前端接入方式\u003C\u002Fa>\n   \u003Cul>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#71-获取公钥和-nonce\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">7.1 获取公钥和 nonce\u003C\u002Fa>\u003C\u002Fli>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#72-使用-rsa-oaep-256-加密\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">7.2 使用 RSA-OAEP-256 加密\u003C\u002Fa>\u003C\u002Fli>\n    \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#73-提交业务接口\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">7.3 提交业务接口\u003C\u002Fa>\u003C\u002Fli>\n   \u003C\u002Ful>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#八密钥轮换\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">八、密钥轮换\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#九联调验收重点\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">九、联调验收重点\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#附录rsa-密钥生成工具类\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">附录：RSA 密钥生成工具类\u003C\u002Fa>\u003C\u002Fli>\n  \u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\u002Fp\u002F22777418#总结\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">总结\u003C\u002Fa>\u003C\u002Fli>\n \u003C\u002Ful>\n\u003C\u002Fdiv>\n\n\u003Chr>\n前言\n\u003Cp>请各大网友尊重本人原创知识分享，谨记本人博客：\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">南国以南i\u003C\u002Fa>、微信公众号：\u003Cstrong>白码梦想家\u003C\u002Fstrong>\u003Cbr>\u003Cimg src=\"https:\u002F\u002Fi1.wp.com\u002Fimg2024.cnblogs.com\u002Fblog\u002F1867541\u002F202608\u002F1867541-20260831165110853-162420906.png?w=720&amp;quality=65&amp;strip=all\" alt=\"关注\">\u003C\u002Fp>\n\u003Cp>\u003Ccode>提示：以下是本篇文章正文内容，下面案例可供参考\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cp>在登录、注册、忘记密码、修改密码这类场景里，密码字段通常会穿过浏览器、网关、后端服务、日志链路、监控平台和异常平台。即使系统已经启用了 HTTPS，内部链路、代理日志、错误日志里仍然可能留下敏感字段的影子。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>重点：\u003C\u002Fstrong>RSA 密码传输加密不是替代 HTTPS，而是在 HTTPS 之上，对密码字段再增加一层应用层保护。\u003C\u002Fp>\n\u003Cp>这套方案的目标很简单：\u003Cstrong>前端只负责用公钥加密密码，后端只负责用私钥解密密码，原有登录、注册、改密等业务流程尽量不变。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Chr>\n一、介绍\n\u003Cp>前端调用后端公钥接口，拿到 \u003Ccode>publicKeyPem\u003C\u002Fcode>、\u003Ccode>keyId\u003C\u002Fcode> 和一次性 \u003Ccode>nonce\u003C\u002Fcode>；提交密码接口时，前端使用 RSA 公钥加密密码字段，并把密文和 \u003Ccode>nonce\u003C\u002Fcode> 一起传给后端。后端通过 AOP 切面在业务方法执行前自动完成 nonce 校验、私钥解密和字段回填，业务代码拿到的仍然是原始明文密码。\u003C\u002Fp>\n\u003Cpre>\u003Ccode>前端获取公钥 \u002F keyId \u002F nonce\n        ↓\n前端 RSA-OAEP-256 加密 password\n        ↓\n提交 RSA:v1:&lt;keyId&gt;:&lt;cipherText&gt; + nonce\n        ↓\n后端 AOP 切面拦截\n        ↓\nRedis 原子消费 nonce\n        ↓\n后端按 keyId 找私钥并解密\n        ↓\nDTO 密文字段被替换成明文\n        ↓\n原业务逻辑继续执行\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>推荐：\u003C\u002Fstrong>把解密逻辑收敛到 AOP 切面，避免每个 Controller 或 Service 都重复写解密代码。\u003C\u002Fp>\n\u003Chr>\n二、RSA 在本方案中的角色\n\u003Cp>RSA 是非对称加密算法，核心特点是：\u003Cstrong>公钥可以公开，私钥必须保密。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>在密码传输加密中，各字段含义如下：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>字段\u003C\u002Fth>\n   \u003Cth>作用\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>publicKeyPem\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>RSA 公钥，可以返回给前端，用于加密密码\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>privateKeyPem\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>RSA 私钥，只能后端保存，用于解密密码\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>keyId\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>密钥编号，用于区分当前密钥和历史兼容密钥\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>nonce\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>一次性随机值，防止同一段密文被重复提交\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>cacheSeconds\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>前端缓存 \u003Ccode>publicKeyPem\u002FkeyId\u003C\u002Fcode> 的时间\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>nonceTtlSeconds\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>\u003Ccode>nonce\u003C\u002Fcode> 在 Redis 中的有效期\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Cstrong>注意：\u003C\u002Fstrong>RSA 密文本身不是“一次性凭证”。如果别人拿到某次请求里的完整密文，理论上仍可以再次提交给同一个接口。真正防重放的是一次性 \u003Ccode>nonce\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Chr>\n三、密文协议格式\n\u003Cp>前端提交密码字段时，建议统一使用下面的格式：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>RSA:v1:&lt;keyId&gt;:&lt;Base64CipherText&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>示例：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>RSA:v1:rsa-202608-001:Wm9uZ0hpZGRlbkNpcGhlclRleHQ...\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>每一段含义：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>片段\u003C\u002Fth>\n   \u003Cth>含义\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>RSA\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>协议标识\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>v1\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>协议版本，方便后续扩展\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>keyId\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>使用哪一把公钥加密\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>Base64CipherText\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>RSA 加密后的密文字节，再做 Base64 编码\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Cstrong>提示：\u003C\u002Fstrong>\u003Ccode>nonce\u003C\u002Fcode> 建议作为请求体独立字段传递，不放进密文里。这样后端可以先校验 nonce，再决定是否继续解密。\u003C\u002Fp>\n\u003Chr>\n四、密钥生成与 Apollo 配置\n\u003Cp>可以使用 Java 离线工具生成 RSA 公私钥。工具类每次运行都会通过 \u003Ccode>SecureRandom\u003C\u002Fcode> 生成一套新的密钥，所以\u003Cstrong>每次生成的公钥和私钥都不一样\u003C\u002Fstrong>。同一次生成的公钥和私钥必须配对使用。\u003C\u002Fp>\n\u003Ch2>4.1 编译工具类\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>javac -encoding UTF-8 tools\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>4.2 执行生成密钥\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs\u002Fsecurity\u002Frsa-202608-001 --quiet\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>这里使用 \u003Ccode>--quiet\u003C\u002Fcode> 是为了避免在控制台打印完整私钥。即使使用 \u003Ccode>--quiet\u003C\u002Fcode>，工具仍会生成完整的 \u003Ccode>apollo.properties\u003C\u002Fcode> 文件。\u003C\u002Fp>\n\u003Ch2>4.3 执行输出示例\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>RSA key pair generated.\nkeyId: rsa-202608-001\nkeySize: 3072\npublicKeyPem: rsa-doc-output\\rsa-202608-001-public.pem\nprivateKeyPem: rsa-doc-output\\rsa-202608-001-private.pem\napolloProperties: rsa-doc-output\\rsa-202608-001-apollo.properties\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>生成结果通常包括：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>rsa-202608-001-public.pem\nrsa-202608-001-private.pem\nrsa-202608-001-apollo.properties\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>禁止：\u003C\u002Fstrong>不要把私钥文件、完整 Apollo 私钥配置、\u003Ccode>outputs\u002Fsecurity\u002F\u003C\u002Fcode> 目录提交到 Git 仓库。\u003C\u002Fp>\n\u003Ch2>4.4 Apollo 配置示例\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>security.crypto.password.enabled=true\nsecurity.crypto.password.mode=compat\nsecurity.crypto.password.algorithm=RSA-OAEP-256\nsecurity.crypto.password.cacheSeconds=1800\nsecurity.crypto.password.nonceTtlSeconds=300\nsecurity.crypto.password.maxPlaintextUtf8Bytes=128\nsecurity.crypto.password.keyId=rsa-202608-001\nsecurity.crypto.password.publicKeyPem=-----BEGIN PUBLIC KEY-----\\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A...\\n-----END PUBLIC KEY-----\nsecurity.crypto.password.privateKeyPem=-----BEGIN PRIVATE KEY-----\\n&lt;replace-with-generated-private-key&gt;\\n-----END PRIVATE KEY-----\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>重点：\u003C\u002Fstrong>\u003Ccode>privateKeyPem\u003C\u002Fcode> 只能保存在后端配置中心或 KMS\u002FHSM 中，不能返回给前端，不能写入前端代码，不能出现在接口响应里。\u003C\u002Fp>\n\u003Chr>\n五、后端接入方式\n\u003Cp>后端建议拆成五个通用组件：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>组件\u003C\u002Fth>\n   \u003Cth>职责\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>PasswordCryptoProperties\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>读取配置中心中的开关、算法、密钥、TTL\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>CryptoPublicKeyController\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>提供公钥和一次性 nonce\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>RsaKeyProvider\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>根据 \u003Ccode>keyId\u003C\u002Fcode> 加载当前私钥或历史私钥\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>CryptoNonceStore\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>Redis 存储和原子消费 nonce\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>DecryptSensitiveFieldsAspect\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>AOP 拦截请求并自动解密敏感字段\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>公钥接口建议返回：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{\n  \"respCode\": \"20000\",\n  \"respMsg\": \"success\",\n  \"data\": {\n    \"enabled\": true,\n    \"mode\": \"compat\",\n    \"version\": \"v1\",\n    \"algorithm\": \"RSA-OAEP-256\",\n    \"keyId\": \"rsa-202608-001\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----...\",\n    \"cacheSeconds\": 1800,\n    \"nonce\": \"one-time-nonce\",\n    \"nonceTtlSeconds\": 300,\n    \"serverTime\": \"2026-08-31T10:00:00.000Z\",\n    \"nonceExpiresAt\": \"2026-08-31T10:05:00.000Z\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>建议：\u003C\u002Fstrong>\u003Ccode>serverTime\u003C\u002Fcode> 和 \u003Ccode>nonceExpiresAt\u003C\u002Fcode> 使用 UTC ISO 8601，例如 \u003Ccode>2026-08-31T10:05:00.000Z\u003C\u002Fcode>。前端展示或预判断时更稳定，也能避免时区字符串被误解析。\u003C\u002Fp>\n\u003Ch2>AOP 注解示例\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface DecryptSensitiveFields {\n    String[] value();\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>业务接口只声明哪些字段需要解密：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>@PostMapping(\"\u002Flogin\")\n@DecryptSensitiveFields({\"password\"})\npublic Result&lt;?&gt; login(@RequestBody LoginRequest request) {\n    \u002F\u002F 进入业务方法时，request.password 已经被切面还原成明文\n    return loginService.login(request);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>修改密码接口可以声明多个字段：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>@PostMapping(\"\u002Fpassword\u002Fchange\")\n@DecryptSensitiveFields({\"oldPassword\", \"newPassword\"})\npublic Result&lt;?&gt; changePassword(@RequestBody ChangePasswordRequest request) {\n    return passwordService.change(request);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>好处：\u003C\u002Fstrong>Controller 和业务 Service 不需要关心 RSA 细节。是否加密、如何解密、nonce 是否过期，都由切面统一处理。\u003C\u002Fp>\n\u003Chr>\n六、后端解密流程\n\u003Cp>AOP 切面进入业务方法前，建议按下面顺序处理：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>1. 判断全局开关 enabled\n2. 判断 mode：compat 或 force\n3. 解析密文格式 RSA:v1:&lt;keyId&gt;:&lt;cipherText&gt;\n4. 从请求体读取 nonce\n5. Redis 原子消费 nonce\n6. 校验 nonce 中绑定的 keyId 与密文 keyId 一致\n7. 按 keyId 找到私钥\n8. 使用 RSA-OAEP-256 解密\n9. 将 DTO 中的密文字段替换为明文\n10. 放行业务方法\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Redis 消费 nonce 必须是原子的：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>local v = redis.call('GET', KEYS[1])\nif v then\n  redis.call('DEL', KEYS[1])\nend\nreturn v\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>如果 Redis 返回空，说明 \u003Ccode>nonce\u003C\u002Fcode> 已过期或已经被使用过，建议返回：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{\n  \"respCode\": \"32007\",\n  \"respMsg\": \"Password encryption nonce expired\",\n  \"data\": null\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>注意：\u003C\u002Fstrong>后端判断 nonce 是否过期依赖 Redis TTL，不依赖前端传入时间，也不依赖用户所在时区。\u003C\u002Fp>\n\u003Chr>\n七、前端接入方式\n\u003Cp>前端只需要关心三件事：获取公钥、加密字段、提交 nonce。\u003C\u002Fp>\n\u003Ch2>7.1 获取公钥和 nonce\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>GET \u002Fsecurity\u002Fcrypto\u002Fpublic-key\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>前端可以按 \u003Ccode>cacheSeconds\u003C\u002Fcode> 缓存 \u003Ccode>publicKeyPem\u002FkeyId\u003C\u002Fcode>，但每次提交密码前都应该获取新的 \u003Ccode>nonce\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>重点：\u003C\u002Fstrong>\u003Ccode>cacheSeconds\u003C\u002Fcode> 只能用于缓存公钥和 \u003Ccode>keyId\u003C\u002Fcode>，不能缓存包含 \u003Ccode>nonce\u003C\u002Fcode> 的完整响应。\u003C\u002Fp>\n\u003Ch2>7.2 使用 RSA-OAEP-256 加密\u003C\u002Fh2>\n\u003Cp>浏览器推荐使用 Web Crypto API：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>async function encryptPassword(publicKey, password) {\n  const encoded = new TextEncoder().encode(password);\n  const cipherBuffer = await crypto.subtle.encrypt(\n    { name: \"RSA-OAEP\" },\n    publicKey,\n    encoded\n  );\n  return btoa(String.fromCharCode(...new Uint8Array(cipherBuffer)));\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>导入公钥时要使用 \u003Ccode>SHA-256\u003C\u002Fcode>：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>const cryptoKey = await crypto.subtle.importKey(\n  \"spki\",\n  publicKeyDer,\n  { name: \"RSA-OAEP\", hash: \"SHA-256\" },\n  false,\n  [\"encrypt\"]\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>7.3 提交业务接口\u003C\u002Fh2>\n\u003Cpre>\u003Ccode>{\n  \"loginId\": \"demo@example.com\",\n  \"password\": \"RSA:v1:rsa-202608-001:Base64CipherText\",\n  \"nonce\": \"one-time-nonce\"\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>后端切面解密后，业务层看到的是：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{\n  \"loginId\": \"demo@example.com\",\n  \"password\": \"plain-password\"\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Chr>\n八、密钥轮换\n\u003Cp>RSA PEM 本身没有业务有效期。\u003Ccode>nonceTtlSeconds\u003C\u002Fcode> 也不是密钥有效期，它只是一次性 nonce 的 Redis TTL。\u003C\u002Fp>\n\u003Cp>密钥有效期应由运维策略或安全策略定义，例如每 90 天轮换一次：\u003C\u002Fp>\n\u003Cpre>\u003Ccode>1. 生成新密钥 rsa-202609-001\n2. 将新密钥配置为当前 keyId\n3. 将旧密钥 rsa-202608-001 放入 compatibleKeys\n4. 等待前端缓存和在途请求自然结束\n5. 下线旧密钥\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Cstrong>提示：\u003C\u002Fstrong>不要用同一个 \u003Ccode>keyId\u003C\u002Fcode> 覆盖新密钥。否则前端缓存的旧公钥会和后端新私钥不匹配，导致解密失败。\u003C\u002Fp>\n\u003Chr>\n九、联调验收重点\n\u003Cp>建议至少验证这些场景：\u003C\u002Fp>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>场景\u003C\u002Fth>\n   \u003Cth>预期\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>正常登录\u003C\u002Ftd>\n   \u003Ctd>密文提交，后端解密成功\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>nonce 重复使用\u003C\u002Ftd>\n   \u003Ctd>返回 \u003Ccode>32007\u003C\u002Fcode>\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>nonce 过期\u003C\u002Ftd>\n   \u003Ctd>返回 \u003Ccode>32007\u003C\u002Fcode>\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>keyId 不存在\u003C\u002Ftd>\n   \u003Ctd>返回密钥不存在或参数非法\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>密文格式错误\u003C\u002Ftd>\n   \u003Ctd>返回参数非法\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>enabled=false\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>密文请求被拒绝或按降级策略处理\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>mode=compat\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>密文和明文都可过渡\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>\u003Ccode>mode=force\u003C\u002Fcode>\u003C\u002Ftd>\n   \u003Ctd>明文密码被拒绝\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Cstrong>最后提醒：\u003C\u002Fstrong>日志里不能打印明文密码、私钥、完整密文。排查问题时只打印 \u003Ccode>keyId\u003C\u002Fcode>、接口路径、字段名和错误码即可。\u003C\u002Fp>\n\u003Chr>\n附录：RSA 密钥生成工具类\n\u003Cp>下面的 Java 工具类可以直接复制到其它项目中使用。它只依赖 JDK 标准库，默认生成 RSA-3072 密钥，私钥格式为 PKCS8，公钥格式为 X.509，适合后端 Java 解析和前端 Web Crypto 使用。\u003C\u002Fp>\n\u003Cpre>\u003Ccode>import java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.security.KeyPair;\nimport java.security.KeyPairGenerator;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\nimport java.time.LocalDate;\nimport java.time.format.DateTimeFormatter;\nimport java.util.Base64;\n\n\u002F**\n * Offline RSA key-pair generator for password transport encryption.\n *\n * Usage:\n *   javac -encoding UTF-8 tools\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes\n *   java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs\u002Fsecurity\u002Frsa-202608-001\n *\u002F\npublic final class RsaKeyPairGeneratorTool {\n\n    private RsaKeyPairGeneratorTool() {\n    }\n\n    private static final String RSA = \"RSA\";\n    private static final int DEFAULT_KEY_SIZE = 3072;\n    private static final String DEFAULT_OUTPUT_ROOT = \"outputs\u002Fsecurity\";\n    private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern(\"yyyyMM\");\n\n    public static void main(String[] args) throws Exception {\n        Options options = Options.parse(args);\n        if (options.help) {\n            printUsage();\n            return;\n        }\n\n        GeneratedRsaKeyPair keyPair = generate(options.keyId, options.keySize);\n        OutputFiles outputFiles = writeToDirectory(keyPair, options.outDir, options.force);\n\n        System.out.println(\"RSA key pair generated.\");\n        System.out.println(\"keyId: \" + keyPair.getKeyId());\n        System.out.println(\"keySize: \" + keyPair.getKeySize());\n        System.out.println(\"publicKeyPem: \" + outputFiles.getPublicPemPath().toAbsolutePath());\n        System.out.println(\"privateKeyPem: \" + outputFiles.getPrivatePemPath().toAbsolutePath());\n        System.out.println(\"apolloProperties: \" + outputFiles.getApolloPropertiesPath().toAbsolutePath());\n        if (!options.quiet) {\n            System.out.println();\n            System.out.println(\"Apollo values:\");\n            System.out.println(toApolloProperties(keyPair));\n        }\n    }\n\n    public static GeneratedRsaKeyPair generate(String keyId) throws NoSuchAlgorithmException {\n        return generate(keyId, DEFAULT_KEY_SIZE);\n    }\n\n    public static GeneratedRsaKeyPair generate(String keyId, int keySize) throws NoSuchAlgorithmException {\n        if (keyId == null || keyId.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"keyId cannot be blank\");\n        }\n        if (keySize &lt; 2048) {\n            throw new IllegalArgumentException(\"keySize must be at least 2048\");\n        }\n        KeyPair keyPair = generateKeyPair(keySize);\n        String publicPem = toPem(\"PUBLIC KEY\", keyPair.getPublic().getEncoded());\n        String privatePem = toPem(\"PRIVATE KEY\", keyPair.getPrivate().getEncoded());\n        return new GeneratedRsaKeyPair(keyId, keySize, publicPem, privatePem);\n    }\n\n    public static OutputFiles writeToDirectory(GeneratedRsaKeyPair keyPair, Path outDir, boolean force) throws IOException {\n        if (keyPair == null) {\n            throw new IllegalArgumentException(\"keyPair cannot be null\");\n        }\n        if (outDir == null) {\n            throw new IllegalArgumentException(\"outDir cannot be null\");\n        }\n        Files.createDirectories(outDir);\n        Path publicPemPath = outDir.resolve(keyPair.getKeyId() + \"-public.pem\");\n        Path privatePemPath = outDir.resolve(keyPair.getKeyId() + \"-private.pem\");\n        Path apolloPath = outDir.resolve(keyPair.getKeyId() + \"-apollo.properties\");\n\n        writeFile(publicPemPath, keyPair.getPublicKeyPem(), force);\n        writeFile(privatePemPath, keyPair.getPrivateKeyPem(), force);\n        writeFile(apolloPath, toApolloProperties(keyPair), force);\n        return new OutputFiles(publicPemPath, privatePemPath, apolloPath);\n    }\n\n    public static String toApolloProperties(GeneratedRsaKeyPair keyPair) {\n        if (keyPair == null) {\n            throw new IllegalArgumentException(\"keyPair cannot be null\");\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"security.crypto.password.keyId=\").append(keyPair.getKeyId()).append('\\n');\n        builder.append(\"security.crypto.password.publicKeyPem=\").append(toApolloValue(keyPair.getPublicKeyPem())).append('\\n');\n        builder.append(\"security.crypto.password.privateKeyPem=\").append(toApolloValue(keyPair.getPrivateKeyPem())).append('\\n');\n        return builder.toString();\n    }\n\n    private static KeyPair generateKeyPair(int keySize) throws NoSuchAlgorithmException {\n        KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA);\n        generator.initialize(keySize, new SecureRandom());\n        return generator.generateKeyPair();\n    }\n\n    private static String toPem(String type, byte[] encoded) {\n        String base64 = Base64.getMimeEncoder(64, new byte[]{'\\n'}).encodeToString(encoded);\n        return \"-----BEGIN \" + type + \"-----\\n\" + base64 + \"\\n-----END \" + type + \"-----\\n\";\n    }\n\n    private static String toApolloValue(String pem) {\n        return pem.trim().replace(\"\\r\\n\", \"\\n\").replace(\"\\n\", \"\\\\n\");\n    }\n\n    private static void writeFile(Path path, String content, boolean force) throws IOException {\n        if (Files.exists(path) &amp;&amp; !force) {\n            throw new IllegalStateException(\"File already exists: \" + path.toAbsolutePath()\n                    + \". Use --force to overwrite.\");\n        }\n        Files.write(path, content.getBytes(StandardCharsets.UTF_8));\n    }\n\n    private static void printUsage() {\n        System.out.println(\"Usage:\");\n        System.out.println(\"  javac -encoding UTF-8 tools\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes\");\n        System.out.println(\"  java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool [options]\");\n        System.out.println();\n        System.out.println(\"Options:\");\n        System.out.println(\"  --key-id &lt;value&gt;    RSA key id. Default: rsa-yyyyMM-001\");\n        System.out.println(\"  --key-size &lt;bits&gt;   RSA key size. Default: 3072\");\n        System.out.println(\"  --out-dir &lt;path&gt;    Output directory. Default: outputs\u002Fsecurity\u002F&lt;key-id&gt;\");\n        System.out.println(\"  --force             Overwrite existing output files\");\n        System.out.println(\"  --quiet             Do not print Apollo values to stdout\");\n        System.out.println(\"  --help              Show this help\");\n    }\n\n    public static final class GeneratedRsaKeyPair {\n        private final String keyId;\n        private final int keySize;\n        private final String publicKeyPem;\n        private final String privateKeyPem;\n\n        private GeneratedRsaKeyPair(String keyId, int keySize, String publicKeyPem, String privateKeyPem) {\n            this.keyId = keyId;\n            this.keySize = keySize;\n            this.publicKeyPem = publicKeyPem;\n            this.privateKeyPem = privateKeyPem;\n        }\n\n        public String getKeyId() {\n            return keyId;\n        }\n\n        public int getKeySize() {\n            return keySize;\n        }\n\n        public String getPublicKeyPem() {\n            return publicKeyPem;\n        }\n\n        public String getPrivateKeyPem() {\n            return privateKeyPem;\n        }\n    }\n\n    public static final class OutputFiles {\n        private final Path publicPemPath;\n        private final Path privatePemPath;\n        private final Path apolloPropertiesPath;\n\n        private OutputFiles(Path publicPemPath, Path privatePemPath, Path apolloPropertiesPath) {\n            this.publicPemPath = publicPemPath;\n            this.privatePemPath = privatePemPath;\n            this.apolloPropertiesPath = apolloPropertiesPath;\n        }\n\n        public Path getPublicPemPath() {\n            return publicPemPath;\n        }\n\n        public Path getPrivatePemPath() {\n            return privatePemPath;\n        }\n\n        public Path getApolloPropertiesPath() {\n            return apolloPropertiesPath;\n        }\n    }\n\n    private static class Options {\n        private String keyId = \"rsa-\" + LocalDate.now().format(MONTH_FORMATTER) + \"-001\";\n        private int keySize = DEFAULT_KEY_SIZE;\n        private Path outDir;\n        private boolean force;\n        private boolean quiet;\n        private boolean help;\n\n        private static Options parse(String[] args) {\n            Options options = new Options();\n            for (int i = 0; i &lt; args.length; i++) {\n                String arg = args[i];\n                if (\"--help\".equals(arg) || \"-h\".equals(arg)) {\n                    options.help = true;\n                    continue;\n                }\n                if (\"--force\".equals(arg)) {\n                    options.force = true;\n                    continue;\n                }\n                if (\"--quiet\".equals(arg)) {\n                    options.quiet = true;\n                    continue;\n                }\n                if (\"--key-id\".equals(arg)) {\n                    options.keyId = requireValue(args, ++i, arg);\n                    continue;\n                }\n                if (\"--key-size\".equals(arg)) {\n                    options.keySize = Integer.parseInt(requireValue(args, ++i, arg));\n                    continue;\n                }\n                if (\"--out-dir\".equals(arg)) {\n                    options.outDir = Paths.get(requireValue(args, ++i, arg));\n                    continue;\n                }\n                throw new IllegalArgumentException(\"Unknown option: \" + arg);\n            }\n            validate(options);\n            if (options.outDir == null) {\n                options.outDir = Paths.get(DEFAULT_OUTPUT_ROOT, options.keyId);\n            }\n            return options;\n        }\n\n        private static String requireValue(String[] args, int index, String optionName) {\n            if (index &gt;= args.length || args[index].startsWith(\"--\")) {\n                throw new IllegalArgumentException(\"Missing value for \" + optionName);\n            }\n            return args[index];\n        }\n\n        private static void validate(Options options) {\n            if (options.keyId == null || options.keyId.trim().isEmpty()) {\n                throw new IllegalArgumentException(\"--key-id cannot be blank\");\n            }\n            if (options.keySize &lt; 2048) {\n                throw new IllegalArgumentException(\"--key-size must be at least 2048\");\n            }\n        }\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n总结\n\u003Cp>我是\u003Ca href=\"https:\u002F\u002Fwww.cnblogs.com\u002Fbgyb\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">南国以南i\u003C\u002Fa>记录点滴每天成长一点点，学习是永无止境的！转载请附原文链接！！！\u003Cbr>\u003Cimg src=\"https:\u002F\u002Fi1.wp.com\u002Fimg2024.cnblogs.com\u002Fblog\u002F1867541\u002F202608\u002F1867541-20260831165110853-162420906.png?w=720&amp;quality=65&amp;strip=all\" alt=\"关注\">\u003C\u002Fp>","@ 目录 前言 一、介绍 二、RSA 在本方案中的角色 三、密文协议格式 四、密钥生成与 Apollo 配置 4.1 编译工具类 4.2 执行生成密钥 4.3 执行输出示例 4.4 Apollo 配置示例 五、后端接入方式 AOP 注解示例 六、后端解密流程 七、前端接入方式 7.1 获取公钥和 nonce 7.2 使用 RSA-OAEP-256 加密 7.3 提交业务接口 八、密钥轮换 九、联调验收重点 附录：RSA 密钥生成工具类 总结 前言 请各大网友尊重本人原创知识分享，谨记本人博客：南国以南i、微信公众号：白码梦想家 提示：以下是本篇文章正文内容，下面案例可供参考 在登录、注册、忘记密码、修改密码这类场景里，密码字段通常会穿过浏览器、网关、后端服务、日志链路、监控平台和异常平台。即使系统已经启用了 HTTPS，内部链路、代理日志、错误日志里仍然可能留下敏感字段的影子。 重点：RSA 密码传输加密不是替代 HTTPS，而是在 HTTPS 之上，对密码字段再增加一层应用层保护。 这套方案的目标很简单：前端只负责用公钥加密密码，后端只负责用私钥解密密码，原有登录、注册、改密等业务流程尽量不变。 一、介绍 前端调用后端公钥接口，拿到 publicKeyPem、keyId 和一次性 nonce；提交密码接口时，前端使用 RSA 公钥加密密码字段，并把密文和 nonce 一起传给后端。后端通过 AOP 切面在业务方法执行前自动完成 nonce 校验、私钥解密和字段回填，业务代码拿到的仍然是原始明文密码。 前端获取公钥 \u002F keyId \u002F nonce ↓ 前端 RSA-OAEP-256 加密 password ↓ 提交 RSA:v1:\u003CkeyId>:\u003CcipherText> + nonce ↓ 后端 AOP 切面拦截 ↓ Redis 原子消费 nonce ↓ 后端按 keyId 找私钥并解密 ↓ DTO 密文字段被替换成明文 ↓ 原业务逻辑继续执行 推荐：把解密逻辑收敛到 AOP 切面，避免每个 Controller 或 Service 都重复写解密代码。 二、RSA 在本方案中的角色 RSA 是非对称加密算法，核心特点是：公钥可以公开，私钥必须保密。 在密码传输加密中，各字段含义如下： 字段 作用 publicKeyPem RSA 公钥，可以返回给前端，用于加密密码 privateKeyPem RSA 私钥，只能后端保存，用于解密密码 keyId 密钥编号，用于区分当前密钥和历史兼容密钥 nonce 一次性随机值，防止同一段密文被重复提交 cacheSeconds 前端缓存 publicKeyPem\u002FkeyId 的时间 nonceTtlSeconds nonce 在 Redis 中的有效期 注意：RSA 密文本身不是“一次性凭证”。如果别人拿到某次请求里的完整密文，理论上仍可以再次提交给同一个接口。真正防重放的是一次性 nonce。 三、密文协议格式 前端提交密码字段时，建议统一使用下面的格式： RSA:v1:\u003CkeyId>:\u003CBase64CipherText> 示例： 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\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes 4.2 执行生成密钥 java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs\u002Fsecurity\u002Frsa-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\u002Fsecurity\u002F 目录提交到 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\u003Creplace-with-generated-private-key>\\n-----END PRIVATE KEY----- 重点：privateKeyPem 只能保存在后端配置中心或 KMS\u002FHSM 中，不能返回给前端，不能写入前端代码，不能出现在接口响应里。 五、后端接入方式 后端建议拆成五个通用组件： 组件 职责 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(\"\u002Flogin\") @DecryptSensitiveFields({\"password\"}) public Result\u003C?> login(@RequestBody LoginRequest request) { \u002F\u002F 进入业务方法时，request.password 已经被切面还原成明文 return loginService.login(request); } 修改密码接口可以声明多个字段： @PostMapping(\"\u002Fpassword\u002Fchange\") @DecryptSensitiveFields({\"oldPassword\", \"newPassword\"}) public Result\u003C?> changePassword(@RequestBody ChangePasswordRequest request) { return passwordService.change(request); } 好处：Controller 和业务 Service 不需要关心 RSA 细节。是否加密、如何解密、nonce 是否过期，都由切面统一处理。 六、后端解密流程 AOP 切面进入业务方法前，建议按下面顺序处理： 1. 判断全局开关 enabled 2. 判断 mode：compat 或 force 3. 解析密文格式 RSA:v1:\u003CkeyId>:\u003CcipherText> 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 \u002Fsecurity\u002Fcrypto\u002Fpublic-key 前端可以按 cacheSeconds 缓存 publicKeyPem\u002FkeyId，但每次提交密码前都应该获取新的 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; \u002F** * Offline RSA key-pair generator for password transport encryption. * * Usage: * javac -encoding UTF-8 tools\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes * java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool --key-id rsa-202608-001 --out-dir outputs\u002Fsecurity\u002Frsa-202608-001 *\u002F 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\u002Fsecurity\"; 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 \u003C 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\u002Fsecurity\u002FRsaKeyPairGeneratorTool.java -d .codex_tmp\u002Fkeygen-classes\"); System.out.println(\" java -cp .codex_tmp\u002Fkeygen-classes RsaKeyPairGeneratorTool [options]\"); System.out.println(); System.out.println(\"Options:\"); System.out.println(\" --key-id \u003Cvalue> RSA key id. Default: rsa-yyyyMM-001\"); System.out.println(\" --key-size \u003Cbits> RSA key size. Default: 3072\"); System.out.println(\" --out-dir \u003Cpath> Output directory. Default: outputs\u002Fsecurity\u002F\u003Ckey-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 \u003C 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 \u003C 2048) { throw new IllegalArgumentException(\"--key-size must be at least 2048\"); } } } } 总结 我是南国以南i记录点滴每天成长一点点，学习是永无止境的！转载请附原文链接！！！",13478,{"id":6,"kind":7,"title":11,"summary":13,"image":14,"href":16,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":40},"2026 · 软件开发","#2563eb","16 \u002F 10",[19],{"targetType":8,"targetId":9,"likedByMe":42,"likeCount":43,"commentCount":43,"contentLikeCount":43,"contentCommentCount":43,"sourceLikeCount":43,"sourceCommentCount":43},false,0,[45,51,58,64,70,77,84,90],{"id":46,"kind":7,"title":47,"summary":48,"image":15,"href":49,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":50},"NEWS_ARTICLE:832","用 runtime-async 写一个支持 async\u002Fawait 的轻量脚本引擎","起因：一个好奇 事情的开头很简单。 给 .NET 做过动态脚本的人大概都碰到过同一堵墙：表达式树也好、Reflection.Emit 也好，都很难支持 async\u002Fawait。原因不在语法，而在 await 的实现方式——C# 编译器要为每个 async 方法生成一个状态机结构体，把方法体切成若干片","\u002Fnews\u002F832",[19],{"id":52,"kind":7,"title":53,"summary":54,"image":55,"href":56,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":57},"NEWS_ARTICLE:855","被罚了500后，整个人都变老实了","我们组有个同事，以前是那种闲不住的人。现在也闲不住 只不过是不敢动了。 去年部门空降了一位新总监，阿里P8，第一次全员会PPT上就八个大字：拥抱变化，永不言弃。 新领导上来搞流程改革，每个模块指定Owner，出事追责到人，A级事故罚款500起步，B级300 依次类推，发版上线改配置全走审批。说实话之","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F273387\u002F202608\u002F273387-20260818222617092-925481998.png","\u002Fnews\u002F855",[19],{"id":59,"kind":7,"title":60,"summary":61,"image":15,"href":62,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":63},"NEWS_ARTICLE:862","go中make声明切片, 修改切片,append给切片扩容,合并切片,复制切片","make 声明切片 make([]类型, 长度， 容量) kage main import (&quot;fmt&quot;) \u002F\u002F 入口函数 func main() { \u002F\u002F make 声明切片，长度是4， 容量是10 var sliceArr1 = make([]int, 4, 10) fmt.","\u002Fnews\u002F862",[19],{"id":65,"kind":7,"title":66,"summary":67,"image":15,"href":68,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":69},"NEWS_ARTICLE:866","Spring Boot事件监听，这东西到底解决啥问题？","一、先聊个场景，你就明白这玩意儿干啥的了 点过外卖吧？那咱们就用这个场景来说事。 你掏出手机下了单，付了钱。接下来会发生啥？ 厨房那头开始备菜炒菜（这件事耽误不得，客户饿着呢） 手机收到一条短信：&quot;您的订单已收到，预计30分钟送达&quot; 你的会员账户里多了一堆积分 店长那个收银小喇叭","\u002Fnews\u002F866",[19],{"id":71,"kind":7,"title":72,"summary":73,"image":74,"href":75,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":76},"NEWS_ARTICLE:888","[开源] LogCrate：免解压、支持筛选和 AI 分析的 PC 端桌面日志工具","日常排查客户软件运行问题时，经常需要反复下载日志、解压缩，再从一堆文件里慢慢翻找，过程比较麻烦；而且很多日志工具也不支持针对具体字段进行筛选。 对比了一些市面上的日志分析工具后，发现能够同时兼容归档阅读、目录监控、到达通知、结构化筛选和 AI 分析的工具并不多，于是就自己动手做了一款。 PS：自己做","https:\u002F\u002Fiili.io\u002FCy4ztaa.png","\u002Fnews\u002F888",[19],{"id":78,"kind":7,"title":79,"summary":80,"image":81,"href":82,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":83},"NEWS_ARTICLE:900","java 多线程开发系列之七：玩转多线程（线程的协作）","线程的协作多种多样，这次主要说说wait和notify两种Object的方法。这也是java早期原生的重要的协作机制之一。先说下这两个英文单词：wait [weɪt] v.等待;等候;(尤指长期地)希望，盼望，期待notify [ˈnəʊtɪfaɪ] vt.通知;(正式)通报;也就是一个等待，一个通","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F704073\u002F202608\u002F704073-20260831103138251-1707185366.png","\u002Fnews\u002F900",[19],{"id":85,"kind":7,"title":86,"summary":87,"image":15,"href":88,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":89},"NEWS_ARTICLE:902","DeepSeek Harness 插件","准备环境 dsh 从源码运行，见 dsh 说明。或见之前分享的 DeepSeek Harness 开始。 开发插件 依照 dsh 文档，动手做一遍： 第一个插件: https:\u002F\u002Fdeepseek-harness.github.io\u002Fdeepseek-harness\u002Fdevelop\u002Fbasic\u002F 第","\u002Fnews\u002F902",[19],{"id":91,"kind":7,"title":92,"summary":93,"image":94,"href":95,"meta":37,"badge":10,"author":12,"stats":-1,"accent":38,"coverRatio":39,"tags":96},"NEWS_ARTICLE:917","[python] pywinauto使用指北","当业务系统没有API、命令行接口或可直接集成的数据通道时，桌面自动化往往是打通业务流程的最后一公里。pywinauto库通过Win32 API与Microsoft UI Automation（UIA）访问Windows窗口及控件，使Python脚本能够驱动桌面应用。本文聚焦于pywinauto的基础","https:\u002F\u002Fgitlab.com\u002Fluohenyueji\u002Farticle_picture_warehouse\u002F-\u002Fraw\u002Fmain\u002FCSDN\u002F%5Bpython%5D%20pywinauto%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8C%97\u002Fimgs\u002F1.jpg","\u002Fnews\u002F917",[19]]