Spring Boot中使用Bouncy Castle实现SM2国密算法(与前端JS加密交互)

Spring Boot中使用Bouncy Castle实现SM2国密算法(与前端JS加密交互)

在现代Web应用中,数据安全传输至关重要。SM2作为我国自主设计的非对称加密算法,在安全性、效率和合规性方面具有显著优势。本文将详细介绍如何在Spring Boot中集成SM2算法,实现与前端JS的无缝加密交互。

一、环境准备

技术栈:

  • Java 1.8
  • Spring Boot 2.1.18
  • Bouncy Castle 1.68+
  • 前端:sm-crypto或类似库

Maven核心依赖:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcprov-jdk15on</artifactId>
        <version>1.68</version>
    </dependency>
</dependencies>

二、核心实现

  1. 密钥生成服务
java 复制代码
@RestController
@RequestMapping("/sm2")
public class SM2Controller {
    
    @GetMapping("/keypair")
    public Map<String, String> generateKeyPair() throws Exception {
        KeyPair keyPair = SM2CryptoUtil.generateKeyPair();
        ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic();
        ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate();
        
        String publicKeyHex = Hex.toHexString(publicKey.getQ().getEncoded(false));
        String privateKeyHex = privateKey.getD().toString(16);
        
        // 标准化私钥格式(64字符)
        privateKeyHex = String.format("%64s", privateKeyHex).replace(' ', '0');
        
        return Map.of(
            "publicKey", publicKeyHex,  // 130字符带04前缀
            "privateKey", privateKeyHex  // 64字符
        );
    }
}
  1. SM2解密服务
java 复制代码
import org.bouncycastle.asn1.gm.GMNamedCurves;
import org.bouncycastle.asn1.x9.X9ECParameters;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
import org.bouncycastle.math.ec.ECPoint;
import org.bouncycastle.util.BigIntegers;
import org.bouncycastle.util.encoders.Hex;

import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Arrays;

/**
 * @author cmamg
 * @title: Base64Util
 * @projectName 
 * @description: TODO
 * @date 2025/7/29
 */
public class SM2CryptoUtil {

    // 加密模式常量
    public static final int C1C2C3 = 0;
    public static final int C1C3C2 = 1;

    // 椭圆曲线参数
    private static final X9ECParameters EC_PARAMS;
    private static final ECDomainParameters DOMAIN_PARAMS;
    private static final BigInteger CURVE_ORDER;

    static {
        Security.addProvider(new BouncyCastleProvider());
        EC_PARAMS = GMNamedCurves.getByName("sm2p256v1");
        DOMAIN_PARAMS = new ECDomainParameters(
                EC_PARAMS.getCurve(),
                EC_PARAMS.getG(),
                EC_PARAMS.getN(),
                EC_PARAMS.getH());
        CURVE_ORDER = EC_PARAMS.getN();
    }

    /**
     * 生成SM2密钥对
     */
    public static KeyPair generateKeyPair() throws Exception {
        ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC");
        kpg.initialize(spec, new SecureRandom());
        return kpg.generateKeyPair();
    }

    /**
     * 获取压缩公钥十六进制字符串
     */
    public static String getCompressedPublicKey(ECPoint publicKey) {
        byte[] compressed = publicKey.getEncoded(true);
        return Hex.toHexString(compressed);
    }

    /**
     * 获取未压缩公钥十六进制字符串(不带04前缀)
     */
    public static String getUncompressedPublicKey(ECPoint publicKey) {
        byte[] uncompressed = publicKey.getEncoded(false);
        // 去掉开头的04标识
        return Hex.toHexString(uncompressed);
    }

    /**
     * 从十六进制字符串解析公钥
     */
    public static ECPoint parsePublicKey(String publicKeyHex) {
        // 添加04前缀表示未压缩格式
        byte[] pubKeyBytes = Hex.decode( publicKeyHex);
        return DOMAIN_PARAMS.getCurve().decodePoint(pubKeyBytes);
    }

    private static BigInteger parsePrivateKey(String privateKeyHex) {
        if (privateKeyHex == null || privateKeyHex.length() != 64) {
            throw new IllegalArgumentException("私钥必须是64字符十六进制字符串");
        }

        try {
            BigInteger privateKey = new BigInteger(privateKeyHex, 16);

            // 验证私钥范围 [1, n-1]
            if (privateKey.signum() <= 0 || privateKey.compareTo(CURVE_ORDER) >= 0) {
                throw new IllegalArgumentException("私钥超出有效范围");
            }

            return privateKey;
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("无效的私钥格式", e);
        }
    }

