Java国密算法实战:基于BouncyCastle实现SM2/SM3/SM4加解密与签名

📅 2026/7/2 23:26:22 👁️ 阅读次数 📝 编程学习
Java国密算法实战:基于BouncyCastle实现SM2/SM3/SM4加解密与签名

1. 项目概述:为什么要在Java里折腾国密算法?

如果你最近在对接国内的金融、政务或者一些对数据安全有特定要求的项目,大概率会碰到一个词:“国密算法”。这可不是什么神秘的黑话,它指的就是我们国家密码管理局认定的一系列商用密码算法标准,主要包括SM2(非对称加密)、SM3(杂凑/哈希算法)和SM4(对称加密)。简单来说,这就是一套我们自己的“安全工具箱”。

那为什么我们放着国际上通用的RSA、AES、SHA-256不用,非要自己搞一套呢?原因其实挺多的。首先当然是自主可控,在密码这种涉及国家命脉的领域,有自己的标准意味着从算法设计、实现到应用的全链条都更安全、更可控,能有效规避潜在的后门风险。其次,国密算法在安全性设计上也有其特点,比如SM2基于椭圆曲线,在相同安全强度下,它的密钥长度比RSA短得多,这意味着计算更快、资源消耗更小,特别适合移动互联网和物联网这些对性能敏感的场景。现在很多行业,特别是金融行业(像网银、数字货币)、电子政务、关键信息基础设施等领域,都在逐步推进国密算法的改造和应用,所以掌握它几乎成了相关领域Java开发者的必备技能。

但是,当你兴冲冲地打开JDK的标准库,会发现一个尴尬的事实:标准的java.security包里并没有直接提供国密算法的实现。这时候,一个强大的第三方密码学库就登场了——BouncyCastle。它是一个提供了大量密码学算法实现的Java库,功能极其丰富,可以说是Java密码学领域的“瑞士军刀”。用BouncyCastle来实现国密算法,是目前最主流、最成熟的选择。

所以,这篇内容就是一次完整的实战记录。我会带你从零开始,基于BouncyCastle库,把SM2、SM3、SM4这三个核心国密算法的加解密、签名验签、摘要计算都手把手实现一遍。过程中不止是贴代码,更重要的是分享我踩过的坑、调试的心得,以及如何让这些代码在实际项目中更健壮、更易用。无论你是正在应对国密改造需求的开发者,还是单纯对密码学应用感兴趣,相信都能从这里找到可以直接“抄作业”的干货。

2. 环境准备与BouncyCastle集成

工欲善其事,必先利其器。在开始写代码之前,我们得先把“战场”布置好。这里没有太多花哨的东西,核心就是引入BouncyCastle库,并让它被JVM的密码学服务框架正确识别。

2.1 依赖引入:Maven与Gradle配置

现在Java项目管理依赖,基本离不开Maven或Gradle。引入BouncyCastle非常简单。我强烈建议使用bcprov-jdk15to18这个版本,它兼容JDK 1.5到1.8,并且也支持更高的JDK版本(如11, 17),通用性最好。

Maven配置:在你的pom.xml文件的<dependencies>部分加入:

<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.78</version> <!-- 撰写本文时最新稳定版,请检查更新 --> </dependency>

Gradle配置:在你的build.gradle文件的dependencies块中加入:

implementation 'org.bouncycastle:bcprov-jdk15to18:1.78'

注意:版本号请务必去 Maven中央仓库 确认最新。密码学库的更新有时会包含重要的安全修复。

2.2 安全提供者(Provider)注册:静态与动态方式

BouncyCastle作为一个密码学服务提供者(Provider),需要向Java的java.security.Security类注册后,才能被CipherKeyPairGeneratorMessageDigest等标准API找到并使用。注册方式有两种:

方式一:静态注册(推荐用于独立应用)在代码启动初期(比如main方法开头或静态块中)执行一次:

