ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

等保2.0接口加解密:RSA+AES混合加密与签名验签实现

等保2.0接口加解密:RSA+AES混合加密与签名验签实现 2. 等保2.0接口加解密方案的整体设计思路2.1 为什么要用RSAAES混合加密而不是只用一种很多刚接触接口安全的朋友会有一个直觉既然要做加密那直接上RSA不就行了前端拿公钥加密后端拿私钥解密简单又对称。真这么做不用等上线压测第一轮你就想骂人了。RSA属于非对称加密安全强度高但性能瓶颈非常明显。2048位密钥的RSA加密单次操作耗时通常在毫秒级甚至更高如果业务接口的请求体是个几百KB的JSON用RSA去加密耗时和资源消耗都是灾难级的。而且RSA对明文长度有硬性限制采用PKCS1Padding时最多只能加密密钥长度减11个字节2048位密钥也就244字节左右根本承载不了业务数据。AES则是对称加密加解密速度极快单条数据加密耗时才几十微秒且没有明文长度限制。但AES的问题是密钥分发——通信双方必须共享同一个密钥如果直接把这个密钥放在请求头里传输等于是把保险柜钥匙贴在保险柜门上。所以业界标准的解法就是混合加密用RSA加密AES密钥解决密钥分发问题用AES加密业务报文解决性能和长度限制问题。这两者组合起来既拿到了RSA的安全性又保留了AES的高效。整套方案在等保2.0三级系统的实践里是主流选择很多省级政务平台和金融类系统都是这个套路。等保2.0中的通信传输完整性与通信传输保密性是两个独立测评项前者要求对通信报文进行完整性校验对应防篡改后者要求加密传输对应保密性。签名验签解决的是前者加解密解决的是后者缺一个都过不了。这也是为什么标题里三样东西一个都不能少——RSA和AES是为了保密性签名验签是为了完整性和防抵赖。2.2 整体架构与一次完整请求的生命周期先把我常用的这套方案的整体流程画在脑子里然后再谈代码实现。一次性完整请求的流程拆解如下客户端在初始化时向后端请求一次公钥后端将RSA公钥发给客户端私钥保存在服务端客户端本地随机生成一个AES密钥每次请求都可以换一个新的或者复用一段时间看业务取舍客户端用后端公钥对AES密钥进行RSA加密得到密文密钥客户端用AES密钥对业务报文进行AES加密得到密文报文客户端对AES密钥密文 报文密文 时间戳 随机数拼接后的字符串做签名签名用的私钥是客户端的RSA私钥请求到达服务端服务端先验签再解密AES密钥最后解密报文响应流程反向执行服务端生成响应的AES密钥可与请求密钥一致也可以独立用客户端公钥加密再用AES加密响应体最后服务端私钥签名客户端收到响应后验签、解密整个链路闭环。这套流程里最关键的一个设计决策是签名和加密必须分开用密钥。前面提到的前端拿公钥加密有一个安全隐患——如果别人拿到了你的公钥他可以伪装成客户端给你发加密数据因为加密是公钥操作不需要私钥。所以必须引入客户端签名机制服务端用客户端的公钥去验签从密码学意义上确认这条数据确实来自声称的那个客户端。等保2.0里面有一项叫做通信实体身份鉴别恰恰就需要这种双向的密钥机制来支撑。单纯做加解密只能满足保密性无法满足身份真实性验证这是不少团队在测评时被打回的原因之一。2.3 防篡改设计的核心签名原文的规范化很多第一次做签名验签的团队都会踩同一个坑签名的时候拼接的字符串和服务端验签时拼接的字符串不一致导致验签永远失败。这个问题的根子在于签名原文规范化做得不好。签名原文的确定规则必须在对接文档里定死。我常用的规则是对需要签名的字段先做字典序排序按字段名的ASCII码升序所有字段值不允许包含null空串用空字符串表示各字段用连接格式为key1value1key2value2最后拼接固定的signTypeRSA2之类的标识字段如果有的话。另外必须将时间戳和随机数Nonce纳入签名原文。时间戳用于防止重放攻击——服务端只接受2~5分钟内的请求Nonce则防止同一时间内完全相同的请求被多次提交。服务端可以简单存一个最近5分钟内的Nonce集合收到请求后先检查Nonce是否重复重复直接拒绝。这两者都能撑起等保2.0测评项中关于抗重放的要求光做加密不做抗重放测评专家照样会提整改项。3. 核心代码实现RSA密钥对管理、AES加解密、签名验签3.1 RSA密钥对生成与加载别在代码里写死私钥第一步是RSA密钥对的管理。在很多现成的RSA加密解决方案里人们习惯把公私钥直接写在配置文件的字符串常量里。这样做开发调试没有问题但到了生产环境就埋了大雷——私钥一旦泄露整个系统加密体系就等于全部公开。等保2.0整改意见里明确要求密钥全生命周期管理从生成、分发、存储、更新到销毁都要有制度和技术手段支撑。我通常的做法是用KeyPairGenerator在系统初始化时生成RSA密钥对生成后将私钥用PKCS12格式存储到服务端安全目录或密钥管理服务KMS文件权限设为600公钥通过HTTP接口明文下发公钥不需要保护泄露了也没关系私钥定期轮换旧密钥保留一段时间的解密能力用于兼容旧客户端新密钥发布后新请求全部使用新密钥。核心生成代码如下import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; public class RsaKeyGenerator { public static KeyPair generateRsaKeyPair(int keySize) throws NoSuchAlgorithmException { KeyPairGenerator generator KeyPairGenerator.getInstance(RSA); // SecureRandom 提供加密强随机数避免使用 new Random() 这种弱随机源 SecureRandom secureRandom new SecureRandom(); generator.initialize(keySize, secureRandom); return generator.generateKeyPair(); } public static void main(String[] args) throws NoSuchAlgorithmException { KeyPair keyPair generateRsaKeyPair(2048); // 生成后请将私钥放到安全存储不要打印在日志里 String publicKey Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); String privateKey Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); System.out.println(Public Key: publicKey); System.out.println(Private Key: privateKey); } }密钥位数方面实测下来2048位是生产环境的最低门槛。1024位在今天的算力下已经不安全等保测评里专家看到1024位会直接给你挂个高风险4096位虽然更安全但加解密耗时大约是2048位的3~5倍对高并发接口来说代价有点大。2024年开始有些金融客户要求必须支持3088位以上的曲线SM2但传统RSA 2048在大多数合规场景里依然够用。私钥加载的核心代码import java.io.FileInputStream; import java.security.KeyStore; import java.security.PrivateKey; import java.security.PublicKey; public class KeyStoreLoader { public static PrivateKey loadPrivateKey(String pfxPath, String password) throws Exception { try (FileInputStream fis new FileInputStream(pfxPath)) { KeyStore keyStore KeyStore.getInstance(PKCS12); keyStore.load(fis, password.toCharArray()); // 别名默认取第一个生产环境建议在生成时指定别名 String alias keyStore.aliases().nextElement(); return (PrivateKey) keyStore.getKey(alias, password.toCharArray()); } } public static PublicKey loadPublicKey(String certPath) throws Exception { // 从X509证书中提取公钥或者直接从Base64字符串构建 // 以Base64字符串为例 byte[] keyBytes Base64.getDecoder().decode(certPath); X509EncodedKeySpec spec new X509EncodedKeySpec(keyBytes); KeyFactory factory KeyFactory.getInstance(RSA); return factory.generatePublic(spec); } }注意一点千万不要把私钥以纯文本形式放在src/main/resources目录下然后打进JAR包。很多人为了图省事这么干结果就是代码仓库泄露等于私钥泄露。JAR包是可以被反编译提取资源的生产环境私钥一定要落在容器外部挂载的加密卷或专门的密钥管理服务里。3.2 AES加解密工具类GCM模式比CBC更省心AES加密的模式选择是个容易踩坑的点。最常见的是CBC模式但它有一个让人头疼的问题——需要单独处理IV初始向量而且CBC模式不提供完整性校验攻击者对密文做位翻转Bit Flipping时解密结果可控地变化配合某些场景甚至能绕过鉴权逻辑。我更推荐用AES/GCM/NoPadding。GCM是AEAD认证加密模式密文里自带认证标签Auth Tag解密时会同时校验完整性密文只要被改动任何一个比特解密直接抛异常。这天然就给防篡改加了一道保险。import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.SecureRandom; import java.util.Base64; public class AesGcmUtil { private static final int GCM_TAG_BITS 128; private static final int IV_LENGTH 12; // GCM推荐12字节 public static SecretKey generateAesKey() throws Exception { KeyGenerator generator KeyGenerator.getInstance(AES); generator.init(256, new SecureRandom()); return generator.generateKey(); } public static String encrypt(String plainText, SecretKey key) throws Exception { byte[] iv new byte[IV_LENGTH]; new SecureRandom().nextBytes(iv); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); byte[] cipherBytes cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 输出格式IV 密文 tag方便解密时直接切分 byte[] result new byte[iv.length cipherBytes.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(cipherBytes, 0, result, iv.length, cipherBytes.length); return Base64.getEncoder().encodeToString(result); } public static String decrypt(String base64Data, SecretKey key) throws Exception { byte[] data Base64.getDecoder().decode(base64Data); byte[] iv new byte[IV_LENGTH]; byte[] cipherBytes new byte[data.length - IV_LENGTH]; System.arraycopy(data, 0, iv, 0, IV_LENGTH); System.arraycopy(data, IV_LENGTH, cipherBytes, 0, cipherBytes.length); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); byte[] plainBytes cipher.doFinal(cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); } }AES密钥长度用256位这里面没有什么纠结的余地。128位在当前算力下虽未被公开破解但等保2.0的测评要求中明确提到采用密码技术保证通信数据保密性时应使用满足国家密码主管部门要求的密钥管理机制实际执行中测评机构普遍会认256位AES。另外Java默认的JDK如果没有装Java Cryptography ExtensionJCE的无限强度策略文件256位AES可能无法使用不过JDK 8u161之后这个限制已经解除了Spring Boot 3.4自带JDK 17完全不需要关心这个旧问题。3.3 签名验签SHA256withRSA的正确用法签名的意义在于证明这段数据确实来自声称的发送方且没有被中途改动过。RSA签名验签使用的是私钥签名、公钥验签这和加密正好相反。每个客户端也维护自己的RSA密钥对客户端的公钥注册到服务端客户端拿私钥签名服务端拿注册过的公钥验签——这就是前面提到的通信实体身份鉴别的技术基础。import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class SignUtil { public static String sign(String content, PrivateKey privateKey) throws Exception { Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); byte[] signed signature.sign(); return Base64.getEncoder().encodeToString(signed); } public static boolean verify(String content, String signBase64, PublicKey publicKey) throws Exception { Signature signature Signature.getInstance(SHA256withRSA); signature.initVerify(publicKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); byte[] signed Base64.getDecoder().decode(signBase64); return signature.verify(signed); } }算法选择上SHA256withRSA是主流标配。MD5withRSA已经被攻破SHA1withRSA在等保测评中也会被标记为弱算法。有些安全性要求更高的场景会选择SHA384withRSA甚至SHA512withRSA但带来的签名长度和计算量增长并不是很划算目前实际项目里SHA256withRSA是使用最广泛的。签名原文的拼接我会单独抽一个方法来做保证客户端和服务端的拼接逻辑完全一致public static String buildSignContent(MapString, String params) { // TreeMap 自动按 Key 的字典序排序保证签名与验签双方字段顺序一致 TreeMapString, String sortedParams new TreeMap(params); StringBuilder content new StringBuilder(); for (Map.EntryString, String entry : sortedParams.entrySet()) { if (entry.getValue() ! null !entry.getValue().isEmpty()) { content.append(entry.getKey()).append().append(entry.getValue()).append(); } } if (content.length() 0) { content.deleteCharAt(content.length() - 1); } return content.toString(); }这个方法里有几个容易被忽略的细节过滤空值、按字典序排序、结尾不残留多余的。如果两端有一端加了空值拼接而另一端没加验签就会失败。排查这类问题的时候耗掉的时间比写整个工具类的时间都多。4. Spring Boot 3.4 实战集成拦截器、注解与配置4.1 自定义注解声明式接口加解密我建议用注解的方式声明哪些接口需要加解密而不是全部接口统一处理。原因很实际有些接口比如图片验证码获取、文件下载响应体是二进制流或图片Base64做统一加密处理反而会引入不必要的性能损耗和兼容性问题。用注解可以灵活控制加解密范围。需要定义三个注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface EncryptResponse { } Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface DecryptRequest { } Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface IgnoreCrypto { }类上打了EncryptResponse的Controller响应的JSON会自动被AES加密后包装打了DecryptRequest的Controller请求体会先解密再进入业务方法。某些方法如健康检查、公钥获取可以直接用IgnoreCrypto跳过处理非常灵活。4.2 拦截器核心逻辑解密请求体与加密响应体Spring Boot里实现这个需求有几种路子Filter、HandlerInterceptor、AOP。我实测下来HandlerInterceptor是最好用的因为它能拿到HandlerMethod可以方便地读取注解信息而且可以在Controller方法执行前解密、执行后加密。Filter虽然优先级更高但要自行解析Handler和注解代码写起来麻烦不少。先看拦截器的主体结构public class CryptoInterceptor implements HandlerInterceptor { private final CryptoService cryptoService; public CryptoInterceptor(CryptoService cryptoService) { this.cryptoService cryptoService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } boolean needDecrypt handlerMethod.getBeanType().isAnnotationPresent(DecryptRequest.class) !handlerMethod.hasMethodAnnotation(IgnoreCrypto.class); boolean needEncrypt handlerMethod.getBeanType().isAnnotationPresent(EncryptResponse.class) !handlerMethod.hasMethodAnnotation(IgnoreCrypto.class); if (!needDecrypt !needEncrypt) { return true; } // 解密通常是包装Request加密则是包装Response CryptoRequestWrapper requestWrapper null; CryptoResponseWrapper responseWrapper null; if (needDecrypt) { String timestamp request.getHeader(X-Timestamp); String nonce request.getHeader(X-Nonce); String sign request.getHeader(X-Sign); String encryptedKey request.getHeader(X-Key); // 验签逻辑保证请求在传输过程中没有被篡改 String contentType request.getContentType(); if (contentType null || !contentType.contains(application/crypto)) { throw new IllegalStateException(Content-Type must be application/crypto); } String cipherBody new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); boolean valid cryptoService.verifyRequestSign(encryptedKey, cipherBody, timestamp, nonce, sign); if (!valid) { throw new SecurityException(签名验证失败请求可能被篡改); } // 防重放 if (!cryptoService.checkNonce(nonce, timestamp)) { throw new SecurityException(重放请求被拒绝); } String plainBody cryptoService.decryptRequest(encryptedKey, cipherBody); requestWrapper new CryptoRequestWrapper(request, plainBody); } if (needEncrypt) { responseWrapper new CryptoResponseWrapper(response); } if (requestWrapper ! null) { // 替换请求流让Controller读到的已经是解密后的明文 request requestWrapper; } if (responseWrapper ! null) { response responseWrapper; } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 响应体加密操作放在response.getOutputStream()被调用之后的finally块比较稳妥 // 但是如果使用ResponseWrapper可以在afterCompletion里统一处理 if (response instanceof CryptoResponseWrapper wrapper) { cryptoService.encryptResponse(wrapper); } } }这里有一个我在项目中踩过的坑拦截器里如果直接把解密后的字符串重新包装成HttpServletRequest需要注意request.getReader()和request.getInputStream()只能读取一次的问题。自定义的CryptoRequestWrapper必须重写这两个方法让它们基于缓存中的解密后字符串来读取并且要保证Controller参数以RequestBody形式接收时走的是InputStream。核心的CryptoService服务类实现Service public class CryptoService { private RSAKeyProperties rsaKeyProperties; private ClientKeyStore clientKeyStore; public String decryptRequest(String encryptedKey, String cipherBody) throws Exception { // 1. 用服务端RSA私钥解密AES密钥 byte[] aesKeyBytes rsaDecrypt(Base64.getDecoder().decode(encryptedKey)); SecretKey aesKey new SecretKeySpec(aesKeyBytes, AES); // 2. 用AES密钥解密请求体 return AesGcmUtil.decrypt(cipherBody, aesKey); } public void encryptResponse(CryptoResponseWrapper responseWrapper) throws Exception { String plainBody responseWrapper.getCapturedResponseBody(); // 1. 生成新的AES密钥 SecretKey aesKey AesGcmUtil.generateAesKey(); // 2. AES加密响应体 String encryptedBody AesGcmUtil.encrypt(plainBody, aesKey); // 3. 用客户端公钥加密AES密钥 String encryptedAesKey rsaEncrypt(aesKey.getEncoded(), clientKeyStore.getClientPublicKey()); // 4. 服务端私钥签名 String signContent buildSignContent(Map.of( key, encryptedAesKey, body, encryptedBody, timestamp, String.valueOf(System.currentTimeMillis()) )); String sign SignUtil.sign(signContent, rsaKeyProperties.getPrivateKey()); // 5. 组装响应JSON String cryptoResponse objectMapper.writeValueAsString(Map.of( key, encryptedAesKey, body, encryptedBody, sign, sign )); responseWrapper.setFinalBody(cryptoResponse); } }这里有两个细节值得说。第一个是响应里的key和body的时间戳不一致问题——我上面的代码里timestamp用的System.currentTimeMillis()而AES密钥生成时间其实也就在几毫秒前这个时间差可以忽略。但如果要求严格应该用一个事务内的统一时间戳。第二个是响应体加密用的AES密钥我每次响应都新生成一个——虽然服务端和客户端各维护一个长期AES密钥也可以但每次请求都生成新密钥可以降低密钥泄露后的影响范围代价仅仅是多一次RSA加密的耗时对整体性能影响微乎其微。4.3 注册拦截器与可配置化Spring Boot 3.4里注册拦截器通过WebMvcConfigurer完成Configuration public class WebConfig implements WebMvcConfigurer { Resource private CryptoInterceptor cryptoInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(cryptoInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/public/**, /actuator/health); } }配置项我用自定义属性类管理ConfigurationProperties(prefix crypto) public class CryptoProperties { /** RSA私钥文件路径生产环境通过环境变量注入 */ private String privateKeyPath; /** RSA私钥密码 */ private String privateKeyPassword; /** 允许的最大时间偏差单位毫秒 */ private long timestampTolerance 300000; /** 白名单请求头签名开关 */ private boolean signEnabled true; }在application.yml里做配置绑定crypto: private-key-path: ${CRYPTO_PRIVATE_KEY_PATH:/etc/crypto/server.p12} private-key-password: ${CRYPTO_PRIVATE_KEY_PASSWORD:} timestamp-tolerance: 300000 sign-enabled: true我习惯把私钥地址和密码都用环境变量注入而不是写在yaml里。Spring Boot 3.4支持${ENV_VAR:default}这种占位语法这样即使代码仓库被拉取敏感信息也不会泄露。4.4 前端对接要点签名与加密的JS侧实现服务端做完了前端对接也得给一套方案。现在的前端主流是axios加一个拦截器在发送请求前对data做加密和签名响应回来后做验签和解密。用Node.js的crypto模块可以很方便地实现RSA和AES加解密。import CryptoJS from crypto-js; import JSEncrypt from jsencrypt; function buildRequestPayload(data: object, serverPublicKey: string, clientPrivateKey: string) { // 1. 生成临时AES密钥 const aesKey CryptoJS.lib.WordArray.random(32); // 256位 // 2. AES加密业务数据 const encryptedData CryptoJS.AES.encrypt(JSON.stringify(data), aesKey, { mode: CryptoJS.mode.GCM, iv: CryptoJS.lib.WordArray.random(12) }).toString(); // 3. RSA加密AES密钥使用服务端公钥 const encryptor new JSEncrypt(); encryptor.setPublicKey(serverPublicKey); const encryptedKey encryptor.encrypt(aesKey.toString()); // 4. 拼接签名原文 const timestamp String(Date.now()); const nonce crypto.randomUUID(); const signSource body${encryptedData}key${encryptedKey}nonce${nonce}timestamp${timestamp}; // 5. 客户端私钥签名 const signer new JSEncrypt(); signer.setPrivateKey(clientPrivateKey); const sign signer.sign(signSource, crypto.SHA256, sha256); return { headers: { Content-Type: application/crypto, X-Key: encryptedKey, X-Timestamp: timestamp, X-Nonce: nonce, X-Sign: sign }, body: encryptedData }; }前端这套方案里最需要注意的是密钥管理客户端的私钥绝不能放在前端代码包或公开CDN上正确的做法是把私钥存到端内的安全存储如iOS的Keychain、Android的KeystoreWeb端可以考虑WebCrypto的IndexedDB非导出私钥或者由后端签名后临时签发一次性签名凭据。实在要兼顾Web场景可以退化为服务端集中托管签名密钥前端请求服务端完成签名虽然多了一次网络往返但安全性有保障。5. 常见问题与排查技巧实录5.1 问题速查表我把实际项目中遇到的高频问题整理一下按现象、原因、解决方案三列给出速查表方便你直接对号入座。现象可能原因解决方案客户端报解密失败AES密钥密文解密后长度不对或RSA私钥与公钥不匹配检查密钥对是否配对确认服务端RSA私钥没有被轮换覆盖服务端验签始终失败签名原文拼接顺序不一致或空值过滤规则不一致统一用上述buildSignContent方法两边跑单测对照同一个入参结果解密后JSON解析报错AES解密成功但内容被改动CBC模式无完整性校验或字符集不统一改用GCM模式确认加解密两端都用UTF-8接口偶发超时RSA加密次数过多2048位耗时长加缓存减少RSA调用次数高并发场景考虑升级到RSA-OAEP并做密钥缓存响应拦截器里修改响应体无效业务代码直接操作response.getOutputStream()会绕过包装流确认包装ResponseWrapper在addInterceptors注册时已先行包装使用ContentCachingResponseWrapper前端报Signature does not match客户端签名前拼接的字符串中某个字段值含中文未做URL编码统一约定所有字符串按UTF-8编码后再拼接不对字段单独做URL编码或全部统一编码Java里解密出现BadPaddingExceptionRSA加密数据时使用的公钥和私钥不匹配或密文被Base64解码错误打印两端的密钥指纹比较是否一致检查Base64是否混用了URLSafe模式RequestBody取到的仍是密文拦截器没生效或包装Request未生效也可能是注册顺序问题检查拦截器是否被Component扫描确认addInterceptors覆盖路径包含该接口5.2 一次真实的排查过程分享有次对接第三方平台对方说我们用POST提交的数据他们验签总是失败。排查了整整三天最后发现原因的细节特别让人无语对方的签名规则里把所有参与签名的字段值先做了URL编码而我们用的是原始值。如果字段里恰好有中文字符或特殊符号两边拼接后的字节流不一致签名自然验证不过。从那以后我在签名的对接文档里一定会加一句话所有参与签名的字段值使用原始字符串签名验签前不进行URL编码、HTML转义或任何形式的内容转换如有特殊字符需在签名前由双方约定统一处理。这种文档约定比代码里的任何防御都重要。另一个高频坑是响应体加密后用ObjectMapper序列化时如果响应里包含了HttpServletResponse自身有时候错误处理机制会塞入Jackson序列化会无限递归导致栈溢出。解决方式是只对Controller返回的DTO做加密全局异常处理器里的错误响应单独走一个不加密的格式或轻量加密格式。我倾向于在全局异常处理里直接返回加密格式的失败信息保证客户端解密逻辑统一。5.3 性能优化与并发控制经验加解密操作比普通JSON读写要慢尤其RSA。压测数据可以参考一下我在一台4核8G的普通云服务器上用JMeter模拟50并发请求纯AES加解密接口QPS大约在5000而每次请求带上RSA解密AES密钥之后QPS掉到1200左右。这个损耗主要来自RSA私钥解密那一次操作。优化的三板斧RSA密钥不每次解析将PrivateKey对象在服务启动时加载一次放入内存缓存。别看这个细节有些代码每次请求都从密钥字符串解析一次PrivateKey对象性能直接再砍一半。减少RSA操作次数在会话级或客户端级复用AES密钥。每次请求都重新生成AES密钥并用RSA加密是一种编码上的惰性实现。如果业务允许可以建立一个AES密钥池客户端每10分钟或每100次请求轮换一次密钥这样RSA操作频率大幅下降整体性能能回到AES直加解密的八九成。异步解密对于非敏感业务或可以容忍短时间延迟的场景用线程池异步处理加解密操作把耗时从请求链路里摘出去。不过这会增加代码复杂度不是必要场景别用。再补充一个安全细节日志打印。很多团队做完加解密之后代码里随手log.info(request body: {}, requestBody)明文报文直接进了日志文件。等保2.0里面有一项是日志信息完整性如果日志里存在大量明文敏感数据这本身就是一条高危整改项。对加解密接口涉及到的请求响应内容要么不打日志要么只打印脱敏后的摘要和请求ID。我做了一个AOP切面对所有标注了加解密注解的Controller方法日志里只记录方法名、耗时、HTTP状态码和请求ID绝不打印请求体和响应体。5.4 密钥轮换与灰度发布实战密钥轮换是等保2.0里密钥定期更换的直接落地。比较稳妥的做法是引入当前密钥版本和历史密钥版本两个概念。服务端维持一个密钥版本号每次轮换时新密钥成为当前版本旧密钥进入历史列表保留一段时间比如7天。请求头里携带X-Key-Version服务端根据版本号选择对应的私钥解密。这样旧客户端在过渡期内依然可以正常通信等所有客户端升级后再将旧密钥从历史列表里移除。代码层面的实现不复杂用一个ConcurrentHashMap存版本号和私钥的映射配合一个定时任务在本地缓存中清理过期密钥即可。真正麻烦的是客户端如何感知密钥轮换。我的经验是提供一个/api/crypto/key接口响应当前版本号和服务端RSA公钥客户端在启动时和每次请求收到密钥版本不一致时重新拉取。当服务端返回密钥版本失效错误码时客户端自动重新拉取公钥并重放一次请求这样业务侧无感知。这套流程我在一个日活百万级的中型系统上稳定跑了一年多最长的无故障运行周期超过200天除了两次例行密钥轮换做过灰度发布外没有因为密钥管理引发过线上事故。等保2.0三级测评时加解密、完整性、身份鉴别、防重放这几项测评项全部一次性通过这也是我个人对这套方案最有信心的地方。6. 实操中的关键决策要回过头再想想做成了一整套方案之后我回过来看有几个关键决策如果重新做一遍我仍然会坚持。第一是坚持用GCM而不是CBCGCM自带完整性校验让防篡改在密码学层面有了双重保障。第二是坚持将签名和加密分离这种双密钥体系虽然在联调阶段多花了不少时间但安全收益在测评和线上对抗测试中都有验证。第三是坚持密钥轮换和版本管理这让系统在面对密钥泄露风险时能快速响应而不是束手无策地重发版本。最后分享一个小经验接口加解密这类功能建议不管业务复杂度高低都把请求报文体的顶层JSON结构固定成{ key: ..., body: ..., sign: ... }这种统一格式。这样前后端联调时的心智负担会小很多后续要加新接口也是纯配置工作不需要重复沟通协议。这个格式在Restful接口、消息队列投递、异步回调等多个场景里都能复用一劳永逸。
返回列表