Spring Boot 3.4接口安全实战:RSA+AES混合加密与签名验签方案
1. 项目概述:为什么我们需要在Spring Boot 3.4中实现接口加解密与防篡改?
最近在做一个金融类的项目,对接的客户方在安全评审时,明确提出了需要满足等保2.0中关于数据传输安全性的要求。核心就两条:一是数据在传输过程中不能被窃听(机密性),二是数据在传输过程中不能被恶意修改(完整性)。这让我不得不重新审视我们基于Spring Boot 3.4构建的这套RESTful API。过去我们可能觉得用了HTTPS就万事大吉,但深入想想,HTTPS确实解决了传输层的安全,可一旦请求到达我们的应用服务器,数据就是明文了。如果我们的服务部署在云上,或者内部网络存在风险,那么从负载均衡器到应用服务、从应用到数据库,这些环节的数据依然可能暴露。更关键的是,HTTPS无法防止数据被篡改后重新签名(虽然概率低,但并非不可能)。所以,在应用层再做一层加解密和签名验签,就成了一道必要的“内网安全门”。
这个实战项目的目标很明确:在Spring Boot 3.4应用中,为关键业务接口(比如支付、用户敏感信息查询)实现一套完整的、非对称与对称加密结合、并带有防篡改签名的安全通信方案。简单说就是,客户端用我们的RSA公钥加密一个随机生成的AES密钥,然后用这个AES密钥加密真正的业务数据,最后对加密后的整体数据做一个签名。服务端收到后,先用自己的RSA私钥解密出AES密钥,再用AES密钥解密业务数据,最后验证签名是否一致。这套组合拳下来,安全性就非常扎实了。
它适合所有对数据安全有较高要求的Spring Boot开发者,特别是涉及金融、政务、医疗、企业核心业务等场景。即使你现在没这个硬性要求,提前掌握这套方案,在技术面试或者架构设计时,也是一个非常加分的亮点。
2. 核心方案设计与密码学选型背后的逻辑
为什么是RSA+AES+签名验签这个组合?而不是直接用RSA加密所有数据,或者只用AES?这里面的门道,我结合等保要求和实际性能,仔细琢磨过。
2.1 非对称加密(RSA)与对称加密(AES)的混合使用
RSA算法非常安全,但有个致命缺点:慢。它不适合加密大量数据。如果我用RSA去加密一个几KB的JSON报文,加解密过程会消耗可观的CPU和时间,在高并发接口下这是不可接受的。而AES作为对称加密算法,加解密速度极快,非常适合处理大批量数据。所以,一个经典的混合加密模式就诞生了:用RSA来加密“钥匙”,用AES来加密“货物”。
具体到我们的流程:
- 客户端随机生成一个AES密钥(比如256位的)。这个密钥就是用来加密业务数据的“会话密钥”。
- 客户端用服务端预先提供的RSA公钥,加密这个AES密钥。因为AES密钥本身很短(一个字符串),用RSA加密很快。
- 客户端用上一步生成的AES密钥,采用AES算法(如AES/CBC/PKCS5Padding)加密实际的业务JSON数据。
- 最后,客户端将加密后的AES密钥(RSA加密结果)和加密后的业务数据(AES加密结果)一起发送给服务端。
服务端收到后,反向操作:
- 用自己的RSA私钥解密出AES密钥。
- 用解密得到的AES密钥,解密出原始的业务数据。
这样,我们既利用了RSA的非对称特性安全地交换了密钥,又享受了AES加密大数据的高性能。这是目前最主流、最稳妥的实践。
2.2 数字签名(验签)的必要性
加解密保证了机密性,但无法保证完整性。想象一个场景:黑客截获了请求,他虽然无法解密出原始数据(因为没有私钥),但他可以恶意篡改加密后的数据块(哪怕只是改一个比特),然后原样发送给服务端。服务端解密时可能会因为数据损坏而报错,但这已经造成了服务拒绝。更糟糕的是,如果黑客精心构造,会不会有可能在不解密的情况下,篡改数据并让服务端解密出他期望的结果?(虽然极其困难,但密码学上要求我们考虑这种可能性)
数字签名就是为了解决这个问题。它的逻辑是:发送方(客户端)对“待发送的数据”(可以是明文,也可以是密文)计算一个哈希值(如SHA256),然后用自己的私钥对这个哈希值进行加密,得到的就是“签名”。接收方(服务端)收到数据和签名后,做两件事:
- 用发送方的公钥解密签名,得到哈希值A。
- 对自己收到的数据计算哈希值,得到哈希值B。
- 比较A和B。如果一致,证明数据在传输过程中未被篡改,且确实来自持有对应私钥的发送方。
在我们的方案里,通常是对“加密后的业务数据”进行签名。因为业务数据已经用AES加密了,所以签名相当于对密文进行完整性保护。有些更严格的场景,会对“明文业务数据”先签名,然后再加密(即“签名后加密”),确保源头可信。我们这里采用对密文签名,在性能和安全性上是一个很好的平衡。
2.3 关键参数选型与等保考量
等保2.0对密码技术有明确要求,我们不能随便选个算法和长度就上。
- RSA密钥长度:等保2.0三级及以上通常要求RSA密钥长度不低于2048位。我们直接选择2048位。生成密钥对时,记得保存好私钥,公钥可以提供给客户端。
- AES模式与填充:AES有多种工作模式,如ECB、CBC、GCM等。ECB模式不安全,不推荐。CBC模式需要初始化向量(IV),安全性好,是常用选择。GCM模式同时提供了加密和认证,更先进,但实现稍复杂。我们这里选择最通用的AES/CBC/PKCS5Padding。注意,CBC模式每次加密必须使用一个随机生成的IV,和加密后的数据一起传给服务端。
- 哈希算法:用于签名的哈希算法,我们选择SHA256withRSA。SHA256是目前公认安全的哈希算法,强度足够。
- 密钥管理:这是安全的核心!RSA私钥必须存储在服务端安全的位置,如配置文件(需加密)、环境变量或专业的密钥管理服务(KMS)中,绝不能硬编码在代码里或提交到代码库。AES的“会话密钥”是每次请求动态生成的,用完即弃,安全性很高。
注意:等保测评时,测评人员会重点关注密钥的生成、存储、分发、使用和销毁的全生命周期管理。一定要有相应的文档和日志记录。
3. 环境准备与核心工具类封装
工欲善其事,必先利其器。在开始编写业务代码前,我们需要先把加解密、签名验签这些底层能力封装成可靠的工具类。我基于Spring Boot 3.4和JDK 17+进行开发。
3.1 项目初始化与依赖引入
创建一个新的Spring Boot 3.4项目,选择Web依赖。在pom.xml中,我们主要需要确保JDK版本,并引入一些辅助工具。
<properties> <java.version>17</java.version> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 用于处理JSON --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!-- 工具类,如Base64编码(JDK自带也可用) --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.12.0</version> </dependency> <!-- 测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>加解密和签名功能,JDK的标准库javax.crypto和java.security已经足够强大,不需要额外引入第三方密码学库(如Bouncy Castle),除非你有特别的需求(如国密算法SM2/SM4)。
3.2 RSA密钥对生成与管理
首先,我们需要生成一对RSA密钥。这里我写一个工具类来生成,并演示如何保存和加载。在实际生产环境中,密钥对通常由运维或安全人员在安全环境中生成,然后以文件或配置的形式提供给应用。
import javax.crypto.Cipher; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class RsaUtil { private static final String RSA_ALGORITHM = "RSA"; private static final int KEY_SIZE = 2048; // 密钥长度 /** * 生成RSA密钥对 * @return 包含公钥和私钥的KeyPair * @throws NoSuchAlgorithmException */ public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM); keyPairGenerator.initialize(KEY_SIZE); return keyPairGenerator.generateKeyPair(); } /** * 获取公钥字符串(Base64编码) */ public static String getPublicKeyStr(KeyPair keyPair) { return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); } /** * 获取私钥字符串(Base64编码) */ public static String getPrivateKeyStr(KeyPair keyPair) { return Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); } /** * 从Base64字符串加载公钥 */ public static PublicKey loadPublicKey(String publicKeyStr) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); return keyFactory.generatePublic(keySpec); } /** * 从Base64字符串加载私钥 */ public static PrivateKey loadPrivateKey(String privateKeyStr) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); return keyFactory.generatePrivate(keySpec); } /** * RSA公钥加密 * @param data 待加密数据 * @param publicKey 公钥 * @return 加密后的Base64字符串 */ public static String encryptWithPublicKey(String data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedBytes = cipher.doFinal(data.getBytes()); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * RSA私钥解密 * @param encryptedData 加密后的Base64字符串 * @param privateKey 私钥 * @return 解密后的原始字符串 */ public static String decryptWithPrivateKey(String encryptedData, PrivateKey privateKey) throws Exception { Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] dataBytes = Base64.getDecoder().decode(encryptedData); byte[] decryptedBytes = cipher.doFinal(dataBytes); return new String(decryptedBytes); } }实操心得:
Cipher.getInstance(“RSA”)默认使用的是RSA/ECB/PKCS1Padding模式。对于仅加密密钥这种短数据,这是安全的。如果你需要加密的数据块长度接近密钥长度,则需要考虑分块加密。我们的场景只加密AES密钥(一个固定长度的字符串),所以直接用这个默认模式没问题。
3.3 AES加解密工具类封装
接下来封装AES工具类。重点在于CBC模式下的IV处理。
import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; public class AesUtil { private static final String AES_ALGORITHM = "AES"; private static final String AES_TRANSFORMATION = "AES/CBC/PKCS5Padding"; // 使用CBC模式 private static final int AES_KEY_SIZE = 256; // AES-256 /** * 生成一个随机的AES密钥 */ public static SecretKey generateAesKey() throws NoSuchAlgorithmException { KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM); keyGenerator.init(AES_KEY_SIZE); return keyGenerator.generateKey(); } /** * 生成一个随机的16字节初始化向量(IV) */ public static byte[] generateIv() { byte[] iv = new byte[16]; // AES块大小是16字节 new SecureRandom().nextBytes(iv); return iv; } /** * AES加密 * @param data 明文数据 * @param secretKey AES密钥 * @param iv 初始化向量 * @return 加密后的Base64字符串 */ public static String encrypt(String data, SecretKey secretKey, byte[] iv) throws Exception { Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); byte[] encryptedBytes = cipher.doFinal(data.getBytes()); // 将IV和加密后的数据一起返回,IV不需要保密,但必须唯一且随机 byte[] combined = new byte[iv.length + encryptedBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); return Base64.getEncoder().encodeToString(combined); } /** * AES解密 * @param combinedData 包含IV和密文的Base64字符串 * @param secretKey AES密钥 * @return 解密后的明文 */ public static String decrypt(String combinedData, SecretKey secretKey) throws Exception { byte[] combined = Base64.getDecoder().decode(combinedData); byte[] iv = new byte[16]; byte[] encryptedBytes = new byte[combined.length - 16]; System.arraycopy(combined, 0, iv, 0, 16); System.arraycopy(combined, 16, encryptedBytes, 0, encryptedBytes.length); Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes); } /** * 将Base64编码的密钥字符串转换为SecretKey对象 */ public static SecretKey convertStringToSecretKey(String encodedKey) { byte[] decodedKey = Base64.getDecoder().decode(encodedKey); return new SecretKeySpec(decodedKey, 0, decodedKey.length, AES_ALGORITHM); } /** * 将SecretKey对象转换为Base64编码的字符串 */ public static String convertSecretKeyToString(SecretKey secretKey) { return Base64.getEncoder().encodeToString(secretKey.getEncoded()); } }3.4 签名与验签工具类封装
最后,我们封装签名和验签的工具。这里我们使用SHA256作为哈希算法,RSA进行签名。
import java.security.*; import java.util.Base64; public class SignatureUtil { private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; /** * 使用私钥对数据进行签名 * @param data 待签名数据(通常是密文) * @param privateKey 签名私钥 * @return 签名的Base64字符串 */ public static String sign(String data, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); signature.initSign(privateKey); signature.update(data.getBytes()); byte[] signBytes = signature.sign(); return Base64.getEncoder().encodeToString(signBytes); } /** * 使用公钥验证签名 * @param data 接收到的数据(应与签名时的数据一致) * @param sign 接收到的签名 * @param publicKey 验证公钥 * @return 验签是否通过 */ public static boolean verify(String data, String sign, PublicKey publicKey) throws Exception { Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); signature.initVerify(publicKey); signature.update(data.getBytes()); byte[] signBytes = Base64.getDecoder().decode(sign); return signature.verify(signBytes); } }至此,我们的核心密码学工具就准备好了。接下来,就是如何将这些工具优雅地集成到Spring Boot的请求/响应流程中。
4. Spring Boot接口的集成实战:请求解密与响应加密
我们不希望在每个Controller里都写一堆加解密的代码,那样太冗余且容易出错。Spring Boot的拦截器(Interceptor)和@ControllerAdvice是处理这类横切关注点的绝佳武器。我的设计思路是:
- 定义一个注解,如
@SecurityApi,标记在需要安全处理的Controller方法上。 - 编写一个拦截器,对带有
@SecurityApi的请求进行解密和验签。 - 使用
@ControllerAdvice和ResponseBodyAdvice,对带有@SecurityApi的响应进行加密和签名。
4.1 定义安全注解与加解密请求/响应体
首先,定义注解和用于传输加密数据的请求/响应体结构。
// 安全API注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface SecurityApi { // 可以扩展,比如指定是否需要验签等 }// 加密请求体 @Data public class EncryptedRequest { /** * 经过RSA公钥加密后的AES密钥(Base64格式) */ private String encryptedKey; /** * 经过上述AES密钥加密后的业务数据(Base64格式) */ private String encryptedData; /** * 对encryptedData的签名(Base64格式) */ private String sign; // 其他可能的信息,如时间戳、随机数等用于防重放 private String timestamp; private String nonce; }// 加密响应体 @Data public class EncryptedResponse { /** * 状态码 */ private int code; /** * 经过RSA公钥加密后的AES密钥(Base64格式) * 注意:响应时,服务端会生成新的AES会话密钥 */ private String encryptedKey; /** * 经过新AES密钥加密后的业务响应数据(Base64格式) */ private String encryptedData; /** * 对encryptedData的签名(Base64格式) */ private String sign; }4.2 构建安全拦截器(请求解密与验签)
这是核心环节。拦截器需要:
- 判断当前请求方法是否有
@SecurityApi注解。 - 读取请求体,解析为
EncryptedRequest对象。 - 用服务端的RSA私钥解密
encryptedKey,得到AES会话密钥。 - 用解密出的AES密钥解密
encryptedData,得到业务JSON字符串。 - 使用客户端的公钥(需要预先配置或从请求头获取)验证
sign签名。 - 验签通过后,将解密后的业务JSON字符串,通过Jackson反序列化成Controller方法实际需要的参数对象。
- 将参数对象设置到请求属性中,供后续处理。
这里有个关键点:Spring的HttpServletRequest的输入流只能读取一次。我们需要自定义一个HttpServletRequestWrapper来缓存请求体。
@Component public class SecurityInterceptor implements HandlerInterceptor { @Autowired private RsaKeyProvider rsaKeyProvider; // 一个提供RSA密钥的服务 @Autowired private ObjectMapper objectMapper; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; SecurityApi securityApi = handlerMethod.getMethodAnnotation(SecurityApi.class); if (securityApi == null) { return true; // 没有注解,放行 } // 1. 读取并解析加密请求体 String requestBody = getRequestBody(request); EncryptedRequest encryptedRequest = objectMapper.readValue(requestBody, EncryptedRequest.class); // 2. RSA解密,获取AES密钥 String aesKeyStr = RsaUtil.decryptWithPrivateKey(encryptedRequest.getEncryptedKey(), rsaKeyProvider.getServerPrivateKey()); SecretKey aesKey = AesUtil.convertStringToSecretKey(aesKeyStr); // 3. AES解密,获取业务数据明文 String businessDataPlain = AesUtil.decrypt(encryptedRequest.getEncryptedData(), aesKey); // 4. 验签(这里假设客户端公钥已配置,实际可能根据appId从数据库或缓存获取) PublicKey clientPublicKey = rsaKeyProvider.getClientPublicKey("defaultClientId"); boolean verifyResult = SignatureUtil.verify(encryptedRequest.getEncryptedData(), encryptedRequest.getSign(), clientPublicKey); if (!verifyResult) { throw new SecurityException("签名验证失败,请求可能被篡改"); } // 5. 防重放攻击检查(可选但重要) // checkTimestampAndNonce(encryptedRequest.getTimestamp(), encryptedRequest.getNonce()); // 6. 将解密后的业务数据绑定到请求参数 // 这里需要知道Controller方法参数的类型,简单起见,我们假设是String或一个特定的DTO // 更复杂的实现需要解析HandlerMethod的参数信息 Class<?>[] parameterTypes = handlerMethod.getMethod().getParameterTypes(); if (parameterTypes.length == 1 && parameterTypes[0].equals(String.class)) { request.setAttribute("DECRYPTED_BODY", businessDataPlain); } else { // 尝试反序列化为第一个参数的类型 Object arg = objectMapper.readValue(businessDataPlain, parameterTypes[0]); request.setAttribute("DECRYPTED_BODY", arg); } // 7. 将AES密钥存入请求上下文,供后续加密响应使用(注意:请求和响应的AES密钥应不同,这里仅为示例流程) request.setAttribute("SESSION_AES_KEY", aesKey); return true; } private String getRequestBody(HttpServletRequest request) throws IOException { // 使用缓存请求体的Wrapper if (request instanceof CachedBodyHttpServletRequest) { return ((CachedBodyHttpServletRequest) request).getBody(); } // 如果不是,则读取流(这种情况应该不会发生,因为需要在Filter中包装) return StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); } // 省略 CachedBodyHttpServletRequest 的实现... }为了让拦截器能读取多次请求体,我们还需要一个Filter来包装Request。
@Component public class CachingRequestBodyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 只对特定路径或内容类型的请求进行包装,避免性能损耗 if (isSecurityRequest(request)) { CachedBodyHttpServletRequest cachedBodyRequest = new CachedBodyHttpServletRequest(request); filterChain.doFilter(cachedBodyRequest, response); } else { filterChain.doFilter(request, response); } } private boolean isSecurityRequest(HttpServletRequest request) { // 根据路径或Header判断是否为需要解密的请求 return true; // 简化实现 } }4.3 实现响应加密与签名(ResponseBodyAdvice)
请求处理完后,我们需要对返回的结果进行加密和签名。这里用@ControllerAdvice实现ResponseBodyAdvice。
@ControllerAdvice(annotations = SecurityApi.class) // 只处理带有@SecurityApi注解的方法 public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> { @Autowired private RsaKeyProvider rsaKeyProvider; @Autowired private ObjectMapper objectMapper; @Override public boolean supports(MethodParameter returnType, Class converterType) { // 检查返回类型,通常我们只加密返回对象,不加密String或void等 return returnType.getMethod().isAnnotationPresent(SecurityApi.class); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { try { // 1. 生成新的AES会话密钥和IV(每次响应都应不同) SecretKey aesKey = AesUtil.generateAesKey(); byte[] iv = AesUtil.generateIv(); // 2. 将业务响应体序列化为JSON字符串 String businessDataJson = objectMapper.writeValueAsString(body); // 3. 使用新的AES密钥加密业务数据 String encryptedData = AesUtil.encrypt(businessDataJson, aesKey, iv); // 4. 使用客户端的RSA公钥加密AES密钥 PublicKey clientPublicKey = rsaKeyProvider.getClientPublicKey("defaultClientId"); String encryptedKey = RsaUtil.encryptWithPublicKey(AesUtil.convertSecretKeyToString(aesKey), clientPublicKey); // 5. 使用服务端私钥对加密后的数据签名 PrivateKey serverPrivateKey = rsaKeyProvider.getServerPrivateKey(); String sign = SignatureUtil.sign(encryptedData, serverPrivateKey); // 6. 构造加密响应体 EncryptedResponse encryptedResponse = new EncryptedResponse(); encryptedResponse.setCode(200); encryptedResponse.setEncryptedKey(encryptedKey); encryptedResponse.setEncryptedData(encryptedData); encryptedResponse.setSign(sign); // 7. 设置响应头,告知客户端内容类型 response.getHeaders().setContentType(MediaType.APPLICATION_JSON); return encryptedResponse; } catch (Exception e) { throw new RuntimeException("响应加密失败", e); } } }4.4 密钥提供器与配置管理
我们需要一个中心化的地方来管理RSA密钥对。这里用@ConfigurationProperties和@Component实现一个简单的密钥提供器。生产环境务必使用更安全的方式,如从KMS获取。
@Component @ConfigurationProperties(prefix = "security.rsa") @Data public class RsaKeyProvider { /** * 服务端私钥(Base64编码) */ private String serverPrivateKeyStr; /** * 客户端公钥(Base64编码,可配置多个,这里简化为一个) */ private String clientPublicKeyStr; private PrivateKey serverPrivateKey; private PublicKey clientPublicKey; @PostConstruct public void init() throws Exception { this.serverPrivateKey = RsaUtil.loadPrivateKey(serverPrivateKeyStr); this.clientPublicKey = RsaUtil.loadPublicKey(clientPublicKeyStr); } public PrivateKey getServerPrivateKey() { return serverPrivateKey; } public PublicKey getClientPublicKey(String clientId) { // 简化处理,实际应根据clientId返回对应的公钥 return clientPublicKey; } }在application.yml中配置:
security: rsa: server-private-key-str: MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAO... (你的私钥Base64) client-public-key-str: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD... (客户端公钥Base64)4.5 编写测试Controller
最后,我们写一个简单的Controller来测试整个流程。
@RestController @RequestMapping("/api/secure") public class SecureDemoController { @PostMapping("/payment") @SecurityApi // 标记此接口需要安全处理 public ApiResponse<PaymentResult> makePayment(@RequestBody PaymentRequest paymentRequest) { // 这里的paymentRequest已经是拦截器解密并反序列化好的对象了! System.out.println("接收到支付请求: " + paymentRequest); // 处理业务逻辑... PaymentResult result = new PaymentResult(); result.setOrderId("ORDER_123456"); result.setStatus("SUCCESS"); return ApiResponse.success(result); // ResponseBodyAdvice会自动将这个ApiResponse对象加密后返回 } @Data static class PaymentRequest { private String orderNo; private BigDecimal amount; private String userId; } @Data static class PaymentResult { private String orderId; private String status; } @Data static class ApiResponse<T> { private int code; private String msg; private T data; public static <T> ApiResponse<T> success(T data) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(200); response.setMsg("成功"); response.setData(data); return response; } } }这样,一个完整的、集成到Spring Boot的接口加解密与防篡改方案就搭建起来了。客户端需要按照我们定义的EncryptedRequest格式组装数据,服务端会自动处理加解密和验签。
5. 客户端调用示例与联调要点
服务端准备好了,客户端(可能是前端、安卓、iOS或其他后端服务)该如何调用呢?这里给出一个Java客户端的示例,其他语言原理相同。
5.1 客户端加密与签名流程
public class SecurityClient { private String serverPublicKeyStr; // 服务端公钥 private String clientPrivateKeyStr; // 客户端私钥(用于签名) private PublicKey serverPublicKey; private PrivateKey clientPrivateKey; private ObjectMapper objectMapper = new ObjectMapper(); public void init() throws Exception { serverPublicKey = RsaUtil.loadPublicKey(serverPublicKeyStr); clientPrivateKey = RsaUtil.loadPrivateKey(clientPrivateKeyStr); } public EncryptedRequest buildEncryptedRequest(Object businessData) throws Exception { // 1. 生成随机的AES密钥和IV SecretKey aesKey = AesUtil.generateAesKey(); byte[] iv = AesUtil.generateIv(); String aesKeyStr = AesUtil.convertSecretKeyToString(aesKey); // 2. 将业务数据转为JSON String businessDataJson = objectMapper.writeValueAsString(businessData); // 3. AES加密业务数据 String encryptedData = AesUtil.encrypt(businessDataJson, aesKey, iv); // 4. RSA加密AES密钥 String encryptedKey = RsaUtil.encryptWithPublicKey(aesKeyStr, serverPublicKey); // 5. 使用客户端私钥对 encryptedData 签名 String sign = SignatureUtil.sign(encryptedData, clientPrivateKey); // 6. 构造请求体 EncryptedRequest request = new EncryptedRequest(); request.setEncryptedKey(encryptedKey); request.setEncryptedData(encryptedData); request.setSign(sign); request.setTimestamp(String.valueOf(System.currentTimeMillis())); request.setNonce(UUID.randomUUID().toString()); // 随机数防重放 return request; } public <T> T parseEncryptedResponse(EncryptedResponse encryptedResponse, Class<T> clazz) throws Exception { // 1. 验证服务端签名(使用服务端公钥) boolean verify = SignatureUtil.verify(encryptedResponse.getEncryptedData(), encryptedResponse.getSign(), serverPublicKey); if (!verify) { throw new SecurityException("服务端响应签名验证失败"); } // 2. RSA解密,获取响应使用的AES密钥 String aesKeyStr = RsaUtil.decryptWithPrivateKey(encryptedResponse.getEncryptedKey(), clientPrivateKey); // 注意:这里用客户端私钥解密 SecretKey aesKey = AesUtil.convertStringToSecretKey(aesKeyStr); // 3. AES解密业务数据 String businessDataJson = AesUtil.decrypt(encryptedResponse.getEncryptedData(), aesKey); // 4. 反序列化 return objectMapper.readValue(businessDataJson, clazz); } }5.2 使用HTTP客户端发送请求
public class ApiClient { private SecurityClient securityClient; private RestTemplate restTemplate; public PaymentResult callSecurePayment(PaymentRequest request) throws Exception { // 1. 构建加密请求体 EncryptedRequest encryptedRequest = securityClient.buildEncryptedRequest(request); // 2. 发送HTTP请求 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<EncryptedRequest> httpEntity = new HttpEntity<>(encryptedRequest, headers); ResponseEntity<EncryptedResponse> responseEntity = restTemplate.postForEntity( "https://your-server.com/api/secure/payment", httpEntity, EncryptedResponse.class ); // 3. 处理加密响应 EncryptedResponse encryptedResponse = responseEntity.getBody(); if (encryptedResponse.getCode() != 200) { throw new RuntimeException("API调用失败: " + encryptedResponse.getCode()); } // 4. 解密并解析响应 ApiResponse<PaymentResult> apiResponse = securityClient.parseEncryptedResponse(encryptedResponse, objectMapper.getTypeFactory().constructParametricType(ApiResponse.class, PaymentResult.class)); return apiResponse.getData(); } }5.3 联调过程中的关键检查点
在实际联调时,最容易出问题的地方往往是一些细节:
- 密钥格式:确保客户端和服务端使用的密钥是同一对(公私钥匹配),并且是Base64编码的PKCS#8格式(对于私钥)和X.509格式(对于公钥)。用
openssl或在线工具生成密钥时要注意格式转换。 - 编码一致性:加解密和签名验签过程中,涉及字符串和字节数组转换的地方,必须统一编码(通常用UTF-8)。
data.getBytes()最好显式指定为data.getBytes(StandardCharsets.UTF_8)。 - AES的IV处理:CBC模式必须使用IV,且每次加密必须随机生成。服务端解密时,必须从
combinedData的前16字节正确提取IV。 - 签名内容:确保客户端和服务端对“哪些数据做签名”达成一致。我们这里是对
encryptedData(AES密文)签名。如果一方对明文签名,另一方对密文验签,肯定失败。 - HTTP请求头:确保客户端发送的
Content-Type是application/json,服务端能正确解析。 - 时间戳与防重放:在生产环境中,务必在拦截器里实现防重放攻击机制。可以检查请求中的
timestamp,如果与服务器时间相差过大(如5分钟),则拒绝。同时,将nonce存入缓存(如Redis),在一段时间内(如5分钟)重复的nonce请求也拒绝。
6. 生产环境进阶考量与性能优化
把demo跑起来只是第一步,要真正上线,还有很多坑要填。
6.1 密钥的安全存储与轮转
- 存储:绝不能把私钥放在代码或配置文件中明文提交到Git。推荐做法:
- 使用环境变量注入。
- 使用云服务商的密钥管理服务(如AWS KMS, Azure Key Vault, 阿里云KMS)。
- 在启动时从安全的配置中心(如Nacos, Apollo)拉取,配置中心本身需加密。
- 轮转:密钥不能永远不换。需要制定密钥轮转策略。例如,每季度更换一次RSA密钥对。轮转期间,新旧密钥需要同时支持一段时间,逐步迁移客户端。
6.2 性能优化策略
全量加解密对CPU有一定消耗,特别是RSA运算。优化思路:
- 选择性加密:并非所有接口都需要如此高的安全级别。使用
@SecurityApi注解可以灵活控制。对于不敏感的数据(如查询公开信息),可以不走加解密流程。 - 连接复用与会话密钥:对于WebSocket或需要频繁交互的接口,可以在首次握手时协商一个AES会话密钥,并在本次会话内复用,避免每次请求都进行RSA加解密。但需要妥善管理会话密钥的生命周期。
- 异步处理:加解密是CPU密集型操作,可以考虑将加解密操作放到独立的线程池中执行,避免阻塞Netty或Tomcat的IO线程。
- 硬件加速:如果性能压力极大,可以考虑使用支持AES-NI指令集的CPU,Java会自动利用其进行硬件加速。
6.3 监控、日志与审计
- 监控:监控加解密微服务的CPU使用率、接口响应时间(特别是解密和验签耗时)。
- 日志:谨慎记录日志。绝对不要在日志中打印明文敏感信息(如解密后的银行卡号、密码)、完整的密钥或IV。可以记录一些元信息,如请求ID、客户端ID、加解密操作的成功/失败状态、签名验证结果等。
- 审计:记录所有安全相关操作,特别是验签失败、解密失败、重放攻击拦截等事件,便于事后追溯和安全分析。
6.4 常见问题排查清单
在实际部署和运维中,你可能会遇到下面这些问题:
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
服务端解密失败:javax.crypto.BadPaddingException | 1. 客户端使用的RSA公钥与服务端私钥不匹配。 2. 加密的数据块长度超过了RSA密钥能处理的最大长度。 3. 传输过程中 encryptedKey字符串被修改(如空格、换行符)。 | 1. 核对双方密钥对是否匹配。 2. 确认RSA只用于加密AES密钥(短字符串)。 3. 检查Base64字符串的编码/解码是否一致,网络传输是否有URL编码问题。 |
服务端AES解密失败:javax.crypto.IllegalBlockSizeException | 1. AES密钥错误。 2. IV提取错误(长度或位置不对)。 3. 密文在传输中被损坏。 | 1. 确认RSA解密AES密钥步骤成功。 2. 调试检查 combinedData拆分IV和密文的逻辑。3. 对比客户端发送的 encryptedData和服务端收到的是否完全一致。 |
| 签名验证失败 | 1. 签名算法不一致(如一端用SHA256,另一端用SHA1)。 2. 签名的原始数据不一致(客户端对A签名,服务端对B验签)。 3. 用于验签的公钥与签名的私钥不匹配。 4. 签名字符串在传输中有变化。 | 1. 确认双方使用的签名算法字符串完全相同(SHA256withRSA)。2. 在客户端和服务端分别打印出用于签名/验签的 encryptedData的Base64字符串,进行比对。3. 核对公私钥对应关系。 |
| 接口响应明显变慢 | 1. RSA加解密成为性能瓶颈。 2. 密钥加载或转换耗时。 | 1. 使用性能分析工具(如Arthas)定位热点。 2. 确保RSA密钥对象( PrivateKey/PublicKey)是单例复用,而不是每次请求都重新加载。 |
| 特定客户端一直失败 | 1. 该客户端使用了错误的公钥或算法参数。 2. 客户端实现有bug(如IV生成逻辑错误)。 | 1. 为该客户端单独提供一份密钥和测试程序,进行隔离调试。 2. 检查客户端代码,特别是字节数组与字符串转换、Base64编码等环节。 |
这套方案从设计到实现,涵盖了等保2.0中关于数据传输安全的核心要求。它不是一个银弹,需要你根据自己业务的实际情况进行调整,比如密钥管理方式、是否引入国密算法、如何与现有的网关和认证体系结合等。但它的核心思想——混合加密确保机密性、数字签名确保完整性——是构建安全通信的坚实基石。在实际踩坑和调优的过程中,你会对应用层安全有更深刻的理解。