import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; public class GmDemo { static { // 如果尚未注册,则添加BouncyCastle提供者 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } // ... 后续代码 }

这种方式简单直接,确保在程序运行期间BouncyCastle全局可用。

方式二:动态指定(更灵活,适用于复杂环境)你也可以不在全局注册,而是在每次使用具体算法时,在API调用中明确指定提供者名称为"BC"

KeyPairGenerator kpg = KeyPairGenerator.getInstance("SM2", "BC"); // 明确使用BC提供者

这种方式的好处是更清晰,避免了全局注册可能带来的潜在冲突(虽然概率极低),特别是在容器化或模块化环境中。

我个人在实际项目中的选择:对于大多数后台服务或独立应用,我直接用静态注册,省心。如果你的代码是作为一个库(Library)被其他人使用,为了避免污染调用方的安全环境,可以考虑动态指定,或者在你的库的初始化方法里注册,并提供清理方法。

2.3 一个验证环境是否OK的简单测试

依赖加好了,Provider也注册了,怎么知道成没成功?写个最简单的SM3摘要测试一下最靠谱。

import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.MessageDigest; import java.security.Security; import java.util.HexFormat; public class EnvTest { public static void main(String[] args) throws Exception { // 1. 注册Provider Security.addProvider(new BouncyCastleProvider()); // 2. 获取SM3摘要实例 MessageDigest md = MessageDigest.getInstance("SM3", "BC"); // 3. 计算摘要 String testData = "Hello, 国密!"; byte[] digest = md.digest(testData.getBytes("UTF-8")); // 4. 输出十六进制结果 String hexDigest = HexFormat.of().formatHex(digest); System.out.println("SM3(\"" + testData + "\") = "); System.out.println(hexDigest); // 一个简单的断言,确保结果长度是32字节(256位) if (digest.length == 32) { System.out.println("环境配置成功!SM3摘要长度正确。"); } else { System.out.println("警告:摘要长度异常,可能环境有问题。"); } } }

运行这个程序,如果能看到一长串64位的十六进制字符串(例如66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0这样的形式),并且没有抛出NoSuchAlgorithmException之类的异常,那么恭喜你,BouncyCastle国密算法环境就搭建成功了。

3. SM3杂凑算法:摘要与验证

SM3是国家密码管理局发布的商用密码杂凑算法标准,它生成一个256位(32字节)的摘要值,类似于国际上的SHA-256。它的应用场景非常广泛:数据完整性校验、数字签名中的消息摘要、生成密钥派生材料等等。在国密体系中,SM2签名通常就是对SM3摘要值进行签名。

3.1 基础用法:计算字符串与文件的摘要

使用BouncyCastle计算SM3摘要非常简单,因为我们已经注册了Provider,可以直接使用Java标准的MessageDigest类。

计算字符串的SM3摘要:

import java.security.MessageDigest; import java.util.HexFormat; public class SM3Demo { /** * 计算字符串的SM3摘要 * @param data 原始字符串 * @return 十六进制格式的摘要字符串 */ public static String hashString(String data) throws Exception { MessageDigest md = MessageDigest.getInstance("SM3", "BC"); md.update(data.getBytes("UTF-8")); byte[] digest = md.digest(); return HexFormat.of().formatHex(digest); // JDK 17+ 的简洁方式 // 对于更早的JDK,可以用:DatatypeConverter.printHexBinary(digest).toLowerCase(); } public static void main(String[] args) throws Exception { String input = "这是一段需要验证完整性的重要数据"; String sm3Hash = hashString(input); System.out.println("输入数据: " + input); System.out.println("SM3摘要: " + sm3Hash); // 输出示例:sm3摘要: 1a3f7e...(64位十六进制数) } }

计算文件的SM3摘要:对于大文件,我们不能一次性读入内存,需要分块更新(update)摘要。

public static String hashFile(Path filePath) throws Exception { MessageDigest md = MessageDigest.getInstance("SM3", "BC"); try (InputStream is = Files.newInputStream(filePath); BufferedInputStream bis = new BufferedInputStream(is)) { byte[] buffer = new byte[8192]; // 8KB缓冲区 int len; while ((len = bis.read(buffer)) != -1) { md.update(buffer, 0, len); } } byte[] digest = md.digest(); return HexFormat.of().formatHex(digest); }

3.2 关键细节与注意事项

  1. 字符编码问题:这是最常踩的坑!String.getBytes()这个方法如果不指定编码,它会使用平台默认的字符集。在Windows中文环境可能是GBK,在Linux可能是UTF-8。这会导致同样的字符串在不同环境下算出不同的摘要,造成数据校验失败。务必显式指定编码,如data.getBytes("UTF-8")data.getBytes(StandardCharsets.UTF_8)。在涉及多方交互的系统里,统一使用UTF-8是行业最佳实践。

  2. 摘要输出格式:摘要结果是byte[]。为了方便传输和比较,通常需要转换成十六进制字符串(64位)或Base64字符串(44位左右)。确保上下游系统对格式的约定一致。十六进制更直观,Base64更紧凑。

  3. MessageDigest对象的状态MessageDigest对象在调用digest()方法后,其内部状态会被重置。如果你想重复使用同一个对象计算新的摘要,需要在digest()之后重新调用update()。更简单的做法是,每次计算都getInstance一个新的实例,因为创建它的开销很小。

3.3 实战技巧:如何安全地比较摘要值?

在验证数据完整性时,我们需要比较计算出的摘要和预期的摘要是否一致。这里有一个重要的安全陷阱:不能直接用字符串的equals()比较,或者用Arrays.equals()比较字节数组后就直接返回成功。

为什么?这涉及到“时间侧信道攻击”。简单的字符串或数组比较,是从第一位开始比,如果第一位不同就立即返回false。攻击者可以通过精确测量比较操作所花费的时间,来逐步猜测出正确的摘要值。

安全的比较方式是使用“常数时间比较”:

/** * 常数时间比较两个字节数组是否相等,防止时序攻击。 */ public static boolean constantTimeEquals(byte[] a, byte[] b) { if (a == b) return true; if (a == null || b == null || a.length != b.length) { return false; } int result = 0; for (int i = 0; i < a.length; i++) { result |= (a[i] ^ b[i]); // 逐位异或,不同则为1 } return result == 0; // 所有位都相同,result才为0 } // 在验证摘要时使用 public static boolean verifyHash(byte[] expectedDigest, byte[] actualDigest) { return constantTimeEquals(expectedDigest, actualDigest); }

这个方法无论两个数组的内容如何,循环次数都是固定的(a.length),因此执行时间不依赖于数据内容,从而避免了信息泄露。对于安全性要求极高的场景(如验证密码哈希、签名),务必使用这种方式。Apache Commons Codec库中的org.apache.commons.codec.binary.Hex类也提供了equalsConstantTime方法。

4. SM4对称加密算法:ECB与CBC模式实战

SM4是一种分组对称加密算法,分组长度和密钥长度都是128位。它相当于国际上的AES算法。对称加密的特点是加解密速度快,适合加密大量数据,但密钥分发和管理是个挑战。SM4通常用于加密业务数据报文、数据库字段等。

4.1 核心概念:工作模式与填充方式

在开始写代码前,必须理解两个关键概念:

  • 工作模式(Mode):定义了如何重复应用密码算法来加密超过一个分组的数据。常见的有:

    • ECB(电子密码本):最简单的模式,每个分组独立加密。致命缺点:相同的明文分组会加密成相同的密文分组,不能隐藏数据模式。除非万不得已,绝对不要用于加密有意义的数据!一般只用于加密密钥本身。
    • CBC(密码分组链接):每个明文分组先与前一个密文分组进行异或操作,然后再加密。需要一个**初始化向量(IV)**来启动这个过程。IV不需要保密,但必须是随机的、不可预测的,且每次加密都应不同。这是最常用、最安全的模式之一。
    • 其他模式:如CTR、GCM等,BouncyCastle也支持,GCM还能提供认证加密。
  • 填充方式(Padding):因为分组密码只能处理固定长度的数据,当明文不是分组的整数倍时,就需要填充。SM4常用PKCS7Padding(也叫PKCS5Padding),它会填充缺少的字节数。

在BouncyCastle中,SM4算法的标准名称是"SM4"。指定模式和填充后,完整的算法标识符是:"SM4/MODE/PADDING",例如"SM4/CBC/PKCS7Padding"

4.2 代码实现:CBC模式加密解密

下面我们以实现更安全、更常用的CBC模式为例。

import org.bouncycastle.jce.provider.BouncyCastleProvider; 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.SecureRandom; import java.security.Security; import java.util.Base64; public class SM4CBCDemo { static { Security.addProvider(new BouncyCastleProvider()); } // 算法/模式/填充 private static final String ALGORITHM = "SM4/CBC/PKCS7Padding"; // 密钥长度(单位:比特),SM4固定为128 private static final int KEY_SIZE = 128; // 初始化向量长度(单位:字节),CBC模式需要16字节(128位)的IV private static final int IV_SIZE = 16; /** * 生成一个随机的SM4密钥 */ public static byte[] generateKey() throws Exception { KeyGenerator kg = KeyGenerator.getInstance("SM4", "BC"); kg.init(KEY_SIZE, new SecureRandom()); SecretKey secretKey = kg.generateKey(); return secretKey.getEncoded(); // 返回原始密钥字节 } /** * 生成一个随机的初始化向量(IV) */ public static byte[] generateIv() { byte[] iv = new byte[IV_SIZE]; new SecureRandom().nextBytes(iv); return iv; } /** * SM4 CBC 模式加密 * @param data 明文数据 * @param key 密钥(16字节) * @param iv 初始化向量(16字节) * @return 密文数据 */ public static byte[] encrypt(byte[] data, byte[] key, byte[] iv) throws Exception { // 1. 根据密钥字节,还原SecretKey对象 SecretKeySpec secretKeySpec = new SecretKeySpec(key, "SM4"); // 2. 根据IV字节,创建IvParameterSpec对象 IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); // 3. 获取Cipher实例并初始化为加密模式 Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); // 4. 执行加密 return cipher.doFinal(data); } /** * SM4 CBC 模式解密 * @param encryptedData 密文数据 * @param key 密钥(16字节) * @param iv 初始化向量(16字节),必须与加密时使用的IV相同 * @return 明文数据 */ public static byte[] decrypt(byte[] encryptedData, byte[] key, byte[] iv) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec(key, "SM4"); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); return cipher.doFinal(encryptedData); } public static void main(String[] args) throws Exception { String originalText = "这是一段需要加密的敏感信息,比如身份证号。"; // 1. 生成密钥和IV byte[] key = generateKey(); byte[] iv = generateIv(); System.out.println("SM4密钥(Base64): " + Base64.getEncoder().encodeToString(key)); System.out.println("IV向量(Base64): " + Base64.getEncoder().encodeToString(iv)); // 2. 加密 byte[] encrypted = encrypt(originalText.getBytes("UTF-8"), key, iv); String encryptedB64 = Base64.getEncoder().encodeToString(encrypted); System.out.println("加密后(Base64): " + encryptedB64); // 3. 解密 byte[] decrypted = decrypt(encrypted, key, iv); String decryptedText = new String(decrypted, "UTF-8"); System.out.println("解密后文本: " + decryptedText); // 4. 验证 System.out.println("解密是否成功: " + originalText.equals(decryptedText)); } }

4.3 常见问题与避坑指南

  1. 密钥管理是核心难题:代码里为了演示,每次都生成随机密钥。真实项目中,密钥必须安全地存储和传输。常见的做法是使用密钥管理系统(KMS),或者用更高级的密钥(如SM2公钥)来加密这个SM4密钥(即“数字信封”技术)。切忌将密钥硬编码在代码或配置文件中!

  2. IV必须随机且唯一:CBC模式的安全性严重依赖于IV的随机性。绝对不要使用固定的IV(比如全零),也不要重复使用同一个IV加密不同的数据。每次加密都应生成新的随机IV。IV可以公开传输,通常和密文拼接在一起。

  3. 密文与IV的传输:解密方需要知道IV。通常的做法是将IV(16字节)放在密文前面,一起传输或存储。接收方先取出前16字节作为IV,剩下的部分作为密文进行解密。

  4. 异常处理Cipher.doFinal()可能会抛出BadPaddingException等异常。这通常意味着密钥、IV或密文在传输过程中被篡改,或者解密密钥错误。在捕获到这类异常时,不要直接暴露具体错误信息给前端(如“填充错误”),而应统一返回“解密失败”等模糊提示,以防止信息泄露帮助攻击者。

  5. 性能考虑Cipher对象初始化(init)开销相对较大。如果需要频繁加解密大量小数据包,考虑复用同一个Cipher对象(但要注意线程安全,或者使用ThreadLocal)。对于大数据流,可以使用CipherInputStreamCipherOutputStream

5. SM2非对称加密算法:密钥对、加密与签名

SM2是基于椭圆曲线密码(ECC)的非对称加密算法。它包含三个功能:数字签名、密钥交换和非对称加密。我们这里主要讲最常用的数字签名非对称加密。非对称加密的特点是有一对密钥:公钥(Public Key)可以公开,用于加密或验证签名;私钥(Private Key)必须严格保密,用于解密或生成签名。SM2相比RSA,在同等安全强度下密钥更短(256位 vs. 2048位以上),运算更快,存储和传输开销小。

5.1 生成SM2密钥对

首先,我们需要生成一对SM2密钥。

import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.*; import java.security.spec.ECGenParameterSpec; import java.util.Base64; public class SM2KeyGenDemo { static { Security.addProvider(new BouncyCastleProvider()); } // SM2的标准椭圆曲线参数名称,在BC中通常使用 "sm2p256v1" private static final String EC_SPEC_NAME = "sm2p256v1"; /** * 生成SM2密钥对 * @return 生成的密钥对 */ public static KeyPair generateKeyPair() throws Exception { // 1. 获取SM2的密钥对生成器 KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC"); // 2. 使用SM2的标准参数初始化 ECGenParameterSpec sm2Spec = new ECGenParameterSpec(EC_SPEC_NAME); kpg.initialize(sm2Spec, new SecureRandom()); // 3. 生成密钥对 return kpg.generateKeyPair(); } public static void main(String[] args) throws Exception { KeyPair keyPair = generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); // 通常将密钥以Base64或十六进制格式导出,方便存储和传输 String pubKeyBase64 = Base64.getEncoder().encodeToString(publicKey.getEncoded()); String priKeyBase64 = Base64.getEncoder().encodeToString(privateKey.getEncoded()); System.out.println("===== SM2 公钥 (Base64) ====="); System.out.println(pubKeyBase64); System.out.println("\n===== SM2 私钥 (Base64) ====="); System.out.println(priKeyBase64); System.out.println("\n注意:私钥必须绝对保密!"); // 获取密钥的算法和格式信息 System.out.println("公钥算法: " + publicKey.getAlgorithm()); System.out.println("公钥格式: " + publicKey.getFormat()); // 通常是 X.509 SubjectPublicKeyInfo System.out.println("私钥格式: " + privateKey.getFormat()); // 通常是 PKCS#8 } }

生成的公钥和私钥是PublicKeyPrivateKey对象。它们的getEncoded()方法返回的是按照特定标准(如X.509、PKCS#8)编码的字节流,通常用Base64编码后存储或传输。

5.2 SM2非对称加密与解密

SM2加密过程:发送方用接收方的公钥加密数据,只有拥有对应私钥的接收方才能解密。

import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import java.security.*; import java.security.spec.ECGenParameterSpec; import java.util.Base64; public class SM2EncryptDemo { static { Security.addProvider(new BouncyCastleProvider()); } // SM2加密算法的标准名称,在BC中是 "SM2" private static final String ALGORITHM = "SM2"; /** * 使用SM2公钥加密数据 */ public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(data); } /** * 使用SM2私钥解密数据 */ public static byte[] decrypt(byte[] encryptedData, PrivateKey privateKey) throws Exception { Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(encryptedData); } public static void main(String[] args) throws Exception { // 1. 生成密钥对(复用上一节的方法) KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC"); kpg.initialize(new ECGenParameterSpec("sm2p256v1"), new SecureRandom()); KeyPair keyPair = kpg.generateKeyPair(); String originalText = "这是一段用SM2公钥加密的秘密消息。"; System.out.println("原始文本: " + originalText); // 2. 加密 byte[] encrypted = encrypt(originalText.getBytes("UTF-8"), keyPair.getPublic()); String encryptedB64 = Base64.getEncoder().encodeToString(encrypted); System.out.println("加密后(Base64): " + encryptedB64); // 3. 解密 byte[] decrypted = decrypt(encrypted, keyPair.getPrivate()); String decryptedText = new String(decrypted, "UTF-8"); System.out.println("解密后文本: " + decryptedText); System.out.println("解密是否成功: " + originalText.equals(decryptedText)); } }

重要提示:SM2非对称加密算法本身对加密的数据长度有限制(与密钥长度和使用的椭圆曲线参数有关)。对于较长的数据,标准的做法是采用“混合加密”机制:即生成一个随机的对称密钥(如SM4密钥),用SM4加密原文数据,再用SM2公钥加密这个SM4密钥。将SM2加密后的密钥和SM4加密后的数据一起发送给接收方。

5.3 SM2数字签名与验签

数字签名用于验证数据的完整性和来源的真实性。发送方用私钥对数据的摘要(通常是SM3摘要)进行签名,接收方用发送方的公钥来验证签名。

import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.*; import java.security.spec.ECGenParameterSpec; import java.util.Base64; public class SM2SignatureDemo { static { Security.addProvider(new BouncyCastleProvider()); } // SM2签名算法的标准名称,在BC中通常是 "SM3withSM2" private static final String SIGN_ALGORITHM = "SM3withSM2"; /** * 使用SM2私钥对数据进行签名 * @param data 原始数据 * @param privateKey 签名私钥 * @return 签名值字节数组 */ public static byte[] sign(byte[] data, PrivateKey privateKey) throws Exception { // 1. 获取签名实例 Signature signature = Signature.getInstance(SIGN_ALGORITHM, "BC"); // 2. 初始化为签名模式,传入私钥 signature.initSign(privateKey); // 3. 传入要签名的数据 signature.update(data); // 4. 生成签名 return signature.sign(); } /** * 使用SM2公钥验证签名 * @param data 原始数据 * @param sign 签名值 * @param publicKey 验证公钥 * @return true验证成功,false验证失败 */ public static boolean verify(byte[] data, byte[] sign, PublicKey publicKey) throws Exception { Signature signature = Signature.getInstance(SIGN_ALGORITHM, "BC"); signature.initVerify(publicKey); signature.update(data); return signature.verify(sign); } public static void main(String[] args) throws Exception { // 生成密钥对 KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC"); kpg.initialize(new ECGenParameterSpec("sm2p256v1"), new SecureRandom()); KeyPair keyPair = kpg.generateKeyPair(); String message = "这是一份需要签名的电子合同内容。"; byte[] data = message.getBytes("UTF-8"); System.out.println("待签名数据: " + message); // 1. 签名 byte[] signatureBytes = sign(data, keyPair.getPrivate()); String signatureB64 = Base64.getEncoder().encodeToString(signatureBytes); System.out.println("数字签名(Base64): " + signatureB64); // 2. 验签(使用正确的数据和公钥) boolean verifyResult1 = verify(data, signatureBytes, keyPair.getPublic()); System.out.println("使用正确数据和公钥验签结果: " + verifyResult1); // 3. 模拟篡改数据后验签 String tamperedMessage = "这是一份被篡改的电子合同内容。"; boolean verifyResult2 = verify(tamperedMessage.getBytes("UTF-8"), signatureBytes, keyPair.getPublic()); System.out.println("使用篡改数据验签结果: " + verifyResult2); // 4. 模拟使用错误公钥验签(生成另一对密钥) KeyPair anotherKeyPair = kpg.generateKeyPair(); boolean verifyResult3 = verify(data, signatureBytes, anotherKeyPair.getPublic()); System.out.println("使用错误公钥验签结果: " + verifyResult3); } }

5.4 SM2实战中的关键要点与坑点

  1. 密钥序列化与反序列化:生成的KeyPair对象需要持久化。getEncoded()得到的是DER编码的字节。存储时通常用Base64。还原密钥时,需要使用KeyFactory

    // 从Base64字符串还原公钥 byte[] pubKeyBytes = Base64.getDecoder().decode(pubKeyBase64Str); X509EncodedKeySpec pubKeySpec = new X509EncodedKeySpec(pubKeyBytes); KeyFactory keyFactory = KeyFactory.getInstance("EC", "BC"); PublicKey publicKey = keyFactory.generatePublic(pubKeySpec); // 从Base64字符串还原私钥 byte[] priKeyBytes = Base64.getDecoder().decode(priKeyBase64Str); PKCS8EncodedKeySpec priKeySpec = new PKCS8EncodedKeySpec(priKeyBytes); PrivateKey privateKey = keyFactory.generatePrivate(priKeySpec);
  2. 签名与验签的数据一致性:必须确保签名和验签时处理的数据完全一致。一个字节的差异(如空格、换行符、编码不同)都会导致验签失败。在涉及网络传输或文件存储时,要明确约定编码和格式。

  3. 国密标准与“裸签名”:国密SM2签名标准中,签名结果通常由两个大整数R和S拼接而成,有时还会在前面加上一个固定的标识头。而BouncyCastle的Signature.sign()返回的字节数组,其格式是ASN.1 DER编码的(包含R和S)。这是最常见的格式。但在与某些硬件加密机或其他严格按照国标(GB/T 32918.2-2016)实现的系统对接时,对方可能要求“裸签名”(即R和S的固定长度字节数组直接拼接)。这时就需要进行格式转换。BC提供了org.bouncycastle.asn1.ASN1Primitive等类来解析和构建ASN.1结构,转换代码稍显复杂,需要根据对接方具体要求编写。

  4. 性能与数据长度:非对称加密解密、签名验签速度远慢于对称加密和哈希。切勿用SM2直接加密大量数据(如超过几百字节)。务必采用前面提到的“混合加密”模式。签名时,也是先对数据做SM3摘要,再对摘要签名,而不是对原始数据直接签名。

6. 综合应用与进阶话题

掌握了三大算法的独立使用后,我们来看看如何将它们组合起来,解决更复杂的实际问题,并探讨一些进阶话题。

6.1 典型应用场景:数字信封与完整数据安全传输

一个经典的端到端数据安全传输流程,会综合运用上述所有算法:

  1. 发送方
    • 生成一个随机的SM4会话密钥
    • 使用SM4(CBC模式)和这个会话密钥,加密实际的业务数据(明文)。
    • 使用接收方的SM2公钥,加密这个SM4会话密钥。
    • 对业务数据(或密文)计算SM3摘要,并使用发送方自己的SM2私钥对该摘要进行签名
    • {SM2加密的会话密钥, SM4加密的数据, SM2签名}打包发送给接收方。
  2. 接收方
    • 使用自己的SM2私钥解密出SM4会话密钥。
    • 使用解密出的SM4会话密钥,解密得到业务数据明文。
    • 对解密出的业务数据(或直接对收到的密文,需与发送方约定一致)计算SM3摘要
    • 使用发送方的SM2公钥,验证收到的签名是否与刚计算的摘要匹配。

这个过程确保了数据的机密性(SM4加密)、完整性(SM3摘要)和不可否认性(SM2签名),并且通过SM2加密会话密钥解决了对称密钥的安全分发问题。这就是一个完整的“数字信封”应用。

6.2 算法标识与Provider名称的坑

不同版本的BouncyCastle,或者在不同的上下文中,算法名称可能有细微差别。如果你遇到NoSuchAlgorithmException,可以尝试以下名称:

  • SM2:"SM2","EC"
  • SM3:"SM3"
  • SM4:"SM4","SM4/CBC/PKCS7Padding","SM4/ECB/PKCS7Padding"

    注意:在指定模式和填充时,PKCS7Padding是BC中常用的名称。虽然PKCS5和PKCS7在分组密码的上下文中基本等价,但BC通常识别PKCS7Padding

最稳妥的方式是查看BC的官方文档或源码,或者通过Security.getAlgorithms("Cipher")等方法列出当前Provider支持的所有算法。

6.3 与Hutool等工具库的对比

你可能听说过Hutool这个优秀的Java工具库,它提供了一个SmUtil类,封装了国密算法。用Hutool可以极大地简化代码:

// Hutool 示例 import cn.hutool.crypto.SmUtil; import cn.hutool.crypto.asymmetric.KeyType; // SM3 String digestHex = SmUtil.sm3("原文"); // SM4 (需要先引入hutool-crypto依赖,且其底层也是BC) String ciphertext = SmUtil.sm4(key.getBytes()).encryptBase64("原文"); // SM2 SM2 sm2 = SmUtil.sm2(priKeyBase64, pubKeyBase64); String sign = sm2.signHex(SmUtil.sm3("原文")); boolean verify = sm2.verifyHex(SmUtil.sm3("原文"), sign);

那么,是直接用BouncyCastle还是用Hutool?

  • 使用BouncyCastle:你对底层实现有更强的控制力,能理解每一步的原理,便于深度定制、排查复杂问题,并且依赖更轻量(只引入BC)。
  • 使用Hutool:追求开发效率,快速实现功能,且对底层细节不关心。Hutool的API设计更符合中文开发者的习惯,文档丰富。但需要注意,Hutool的版本更新可能滞后于BC,且封装可能隐藏了一些高级选项。

我的建议是:如果你是学习、研究,或者项目对密码学有定制化需求,从BC开始学起是很好的选择。如果是快速业务开发,追求稳定和效率,Hutool是非常棒的封装。了解BC的原理,也能让你更好地使用Hutool。

6.4 性能优化与最佳实践

  1. 对象复用Cipher,Signature,MessageDigest等对象的创建有一定开销。在高并发场景下,可以考虑使用对象池(如Apache Commons Pool)或ThreadLocal来复用它们。但务必注意,Cipher等对象不是线程安全的,每个线程必须使用独立的实例。
  2. 密钥缓存:频繁地从Base64字符串或字节数组解析PublicKey/PrivateKey对象(使用KeyFactory)是昂贵的操作。如果公钥/私钥是固定的,应在服务初始化时解析一次并缓存起来。
  3. 选择正确的模式:对称加密永远优先选择CBC或GCM等带IV的模式,避免ECB。GCM模式还能同时提供加密和认证,是更现代的选择(BC也支持SM4/GCM/NoPadding)。
  4. 错误日志:密码学操作失败时,日志要格外小心。记录“认证失败”、“解密错误”即可,切勿在日志或异常信息中打印密钥、IV、明文或密文的片段。
  5. 依赖管理:确保团队所有服务使用的BouncyCastle版本一致,避免因版本差异导致的算法实现或默认参数不同,引发联调问题。

国密算法的集成和应用,核心在于理解每种算法的用途、限制和最佳实践。BouncyCastle提供了强大的底层支持,而如何安全、正确、高效地使用它们,则取决于开发者的设计。希望这篇超过五千字的实战记录,能帮你绕过我当年踩过的那些坑,更顺畅地在你的Java项目中应用国密算法。如果在实际使用中遇到更具体的问题,比如与特定硬件加密机对接、处理特殊的签名格式,那往往需要结合具体的厂商文档和BC的ASN.1处理能力进行更深入的探索了。