AES + RSA 混合加密方案

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 登录后触发密钥交换