1. 项目概述
最近在做一个需要对接政务平台的项目,对方明确要求使用国密SM2算法进行数据加密和签名验签。说实话,第一次接到这个需求时,我有点懵圈。虽然对RSA、AES这些国际通用算法还算熟悉,但国密SM2对我来说是个新领域。网上搜了一圈,资料零散,要么是纯理论,要么代码老旧跑不通,特别是关于如何在Spring Boot这种现代框架里优雅集成,更是少之又少。踩了不少坑之后,我决定把这次从零到一集成BouncyCastle 1.68实现SM2的完整过程记录下来,不仅是为了备忘,更是给后来者铺条路。你会发现,只要理清了国密算法的“脾气”和BouncyCastle的“用法”,在Spring Boot项目里用上SM2,其实并没有想象中那么复杂。
这篇文章会手把手带你走通全流程:从理解SM2和BouncyCastle是什么、为什么选它们,到在Spring Boot项目中引入依赖、生成密钥对、实现加密解密、签名验签的核心代码,最后封装成开箱即用的工具类。我会把过程中遇到的“坑”,比如版本冲突、编码格式、国标规范兼容性等问题,以及对应的解决方案,毫无保留地分享出来。文末会提供完整的、可运行的代码仓库地址。无论你是正在对接政府、金融等有合规要求的系统,还是单纯对国密算法感兴趣,这篇实战指南都能让你快速上手。
2. 技术选型与核心依赖解析
2.1 为什么是SM2和BouncyCastle?
在开始敲代码之前,我们得先搞清楚两个核心问题:为什么用SM2?以及为什么用BouncyCastle?
SM2是国家密码管理局发布的椭圆曲线公钥密码算法标准,属于国密算法体系中的非对称加密部分。相比于我们更熟悉的RSA算法,SM2在相同安全强度下,所需的密钥长度更短(256位SM2约等于2048位RSA的安全强度),这意味着计算更快、存储更省、带宽占用更小。更重要的是,在金融、政务、关键基础设施等涉及国家安全的领域,使用国密算法是合规性的硬性要求。所以,如果你的项目有这类背景,SM2不是可选项,而是必选项。
那为什么选择BouncyCastle这个第三方库呢?虽然从Java 7开始,JDK提供了对ECC(椭圆曲线密码学)的支持,但其原生API并未直接实现SM2这一特定算法标准。SM2在标准ECC的基础上,定义了特定的椭圆曲线参数(如sm2p256v1)、哈希算法(SM3)和消息摘要格式。BouncyCastle作为一个成熟、广泛使用的密码学提供者(Provider),它完整地实现了包括SM2、SM3、SM4在内的全套国密算法,并且提供了友好、稳定的Java API。选择BouncyCastle 1.68这个版本,是因为它是一个经过长期验证的稳定版本,API完善,社区资料相对丰富,能有效避免因使用最新版而可能遇到的未知兼容性问题。
2.2 项目依赖配置与潜在冲突解决
在Spring Boot项目中集成BouncyCastle,首先就是在pom.xml中添加依赖。这一步看似简单,但版本管理和依赖冲突是第一个容易踩坑的地方。
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.68</version> </dependency>这里的关键是bcprov-jdk15on,它表示这个版本适用于JDK 1.5及以上。添加后,Maven会自动将其依赖引入。但是,仅仅引入依赖是不够的,BouncyCastle需要被注册为JVM的一个安全提供者(Security Provider),我们的代码才能使用它来实现SM2。
通常,我们会在一个配置类里,通过静态代码块来动态注册:
import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; @Configuration public class CryptoConfig { static { // 防止重复注册 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } }注意:这里有个非常重要的细节。你可能会在网上看到一些老文章,让人在
java.security配置文件里添加提供者。但在容器化部署(如Docker)或不同环境迁移时,修改JRE系统文件既麻烦又不优雅。通过代码动态注册是更推荐的方式,它保证了应用的可移植性。
第一个大坑:依赖冲突。Spring Boot项目常常会引入诸如spring-boot-starter-security等模块,它们内部可能依赖了其他版本的BouncyCastle(比如bcpkix-jdk15on)。这会导致NoSuchAlgorithmException或ClassNotFoundException等诡异错误。解决办法是使用Maven的dependencyManagement统一版本,或者在引入bcprov时,使用exclusions排除掉传递进来的冲突版本。在项目根目录下执行mvn dependency:tree命令,仔细检查BouncyCastle相关依赖的版本情况,是排查这类问题的标准操作。
3. SM2密钥对生成与管理
3.1 使用BouncyCastle生成SM2密钥对
密钥对是非对称加密的基石。SM2的密钥对包括一个公钥(Public Key)和一个私钥(Private Key)。公钥可以公开,用于加密和验证签名;私钥必须严格保密,用于解密和生成签名。
BouncyCastle使得生成SM2密钥对变得非常简单。核心是使用其定义的特定椭圆曲线参数SM2P256V1。
import org.bouncycastle.asn1.gm.GMNamedCurves; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.generators.ECKeyPairGenerator; import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.crypto.params.ECKeyGenerationParameters; import org.bouncycastle.crypto.params.ECPrivateKeyParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; import org.bouncycastle.jce.spec.ECParameterSpec; import java.security.KeyPair; import java.security.SecureRandom; public class Sm2KeyGenerator { /** * 生成SM2密钥对 * @return 包含BCECPublicKey和BCECPrivateKey的KeyPair对象 */ public static KeyPair generateSm2KeyPair() { try { // 1. 获取SM2的椭圆曲线参数 X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1"); if (sm2ECParameters == null) { throw new RuntimeException("未找到SM2椭圆曲线参数:sm2p256v1"); } // 2. 构建椭圆曲线域参数 ECDomainParameters domainParams = new ECDomainParameters( sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN(), sm2ECParameters.getH() ); // 3. 创建密钥对生成器并初始化 ECKeyPairGenerator keyPairGenerator = new ECKeyPairGenerator(); ECKeyGenerationParameters keyGenParams = new ECKeyGenerationParameters( domainParams, new SecureRandom() // 使用强随机数源 ); keyPairGenerator.init(keyGenParams); // 4. 生成密钥对 AsymmetricCipherKeyPair cipherKeyPair = keyPairGenerator.generateKeyPair(); ECPrivateKeyParameters privateKeyParams = (ECPrivateKeyParameters) cipherKeyPair.getPrivate(); ECPublicKeyParameters publicKeyParams = (ECPublicKeyParameters) cipherKeyPair.getPublic(); // 5. 转换为JCE标准的KeyPair对象,方便后续使用 ECParameterSpec ecSpec = new ECParameterSpec( sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN(), sm2ECParameters.getH() ); BCECPrivateKey privateKey = new BCECPrivateKey( "EC", privateKeyParams, ecSpec, BouncyCastleProvider.CONFIGURATION ); BCECPublicKey publicKey = new BCECPublicKey( "EC", publicKeyParams, ecSpec, BouncyCastleProvider.CONFIGURATION ); return new KeyPair(publicKey, privateKey); } catch (Exception e) { throw new RuntimeException("生成SM2密钥对失败", e); } } }这段代码清晰地展示了生成密钥对的五个步骤。关键在于GMNamedCurves.getByName("sm2p256v1"),它从BouncyCastle的预定义曲线中获取了国密SM2的标准参数。使用SecureRandom确保密钥的随机性,这是密码学安全的基本要求。
3.2 密钥的存储与格式转换
生成密钥对后,我们通常需要将它们保存下来(比如存为文件或数据库),或者在不同的系统间传递。这时就需要进行序列化和反序列化。
公钥和私钥通常以两种格式存储:
- PKCS#8 (私钥) / X.509 (公钥) 的DER编码格式:这是Java
Key接口标准getEncoded()方法返回的二进制格式,通用性强。 - Base64编码的字符串:为了方便在配置文件、JSON或网页中传输,常将上述二进制格式进行Base64编码。
import java.util.Base64; public class KeyUtils { /** * 将KeyPair中的公钥和私钥转换为Base64字符串 */ public static Map<String, String> keyPairToBase64(KeyPair keyPair) { Map<String, String> keyMap = new HashMap<>(); // 公钥:X.509格式 String publicKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); // 私钥:PKCS#8格式 String privateKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); keyMap.put("publicKey", publicKeyBase64); keyMap.put("privateKey", privateKeyBase64); return keyMap; } /** * 从Base64字符串还原公钥 */ public static PublicKey getPublicKeyFromBase64(String base64PublicKey) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); return keyFactory.generatePublic(keySpec); } /** * 从Base64字符串还原私钥 */ public static PrivateKey getPrivateKeyFromBase64(String base64PrivateKey) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME); return keyFactory.generatePrivate(keySpec); } }实操心得:这里有一个至关重要的点。在调用
KeyFactory.getInstance(“EC”, …)时,第二个参数必须明确指定提供者为BouncyCastleProvider.PROVIDER_NAME(即“BC”)。如果省略,JDK可能会使用其默认的SunEC提供者,而它无法正确识别和处理SM2密钥的编码格式,会导致InvalidKeySpecException。这是集成过程中一个非常典型的错误。
4. SM2加密与解密实现详解
4.1 理解SM2加密流程与国标规范
SM2的公钥加密算法本质上是一种基于椭圆曲线的集成加密方案(ECIES),但加入了国密特定的哈希算法SM3和密钥派生函数(KDF)。其加密过程可以简化为以下几步:
- 发送方获取接收方的SM2公钥。
- 生成一个临时随机数k,计算椭圆曲线点C1 = [k]G(G是基点)。
- 计算共享秘密点S = [k]P_b(P_b是接收方公钥对应的点)。
- 使用KDF函数,从S的坐标中派生出对称密钥,用于加密实际消息M,得到密文C2。
- 使用SM3哈希函数,计算C1、S坐标和明文M的杂凑值,得到C3(用于完整性校验)。
- 输出密文。根据国标GM/T 0009-2012,标准的输出顺序是C1 || C3 || C2,并且C1、C3、C2通常以ASN.1 DER序列的形式编码。这也是与某些早期实现或国际标准(如
C1C2C3顺序)的主要区别。
解密则是加密的逆过程,使用私钥恢复出共享秘密点S‘,进而派生密钥解密C2,并重新计算杂凑值与C3比对,验证密文完整性。
4.2 基于BouncyCastle的加解密核心代码
BouncyCastle在org.bouncycastle.crypto.engines包中提供了SM2Engine类,它封装了上述复杂的数学运算。我们的任务就是正确地使用它。
import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.ECPrivateKeyParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.params.ParametersWithRandom; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; import org.bouncycastle.crypto.InvalidCipherTextException; import java.security.SecureRandom; public class Sm2CryptoService { private final SM2Engine.Mode mode; /** * 默认使用国标推荐的C1C3C2模式 */ public Sm2CryptoService() { this.mode = SM2Engine.Mode.C1C3C2; } /** * 使用公钥加密 * @param publicKey 接收方公钥 * @param plainData 明文数据 * @return 加密后的密文(字节数组) */ public byte[] encrypt(BCECPublicKey publicKey, byte[] plainData) throws InvalidCipherTextException { // 1. 将JCE公钥转换为BouncyCastle加密引擎所需的参数 ECPublicKeyParameters ecPublicKeyParameters = new ECPublicKeyParameters( publicKey.getQ(), publicKey.getParameters() ); // 2. 创建SM2加密引擎,并指定模式 SM2Engine engine = new SM2Engine(mode); // 3. 初始化引擎为加密模式,并传入公钥和随机数 engine.init(true, new ParametersWithRandom(ecPublicKeyParameters, new SecureRandom())); // 4. 执行加密 return engine.processBlock(plainData, 0, plainData.length); } /** * 使用私钥解密 * @param privateKey 接收方私钥 * @param cipherData 密文数据 * @return 解密后的明文(字节数组) */ public byte[] decrypt(BCECPrivateKey privateKey, byte[] cipherData) throws InvalidCipherTextException { // 1. 将JCE私钥转换为BouncyCastle加密引擎所需的参数 ECPrivateKeyParameters ecPrivateKeyParameters = new ECPrivateKeyParameters( privateKey.getD(), privateKey.getParameters() ); // 2. 创建SM2加密引擎,并指定模式(必须与加密时一致!) SM2Engine engine = new SM2Engine(mode); // 3. 初始化引擎为解密模式,传入私钥 engine.init(false, ecPrivateKeyParameters); // 4. 执行解密 return engine.processBlock(cipherData, 0, cipherData.length); } }代码看起来非常简洁,这正是使用成熟库的好处。但简洁的背后有几个必须注意的细节:
- 模式一致性:加密和解密时使用的
SM2Engine.Mode必须完全相同。如果加密用了C1C3C2,解密也必须用C1C3C2,否则解密必然失败。强烈建议统一使用C1C3C2,这是国标规范。 - 密钥转换:我们持有的是JCE标准的
BCECPublicKey和BCECPrivateKey对象,但在初始化SM2Engine时,需要的是BouncyCastle底层Crypto API的ECPublicKeyParameters和ECPrivateKeyParameters。通过getQ()(公钥点)和getD()(私钥大整数)可以完成转换。 - 异常处理:
InvalidCipherTextException是解密失败时抛出的异常,可能原因包括:密文被篡改、密钥不匹配、模式不一致等。在生产环境中,需要妥善处理此异常,记录日志,但不要将具体的错误细节(如哪一步失败)直接返回给前端,以防信息泄露。
4.3 处理中文与长文本:编码与分段
在实际应用中,我们加密的往往是字符串(如JSON报文)。这里就涉及到字符编码问题。必须确保加密前的明文字节数组和解密后还原字节数组所使用的字符编码一致,通常使用UTF-8。
public String encryptToBase64(String publicKeyBase64, String plainText) throws Exception { // 1. 还原公钥 PublicKey publicKey = KeyUtils.getPublicKeyFromBase64(publicKeyBase64); if (!(publicKey instanceof BCECPublicKey)) { throw new IllegalArgumentException("无效的SM2公钥"); } // 2. 明文转字节(UTF-8编码) byte[] plainData = plainText.getBytes(StandardCharsets.UTF_8); // 3. 加密 byte[] cipherData = sm2CryptoService.encrypt((BCECPublicKey) publicKey, plainData); // 4. 密文转Base64字符串,便于传输 return Base64.getEncoder().encodeToString(cipherData); }对于超长文本,SM2算法本身作为非对称加密,不适合直接加密大量数据(性能差)。标准做法是:
- 生成一个随机的对称密钥(如SM4或AES密钥)。
- 使用对称密钥加密原文。
- 使用SM2公钥加密这个对称密钥。
- 将“SM2加密后的对称密钥”和“对称加密后的密文”一起发送。 接收方则先用SM2私钥解密出对称密钥,再用对称密钥解密密文。BouncyCastle的
SM2Engine主要用于加密密钥这类短数据。
5. SM2签名与验签实战
5.1 签名验签原理与数据摘要
数字签名用于验证数据的完整性和来源真实性。SM2的签名算法也基于椭圆曲线,其过程大致为:
- 签名方:对原始消息用SM3计算摘要(杂凑值)
e。使用私钥和随机数k,通过一系列椭圆曲线运算,生成两个大整数(r, s),这就是签名值。 - 验签方:同样计算消息摘要
e'。使用签名方的公钥和收到的签名(r, s),通过运算验证等式是否成立。如果成立,则证明签名有效。
一个关键点是,SM2签名标准(GM/T 0003.2-2012)规定,在计算摘要e时,并非直接对原始消息M进行哈希,而是对Z_A || M进行哈希。其中Z_A是用户标识符和公钥等信息的杂凑值,这增加了签名的身份绑定特性。BouncyCastle的签名器内部已经帮我们处理了Z_A的拼接。
5.2 实现签名与验签功能
BouncyCastle通过org.bouncycastle.jcajce.provider.asymmetric.ec.SignatureSpi提供了签名功能。我们通常使用java.security.Signature这个标准接口来调用。
import java.security.Signature; import java.security.PrivateKey; import java.security.PublicKey; public class Sm2SignatureService { private static final String ALGORITHM = “SM3withSM2”; /** * 使用私钥对数据进行签名 * @param privateKey 签名私钥 * @param sourceData 原始数据 * @return 签名值(字节数组) */ public byte[] sign(PrivateKey privateKey, byte[] sourceData) throws Exception { // 1. 获取Signature实例,并指定算法为“SM3withSM2” Signature signature = Signature.getInstance(ALGORITHM, BouncyCastleProvider.PROVIDER_NAME); // 2. 初始化为签名模式,传入私钥 signature.initSign(privateKey); // 3. 传入待签名的数据 signature.update(sourceData); // 4. 执行签名 return signature.sign(); } /** * 使用公钥验证签名 * @param publicKey 签名公钥 * @param sourceData 原始数据 * @param signData 签名值 * @return true-验签成功,false-验签失败 */ public boolean verify(PublicKey publicKey, byte[] sourceData, byte[] signData) throws Exception { Signature signature = Signature.getInstance(ALGORITHM, BouncyCastleProvider.PROVIDER_NAME); signature.initVerify(publicKey); signature.update(sourceData); return signature.verify(signData); } /** * 对字符串进行签名,返回Base64编码的签名结果 */ public String signToBase64(String privateKeyBase64, String data) throws Exception { PrivateKey privateKey = KeyUtils.getPrivateKeyFromBase64(privateKeyBase64); byte[] signBytes = sign(privateKey, data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signBytes); } /** * 验证Base64编码的签名 */ public boolean verifyFromBase64(String publicKeyBase64, String data, String base64Sign) throws Exception { PublicKey publicKey = KeyUtils.getPublicKeyFromBase64(publicKeyBase64); byte[] signBytes = Base64.getDecoder().decode(base64Sign); return verify(publicKey, data.getBytes(StandardCharsets.UTF_8), signBytes); } }这段代码非常直观,因为它遵循了Java标准签名API。核心在于Signature.getInstance(“SM3withSM2”, “BC”),这告诉JCE我们要使用BouncyCastle(“BC”)提供的、SM3作为哈希算法、SM2作为签名算法的签名方案。
注意事项:签名的输出通常是DER编码的ASN.1序列,包含了
r和s两个整数值。有些第三方系统(如某些硬件加密机或前端JS库)可能要求签名值是r和s的简单拼接(各32字节,共64字节),或者是以特定分隔符连接的十六进制字符串。在跨系统对接时,务必确认对方要求的签名值格式。如果格式不匹配,可以使用BouncyCastle的ASN1Sequence类对签名字节进行编解码,以提取或组装r和s。
6. 封装Spring Boot Starter与工具类
6.1 设计可配置的加密服务Bean
在Spring Boot项目中,我们通常希望将SM2的功能封装成即插即用的服务。最佳实践是创建一个配置类,根据配置文件动态生成密钥对或加载外部密钥,并将核心服务类声明为Spring Bean。
@Configuration @ConfigurationProperties(prefix = “sm2”) @Data public class Sm2AutoConfiguration { /** * 可选的Base64编码的公钥字符串。 * 如果未配置,且generateIfNotExists为true,则会自动生成。 */ private String publicKeyBase64; /** * 可选的Base64编码的私钥字符串。 * 如果未配置,且generateIfNotExists为true,则会自动生成。 */ private String privateKeyBase64; /** * 当未配置密钥时,是否自动生成一对。默认false,建议生产环境显式配置密钥。 */ private boolean generateIfNotExists = false; @Bean @ConditionalOnMissingBean public KeyPair sm2KeyPair() throws Exception { if (StringUtils.hasText(publicKeyBase64) && StringUtils.hasText(privateKeyBase64)) { // 从配置加载 PublicKey pubKey = KeyUtils.getPublicKeyFromBase64(publicKeyBase64); PrivateKey priKey = KeyUtils.getPrivateKeyFromBase64(privateKeyBase64); return new KeyPair(pubKey, priKey); } else if (generateIfNotExists) { // 自动生成 log.warn(“SM2密钥对未配置,正在自动生成。生产环境请务必在配置文件中显式配置固定密钥!”); return Sm2KeyGenerator.generateSm2KeyPair(); } else { throw new IllegalStateException(“SM2公钥或私钥未配置,且未启用自动生成。请在application.yml中配置sm2.public-key-base64和sm2.private-key-base64。”); } } @Bean @ConditionalOnMissingBean public Sm2CryptoService sm2CryptoService() { // 默认使用国标C1C3C2模式 return new Sm2CryptoService(); } @Bean @ConditionalOnMissingBean public Sm2SignatureService sm2SignatureService() { return new Sm2SignatureService(); } }对应的application.yml配置如下:
sm2: # 生产环境请替换为实际生成的、安全的密钥 public-key-base64: MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEXyN8…(示例,实际很长) private-key-base64: MIGHAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBG0wawIBAQQg…(示例,实际很长) generate-if-not-exists: false # 生产环境务必设为false这种设计提供了灵活性:开发环境可以设置generate-if-not-exists: true快速启动;生产环境则通过配置中心注入固定的密钥,保证一致性。
6.2 提供完整的工具类与对外API
最后,我们将所有功能整合到一个工具类中,对外提供简洁的API。这个工具类可以处理Base64编码、字符串与字节的转换等琐事,让业务代码调用起来一目了然。
@Component @Slf4j public class Sm2Utils { @Autowired private KeyPair sm2KeyPair; @Autowired private Sm2CryptoService sm2CryptoService; @Autowired private Sm2SignatureService sm2SignatureService; // --- 加密解密 --- public String encrypt(String plainText) { try { BCECPublicKey publicKey = (BCECPublicKey) sm2KeyPair.getPublic(); byte[] cipherData = sm2CryptoService.encrypt(publicKey, plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(cipherData); } catch (Exception e) { log.error(“SM2加密失败”, e); throw new RuntimeException(“加密失败”, e); } } public String decrypt(String base64CipherText) { try { BCECPrivateKey privateKey = (BCECPrivateKey) sm2KeyPair.getPrivate(); byte[] cipherData = Base64.getDecoder().decode(base64CipherText); byte[] plainData = sm2CryptoService.decrypt(privateKey, cipherData); return new String(plainData, StandardCharsets.UTF_8); } catch (InvalidCipherTextException e) { log.error(“SM2解密失败:密文无效或密钥不匹配”, e); throw new RuntimeException(“解密失败:密文无效”, e); } catch (Exception e) { log.error(“SM2解密失败”, e); throw new RuntimeException(“解密失败”, e); } } // --- 签名验签 --- public String sign(String data) { try { byte[] signBytes = sm2SignatureService.sign(sm2KeyPair.getPrivate(), data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signBytes); } catch (Exception e) { log.error(“SM2签名失败”, e); throw new RuntimeException(“签名失败”, e); } } public boolean verify(String data, String base64Sign) { try { return sm2SignatureService.verify(sm2KeyPair.getPublic(), data.getBytes(StandardCharsets.UTF_8), Base64.getDecoder().decode(base64Sign)); } catch (Exception e) { log.error(“SM2验签过程异常”, e); return false; } } // --- 密钥获取(谨慎暴露)--- public String getPublicKeyBase64() { return Base64.getEncoder().encodeToString(sm2KeyPair.getPublic().getEncoded()); } // 私钥通常不提供获取方法,仅在内部使用 }现在,在Spring Boot的任何服务或控制器中,你都可以通过@Autowired注入Sm2Utils,然后轻松地调用encrypt、decrypt、sign、verify方法了。
7. 常见问题排查与性能优化
7.1 典型错误与解决方案速查表
在实际集成和联调过程中,你几乎一定会遇到下面这些问题。这里我把它整理成表,方便你快速对照排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
java.security.NoSuchAlgorithmException: no such algorithm: SM3withSM2 for provider BC | 1. BouncyCastle Provider未成功注册。 2. 依赖版本冲突,正确的JAR包未被加载。 | 1. 检查CryptoConfig中的静态代码块是否执行,确认Security.getProvider(“BC”)不为null。2. 执行 mvn dependency:tree,排除其他模块引入的旧版本BouncyCastle。 |
java.security.InvalidKeyException: IOException : algid parse error, not a sequence或InvalidKeySpecException | 尝试用JDK默认的KeyFactory(SunEC)去解析SM2格式的密钥。 | 在调用KeyFactory.getInstance(“EC”, **“BC”**)和Signature.getInstance(“SM3withSM2”, **“BC”**)时,务必显式指定Provider为“BC”。 |
解密时抛出InvalidCipherTextException | 1. 加密和解密使用的SM2Engine.Mode不一致。2. 用于解密的私钥和加密的公钥不配对。 3. 密文在传输过程中被损坏或编码错误(如Base64解码出错)。 | 1. 统一使用C1C3C2模式。2. 确认密钥对匹配。可以写单元测试验证。 3. 检查密文字符串在传输前后是否一致,确保Base64编解码正确。 |
| 与其他系统(如C++、Go服务)加解密或签名验签不互通 | 1. 双方使用的椭圆曲线参数不一致(必须同为sm2p256v1)。2. 数据格式不一致,如对方要求签名值为64字节`r | |
| 加密稍长的数据(如超过100字节)性能明显下降或报错 | 误用非对称加密算法直接加密大量数据。 | 采用“SM2加密对称密钥,对称密钥加密数据”的混合加密方案。用SM2加密一个随机的AES或SM4密钥(通常16/32字节),再用该密钥加密实际数据。 |
| 验签始终返回false | 1. 签名或验签时,计算摘要的原始数据字节不一致(如编码不同、有不可见字符)。 2. 公钥不匹配。 | 1. 确保待签名的源数据data在签名和验签两端完全一致,最好在计算前打印或日志记录其十六进制值进行比对。2. 确认使用的是签名方的公钥。 |
7.2 性能考量与最佳实践
- 密钥管理:绝对不要将私钥硬编码在代码中或提交到版本库。生产环境应使用配置中心(如Apollo、Nacos)、环境变量或专业的密钥管理服务(KMS)来注入密钥。
application.yml中的示例密钥仅用于开发。 - 算法选择:非对称加密计算开销大,仅适用于加密小块关键数据(如会话密钥、文件密钥)或进行签名。对大量数据进行加密,务必采用上文提到的混合加密模式。
- 线程安全:
SM2Engine和Signature对象本身不是线程安全的。但我们在Sm2CryptoService和Sm2SignatureService的每个方法中都创建了新的实例,因此封装后的工具类是线程安全的。如果考虑极致性能,可以考虑使用ThreadLocal或对象池来复用这些初始化成本较高的对象,但复杂度会增加。 - 异常处理:密码学操作失败可能意味着遭受攻击。在捕获到
InvalidCipherTextException或验签失败时,除了返回业务错误,应在服务端记录详细的审计日志(注意不要记录敏感信息本身),便于安全分析。 - 依赖版本锁定:在父POM的
dependencyManagement中锁定BouncyCastle的版本,避免因间接依赖升级导致不兼容。本文基于1.68版本,这是一个长期稳定的选择。
走完以上所有步骤,你的Spring Boot项目就已经完整集成了国密SM2算法。从密钥生成、加密解密到签名验签,都有了生产可用的代码模块。集成国密算法不再是黑盒,而是一个清晰、可控的过程。最后再分享一个我自己的体会:密码学集成,三分在代码,七分在联调和沟通。尤其是在与不同技术栈的系统对接时,提前花时间明确双方的数据格式、编码和流程规范,能节省后面大量的调试时间。希望这篇超详细的实战指南能帮你绕过我踩过的那些坑,顺利搞定SM2集成。