AES + RSA 混合加密方案
一、架构概览
核心思想:RSA 只用于密钥交换(传输 AES 密钥),AES-GCM 负责所有消息内容的加解密。非对称 + 对称混合,兼顾安全与性能。
二、算法参数
| 参数 | 值 | 说明 |
|---|---|---|
| RSA 密钥长度 | 2048 bit | 仅用于密钥交换 |
| RSA 填充模式 | OAEP-SHA256 | 比 PKCS1v1.5 更安全,抗选择密文攻击 |
| AES 密钥长度 | 128 bit(16字节) | GCM 模式,每次加密随机生成 IV |
| AES 模式 | AES/GCM/NoPadding | 自带认证标签,防篡改 |
| GCM IV 长度 | 12 字节(96 bit) | 每次加密随机生成 |
| GCM Tag 长度 | 128 bit | 认证标签,防篡改 |
| 密文格式 | IV(12B) + 密文 + Tag(16B) | 拼接后统一 Base64 编码 |
三、后端核心代码
3.1 工具类 —CryptoUtils.java
talk-common/src/main/java/com/talk/common/utils/CryptoUtils.java
// ========== RSA 密钥对生成 ==========publicstaticKeyPairgenerateRsaKeyPair(){returnSecureUtil.generateKeyPair("RSA",2048);}// ========== RSA-OAEP 加密(公钥加密 AES 密钥) ==========privatestaticfinalStringRSA_ALGORITHM="RSA/ECB/OAEPWithSHA-256AndMGF1Padding";publicstaticStringrsaEncrypt(Stringdata,StringpublicKey){PublicKeykey=decodePublicKey(publicKey);Ciphercipher=Cipher.getInstance(RSA_ALGORITHM);OAEPParameterSpecspec=newOAEPParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,PSource.PSpecified.DEFAULT);cipher.init(Cipher.ENCRYPT_MODE,key,spec);byte[]encrypted=cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));returnBase64.encode(encrypted);}// ========== AES-GCM 加密 ==========publicstaticStringaesEncrypt(Stringplaintext,StringaesKey){byte[]keyBytes=Base64.decode(aesKey);byte[]iv=newbyte[12];// 12字节随机 IVnewSecureRandom().nextBytes(iv);Ciphercipher=Cipher.getInstance("AES/GCM/NoPadding");GCMParameterSpecspec=newGCMParameterSpec(128,iv);// 128-bit Tagcipher.init(Cipher.ENCRYPT_MODE,newSecretKeySpec(keyBytes,"AES"),spec);byte[]encrypted=cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));// IV + 密文 拼接后 Base64byte[]result=newbyte[iv.length+encrypted.length];System.arraycopy(iv,0,result,0,iv.length);System.arraycopy(encrypted,0,result,iv.length,encrypted.length);returnBase64.encode(result);}3.2 密钥交换 —CryptoController.java
talk-chat/src/main/java/com/talk/chat/controller/CryptoController.java
步骤 1:获取公钥
@GetMapping("/public-key")publicAjaxResult<KeyExchangeResponse>getPublicKey(){KeyPairkeyPair=CryptoUtils.generateRsaKeyPair();StringpublicKeyBase64=CryptoUtils.encodePublicKey(keyPair.getPublic());StringprivateKeyBase64=CryptoUtils.encodePrivateKey(keyPair.getPrivate());// 完整 SHA-256 指纹,防碰撞Stringfingerprint=DigestUtil.sha256Hex(publicKeyBase64);// 私钥存 Redis(10分钟 TTL),key = rsa_keypair:{fingerprint}redisTemplate.opsForValue().set(Constants.REDIS_RSA_KEY_PREFIX+fingerprint,privateKeyBase64,10,TimeUnit.MINUTES);returnAjaxResult.success(newKeyExchangeResponse(publicKeyBase64,fingerprint));}步骤 2:交换 AES 密钥
@PostMapping("/exchange")publicAjaxResult<String>exchangeKey(@Valid@RequestBodyKeyExchangeRequestrequest){// 从 Redis 取私钥StringprivateKeyBase64=redisTemplate.opsForValue().get(Constants.REDIS_RSA_KEY_PREFIX+request.getFingerprint());// RSA 解密得到 AES 密钥StringaesKey=CryptoUtils.rsaDecrypt(request.getEncryptedAesKey(),privateKeyBase64);// AES 密钥存 Redis(30分钟 TTL),key = aes_key:{userId}:{keyId}StringkeyId=UUID.randomUUID().toString().replace("-","").substring(0,16);redisTemplate.opsForValue().set(Constants.REDIS_AES_KEY_PREFIX+userId+":"+keyId,aesKey,30,TimeUnit.MINUTES);// 用完即弃:删除 RSA 私钥redisTemplate.delete(Constants.REDIS_RSA_KEY_PREFIX+request.getFingerprint());returnAjaxResult.success(keyId);}3.3 加密过滤器 —CryptoFilter.java
talk-framework/src/main/java/com/talk/framework/filter/CryptoFilter.java
触发条件:请求头X-Encrypted: true
三层安全校验:
① 时间戳校验 → |now - timestamp| < 5分钟(防过期请求) ② Nonce 校验 → Redis 查重,5分钟内不可重复(防重放) ③ AES 密钥 → 从 Redis 取,失效返回 401@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,...){// 1. 时间戳容差校验(5分钟)longtimestamp=Long.parseLong(request.getHeader("X-Timestamp"));if(Math.abs(System.currentTimeMillis()-timestamp)>5*60*1000){returnerror("请求已过期");}// 2. Nonce 防重放(Redis 标记,5分钟 TTL)StringnonceKey="nonce:"+request.getHeader("X-Nonce");if(redisTemplate.hasKey(nonceKey)){returnerror("请求重复");// 检测到重放攻击}redisTemplate.opsForValue().set(nonceKey,"1",5,TimeUnit.MINUTES);// 3. 包装请求(解密)和响应(加密)CryptoRequestWrapperreqWrapper=newCryptoRequestWrapper(request,aesKey);CryptoResponseWrapperresWrapper=newCryptoResponseWrapper(response,aesKey);filterChain.doFilter(reqWrapper,resWrapper);resWrapper.finishResponse();// 加密响应体}跳过加密的路径(shouldNotFilter):/auth/、/crypto/、/upload/、/stream(SSE)、Swagger 文档等。
四、前端核心代码
4.1 加密工具 —crypto.js
talk-ui/src/api/crypto.js
// ========== AES-GCM 加密(Web Crypto API,浏览器原生) ==========exportasyncfunctionaesEncrypt(plaintext,aesKeyBase64){constkeyBytes=base64ToArrayBuffer(aesKeyBase64)constkey=awaitcrypto.subtle.importKey('raw',keyBytes,{name:'AES-GCM'},false,['encrypt'])constiv=crypto.getRandomValues(newUint8Array(12))// 12字节随机 IVconstencoded=newTextEncoder().encode(plaintext)constencrypted=awaitcrypto.subtle.encrypt({name:'AES-GCM',iv},key,encoded)// IV + 密文 拼接constresult=newUint8Array(iv.length+encrypted.byteLength)result.set(iv,0)result.set(newUint8Array(encrypted),iv.length)returnarrayBufferToBase64(result.buffer)}// ========== RSA-OAEP 加密(JSEncrypt 库)==========exportfunctionrsaEncrypt(data,publicKeyPem){constencryptor=newJSEncrypt()encryptor.setPublicKey(base64ToPem(publicKeyPem,'PUBLIC'))encryptor.setOptions({encryptionScheme:'pkcs1_oaep',// OAEP 填充,与后端一致signingScheme:'pkcs1v15'})returnencryptor.encrypt(data)}4.2 密钥交换流程
exportasyncfunctionexchangeKeys(){// ① 获取服务端 RSA 公钥const{publicKey,fingerprint}=awaitfetchPublicKey()// ② 本地生成 AES-128 密钥constaesKey=awaitgenerateAesKey()// ③ RSA-OAEP 加密 AES 密钥,发送给服务端constencryptedAesKey=rsaEncrypt(aesKey,publicKey)constkeyId=awaitsendEncryptedAesKey(fingerprint,encryptedAesKey)// ④ 内存保存(不持久化,每次登录重新交换)tokenManager.setCryptoKey(keyId,aesKey)}4.3 请求自动加解密 —request.js拦截
talk-ui/src/api/request.js
// 请求加密:自动注入 X-Encrypted / X-Key-Id / X-Nonce / X-Timestampconst{encrypted,headers}=awaitencryptRequest(requestData)// encrypted = { keyId: "xxx", data: "Base64密文" }// 响应解密:自动检测 EncryptedPayload 格式并解密constdecrypted=awaitdecryptResponse(data)4.4 密钥生命周期 —token-manager.js
talk-ui/src/api/token-manager.js
// 内存存储(不持久化到 Storage)setCryptoKey(keyId,aesKey)// 登录后调用getCryptoKey()// 返回 { keyId, aesKey }clearCryptoKey()// 退出登录时清除// 退出登录时自动清除clearTokens(){cryptoKeyId=null;// 密钥随登录态一起销毁cryptoAesKey=null;}五、Redis Key 设计
| Key 前缀 | 格式 | TTL | 用途 |
|---|---|---|---|
rsa_keypair: | rsa_keypair:{fingerprint} | 10 分钟 | RSA 私钥(交换阶段临时) |
aes_key: | aes_key:{userId}:{keyId} | 30 分钟 | AES 密钥(与 Access Token 同生命周期) |
nonce: | nonce:{nonce} | 5 分钟 | 防重放攻击标记 |
六、安全措施总结
| 措施 | 实现 |
|---|---|
| 机密性 | AES-GCM 加密消息内容,密钥通过 RSA-OAEP 传输 |
| 完整性 | GCM 模式自带 128-bit 认证标签,篡改即解密失败 |
| 防重放 | 随机 nonce(32位十六进制)+ Redis 去重 + 时间戳 5 分钟容差 |
| 前向安全 | AES 密钥每次登录重新交换,RSA 私钥用后即删 |
| 密钥隔离 | AES 密钥按 userId 隔离存储(key =aes_key:{userId}:{keyId}) |
| 最小暴露 | RSA 私钥仅存 Redis 10 分钟,AES 密钥前端仅存内存 |
| 降级兼容 | 未交换密钥时自动降级为明文传输,不影响基本功能 |
| SSE 豁免 | /stream路径跳过加密过滤器,SSE 流不受影响 |
七、涉及文件清单
| 层 | 文件 | 职责 |
|---|---|---|
| 后端-工具 | talk-common/.../utils/CryptoUtils.java | AES/RSA 加解密核心 |
| 后端-DTO | talk-common/.../dto/crypto/EncryptedPayload.java | 加密载荷包装 |
| 后端-DTO | talk-common/.../dto/crypto/KeyExchangeRequest.java | 密钥交换请求 |
| 后端-DTO | talk-common/.../dto/crypto/KeyExchangeResponse.java | 密钥交换响应 |
| 后端-常量 | talk-common/.../constant/Constants.java | Redis Key / Header / TTL |
| 后端-控制器 | talk-chat/.../controller/CryptoController.java | 密钥交换 API |
| 后端-过滤器 | talk-framework/.../filter/CryptoFilter.java | 请求解密 + 响应加密 |
| 后端-包装器 | talk-framework/.../filter/CryptoRequestWrapper.java | 请求体解密 |
| 后端-包装器 | talk-framework/.../filter/CryptoResponseWrapper.java | 响应体加密 |
| 前端 | talk-ui/src/api/crypto.js | 前端 AES/RSA/密钥交换 |
| 前端 | talk-ui/src/api/token-manager.js | 密钥内存管理 |
| 前端 | talk-ui/src/api/request.js | 请求/响应自动加解密拦截 |
| 前端 | talk-ui/src/pages/login/index.vue | 登录后触发密钥交换 |