1. 项目概述与核心诉求
最近在做一个内部系统的安全测评,结果被揪出一个典型的安全漏洞:用户登录时的账号密码竟然是明文传输。这问题说大不大,说小不小,在如今这个安全至上的时代,任何一个明文传输敏感信息的系统,都像是在裸奔。测评报告上那个醒目的“中危”标签,就是最好的鞭策。我们的目标很明确,就是要把这个明文传输的通道给加密堵上,让数据在网络上跑起来的时候是密文,即使被截获也看不懂。
方案选型上,非对称加密里的RSA成了不二之选。为什么是RSA而不是别的?核心原因在于它的公私钥分离机制。前端(比如浏览器)只需要持有公钥,这个公钥甚至可以公开,用它来加密数据;而后端则牢牢保管私钥,用于解密。这样一来,私钥永远不需要在网络中传输,从根本上避免了密钥分发过程中的泄露风险。像AES这类对称加密算法,虽然加解密速度快,但密钥需要在前后端共享,这个共享过程本身就需要一个安全的通道,对于我们当前要解决的“首次通信安全”问题,有点“先有鸡还是先有蛋”的悖论。RSA正好解决了这个信任起点的问题。
这个改造涉及前后端协同。前端负责在登录表单提交前,用JavaScript获取公钥并对账号密码进行加密;后端则负责生成RSA密钥对、提供公钥接口、以及用私钥解密登录请求。听起来步骤不少,但每一步拆解开来,都是清晰可控的。接下来,我就把这次从漏洞修复到完整实现RSA加密登录的实操过程、踩过的坑和最终沉淀下来的稳定方案,详细分享一下。
2. RSA加密登录的整体架构设计
2.1 为什么选择RSA而非HTTPS?
看到这里,可能有朋友会问:既然怕明文传输,为什么不直接全站上HTTPS?这是一个非常好的问题。HTTPS(HTTP 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 技术栈选型考量
- 后端(Java):Java生态中处理RSA的标准选择是
java.security包下的KeyPairGenerator,Cipher等类。它们属于JCA(Java 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 ResponseEntity<Map<String, String>> getPublicKey() { Map<String, 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 src="https://cdn.jsdelivr.net/npm/jsencrypt@3.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.BadPaddingException | 1.前后端填充模式不一致 2.密钥不匹配(用的不是一对) 3.密文在传输中被篡改或编码错误 | 1. 确认后端Cipher.getInstance(“RSA/ECB/PKCS1Padding”),前端jsencrypt默认即PKCS1Padding。2. 核对后端用于解密的私钥,是否与生成前端公钥的那个密钥对匹配。将前端用的公钥在后端用对应的私钥解密一个固定字符串测试。 3. 检查网络请求,确保密文参数完整无误地传到后端。用Base64解码工具验证密文是否合法。 |
前端加密时报错,如Message too long | RSA加密有明文长度限制。对于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的安全分发优势,又获得了对称加密的高性能。
安全是一个持续的过程,而不是一次性的任务。修复了明文传输漏洞,我们还需要关注密码的存储安全(使用强哈希算法加盐)、防止暴力破解(增加验证码、登录尝试限制)、会话安全等其他方面。每一层防御的加固,都让我们的系统更加稳健。