    public static String decryptStr(String ciphertextHex, String privateKeyHex) throws Exception {
        return new String(decrypt(ciphertextHex,privateKeyHex, 1), "UTF-8");
    }


     /**
     * SM2解密
     */
    public static byte[] decrypt(String ciphertextHex, String privateKeyHex, int cipherMode) throws Exception {
        // 1. 验证并解析私钥
        BigInteger privateKey = parsePrivateKey(privateKeyHex);

        // 2. 解析密文
        byte[] ciphertext = Hex.decode(ciphertextHex);

        // 验证最小长度 = C1(64) + C3(32) = 96字节
        if (ciphertext.length < 96) {
            throw new IllegalArgumentException("密文太短");
        }

        // 3. 拆分密文
        byte[] c1 = Arrays.copyOfRange(ciphertext, 0, 64); // 64字节
        byte[] c3;
        byte[] c2;

        if (cipherMode == C1C2C3) {
            // C1C2C3模式: C1(64) + C2 + C3(32)
            c3 = Arrays.copyOfRange(ciphertext, ciphertext.length - 32, ciphertext.length);
            c2 = Arrays.copyOfRange(ciphertext, 64, ciphertext.length - 32);
        } else {
            // C1C3C2模式: C1(64) + C3(32) + C2
            c3 = Arrays.copyOfRange(ciphertext, 64, 96);
            c2 = Arrays.copyOfRange(ciphertext, 96, ciphertext.length);
        }

        // 4. 重建C1点
        byte[] c1Full = new byte[65]; // 04 + 64字节
        c1Full[0] = 0x04; // 添加未压缩标识
        System.arraycopy(c1, 0, c1Full, 1, 64);

        ECPoint c1Point;
        try {
            c1Point = DOMAIN_PARAMS.getCurve().decodePoint(c1Full);
        } catch (Exception e) {
            throw new IllegalArgumentException("无效的C1点", e);
        }

        // 5. 计算共享点 (x2, y2) = privateKey * C1
        ECPoint s = c1Point.multiply(privateKey).normalize();

        // 验证点是否在曲线上
        if (!s.isValid()) {
            throw new SecurityException("计算出的点不在曲线上");
        }

        byte[] x2 = BigIntegers.asUnsignedByteArray(32, s.getXCoord().toBigInteger());
        byte[] y2 = BigIntegers.asUnsignedByteArray(32, s.getYCoord().toBigInteger());

        // 6. KDF生成密钥流
        byte[] z = new byte[x2.length + y2.length];
        System.arraycopy(x2, 0, z, 0, x2.length);
        System.arraycopy(y2, 0, z, x2.length, y2.length);

        byte[] t = kdf(z, c2.length);

        // 7. 异或解密
        byte[] msg = new byte[c2.length];
        for (int i = 0; i < c2.length; i++) {
            msg[i] = (byte) (c2[i] ^ t[i]);
        }

        // 8. 验证C3
        byte[] u = new byte[x2.length + msg.length + y2.length];
        System.arraycopy(x2, 0, u, 0, x2.length);
        System.arraycopy(msg, 0, u, x2.length, msg.length);
        System.arraycopy(y2, 0, u, x2.length + msg.length, y2.length);
        byte[] calculatedC3 = sm3(u);

        if (!Arrays.equals(c3, calculatedC3)) {
            throw new SecurityException("C3验证失败: 数据可能被篡改或密钥错误");
        }

        return msg;
    }

    /**
     * KDF密钥派生函数
     */
    private static byte[] kdf(byte[] z, int keylen) {
        int ct = 1;
        int offset = 0;
        byte[] result = new byte[keylen];
        SM3Digest digest = new SM3Digest();

        while (offset < keylen) {
            // 准备计数器字节
            byte[] ctBytes = new byte[]{
                (byte) (ct >>> 24),
                (byte) (ct >>> 16),
                (byte) (ct >>> 8),
                (byte) ct
            };

            // 计算SM3哈希
            digest.update(z, 0, z.length);
            digest.update(ctBytes, 0, 4);
            byte[] hash = new byte[digest.getDigestSize()];
            digest.doFinal(hash, 0);

            // 填充结果
            int copyLen = Math.min(keylen - offset, hash.length);
            System.arraycopy(hash, 0, result, offset, copyLen);
            offset += copyLen;
            ct++;
            digest.reset();
        }
        return result;
    }

