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
java
// ========== RSA 密钥对生成 ==========
public static KeyPair generateRsaKeyPair() {
return SecureUtil.generateKeyPair("RSA", 2048);
}
// ========== RSA-OAEP 加密(公钥加密 AES 密钥) ==========
private static final String RSA_ALGORITHM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
public static String rsaEncrypt(String data, String publicKey) {
PublicKey key = decodePublicKey(publicKey);
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
OAEPParameterSpec spec = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.encode(encrypted);
}
// ========== AES-GCM 加密 ==========
public static String aesEncrypt(String plaintext, String aesKey) {
byte[] keyBytes = Base64.decode(aesKey);
byte[] iv = new byte[12]; // 12字节随机 IV
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv); // 128-bit Tag
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), spec);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// IV + 密文 拼接后 Base64
byte[] result = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(encrypted, 0, result, iv.length, encrypted.length);
return Base64.encode(result);
}
3.2 密钥交换 --- CryptoController.java
talk-chat/src/main/java/com/talk/chat/controller/CryptoController.java
步骤 1:获取公钥
java
@GetMapping("/public-key")
public AjaxResult<KeyExchangeResponse> getPublicKey() {
KeyPair keyPair = CryptoUtils.generateRsaKeyPair();
String publicKeyBase64 = CryptoUtils.encodePublicKey(keyPair.getPublic());
String privateKeyBase64 = CryptoUtils.encodePrivateKey(keyPair.getPrivate());
// 完整 SHA-256 指纹,防碰撞
String fingerprint = DigestUtil.sha256Hex(publicKeyBase64);
// 私钥存 Redis(10分钟 TTL),key = rsa_keypair:{fingerprint}
redisTemplate.opsForValue().set(
Constants.REDIS_RSA_KEY_PREFIX + fingerprint,
privateKeyBase64, 10, TimeUnit.MINUTES);
return AjaxResult.success(new KeyExchangeResponse(publicKeyBase64, fingerprint));
}
步骤 2:交换 AES 密钥
java
@PostMapping("/exchange")
public AjaxResult<String> exchangeKey(@Valid @RequestBody KeyExchangeRequest request) {
// 从 Redis 取私钥
String privateKeyBase64 = redisTemplate.opsForValue().get(
Constants.REDIS_RSA_KEY_PREFIX + request.getFingerprint());
// RSA 解密得到 AES 密钥
String aesKey = CryptoUtils.rsaDecrypt(request.getEncryptedAesKey(), privateKeyBase64);
// AES 密钥存 Redis(30分钟 TTL),key = aes_key:{userId}:{keyId}
String keyId = 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());
return AjaxResult.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
java
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
// 1. 时间戳容差校验(5分钟)
long timestamp = Long.parseLong(request.getHeader("X-Timestamp"));
if (Math.abs(System.currentTimeMillis() - timestamp) > 5 * 60 * 1000) {
return error("请求已过期");
}
// 2. Nonce 防重放(Redis 标记,5分钟 TTL)
String nonceKey = "nonce:" + request.getHeader("X-Nonce");
if (redisTemplate.hasKey(nonceKey)) {
return error("请求重复"); // 检测到重放攻击
}
redisTemplate.opsForValue().set(nonceKey, "1", 5, TimeUnit.MINUTES);
// 3. 包装请求(解密)和响应(加密)
CryptoRequestWrapper reqWrapper = new CryptoRequestWrapper(request, aesKey);
CryptoResponseWrapper resWrapper = new CryptoResponseWrapper(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
javascript
// ========== AES-GCM 加密(Web Crypto API,浏览器原生) ==========
export async function aesEncrypt(plaintext, aesKeyBase64) {
const keyBytes = base64ToArrayBuffer(aesKeyBase64)
const key = await crypto.subtle.importKey('raw', keyBytes,
{ name: 'AES-GCM' }, false, ['encrypt'])
const iv = crypto.getRandomValues(new Uint8Array(12)) // 12字节随机 IV
const encoded = new TextEncoder().encode(plaintext)
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
// IV + 密文 拼接
const result = new Uint8Array(iv.length + encrypted.byteLength)
result.set(iv, 0)
result.set(new Uint8Array(encrypted), iv.length)
return arrayBufferToBase64(result.buffer)
}
// ========== RSA-OAEP 加密(JSEncrypt 库)==========
export function rsaEncrypt(data, publicKeyPem) {
const encryptor = new JSEncrypt()
encryptor.setPublicKey(base64ToPem(publicKeyPem, 'PUBLIC'))
encryptor.setOptions({
encryptionScheme: 'pkcs1_oaep', // OAEP 填充,与后端一致
signingScheme: 'pkcs1v15'
})
return encryptor.encrypt(data)
}
4.2 密钥交换流程
javascript
export async function exchangeKeys() {
// ① 获取服务端 RSA 公钥
const { publicKey, fingerprint } = await fetchPublicKey()
// ② 本地生成 AES-128 密钥
const aesKey = await generateAesKey()
// ③ RSA-OAEP 加密 AES 密钥,发送给服务端
const encryptedAesKey = rsaEncrypt(aesKey, publicKey)
const keyId = await sendEncryptedAesKey(fingerprint, encryptedAesKey)
// ④ 内存保存(不持久化,每次登录重新交换)
tokenManager.setCryptoKey(keyId, aesKey)
}
4.3 请求自动加解密 --- request.js 拦截
talk-ui/src/api/request.js
javascript
// 请求加密:自动注入 X-Encrypted / X-Key-Id / X-Nonce / X-Timestamp
const { encrypted, headers } = await encryptRequest(requestData)
// encrypted = { keyId: "xxx", data: "Base64密文" }
// 响应解密:自动检测 EncryptedPayload 格式并解密
const decrypted = await decryptResponse(data)
4.4 密钥生命周期 --- token-manager.js
talk-ui/src/api/token-manager.js
javascript
// 内存存储(不持久化到 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 |
登录后触发密钥交换 |