公司动态

前后端数据安全传输:AES+RSA混合加密方案设计与实战

📅 2026/7/29 17:14:15
前后端数据安全传输:AES+RSA混合加密方案设计与实战
1. 项目概述与核心价值最近在做一个需要前后端深度交互的项目前端是JavaScriptVue/React后端是JavaSpring Boot数据传输安全成了头等大事。直接明文传肯定不行。只用AES对称加密密钥怎么安全地给前端只用RSA非对称加密数据量一大性能就顶不住。这几乎是所有涉及敏感数据交互的Web或移动应用都会遇到的经典难题。于是“AESRSA混合加密”这个方案就成了必然选择它不是什么新潮概念但却是经过实战检验的、兼顾安全与效率的黄金组合。简单来说这个项目的核心就是实现一套JavaScript前端与Java后端能够完美互通的混合加解密机制。其核心流程可以概括为后端生成RSA密钥对将公钥交给前端前端每次会话随机生成一个AES密钥用RSA公钥加密后传给后端后端用RSA私钥解密拿到AES密钥之后的双向通信全都用这个“会话级”的AES密钥进行高速的对称加密解密。这样一来既利用了RSA在密钥分发上的安全性又享受了AES在处理大量数据时的高性能。这个方案的价值在于它解决了纯前端加密中“密钥硬编码”的安全隐患也避免了全程RSA加密的性能瓶颈。无论是用户的登录凭证、支付信息、还是个人隐私数据都能得到有效保护。下面我就结合具体的代码实现拆解其中的每一个技术细节、踩过的坑以及确保跨平台互通的关键点。2. 混合加密方案设计与核心思路拆解2.1 为什么是AESRSA在开始写代码之前我们必须搞清楚为什么选这个组合而不是单一算法。对称加密AES的困境AES速度快适合加密大量数据。但它的核心问题是加密和解密使用同一个密钥。这个密钥如何在客户端不可信环境和服务端可信环境之间安全传输如果你把密钥硬编码在JavaScript代码里等同于把家门钥匙挂在门上毫无安全性可言。非对称加密RSA的瓶颈RSA使用公钥加密、私钥解密完美解决了密钥分发问题——公钥可以随便给只有持有私钥的一方才能解密。但它的计算非常复杂速度比AES慢几个数量级。如果用它来加密每次请求的整个数据体服务器CPU很快就会不堪重负用户体验也会急剧下降。混合加密的智慧混合方案取二者之长避二者之短。用RSA来解决最关键的、数据量很小的“AES密钥传输”问题。一旦双方安全地共享了同一个AES密钥后续所有大数据量的通信都交给高效的AES来处理。这就像用一把坚固的密码锁RSA来保护传递一个一次性的快递柜密码AES之后就用这个快递柜密码来存取物品既安全又方便。2.2 方案选型与关键参数确定确定了方向接下来要定具体参数这是确保两端互通的基础。RSA部分密钥长度2048位是当前安全与性能平衡下的标准选择。1024位已被认为不够安全4096位则性能损耗较大对于Web场景2048位是主流。填充方案这是跨平台互通最容易出问题的地方必须使用PKCS#1 v1.5 Padding。虽然OAEP填充更安全但在Web Crypto API和一些Java库的默认配置下PKCS#1 v1.5的兼容性最好。我们首要目标是打通所以选择兼容性优先的RSA/ECB/PKCS1Padding。格式后端生成的密钥需要以PKCS#8格式Java默认导出为PEM字符串供前端使用。前端Web Crypto API通常需要SPKI格式的公钥和PKCS#8格式的私钥。AES部分算法模式选择CBC模式。虽然GCM模式能同时提供加密和认证更先进但CBC模式的支持度最广从较老的Java环境到现代浏览器都能很好支持。我们同样优先保证互通。密钥长度256位。这是AES的标准强度在安全和性能上都是最佳选择。填充方案PKCS7Padding。需要注意的是在Java标准库中这个标准叫PKCS5Padding对于AES块大小两者等价。但在JavaScript端我们通常需要实现或使用支持PKCS7的库。初始化向量CBC模式必须使用一个随机且唯一的IV初始化向量并随密文一起传输。IV不需要保密但必须不可预测。2.3 整体交互流程设计整个安全会话的建立过程如下理解这个流程对编码和调试至关重要后端初始化服务端启动时生成一对2048位的RSA密钥公钥publicKey私钥privateKey。私钥妥善保存在服务器内存或安全存储中绝不外泄。公钥准备通过接口暴露给前端。前端获取公钥前端如用户登录前调用一个无需认证的接口如/api/security/public-key获取后端的RSA公钥PEM格式字符串。前端生成会话密钥前端使用密码学安全的随机数生成器生成一个256位的随机字节串作为本次会话的AES密钥aesSessionKey。同时为本次AES加密生成一个随机的16字节IV。加密AES密钥前端使用获取到的RSA公钥加密上一步生成的aesSessionKey。由于AES密钥是固定长度32字节用RSA加密后得到一段密文。发起安全请求前端将需要发送的敏感数据如{username: ‘xxx‘, password: ‘xxx‘}用aesSessionKey和IV进行AES-CBC加密得到数据密文。然后构造一个请求体至少包含encryptedKey: RSA加密后的AES密钥Base64编码。iv: 本次AES加密使用的IVBase64编码。encryptedData: AES加密后的业务数据Base64编码。后端解密与处理后端收到请求后 a. 用RSA私钥解密encryptedKey得到明文的aesSessionKey。 b. 使用解密得到的aesSessionKey和请求中的iv对encryptedData进行AES解密得到原始的业务数据。 c. 处理业务逻辑。 d. 如需返回敏感数据则使用同一个aesSessionKey和一个新的随机IV对响应数据进行AES加密并将新IV和密文返回给前端。前端解密响应前端使用本地保存的aesSessionKey和响应中的新IV解密得到后端返回的明文数据。注意这个aesSessionKey可以在前端缓存一段时间例如用户登录会话期间用于加密后续多个请求的数据避免每次请求都重复RSA加密过程。但出于更高安全考虑也可以设计为每次关键请求都更换一次会话密钥。3. 核心细节解析与实操要点3.1 JavaScript前端核心实现要点前端我们主要使用现代浏览器支持的Web Crypto API它比第三方库更原生、更安全。对于不支持的老旧浏览器需要引入node-forge或crypto-js等polyfill这里我们以现代浏览器为例。1. 导入RSA公钥从后端拿到PEM格式的公钥字符串后需要将其转换为CryptoKey对象才能使用。async function importPublicKey(pem) { // 移除PEM格式的头尾标记和换行符 const pemHeader ‘-----BEGIN PUBLIC KEY-----‘; const pemFooter ‘-----END PUBLIC KEY-----‘; const pemContents pem.replace(pemHeader, ‘‘).replace(pemFooter, ‘‘).replace(/\s/g, ‘‘); // 将Base64字符串转换为ArrayBuffer const binaryDer Uint8Array.from(atob(pemContents), c c.charCodeAt(0)); // 使用Web Crypto API导入密钥 return await window.crypto.subtle.importKey( ‘spki‘, // 标准公钥格式 binaryDer.buffer, { name: ‘RSA-OAEP‘, // 注意Web Crypto API的RSA加密通常使用OAEP但我们需要与后端PKCS1Padding互通这里是个关键点 hash: ‘SHA-256‘, }, false, // 是否可导出 [‘encrypt‘] // 密钥用途 ); }这里有一个巨大的坑Web Crypto API的RSA-OAEP对应Java的RSA/ECB/OAEPWithSHA-256AndMGF1Padding而不是我们为了兼容性选择的PKCS1Padding。为了实现互通前端通常不能直接使用Web Crypto API进行RSA加密除非后端也改用OAEP填充。为了与后端PKCS1Padding互通我们往往需要在前端使用一个纯JavaScript的RSA库例如jsencrypt或node-rsa在浏览器中通过Browserify等方式使用。这是混合加密跨平台的第一道难关。2. 生成AES密钥与IV使用Crypto.getRandomValues()生成密码学安全的随机数。function generateAesKeyAndIv() { // 生成32字节256位的AES密钥 const aesKeyBytes window.crypto.getRandomValues(new Uint8Array(32)); // 生成16字节128位的CBC模式IV const ivBytes window.crypto.getRandomValues(new Uint8Array(16)); return { aesKeyBytes, ivBytes }; }3. 使用AES-CBC加密数据async function encryptWithAes(plainText, keyBytes, ivBytes) { // 导入AES密钥 const cryptoKey await window.crypto.subtle.importKey( ‘raw‘, keyBytes, { name: ‘AES-CBC‘ }, false, [‘encrypt‘] ); // 将字符串明文转换为Uint8Array const encoder new TextEncoder(); const data encoder.encode(plainText); // 执行加密 const encrypted await window.crypto.subtle.encrypt( { name: ‘AES-CBC‘, iv: ivBytes }, cryptoKey, data ); // 返回Base64编码的密文 return btoa(String.fromCharCode(...new Uint8Array(encrypted))); }4. 使用第三方库进行RSA加密以兼容PKCS1Padding假设我们引入jsencrypt库。// 使用jsencrypt库加密AES密钥 function encryptAesKeyWithRsa(aesKeyBytes, publicKeyPem) { const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKeyPem); // 将AES密钥字节数组转换为Base64字符串 const aesKeyBase64 btoa(String.fromCharCode(...aesKeyBytes)); // 使用公钥加密jsencrypt内部使用PKCS1Padding const encryptedKey encryptor.encrypt(aesKeyBase64); // 注意jsencrypt.encrypt输出的是Base64字符串 return encryptedKey; }实操心得前端加密库的选择至关重要。如果后端坚持使用PKCS1Padding前端就几乎必须使用jsencrypt这类库。如果项目可控强烈建议前后端协商统一使用OAEP填充这样前端就能直接使用更安全、更原生的Web Crypto API。这是项目初期必须定好的协议。3.2 Java后端核心实现要点后端我们使用Java标准库javax.crypto和java.security。1. 生成并管理RSA密钥对import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; public class RsaKeyHolder { private static PrivateKey privateKey; private static PublicKey publicKey; private static final String PUBLIC_KEY_PEM_HEADER -----BEGIN PUBLIC KEY-----\n; private static final String PUBLIC_KEY_PEM_FOOTER \n-----END PUBLIC KEY-----; static { try { KeyPairGenerator keyGen KeyPairGenerator.getInstance(RSA); keyGen.initialize(2048); KeyPair pair keyGen.generateKeyPair(); privateKey pair.getPrivate(); publicKey pair.getPublic(); } catch (Exception e) { throw new RuntimeException(Failed to generate RSA key pair, e); } } public static String getPublicKeyPem() { byte[] encoded publicKey.getEncoded(); // 这是X.509 SPKI格式 String base64Key Base64.getEncoder().encodeToString(encoded); // 格式化为PEM return PUBLIC_KEY_PEM_HEADER base64Key.replaceAll((.{64}), $1\n) PUBLIC_KEY_PEM_FOOTER; } public static PrivateKey getPrivateKey() { return privateKey; } }2. 提供获取公钥的接口RestController RequestMapping(/api/security) public class SecurityController { GetMapping(/public-key) public ResponseEntityString getPublicKey() { return ResponseEntity.ok(RsaKeyHolder.getPublicKeyPem()); } }3. 解密前端传来的RSA加密的AES密钥import javax.crypto.Cipher; import java.security.PrivateKey; import java.util.Base64; public class RsaUtil { public static byte[] decryptWithPrivateKey(byte[] encryptedData, PrivateKey privateKey) throws Exception { Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); // 关键与前端jsencrypt对应 cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(encryptedData); } }在Service中调用// 假设请求体对象 SecurityReq 包含 encryptedKey, iv, encryptedData 三个Base64字符串字段 public void handleSecureRequest(SecurityReq request) throws Exception { // 1. Base64解码前端传过来的加密密钥 byte[] encryptedAesKeyBytes Base64.getDecoder().decode(request.getEncryptedKey()); // 2. 用RSA私钥解密得到AES密钥明文 byte[] aesKeyBytes RsaUtil.decryptWithPrivateKey(encryptedAesKeyBytes, RsaKeyHolder.getPrivateKey()); // 此时aesKeyBytes就是32字节的AES密钥 // ... 后续用于AES解密数据 }4. 使用AES-CBC解密数据import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AesUtil { public static String decryptCbc(byte[] encryptedData, byte[] keyBytes, byte[] ivBytes) throws Exception { SecretKeySpec secretKeySpec new SecretKeySpec(keyBytes, AES); IvParameterSpec ivParameterSpec new IvParameterSpec(ivBytes); // 注意Java中PKCS5Padding对应PKCS7 Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] decryptedBytes cipher.doFinal(encryptedData); return new String(decryptedBytes, StandardCharsets.UTF_8); } }在Service中继续// 3. Base64解码IV和加密数据 byte[] ivBytes Base64.getDecoder().decode(request.getIv()); byte[] encryptedDataBytes Base64.getDecoder().decode(request.getEncryptedData()); // 4. 使用AES密钥和IV解密业务数据 String originalDataJson AesUtil.decryptCbc(encryptedDataBytes, aesKeyBytes, ivBytes); // 5. 将JSON字符串转为业务对象 YourBusinessDTO dto objectMapper.readValue(originalDataJson, YourBusinessDTO.class); // ... 处理业务逻辑5. 加密返回给前端的数据处理完业务后如果需要返回敏感数据应使用同一个AES会话密钥但必须生成一个新的IV。public static byte[] encryptCbc(String plainText, byte[] keyBytes, byte[] ivBytes) throws Exception { SecretKeySpec secretKeySpec new SecretKeySpec(keyBytes, AES); IvParameterSpec ivParameterSpec new IvParameterSpec(ivBytes); Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); return cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); } // 在返回响应前 byte[] newIvForResponse generateRandomIv(); // 生成16字节随机IV String responseDataJson objectMapper.writeValueAsString(yourResponseDTO); byte[] encryptedResponse AesUtil.encryptCbc(responseDataJson, aesKeyBytes, newIvForResponse); // 构造响应对象包含 newIv 和 encryptedResponse 的Base64字符串 SecurityResp resp new SecurityResp(); resp.setIv(Base64.getEncoder().encodeToString(newIvForResponse)); resp.setEncryptedData(Base64.getEncoder().encodeToString(encryptedResponse)); return resp;4. 完整交互流程与代码整合4.1 前端完整请求封装示例下面是一个使用Axios和上述工具函数的完整请求示例import axios from ‘axios‘; import JSEncrypt from ‘jsencrypt‘; // 需先安装 class SecureHttpClient { constructor() { this.aesSessionKey null; // 缓存本次会话的AES密钥 this.rsaPublicKeyPem null; } async initialize() { // 1. 从后端获取RSA公钥 const resp await axios.get(‘/api/security/public-key‘); this.rsaPublicKeyPem resp.data; } async makeSecureRequest(url, data) { if (!this.aesSessionKey) { // 首次请求生成新的AES会话密钥和IV const { aesKeyBytes, ivBytes } generateAesKeyAndIv(); this.aesSessionKey aesKeyBytes; this.currentIv ivBytes; } // 2. 加密业务数据 const encryptedDataBase64 await encryptWithAes( JSON.stringify(data), this.aesSessionKey, this.currentIv ); // 3. 加密AES密钥 (使用缓存的RSA公钥) const encryptedKeyBase64 encryptAesKeyWithRsa(this.aesSessionKey, this.rsaPublicKeyPem); // 4. 构造安全请求体 const secureRequestBody { encryptedKey: encryptedKeyBase64, iv: btoa(String.fromCharCode(...this.currentIv)), // 将IV字节数组转Base64 encryptedData: encryptedDataBase64 }; // 5. 发送请求 const response await axios.post(url, secureRequestBody); // 6. 处理安全响应 (假设响应结构类似 {iv: ‘...‘, data: ‘...‘}) const respIvBase64 response.data.iv; const respDataBase64 response.data.data; const respIvBytes Uint8Array.from(atob(respIvBase64), c c.charCodeAt(0)); const decryptedRespJson await decryptWithAes(respDataBase64, this.aesSessionKey, respIvBytes); return JSON.parse(decryptedRespJson); } } // 使用示例 const client new SecureHttpClient(); await client.initialize(); const loginResult await client.makeSecureRequest(‘/api/auth/login‘, { username: ‘user‘, password: ‘pass‘ }); console.log(‘登录结果‘, loginResult);4.2 后端完整控制器与全局处理后端可以设计一个RequestBody注解的实体类并利用Spring的拦截器或过滤器自动完成解密和加密过程避免业务代码被加解密逻辑污染。1. 请求解密拦截器Component public class DecryptionInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断是否需要解密例如通过注解或URL模式 if (requiresDecryption(handler)) { // 读取请求体 String body request.getReader().lines().collect(Collectors.joining()); SecurityReq securityReq objectMapper.readValue(body, SecurityReq.class); // 执行RSA解密AES密钥再AES解密数据 byte[] aesKey decryptAesKey(securityReq.getEncryptedKey()); String plainBody decryptData(securityReq.getEncryptedData(), aesKey, securityReq.getIv()); // 将解密后的JSON字符串重新放入请求体供后续的RequestBody解析 byte[] newBodyBytes plainBody.getBytes(StandardCharsets.UTF_8); request new ContentCachingRequestWrapper(request, newBodyBytes); } return true; } // ... requiresDecryption, decryptAesKey, decryptData 方法实现 }2. 响应加密拦截器Component public class EncryptionInterceptor implements HandlerInterceptor { Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 判断是否需要加密响应 if (requiresEncryption(handler)) { // 获取当前请求的AES会话密钥可从ThreadLocal或请求属性中获取 byte[] aesKey (byte[]) request.getAttribute(“CURRENT_AES_KEY“); // 获取原始的响应内容 String originalResponseBody ...; // 需要包装Response来捕获 // 生成新IV并加密 byte[] newIv generateRandomIv(); byte[] encryptedData AesUtil.encryptCbc(originalResponseBody, aesKey, newIv); // 构造安全响应对象并写回 SecurityResp resp new SecurityResp(Base64.getEncoder().encodeToString(newIv), Base64.getEncoder().encodeToString(encryptedData)); response.setContentType(“application/json“); response.getWriter().write(objectMapper.writeValueAsString(resp)); } } }通过拦截器业务Controller可以像处理普通请求一样编写完全感知不到加解密的存在PostMapping(“/login“) public ApiResponseUserInfo login(RequestBody LoginDTO loginDTO) { // 这里拿到的是解密后的对象 // 直接进行业务处理... return ApiResponse.success(userService.login(loginDTO)); }5. 常见问题、排查技巧与性能优化5.1 跨平台互通问题排查清单这是混合加密项目中最令人头疼的部分。一旦出现解密失败请按以下顺序排查现象可能原因排查步骤后端RSA解密AES密钥失败1. 前后端RSA填充模式不一致。2. 前端加密的密钥格式不对如未Base64编码或编码错误。3. 后端拿到的encryptedKey字符串在传输过程中被修改如URL编码问题。1.确认填充模式前端jsencrypt默认PKCS1后端必须用RSA/ECB/PKCS1Padding。如果前端用Web Crypto API的OAEP后端必须用OAEPPadding。2.打印日志在后端将接收到的encryptedKey进行Base64解码查看字节长度。RSA 2048加密后的密文长度固定是256字节。如果不是说明传输过程有问题。3.对比原始数据前端在加密后将encryptedKey的Base64字符串打印出来后端收到后也打印出来直接比较字符串是否完全一致。后端AES解密业务数据失败1. AES密钥错误RSA解密得到的密钥不对。2. IV错误长度不是16字节或与加密时用的不一致。3. 加密模式或填充不匹配。4. 数据在传输中被篡改或编码错误。1.先确保RSA解密成功。2.检查IV确认前端传来的IV是16字节并且后端正确Base64解码。3.统一算法字符串前端AES-CBC对应后端AES/CBC/PKCS5Padding。确保没有拼写错误。4.字符编码确保加密前的字符串和解密后的字符串使用相同的字符编码强烈建议统一为UTF-8。前端解密后端响应失败1. 前端使用的AES密钥与后端本次解密使用的不是同一个。2. 后端响应中的IV解码错误或长度不对。3. 后端响应数据被网关、过滤器额外处理如压缩、包装。1.会话密钥一致性确保前端缓存了本次会话的AES密钥并且后端在处理响应时使用的是从当前请求解密出的同一个密钥。2.检查响应结构确保前端正确解析了响应JSON拿到了iv和data字段。3.网络检查使用浏览器开发者工具或抓包工具如Fiddler查看原始HTTP响应体确认与后端发送的一致。实操心得“先Base64后字节操作”是铁律。所有在网络上传输的二进制数据密钥、IV、密文都必须先进行Base64编码成字符串。在JavaScript和Java中对Base64字符串的解码要确保使用标准方法atob/btoavsBase64.getDecoder().decode注意处理换行符和URL安全字符问题。5.2 安全增强与性能考量密钥生命周期管理会话密钥过期不要一个AES密钥用到底。可以为会话密钥设置一个较短的有效期如15分钟或者每次关键操作如支付前都重新交换一次密钥。RSA密钥轮转后端的RSA密钥对也应定期更换如每季度更换后需要通知所有客户端重新获取公钥。防重放攻击在加密数据包中加入时间戳和随机数Nonce服务端校验请求的时效性如5分钟内有效和Nonce的唯一性可以防止攻击者截获请求数据包后重复发送。性能优化缓存RSA解密结果在一次HTTP请求会话中AES密钥只需要用RSA解密一次。可以将解密后的AES密钥缓存在ThreadLocal或请求属性中供本次请求的后续加解密步骤使用。连接复用与密钥复用在WebSocket或长连接场景中可以在建立连接时交换一次AES密钥后续通信全部使用该密钥避免频繁的RSA运算。非对称加密性能RSA解密是CPU密集型操作。在高并发场景下需要监控服务器CPU。如果成为瓶颈可以考虑使用ECC椭圆曲线加密算法替代RSA在相同安全强度下ECC的密钥更短计算更快。HTTPS是基础务必记住这套混合加密方案是运行在HTTPSTLS之上的。TLS已经提供了传输层的加密和认证。我们这里的应用层加密主要目的是防止服务器端数据泄露后导致的明文数据暴露假设数据库被拖库但存储的密文无法解密以及提供更细粒度的端到端安全。没有HTTPS中间人攻击可以轻易篡改你前端加载的JavaScript代码或公钥整个安全模型就崩塌了。5.3 针对特定场景的调整移动端React Native/Flutter原理完全一致。需要寻找对应平台支持PKCS1Padding的RSA库和稳定的AES-CBC库。在DartFlutter中pointycastle库是一个选择在React Native中可以寻找react-native-rsa等桥接库或使用纯JS库。Node.js后端如果后端也是Node.js那么加解密库的选择就灵活很多可以使用crypto模块直接实现更容易保证与JavaScript前端的一致。核心依然是确保填充、模式等参数对齐。数据格式示例中使用了JSON作为明文数据格式。实际上你可以加密任何序列化后的数据如Protocol Buffers甚至直接加密二进制文件流。只需确保前后端对“加密原数据”的格式有共识。实现这样一套跨平台的混合加密机制就像在两端搭建一座既坚固又高效的加密桥梁。最耗费时间的往往不是编码而是调试两端参数不一致导致的解密失败。最好的办法就是从一开始就严格定义一份“加密通信协议文档”明确列出算法、模式、填充、密钥格式、编码方式等所有细节并在前后端团队间达成一致。一旦调通这套架构就能为你的应用数据安全提供一个强大的保护层。