广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在Spring Boot中整合数字签名实现数据完整性校验?

接口数据完整性校验是很多后端系统都需要面对的问题,但单纯依靠HTTPS并不等于数据一定不会被篡改,特别是在服务端与客户端之间多级转发或日志留痕场景中,接收方需要一种机制来确认报文内容没有被第三方改动。数字签名通过非对称加密算法对原始数据生成摘要并加密,天然适合作为数据完整性校验的技术方案。本文围绕Spring Boot环境,从签名原理、工具类封装到Controller层验签,逐步完成一套可落地的实现。

如何在Spring Boot中整合数字签名实现数据完整性校验?

数字签名与数据完整性校验的关系

简单来说,发送方先对原始报文计算摘要,比如使用SHA-256算法得到一个固定长度的哈希值,然后用私钥对这个摘要进行加密,生成签名值。接收方拿到报文和签名值后,先用对应公钥解密签名值,还原出摘要,再对收到的原始报文重新计算摘要。如果两个摘要完全一致,就说明报文没有被篡改过。因为攻击者没有私钥,无法伪造出一个能通过公钥验签的签名值,即使他修改了报文内容,重新计算的摘要也会和解密出来的摘要对不上。

为什么不直接用私钥加密整个原文,而是只对摘要签名?原因在于非对称加密的运算速度远慢于对称加密,面对大体积报文时,完整加密会拖垮接口性能。摘要算法把任意长度数据压缩成固定长度的短字符串,只对这个短字符串做非对称运算,既保证了完整性校验的能力,又兼顾了吞吐量。这也是数字签名在各类接口安全体系中被广泛采用的原因。

数字签名除了校验数据完整性,还顺带提供了来源认证的能力。私钥只有发送方持有,只要验签通过,接收方就能确认报文确实来自持有私钥的那一方。不过要注意,数字签名本身并不加密数据,如果报文包含身份证号、银行卡号等敏感信息,还需要另外做加密处理,完整性校验和机密性保护是两个独立的安全维度。

封装基于JDK的签名工具类

Spring Boot项目不需要额外引入BouncyCastle等第三方库,JDK自带的java.security包已经提供了完整的密钥生成、签名和验签能力。常用的组合是RSA密钥对配合SHA256withRSA签名算法,密钥长度建议使用2048位或以上,这样能在安全性和运算时间之间取得平衡。先创建一个工具类,把生成密钥对、签名、验签三个核心动作封装起来,方便后续复用。

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
public class SignatureUtil {
    public static KeyPair generateKeyPair() throws Exception {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        return generator.generateKeyPair();
    }
    public static String sign(String data, PrivateKey privateKey) throws Exception {
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(privateKey);
        signature.update(data.getBytes("UTF-8"));
        byte[] signedBytes = signature.sign();
        return Base64.getEncoder().encodeToString(signedBytes);
    }
    public static boolean verify(String data, String signatureStr, PublicKey publicKey) throws Exception {
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(data.getBytes("UTF-8"));
        byte[] signedBytes = Base64.getDecoder().decode(signatureStr);
        return signature.verify(signedBytes);
    }
}

签名方法sign接收原始数据和私钥,内部先通过Signature.getInstance("SHA256withRSA")拿到签名器实例,然后调用initSign进入签名模式,再通过update方法把原始数据写入缓冲区,最后执行sign得到二进制签名。二进制签名不方便在JSON或表单中传输,所以使用Base64编码成字符串。验签方法verify则把Base64字符串解码回字节数组,与原始数据一起送入公钥验签器,最终返回布尔值。

实际项目中密钥不能硬编码在类文件里,应该保存到磁盘文件或者密钥库中。下面这个KeyStoreUtil类演示了如何保存密钥对,以及如何从文件中读取私钥和公钥。私钥使用PKCS8EncodedKeySpec格式读取,公钥使用X509EncodedKeySpec格式读取,这是Java中处理外部密钥的标准方式。

import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
public class KeyStoreUtil {
    private static final String ALGORITHM = "RSA";
    public static void saveKeyPair(String privatePath, String publicPath) throws Exception {
        KeyPair keyPair = SignatureUtil.generateKeyPair();
        try (FileOutputStream fos = new FileOutputStream(privatePath)) {
            fos.write(keyPair.getPrivate().getEncoded());
        }
        try (FileOutputStream fos = new FileOutputStream(publicPath)) {
            fos.write(keyPair.getPublic().getEncoded());
        }
    }
    public static PrivateKey getPrivateKey(String path) throws Exception {
        byte[] keyBytes = Files.readAllBytes(Paths.get(path));
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        return keyFactory.generatePrivate(spec);
    }
    public static PublicKey getPublicKey(String path) throws Exception {
        byte[] keyBytes = Files.readAllBytes(Paths.get(path));
        X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        return keyFactory.generatePublic(spec);
    }
}

