
1. 项目概述与核心诉求最近在做一个内部系统的安全测评结果被揪出一个典型的安全漏洞用户登录时的账号密码竟然是明文传输。这问题说大不大说小不小在如今这个安全至上的时代任何一个明文传输敏感信息的系统都像是在裸奔。测评报告上那个醒目的“中危”标签就是最好的鞭策。我们的目标很明确就是要把这个明文传输的通道给加密堵上让数据在网络上跑起来的时候是密文即使被截获也看不懂。方案选型上非对称加密里的RSA成了不二之选。为什么是RSA而不是别的核心原因在于它的公私钥分离机制。前端比如浏览器只需要持有公钥这个公钥甚至可以公开用它来加密数据而后端则牢牢保管私钥用于解密。这样一来私钥永远不需要在网络中传输从根本上避免了密钥分发过程中的泄露风险。像AES这类对称加密算法虽然加解密速度快但密钥需要在前后端共享这个共享过程本身就需要一个安全的通道对于我们当前要解决的“首次通信安全”问题有点“先有鸡还是先有蛋”的悖论。RSA正好解决了这个信任起点的问题。这个改造涉及前后端协同。前端负责在登录表单提交前用JavaScript获取公钥并对账号密码进行加密后端则负责生成RSA密钥对、提供公钥接口、以及用私钥解密登录请求。听起来步骤不少但每一步拆解开来都是清晰可控的。接下来我就把这次从漏洞修复到完整实现RSA加密登录的实操过程、踩过的坑和最终沉淀下来的稳定方案详细分享一下。2. RSA加密登录的整体架构设计2.1 为什么选择RSA而非HTTPS看到这里可能有朋友会问既然怕明文传输为什么不直接全站上HTTPS这是一个非常好的问题。HTTPSHTTP over TLS/SSL确实是解决传输层安全的主流且推荐方案它提供了端到端的加密、身份认证和完整性保护。在我们这个案例中系统本身已经部署了HTTPS。那为什么还要在应用层再做一层RSA加密呢这里涉及到安全防御的“纵深防御”原则。HTTPS保护的是整个通信链路防止中间人窃听和篡改。然而在某些特定安全审计或等保测评要求中会明确要求“关键敏感信息如口令在客户端应进行不可逆加密或非对称加密后传输”。这意味着即使HTTPS链路在理论上绝对安全我们仍需在应用层证明我们对敏感数据的处理是符合安全规范的。此外考虑以下场景防止内部日志泄露应用服务器或负载均衡器的访问日志中可能会记录请求URL和参数。如果密码是明文即便在HTTPS下它也可能出现在日志文件里。加密后日志里留下的就是密文。前端安全边界延伸将加密职责赋予前端意味着从用户输入到密文离开浏览器这个阶段数据也得到了保护降低了恶意浏览器插件或脚本直接窃取明文的风险。所以我们的架构是HTTPS (传输层安全) RSA (应用层敏感信息加密)两者不是替代关系而是互补与增强。RSA在这里专注解决“密码明文在应用层协议中暴露”的问题。2.2 核心流程与组件交互整个加密登录流程可以梳理为以下几个核心步骤我画了一个简单的顺序图来帮助理解这里用文字描述流程密钥对生成与托管在服务端启动时或通过一个管理命令生成一对RSA公钥和私钥。私钥必须被极其安全地存储例如放入服务器的配置文件严格设置文件权限、或专用的密钥管理服务KMS中绝对禁止写入前端代码或通过不安全的接口暴露。公钥则可以提供给前端。前端获取公钥用户打开登录页时前端JavaScript主动调用后端的一个API例如GET /api/auth/public-key获取当前的RSA公钥。为了提高体验和减少请求这个公钥可以设置一个较长的缓存时间或者在后端公钥不变的情况下直接内嵌到页面中需注意缓存更新策略。加密与提交用户在表单中输入账号密码点击登录。提交事件被拦截前端JS使用获取到的公钥对密码字段通常只加密密码账号可加密也可不加密为了一致性建议都加密进行RSA加密。加密后的结果是一段Base64编码的字符串。然后用这个密文替换原表单的明文密码值再将表单正常提交。后端解密与验证后端接收到登录请求从请求参数中获取到加密后的密文Base64格式。使用安全存储的私钥对密文进行解密还原出明文密码。后续的流程就和普通登录一样了根据账号查找用户、比对密码哈希值等。这里的一个关键设计点是后端不需要存储“密码的RSA密文”。RSA加密是临时性的、仅用于传输过程。后端解密后得到的明文密码会立即用于密码校验通常是比对BCrypt、PBKDF2等算法生成的哈希值随后明文密码就从内存中丢弃。这符合“尽快销毁敏感数据”的安全实践。2.3 技术栈选型考量后端JavaJava生态中处理RSA的标准选择是java.security包下的KeyPairGenerator,Cipher等类。它们属于JCAJava Cryptography Architecture的一部分可靠且标准。也有像BouncyCastle这样的强大第三方库提供更多算法和特性但对于标准的RSA加解密JCA已完全足够避免引入不必要的依赖。前端JavaScript在浏览器端执行非对称加密需要一个可靠的JS库。jsencrypt是一个专为RSA设计的纯JavaScript库API简洁文档丰富且兼容性较好是我们的首选。需要注意的是由于RSA加密是计算密集型操作在性能较弱的移动设备上加密大量数据可能会有可感知的延迟因此我们只加密短文本账号密码。密钥格式RSA密钥有多种格式PKCS#1, PKCS#8, X.509等。为了前后端兼容我们统一使用PKCS#8格式的私钥BEGIN PRIVATE KEY和X.509格式的公钥BEGIN PUBLIC KEY。jsencrypt库默认支持这种格式的公钥。3. 服务端核心实现详解3.1 RSA密钥对的生成与管理密钥对的生成是一次性的但必须安全可靠。我们选择在服务启动时生成并将其加载到内存中。在实际生产环境中更推荐将密钥对尤其是私钥预生成后存放在环境变量或安全的配置中心。import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; public class RSAKeyPairGenerator { /** * 生成RSA密钥对 * param keySize 密钥长度推荐2048或4096。1024已不安全。 * return 包含公钥和私钥的KeyPair对象 */ public static KeyPair generateKeyPair(int keySize) throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(RSA); // 初始化密钥生成器指定长度 keyPairGenerator.initialize(keySize); return keyPairGenerator.generateKeyPair(); } /** * 将公钥转换为Base64编码的字符串格式便于传输给前端 */ public static String getPublicKeyBase64(KeyPair keyPair) { byte[] publicKeyBytes keyPair.getPublic().getEncoded(); // X.509格式 return Base64.getEncoder().encodeToString(publicKeyBytes); } /** * 将私钥转换为Base64编码的PKCS#8格式字符串妥善保存 */ public static String getPrivateKeyBase64(KeyPair keyPair) { byte[] privateKeyBytes keyPair.getPrivate().getEncoded(); // PKCS#8格式 return Base64.getEncoder().encodeToString(privateKeyBytes); } // 示例生成并打印密钥对 public static void main(String[] args) throws Exception { KeyPair keyPair generateKeyPair(2048); System.out.println( Public Key (Base64) ); System.out.println(getPublicKeyBase64(keyPair)); System.out.println(\n Private Key (Base64) ); System.out.println(getPrivateKeyBase64(keyPair)); // 重要私钥必须保存到安全的地方如配置文件权限600、或密钥管理服务。 } }注意密钥长度与性能安全权衡。RSA 2048位是目前公认的安全最小长度预计安全期到2030年左右。对于要求更高的场景可以使用4096位但加解密性能会下降尤其是前端加密耗时会更明显。切勿使用1024位它已被认为是不安全的。3.2 提供公钥接口与解密服务后端需要提供两个核心接口一个用于分发公钥一个用于处理登录内含解密逻辑。1. 公钥获取接口这个接口非常简单返回当前使用的公钥字符串。可以考虑加上缓存控制头让浏览器缓存一段时间减少请求。RestController RequestMapping(/api/auth) public class AuthController { Value(${rsa.public-key}) // 从配置文件中注入预生成的公钥 private String rsaPublicKey; GetMapping(/public-key) public ResponseEntityMapString, String getPublicKey() { MapString, String result new HashMap(); result.put(publicKey, rsaPublicKey); // 可以添加一个密钥ID用于支持多版本密钥轮转 result.put(keyId, key-20240527); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)) // 缓存1小时 .body(result); } }2. 登录接口与解密逻辑这是核心所在。控制器接收加密后的参数在服务层进行解密。Service public class LoginService { Value(${rsa.private-key}) private String rsaPrivateKeyBase64; /** * 使用RSA私钥解密字符串 * param encryptedBase64 前端传来的Base64编码密文 * return 解密后的明文 */ public String decryptByPrivateKey(String encryptedBase64) throws Exception { // 1. Base64解码 byte[] encryptedData Base64.getDecoder().decode(encryptedBase64); // 2. 解码私钥 byte[] privateKeyBytes Base64.getDecoder().decode(rsaPrivateKeyBase64); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(privateKeyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); PrivateKey privateKey keyFactory.generatePrivate(keySpec); // 3. 配置Cipher进行解密 Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); // 注意这个变换 cipher.init(Cipher.DECRYPT_MODE, privateKey); // 4. 执行解密 byte[] decryptedData cipher.doFinal(encryptedData); return new String(decryptedData, StandardCharsets.UTF_8); } public LoginResult login(LoginRequest request) { // 解密密码 String plainPassword; try { plainPassword decryptByPrivateKey(request.getEncryptedPassword()); // 解密后立即将请求对象中的密文引用置空帮助GC request.clearEncryptedPassword(); } catch (Exception e) { log.error(密码解密失败, e); throw new BusinessException(登录信息异常); } // 后续流程根据request.getUsername()查找用户使用BCrypt等验证plainPassword User user userService.findByUsername(request.getUsername()); if (user ! null passwordEncoder.matches(plainPassword, user.getPasswordHash())) { // 登录成功生成Token等... return LoginResult.success(...); } else { return LoginResult.fail(账号或密码错误); } } }关键点Cipher.getInstance(“RSA/ECB/PKCS1Padding”)。这个变换字符串非常重要。RSA是算法。ECB是加密模式。对于非对称加密由于每次加密的数据块大小受限例如2048位密钥最多加密245字节明文通常使用ECB模式。这不同于对称加密如AES中不推荐使用ECB的情况。PKCS1Padding是填充方案。这是最常用的RSA填充方案之一也是jsencrypt库默认使用的填充方式。前后端的填充方案必须严格一致否则解密会失败。另一种常见的填充是OAEPPadding更安全但可能需额外配置。4. 前端加密实现与集成4.1 引入jsencrypt与公钥获取首先在项目中引入jsencrypt库。可以通过npm安装或者直接使用CDN。!-- 方式一CDN引入 -- script srchttps://cdn.jsdelivr.net/npm/jsencrypt3.3.2/bin/jsencrypt.min.js/script !-- 方式二npm安装后导入 -- // import JSEncrypt from jsencrypt;在登录页面的JavaScript逻辑中我们需要先获取公钥。// login.js let publicKey ; let keyId ; // 获取公钥函数 async function fetchPublicKey() { try { // 尝试从sessionStorage读取缓存的公钥 const cached sessionStorage.getItem(rsaPublicKey); const cachedKeyId sessionStorage.getItem(rsaKeyId); if (cached cachedKeyId) { publicKey cached; keyId cachedKeyId; console.log(使用缓存的RSA公钥); return; } // 缓存不存在或失效从接口获取 const response await fetch(/api/auth/public-key); const data await response.json(); publicKey data.publicKey; keyId data.keyId || default; // 存储到sessionStorage浏览器会话期间有效 sessionStorage.setItem(rsaPublicKey, publicKey); sessionStorage.setItem(rsaKeyId, keyId); console.log(已获取并缓存新的RSA公钥); } catch (error) { console.error(获取RSA公钥失败:, error); // 可以根据策略决定是否阻止登录或降级为明文传输不推荐 alert(系统初始化失败请刷新页面重试); } } // 页面加载时获取公钥 document.addEventListener(DOMContentLoaded, fetchPublicKey);4.2 表单提交拦截与加密处理接下来为登录表单的提交事件添加拦截器在提交前对密码进行加密。// login.js (续) const loginForm document.getElementById(loginForm); const usernameInput document.getElementById(username); const passwordInput document.getElementById(password); const hiddenEncryptedPassword document.getElementById(encryptedPassword); // 一个隐藏域 const hiddenKeyId document.getElementById(keyId); // 用于标识加密使用的密钥版本 loginForm.addEventListener(submit, async function(event) { // 1. 阻止表单默认提交行为 event.preventDefault(); // 2. 检查公钥是否已加载 if (!publicKey) { alert(安全模块未就绪请稍后再试); await fetchPublicKey(); // 尝试重新获取 if (!publicKey) return; // 仍然失败则退出 } const username usernameInput.value.trim(); const plainPassword passwordInput.value; // 3. 非空校验 if (!username || !plainPassword) { alert(请输入账号和密码); return; } // 4. 使用公钥加密密码 let encryptedPwd ; try { const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); // 设置公钥 // jsencrypt加密后返回的是Base64编码的字符串 encryptedPwd encryptor.encrypt(plainPassword); if (!encryptedPwd) { throw new Error(加密返回结果为空); } } catch (encryptError) { console.error(密码加密失败:, encryptError); alert(信息加密失败请检查输入或刷新页面); return; } // 5. 将密文和密钥ID放入隐藏域清空明文密码输入框 hiddenEncryptedPassword.value encryptedPwd; hiddenKeyId.value keyId; passwordInput.value ; // 清空明文防止内存残留尽管现代浏览器已做处理 // 6. 可选对用户名也进行加密如需 // const encryptedUser encryptor.encrypt(username); // ... // 7. 使用FormData或直接提交表单 // 方式A构建新的FormData提交 const formData new FormData(); formData.append(username, username); // 账号可以传明文也可加密 formData.append(encryptedPassword, encryptedPwd); formData.append(keyId, keyId); // 显示加载状态 submitButton.disabled true; try { const response await fetch(/api/auth/login, { method: POST, body: formData // headers 通常由浏览器自动设置 }); const result await response.json(); // 处理登录结果... } catch (error) { console.error(登录请求失败:, error); alert(网络请求异常); } finally { submitButton.disabled false; } // 方式B如果表单原本就是同步提交可以动态创建隐藏input再submit() // 但现代应用更推荐使用上面的Fetch API进行异步提交。 });4.3 前端实现注意事项公钥缓存公钥不需要每次登录都获取。使用sessionStorage缓存是一个简单有效的方法它在页面会话期间有效页面关闭后清除。也可以使用localStorage并设置一个合理的过期时间但需要注意公钥更新的问题。加密失败处理加密过程可能因为公钥格式错误、明文过长RSA有长度限制等原因失败。必须有良好的异常捕获和用户提示。清空明文加密完成后手动清空密码输入框的value是一个好习惯虽然现代浏览器在提交后会自动清理但这样做更显式地减少了明文在内存中的暴露时间。性能在低端设备上RSA加密尤其是4096位可能会有几百毫秒的延迟。可以考虑添加一个加载动画提示用户“正在安全加密中...”提升体验。5. 常见问题、调试技巧与安全强化5.1 加解密过程排错指南前后端联调时加解密失败是最常见的问题。下面是一个排查清单现象可能原因排查步骤前端加密成功后端解密失败报javax.crypto.BadPaddingException1.前后端填充模式不一致2.密钥不匹配用的不是一对3.密文在传输中被篡改或编码错误1. 确认后端Cipher.getInstance(“RSA/ECB/PKCS1Padding”)前端jsencrypt默认即PKCS1Padding。2. 核对后端用于解密的私钥是否与生成前端公钥的那个密钥对匹配。将前端用的公钥在后端用对应的私钥解密一个固定字符串测试。3. 检查网络请求确保密文参数完整无误地传到后端。用Base64解码工具验证密文是否合法。前端加密时报错如Message too longRSA加密有明文长度限制。对于2048位密钥和PKCS1Padding最大明文长度约为245字节ASCII字符。1. 确保只加密密码不要加密超长的字符串。2. 如果确实需要加密更长数据需采用“混合加密”用RSA加密一个随机生成的AES密钥再用这个AES密钥加密长数据。但登录场景通常不需要。后端解密成功但得到乱码前后端字符编码不一致。加密前是UTF-8解密后也用UTF-8解码。确保前端JSEncrypt.encrypt()对字符串加密后端new String(decryptedBytes, “UTF-8”)解码。公钥格式错误前端setPublicKey失败公钥字符串格式不正确缺少头尾标记或含有非法字符。标准的X.509 PEM格式公钥应以-----BEGIN PUBLIC KEY-----开头以-----END PUBLIC KEY-----结尾。确保从后端接口获取的公钥字符串包含这些标记且无多余换行或空格。jsencrypt的setPublicKey方法接受这种PEM格式。一个实用的调试方法构造单元测试。在后端编写一个单元测试模拟整个流程用固定的密钥对。用一个已知的字符串如”testPassword123″在测试中调用Java的加密方法用公钥生成密文A。再用私钥解密密文A验证是否能得到原字符串。把这个固定的公钥给前端同事让他用jsencrypt加密同一个字符串得到密文B。在后端测试中用私钥解密密文B看是否能成功。 这样可以快速隔离是前端加密问题还是后端解密问题。5.2 安全强化措施实现基础功能只是第一步要投入生产环境还需要考虑更多安全细节密钥轮转一对密钥不应无限期使用。应制定密钥轮转策略例如每季度或每半年更换一次。方案可以是后端同时支持多对密钥每个密钥有一个ID。前端获取公钥时后端返回当前活跃的多个公钥及其ID。前端加密时随机选择一个或指定使用最新的公钥并将使用的keyId随请求发送。后端根据keyId选择对应的私钥解密。旧密钥在度过一个重叠期后下线。防御重放攻击仅仅加密不能防止攻击者截获加密后的请求包并重复发送重放攻击。需要在登录请求中加入一次性凭证如时间戳服务器校验请求时间与当前时间差是否在合理范围内如5分钟。随机数Nonce服务器缓存最近一段时间内使用过的Nonce如果收到重复的则拒绝。可以将Nonce也加密到请求参数中或者作为明文参数与密文一起用签名保护。日志脱敏确保应用日志、访问日志中不会记录加密前的明文密码。在打印LoginRequest对象时要重写toString()方法将encryptedPassword字段显示为******或直接忽略。传输层安全HTTPS是基础再次强调RSA加密不能替代HTTPS。必须确保全站启用HTTPS否则公钥在传输过程中可能被中间人替换加密也就失去了意义。前端代码混淆虽然公钥可以公开但前端加密逻辑和代码应进行混淆和压缩增加攻击者分析和篡改的难度。5.3 性能考量与优化RSA加解密是CPU密集型操作尤其是解密私钥操作比加密公钥操作更慢。后端解密性能一个2048位的RSA解密操作在普通服务器CPU上可能需要几毫秒。如果登录QPS非常高例如每秒上万次这可能会成为瓶颈。优化方法连接池与异步处理确保服务有足够的线程处理并发解密请求。硬件加速某些服务器和Java版本支持使用硬件安全模块HSM或CPU的AES-NI等指令加速加密操作。监控与告警对登录接口的解密耗时进行监控。前端加密性能在低端手机或老旧电脑上加密操作可能导致页面短暂“卡顿”。优化方法使用Web Workers将加密操作放到Web Worker线程中执行避免阻塞主线程和UI渲染。延迟加载加密库不要在首屏就加载jsencrypt而是在用户即将点击登录时再动态加载。6. 总结与扩展思考经过以上步骤我们成功地将一个明文传输登录信息的系统改造为使用RSA非对称加密的密文传输系统。这个方案直接、有效地修复了安全测评中发现的漏洞并且其架构清晰易于理解和维护。回顾整个实施过程最关键的点在于理解RSA非对称加密的原理和适用场景它完美解决了在不安全信道上安全分发密钥的初始信任问题。而具体实现中前后端填充模式、密钥格式的严格一致是联调试通的前提。我个人在实际部署中的体会是测试和监控至关重要。在上线前必须进行充分的测试单元测试覆盖各种加解密场景集成测试模拟前端加密后端解密的全流程压力测试评估在高并发下解密服务的性能。上线后要密切关注登录接口的错误率、平均响应时间特别是解密失败相关的异常日志。这个方案还可以进一步扩展。例如对于更复杂的交互可以升级为完整的“挑战-响应”机制。又或者考虑到RSA对长数据的限制和性能开销可以采用更高效的“混合加密”体系每次会话由前端生成一个随机的AES对称密钥用RSA公钥加密这个AES密钥传给后端后续通信全部使用AES加密。这既利用了RSA的安全分发优势又获得了对称加密的高性能。安全是一个持续的过程而不是一次性的任务。修复了明文传输漏洞我们还需要关注密码的存储安全使用强哈希算法加盐、防止暴力破解增加验证码、登录尝试限制、会话安全等其他方面。每一层防御的加固都让我们的系统更加稳健。