    /**
     * SM3哈希计算
     */
    private static byte[] sm3(byte[] input) {
        SM3Digest digest = new SM3Digest();
        digest.update(input, 0, input.length);
        byte[] hash = new byte[digest.getDigestSize()];
        digest.doFinal(hash, 0);
        return hash;
    }
}
  1. 前端加密示例
javascript 复制代码
import { sm2 } from 'sm-crypto';

// 使用后端生成的公钥(130字符带04前缀)
const publicKey = '04d4de...'; 

function encryptMessage(message) {
    // 使用C1C3C2模式加密
    const ciphertext = sm2.doEncrypt(
        message, 
        publicKey, 
        1 // cipherMode=1 表示C1C3C2
    );
    return ciphertext; // 十六进制字符串
}

// 调用示例
const encrypted = encryptMessage('敏感数据123');

三、前后端交互流程

密钥获取:

http 复制代码
GET /sm2/keypair
Response: { "publicKey": "04...", "privateKey": "a1b2..." }

前端加密:

javascript 复制代码
const ciphertext = sm2.doEncrypt(data, publicKey, 1);

后端解密:

http 复制代码
POST /sm2/decrypt
{
  "ciphertext": "a1b2c3...",
  "privateKey": "a1b2...",
  "mode": 1
}

四、关键问题解决方案

  1. 公钥格式一致性
    前端要求公钥带04前缀(未压缩格式),后端需确保:
java 复制代码
public String getPublicKeyHex(ECPoint publicKey) {
    return Hex.toHexString(publicKey.getEncoded(false)); // 带04前缀
}
  1. 私钥范围验证
    防止Scalar not in interval错误:
java 复制代码
private static final BigInteger CURVE_ORDER = EC_PARAMS.getN();

if (privateKey.signum() <= 0 || 
    privateKey.compareTo(CURVE_ORDER) >= 0) {
    throw new IllegalArgumentException("无效私钥范围");
}
  1. C1点重建
    前端密文中的C1点不带04前缀,后端需重建:
java 复制代码
byte[] c1Full = new byte[65];
c1Full[0] = 0x04; // 添加前缀
System.arraycopy(c1, 0, c1Full, 1, 64);

五、常见问题排查

错误现象 可能原因 解决方案

Scalar not in interval 私钥格式错误或越界 验证私钥长度64字符,值在[1, n-1]范围内

C3验证失败 密钥错误或数据篡改 检查公私钥配对,重试加密流程

无效的C1点 密文格式错误 确认使用C1C3C2模式,检查密文长度

解密乱码 编码不一致 统一使用UTF-8编码

六、最佳实践建议

密钥管理:

前端不存储私钥

后端使用HSM或KMS管理私钥

定期轮换密钥

性能优化:

java 复制代码
// 重用SM3Digest实例
private static final ThreadLocal<SM3Digest> sm3Cache = 
    ThreadLocal.withInitial(SM3Digest::new);

安全增强:

java 复制代码
// 防止时序攻击
if (!MessageDigest.isEqual(c3, calculatedC3)) {
    throw new SecurityException("C3验证失败");
}

七、总结

本文实现了Spring Boot中完整的SM2算法集成方案,重点解决了:

密钥生成与格式标准化

与前端JS的加密交互

解密过程中的异常处理

通过此方案,开发者可以快速构建符合国密标准的安全应用,确保数据传输的机密性和完整性。在实际业务中,建议结合HTTPS等传输层安全措施,构建纵深防御体系。

相关推荐
phltxy3 分钟前
ArrayList与顺序表
java·算法
Doris_LMS17 分钟前
保姆级别IDEA关联数据库方式、在IDEA中进行数据库的可视化操作(包含图解过程)
java·mysql·postgresql
衍生星球32 分钟前
JSP 程序设计之 Web 技术基础
java·开发语言·jsp
Java编程乐园36 分钟前
Java函数式编程之【Stream终止操作】【下】【三】【收集操作collect()与分组分区】【下游收集器】
java
yinyan131438 分钟前
一起学springAI系列一:初体验
java·人工智能·ai
永卿0011 小时前
设计模式-责任链模式
java·设计模式·责任链模式
hello 早上好1 小时前
深入解析AOP调用链:递归与责任链模式的协同实现
java·责任链模式
wangmengxxw1 小时前
Spring-常用注解
java·数据库·spring·注解
籍籍川草1 小时前
JVM指针压缩的那些事
java·开发语言·jvm
小拇指~1 小时前
梯度下降的基本原理
人工智能·算法·计算机视觉