上面的代码把密钥文件路径作为参数传入,生产环境可以通过配置中心或环境变量动态指定,避免把路径写死在代码中。私钥文件需要设置严格的操作系统权限,比如仅允许运行Spring Boot服务的账号可读。公钥文件可以分发给需要验签的客户端或合作方,即使泄露也不会影响签名伪造,因为公钥只能验证签名,不能生成签名。

在Spring Boot业务接口中接入验签

如果只有一两个接口需要验签,直接在Controller方法里调用工具类就足够了。先定义一个请求体DTO,包含原始数据、签名值和请求时间戳。时间戳用于后续的防重放判断,签名计算时必须把时间戳也纳入原文,否则攻击者可以修改时间戳而不会被发现。

public class SignedRequest {
    private String data;
    private String signature;
    private String timestamp;
    public String getData() {
        return data;
    }
    public void setData(String data) {
        this.data = data;
    }
    public String getSignature() {
        return signature;
    }
    public void setSignature(String signature) {
        this.signature = signature;
    }
    public String getTimestamp() {
        return timestamp;
    }
    public void setTimestamp(String timestamp) {
        this.timestamp = timestamp;
    }
}

在Controller中接收SignedRequest对象,从请求体取出数据和签名,加载公钥后执行验签。如果验签失败,接口直接返回400状态码,拒绝进入业务逻辑。成功后再根据实际需求处理数据。示例代码如下。

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class VerifyController {
    @PostMapping("/api/verify")
    public ResponseEntity<String> verify(@RequestBody SignedRequest request) throws Exception {
        PublicKey publicKey = KeyStoreUtil.getPublicKey("/etc/app/public.key");
        boolean valid = SignatureUtil.verify(request.getData(), request.getSignature(), publicKey);
        if (!valid) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("签名验证失败");
        }
        return ResponseEntity.ok("数据完整,验签通过");
    }
}

当接口数量变多,每个方法都重复写验签逻辑就不太合适了。更推荐使用Spring Boot的拦截器或过滤器统一处理,这样业务Controller可以保持干净。拦截器在请求到达Controller之前执行,从请求头中获取数据、签名和时间戳,完成验签后再放行。如果验签失败,直接抛出异常并由全局异常处理器返回统一错误格式。

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
public class SignatureInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String data = request.getHeader("X-Data");
        String signature = request.getHeader("X-Signature");
        String timestamp = request.getHeader("X-Timestamp");
        if (data == null || signature == null || timestamp == null) {
            throw new RuntimeException("缺少签名参数");
        }
        long requestTime = Long.parseLong(timestamp);
        if (Math.abs(System.currentTimeMillis() - requestTime) > 300000) {
            throw new RuntimeException("请求已过期");
        }
        PublicKey publicKey = KeyStoreUtil.getPublicKey("/etc/app/public.key");
        boolean valid = SignatureUtil.verify(data, signature, publicKey);
        if (!valid) {
            throw new RuntimeException("验签失败");
        }
        return true;
    }
}

拦截器中的时间戳判断设置了五分钟有效期,客户端和服务端需要保证时钟基本同步。如果业务对时间敏感,可以把有效期缩短到一分钟。签名原文应该由客户端按照约定规则拼接,例如data + "|" + timestamp,服务端也必须使用完全相同的拼接方式,否则验签必然失败。这是实际联调中最容易出错的地方。

实际部署中的安全注意事项

私钥保护是整个数字签名体系中最关键的一环。私钥一旦泄露,攻击者就能伪造任意请求的签名,完整性校验机制形同虚设。生产环境不要把私钥文件打包进代码仓库,也不要直接硬编码在配置文件里。推荐将私钥存储在专用的密钥管理服务中,或者使用环境变量授权的方式读取,并定期轮换密钥对。

防重放是另一个容易被忽视的问题。签名只能证明数据来自持有私钥的一方,不能阻止攻击者截获一个合法请求后原样重复提交。时间戳可以限制请求窗口,但如果攻击者在有效期内重放,仍然可能造成重复扣款等业务风险。更严格的做法是在请求中增加随机数nonce,服务端用Redis等缓存记录已经处理过的nonce,在有效期内拒绝重复的nonce。

算法选择上也要避开老旧的MD5withRSA和SHA1withRSA,它们已经被证明存在碰撞或弱化风险,应该使用SHA256withRSA或者更高强度的SHA384withRSA。密钥长度至少2048位,有更高安全要求时可以选择3072位。对于多客户端场景,可以同时保存多个公钥,根据请求头中的公钥编号选择对应公钥验签,这样密钥轮换时旧客户端可以平滑过渡,不必同一时间全部升级。

综合来看,Spring Boot整合数字签名实现数据完整性校验,核心技术点并不复杂,利用JDK自带的java.security包就能完成。真正需要花心思的是签名参数设计、统一验签拦截、私钥安全存储以及防重放机制。把这些细节做好,普通业务接口也能获得可靠的数据完整性保障,降低被篡改报文攻击的风险。

广告位 · 文章底部(后台-模块管理替换此区域)

猜你喜欢

YOU LIKE