国密SM4前后端互通实战:JavaScript与Java加解密全流程详解
1. 项目概述:为什么SM4互通是个“技术活”?
最近在做一个涉及金融数据安全传输的项目,前端是Vue,后端是Spring Boot,甲方爸爸明确要求核心敏感字段必须使用国密SM4算法进行加解密。这要求听起来挺合理,对吧?国密算法,自主可控,安全合规。但真干起来,才发现从JavaScript到Java的SM4互通,简直是一个接一个的坑。你以为两边都调个库,传个密文就完事了?Too young, too simple.
最典型的场景就是,前端用JavaScript加密一串用户身份证号,洋洋洒洒传到后端,Java这边一解密,要么直接报错,要么解出来一堆乱码。你盯着控制台那串看似完美的Hex或Base64字符串,百思不得其解——明明算法都是SM4,怎么就不通了呢?这背后涉及到的,远不止一个算法调用那么简单。它关乎加密模式(是ECB还是CBC?)、填充方式(PKCS#5还是PKCS#7?)、密钥与IV的处理(是字符串还是字节数组?编码是啥?)、以及数据格式的转换(Hex, Base64, 还是直接传字节?)。任何一个环节对不上,整个流程就崩了。
这个项目折腾了我差不多一周,把bcprov-jdk18on、sm-crypto这些库翻了个底朝天,才把这条互通之路跑通。今天我就把其中最关键的技术点、最容易踩的坑,以及最终的解决方案,掰开揉碎了跟大家聊聊。无论你是前端同学需要对接国密后端,还是后端同学要提供国密接口给前端,这篇“避坑指南”都能让你少走很多弯路。我们的目标很简单:让数据安全、正确地在浏览器和服务器之间跑起来。
2. 核心概念与互通难点拆解
在动手写代码之前,我们必须把SM4互通的基本概念和那些“魔鬼细节”搞清楚。很多人一上来就找代码,结果就是不断试错,浪费大量时间。
2.1 国密SM4算法简介
SM4是一种分组密码算法,分组长度和密钥长度均为128位(即16字节)。这意味着它一次处理16个字节的数据。对于不足16字节的数据,就需要进行填充(Padding);对于超过16字节的数据,就需要进行分组迭代,这就引出了不同的工作模式。
算法本身是标准的,但如何运用这个算法,就产生了不同的“配方”,这也是互通的第一个拦路虎。
2.2 互通的核心四要素
要让JavaScript和Java端的SM4加解密结果一致,以下四个要素必须完全匹配,缺一不可:
密钥(Key):必须是128位(16字节)。如果给你的密钥是字符串(比如一个密码),那么你需要一个双方一致的转换规则,将其变成16字节的数组。常见的做法是使用MD5、SHA-256等哈希函数对字符串密钥进行摘要,然后取前16字节,或者直接对字符串进行UTF-8编码后截断/补位。关键在于,两端用于加密和解密的字节数组必须一模一样。
初始化向量(IV):在使用CBC、CFB等模式时必需。IV也是一个16字节的数组,用于增加加密的随机性,防止相同的明文生成相同的密文。IV不需要保密,但必须随机生成,并且在一次加密和解密过程中保持一致。通常,前端随机生成IV,将其和密文一起传给后端;或者双方约定一个固定的IV(安全性较低,不推荐用于高敏感数据)。
加密模式(Mode):最常见的是ECB和CBC。
- ECB(电子密码本):最简单,每个分组独立加密。缺点非常明显:相同的明文分组会得到相同的密文分组,容易受到攻击,不推荐用于加密有规律的数据。但它的好处是无需IV,实现简单。
- CBC(密码分组链接):当前一个分组的密文参与下一个分组的加密运算,增强了安全性。这是目前最推荐、也是最常用的模式。使用CBC必须配合IV。
填充方式(Padding):因为SM4是分组加密,必须处理数据长度不是16字节整数倍的情况。
- PKCS#5/PKCS#7:这是最常用的填充方式。本质上,PKCS#5是PKCS#7的子集(仅用于8字节分组,如DES)。对于16字节分组的SM4,我们说的PKCS#5填充实际就是指PKCS#7。它的规则是:缺少N个字节,就填充N个值为N的字节。例如,明文最后缺少3个字节,则填充
0x03 0x03 0x03。 - NoPadding:不填充。这就要求你加密的数据长度必须是16字节的整数倍,否则会出错。一般用于自己已经处理好填充的场景。
- PKCS#5/PKCS#7:这是最常用的填充方式。本质上,PKCS#5是PKCS#7的子集(仅用于8字节分组,如DES)。对于16字节分组的SM4,我们说的PKCS#5填充实际就是指PKCS#7。它的规则是:缺少N个字节,就填充N个值为N的字节。例如,明文最后缺少3个字节,则填充
关键避坑点1:默认配置陷阱不同的加密库,其默认的Mode和Padding可能不同!比如,某个Java库默认是
ECB/PKCS5Padding,而某个JavaScript库默认可能是CBC/PKCS7Padding。如果你不显式指定,那么两端默认不一致,必然导致失败。最佳实践是:无论在JS端还是Java端,都显式、明确地指定Mode和Padding。
2.3 数据格式的约定
加解密操作的对象是字节数组(byte[]或Uint8Array)。但我们在网络传输或存储时,通常使用可打印的字符串格式。这就需要编码和解码。
- Hex(十六进制):将每个字节转换为两个十六进制字符。例如,字节
0xAB表示为字符串"AB"。长度会扩大一倍,但可读性好。 - Base64:将3个字节编码为4个可打印字符。长度增加约33%,是网络传输中最常用的格式,因为它比Hex更紧凑,并且可以安全地放在URL、JSON中。
互通时,必须约定好传输的密文和IV是什么格式。常见做法是:前端将密文和IV都转换为Base64字符串,通过JSON传给后端;后端收到后,先进行Base64解码,得到字节数组,再进行解密。
3. 前端JavaScript(Web)实现详解
前端我们选用一个比较成熟且维护良好的国密算法库:sm-crypto。它支持SM2、SM3、SM4,且纯JavaScript实现,不依赖任何本地模块,非常适合浏览器环境。
3.1 环境准备与库安装
首先,在你的前端项目(如Vue、React或纯HTML项目)中安装sm-crypto。
npm install sm-crypto --save # 或 yarn add sm-crypto安装后,在需要的组件或模块中引入SM4模块:
import { sm4 } from 'sm-crypto';3.2 核心加密函数实现
假设我们与后端约定使用CBC模式、PKCS7填充。密钥是一个16字节的字符串(例如'1234567890abcdef')。注意,如果密钥字符串不是16字节,你需要先将其转换为16字节,方法后面会讲。
这里我们实现一个完整的加密函数,包含IV的生成和处理。
/** * 使用SM4 CBC模式加密文本 * @param {string} plaintext - 待加密的明文 * @param {string} key - 密钥字符串(需为16字节长度) * @returns {object} 返回包含密文和IV的对象,均为Base64格式 */ function encryptSM4CBC(plaintext, key) { // 1. 检查密钥长度(UTF-8编码下的字节长度) const keyBytes = new TextEncoder().encode(key); if (keyBytes.length !== 16) { throw new Error('密钥必须为16字节(16个英文字符或8个中文字符)'); } // 2. 生成16字节的随机IV (Initialization Vector) const ivArray = new Uint8Array(16); crypto.getRandomValues(ivArray); // 使用Web Crypto API生成密码学安全的随机数 // 3. 执行加密 // sm4.encrypt() 参数说明: (明文, 密钥, 配置对象) // 配置对象: { mode: 'cbc', iv: iv数组 }, 默认填充是PKCS7 const encryptData = sm4.encrypt(plaintext, key, { mode: 'cbc', iv: ivArray, // 传入Uint8Array格式的IV }); // 4. 数据转换与返回 // sm-crypto的encrypt方法默认返回16进制(Hex)字符串。 // 但为了传输方便,我们将其和IV都转为Base64。 const cipherTextBase64 = hexToBase64(encryptData); const ivBase64 = arrayBufferToBase64(ivArray.buffer); return { cipherText: cipherTextBase64, iv: ivBase64, }; } // 辅助函数:16进制字符串转Base64 function hexToBase64(hexString) { // 将16进制字符串转换为字节数组 const byteArray = new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); // 将字节数组转换为Base64 return btoa(String.fromCharCode.apply(null, byteArray)); } // 辅助函数:ArrayBuffer转Base64 function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); const binary = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), ''); return btoa(binary); }3.3 核心解密函数实现
解密是加密的逆过程。我们需要从后端接收或从存储中取得Base64格式的密文和IV。
/** * 使用SM4 CBC模式解密文本 * @param {string} cipherTextBase64 - Base64格式的密文 * @param {string} key - 密钥字符串(需为16字节长度) * @param {string} ivBase64 - Base64格式的IV * @returns {string} 解密后的明文 */ function decryptSM4CBC(cipherTextBase64, key, ivBase64) { // 1. 将Base64格式的密文和IV转换为16进制字符串(sm-crypto解密需要Hex输入) const cipherTextHex = base64ToHex(cipherTextBase64); const ivArray = base64ToUint8Array(ivBase64); // 2. 执行解密 // sm4.decrypt() 参数说明: (密文Hex, 密钥, 配置对象) const decryptData = sm4.decrypt(cipherTextHex, key, { mode: 'cbc', iv: ivArray, }); return decryptData; // 解密结果已是字符串 } // 辅助函数:Base64转16进制字符串 function base64ToHex(base64String) { const raw = atob(base64String); let result = ''; for (let i = 0; i < raw.length; i++) { const hex = raw.charCodeAt(i).toString(16); result += (hex.length === 2 ? hex : '0' + hex); } return result; } // 辅助函数:Base64转Uint8Array function base64ToUint8Array(base64String) { const binaryString = atob(base64String); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; }3.4 密钥处理与安全注意事项
很多时候,我们获得的密钥可能是一个任意长度的字符串(比如用户输入的密码),而不是标准的16字节。
处理方法:使用哈希函数(如SM3)生成固定长度的密钥。
sm-crypto也提供了SM3哈希功能。
import { sm3 } from 'sm-crypto'; /** * 从任意字符串生成16字节的SM4密钥 * @param {string} password - 任意长度的密码字符串 * @returns {string} 16字节的Hex字符串形式的密钥 */ function generateSM4KeyFromPassword(password) { // 1. 使用SM3对密码进行哈希,得到32字节(64位Hex)的摘要 const hashHex = sm3(password); // 输出是64字符的Hex字符串 // 2. 取前32个字符(即前16字节)作为SM4密钥 const sm4KeyHex = hashHex.substring(0, 32); // 3. 如果你想直接得到字符串形式的密钥,可以将其Hex转回ASCII(但要求这16字节是可打印字符) // 更通用的做法是直接使用这个Hex字符串作为密钥,但注意sm-crypto的encrypt/decrypt方法要求密钥是字符串。 // 实际上,sm-crypto的encrypt方法内部会处理Hex密钥。 // 所以我们可以直接返回这个Hex字符串,并在加密时使用。 return sm4KeyHex; } // 使用示例 const userPassword = 'MySecretPassword123'; const derivedKeyHex = generateSM4KeyFromPassword(userPassword); console.log('Derived Key (Hex):', derivedKeyHex); // 长度为32的字符串 // 加密时,直接将这个Hex字符串作为key参数传入 const encrypted = sm4.encrypt('hello world', derivedKeyHex, { mode: 'cbc', iv: someIV });关键避坑点2:密钥一致性前端使用
sm3(password).substring(0, 32)生成的Hex密钥,后端必须用完全相同的算法和步骤生成相同的字节数组。如果后端用password.getBytes("UTF-8")然后取前16字节,那结果肯定对不上。因此,前后端必须严格约定密钥派生算法。
4. 后端Java实现详解
后端我们使用Bouncy Castle这个强大的密码学提供者,它提供了对国密算法的完整支持。
4.1 依赖引入与环境配置
在Maven项目的pom.xml中添加依赖。推荐使用bcprov-jdk18on,它支持到JDK 18。
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>1.78</version> <!-- 请使用最新稳定版本 --> </dependency>在应用启动时,或者在使用加密解密功能之前,需要将Bouncy Castle注册为安全提供者。
import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; public class SecurityConfig { static { // 注册Bouncy Castle Provider if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } }4.2 核心工具类构建
我们创建一个Sm4Util工具类,封装加密和解密方法。这里同样采用CBC模式、PKCS7填充(在BC中通常指定为PKCS5Padding,对于16字节分组,它实际执行PKCS7)。
import lombok.extern.slf4j.Slf4j; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.util.encoders.Base64; import org.bouncycastle.util.encoders.Hex; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.Security; @Slf4j public class Sm4Util { private static final String ALGORITHM_NAME = "SM4"; private static final String TRANSFORMATION_CBC = "SM4/CBC/PKCS5Padding"; // 使用PKCS5Padding,BC会按PKCS7处理 private static final String TRANSFORMATION_ECB = "SM4/ECB/PKCS5Padding"; private static final int KEY_LENGTH = 16; // 128 bits static { // 确保提供者已注册 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } /** * SM4 CBC模式加密 * @param plaintext 明文 * @param keyBytes 16字节的密钥 * @param ivBytes 16字节的初始化向量 * @return Base64编码的密文 */ public static String encryptCbc(byte[] plaintext, byte[] keyBytes, byte[] ivBytes) throws Exception { validateKeyAndIv(keyBytes, ivBytes); Cipher cipher = Cipher.getInstance(TRANSFORMATION_CBC, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, ALGORITHM_NAME); IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes = cipher.doFinal(plaintext); return Base64.toBase64String(encryptedBytes); } /** * SM4 CBC模式解密 * @param cipherTextBase64 Base64编码的密文 * @param keyBytes 16字节的密钥 * @param ivBytes 16字节的初始化向量 * @return 解密后的明文字节数组 */ public static byte[] decryptCbc(String cipherTextBase64, byte[] keyBytes, byte[] ivBytes) throws Exception { validateKeyAndIv(keyBytes, ivBytes); byte[] cipherBytes = Base64.decode(cipherTextBase64); Cipher cipher = Cipher.getInstance(TRANSFORMATION_CBC, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, ALGORITHM_NAME); IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); return cipher.doFinal(cipherBytes); } /** * 验证密钥和IV长度 */ private static void validateKeyAndIv(byte[] keyBytes, byte[] ivBytes) { if (keyBytes.length != KEY_LENGTH) { throw new IllegalArgumentException("密钥长度必须为16字节"); } if (ivBytes != null && ivBytes.length != KEY_LENGTH) { throw new IllegalArgumentException("IV长度必须为16字节"); } } /** * 从密码派生SM4密钥(使用SM3哈希,取前16字节) * 注意:此方法需要bcprov-ext-jdk18on依赖以使用SM3,或使用其他SM3实现。 * 这里为简化,先使用SHA-256示例。确保与前端的派生算法一致! */ public static byte[] generateSm4KeyFromPassword(String password) throws Exception { // 重要:这里必须使用与前端的JavaScript端完全相同的算法! // 前端使用 SM3(password).substring(0, 32) (Hex) // 后端也需要用SM3计算哈希。 // 假设我们有一个SM3的工具类 `Sm3Util.digest(password)` // byte[] hash = Sm3Util.digest(password.getBytes(StandardCharsets.UTF_8)); // return Arrays.copyOf(hash, 16); // 取前16字节 // 临时示例:使用SHA-256 (仅用于演示,务必与前端对齐) java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(password.getBytes(StandardCharsets.UTF_8)); return java.util.Arrays.copyOf(hash, 16); } }4.3 在Spring Boot控制器中的应用
现在,我们创建一个REST接口来接收前端加密的数据并解密。
import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/sm4") public class Sm4Controller { // 假设这是一个双方预先约定好的固定密钥(示例,生产环境应从安全配置读取) private static final String SECRET_KEY = "1234567890abcdef"; // 16字节字符串 @PostMapping("/decrypt") public ApiResponse decryptData(@RequestBody EncryptRequest request) { try { // 1. 将固定密钥字符串转换为字节数组 byte[] keyBytes = SECRET_KEY.getBytes(StandardCharsets.UTF_8); // 2. 前端传来的IV是Base64格式,需要解码 byte[] ivBytes = java.util.Base64.getDecoder().decode(request.getIv()); // 3. 执行解密 byte[] decryptedBytes = Sm4Util.decryptCbc(request.getCipherText(), keyBytes, ivBytes); String plaintext = new String(decryptedBytes, StandardCharsets.UTF_8); // 4. 返回解密结果 return ApiResponse.success(plaintext); } catch (Exception e) { log.error("SM4解密失败", e); return ApiResponse.error("解密失败: " + e.getMessage()); } } // 请求体 @Data // 使用Lombok public static class EncryptRequest { private String cipherText; // Base64密文 private String iv; // Base64 IV } // 响应体 @Data public static class ApiResponse { private int code; private String message; private Object data; public static ApiResponse success(Object data) { ApiResponse resp = new ApiResponse(); resp.code = 200; resp.message = "success"; resp.data = data; return resp; } public static ApiResponse error(String msg) { ApiResponse resp = new ApiResponse(); resp.code = 500; resp.message = msg; return resp; } } }5. 前后端联调与互通实战
理论说完,代码写完,最激动人心(也最容易崩溃)的联调环节来了。这里我模拟一个完整的流程,并附上每一步的检查点。
5.1 完整流程演练
场景:前端需要加密用户手机号"13800138000"并发送给后端。
第1步:前端加密
- 确定密钥:双方约定密钥为字符串
"my-secret-key-16"。注意,这个字符串的UTF-8字节长度正好是16。 - 执行加密:
const plaintext = "13800138000"; const key = "my-secret-key-16"; const encryptedResult = encryptSM4CBC(plaintext, key); console.log('加密结果:', encryptedResult); // 输出可能类似: { cipherText: "L4A8...xx==", iv: "kR8q...Yf0=" } - 将
encryptedResult.cipherText和encryptedResult.iv作为JSON参数发起请求。
第2步:网络传输
{ "cipherText": "L4A8zT...(Base64字符串)", "iv": "kR8qFg...(Base64字符串)" }第3步:后端解密
- 后端接收到JSON,提取
cipherText和iv。 - 使用相同的密钥字符串
"my-secret-key-16",转换为UTF-8字节数组。 - 对
iv进行Base64解码,得到IV字节数组。 - 调用
Sm4Util.decryptCbc(cipherText, keyBytes, ivBytes)。 - 将解密后的字节数组用UTF-8解码成字符串,得到
"13800138000"。
5.2 联调检查清单(避坑宝典)
当你的加解密不通时,请按照以下清单逐一排查,99%的问题都能找到:
| 检查项 | 前端(JavaScript) | 后端(Java) | 排查命令/方法 |
|---|---|---|---|
| 1. 密钥一致性 | 密钥字符串的字节表示是否16位? 是否经过了哈希派生? | 密钥字节数组是否与前端的字节表示完全一致? 派生算法是否相同? | 前端:console.log(new TextEncoder().encode(key).length)后端: System.out.println(keyBytes.length);并打印Hex对比。 |
| 2. 加密模式 | sm4.encrypt是否指定{ mode: 'cbc' }? | Cipher.getInstance是否使用"SM4/CBC/..."? | 确认代码中显式指定了CBC。 |
| 3. 填充方式 | sm-crypto默认PKCS7,是否更改? | Cipher.getInstance是否使用".../PKCS5Padding"? | 保持两端均为PKCS7/PKCS5Padding。 |
| 4. IV处理 | IV是否随机生成并参与加密? IV是否随密文一起传输? | 解密时使用的IV是否与加密时的IV(解码后)完全相同? | 对比传输的Base64 IV字符串,解码后比较字节数组。 |
| 5. 数据格式 | 传给后端的密文和IV是否是Base64字符串? | 收到后是否先进行Base64解码,再进行解密操作? | 前端:typeof cipherText === 'string'且符合Base64特征。后端:使用 Base64.getDecoder().decode()。 |
| 6. 字符编码 | 明文转字节、密钥转字节是否使用UTF-8? | 解密后字节转字符串是否使用UTF-8? | 前后端统一使用UTF-8。Java中明确指定StandardCharsets.UTF_8。 |
| 7. 库与提供商 | 使用的是sm-crypto库。 | 已添加bcprov依赖,并注册BouncyCastleProvider。 | 后端检查Security.getProviders()是否包含BC。 |
关键避坑点3:字节级的对比调试当出现问题时,不要只看字符串。将前后端在关键步骤(如密钥、IV、加密前的明文、解密后的字节)的数据,都以十六进制(Hex)的形式打印出来进行对比。一个字节的差异都会导致失败。例如,在Java端:
System.out.println(Hex.toHexString(keyBytes));,在JS端:console.log(arrayBufferToHex(keyBuffer))。
5.3 一个实用的联调试错示例
假设后端解密报错:javax.crypto.BadPaddingException: pad block corrupted。
这个错误通常意味着:
- 密钥错了。
- IV错了。
- 密文在传输或解码过程中被篡改或损坏。
- 加密模式或填充不匹配。
调试步骤:
- 隔离测试:写一个简单的Java单元测试,用固定的密钥、IV和密文(从前端日志中复制)解密,看是否成功。如果单元测试成功,问题可能出在网络传输或接口层。
- 日志输出:在前后端的关键节点打印Hex值。
- 前端:打印加密前的明文Hex、密钥Hex、生成的IV Hex、加密后的密文Hex。
- 后端:打印接收到的Base64密文和IV,解码后的Hex,以及从配置中读取的密钥Hex。
- 逐项对比:
- 对比密钥Hex是否完全一致。
- 对比IV Hex是否完全一致。
- 手动将前端的密文Hex进行Base64编码,看是否与传输的Base64字符串一致(验证传输过程)。
- 通过以上对比,几乎一定能定位到是哪个环节的数据出现了偏差。
6. 进阶话题与性能优化
当基本功能跑通后,我们可能会考虑更多实际生产中的问题。
6.1 使用GCM模式实现认证加密
CBC模式能保证机密性,但不能保证密文的完整性(即无法防止密文被篡改)。GCM(Galois/Counter Mode)模式同时提供了机密性和完整性认证,是更推荐的选择。sm-crypto和Bouncy Castle都支持SM4-GCM。
前端JS (sm-crypto) 注意事项:sm-crypto的GCM模式调用与CBC类似,但需要指定额外的additionalData(可选)和tagLength(通常为128位)。
const encrypted = sm4.encrypt('hello world', key, { mode: 'gcm', iv: ivArray, additionalData: 'some-auth-data', // 可选 tagLength: 128 // 位 });GCM加密输出的密文已经包含了认证标签(Tag)。解密时需要相同的配置。
后端Java (Bouncy Castle) 实现: 需要使用GCMParameterSpec。
public static String encryptGcm(byte[] plaintext, byte[] keyBytes, byte[] ivBytes) throws Exception { Cipher cipher = Cipher.getInstance("SM4/GCM/NoPadding", BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "SM4"); // GCM通常使用12字节(96位)的IV,标签长度128位 GCMParameterSpec gcmSpec = new GCMParameterSpec(128, ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); // 可以添加附加认证数据(AAD) // cipher.updateAAD(additionalData.getBytes(StandardCharsets.UTF_8)); byte[] encrypted = cipher.doFinal(plaintext); return Base64.toBase64String(encrypted); }GCM解密时,密文包含了标签,初始化方式相同。
关键避坑点4:GCM的IV长度GCM模式推荐使用12字节(96位)的IV,而不是CBC的16字节。前后端需要对此进行约定。如果使用16字节IV,BC可能会自动处理,但最好遵循标准。
6.2 性能考量与最佳实践
- 密钥管理:绝对不要将硬编码的密钥放在前端代码中。前端密钥应通过安全通道(如HTTPS)从服务端动态获取,或使用非对称加密(如SM2)来协商对称密钥。生产环境中,后端密钥也应存放在安全的配置中心或硬件安全模块(HSM)中。
- IV的生成与传递:每次加密都应使用随机生成的IV。IV可以公开传递,但必须保证唯一性和随机性(使用密码学安全的随机数生成器)。可以将IV预置在密文前一起传输(例如
IV + CipherText),也可以作为单独字段传输。 - 错误处理:加解密操作必须进行完整的异常捕获。不要将底层的加密异常(如
BadPaddingException)直接抛给用户,应转换为统一的业务异常信息。 - 数据长度:对称加密适合加密数据块。对于大文件,应使用流式加密或先分段加密。注意,使用CBC等模式时,加密后的数据长度会因为填充而增加。
- 依赖版本:保持
sm-crypto和bcprov库的版本稳定,并关注更新日志。不同版本间可能会有细微的兼容性差异。
7. 常见问题排查实录
这里记录了几个我在实际开发中遇到的典型问题及其解决方案。
问题1:前端加密成功,后端解密报InvalidKeyException: Illegal key size
- 原因:早期JDK有默认的加密强度限制(JCE策略限制)。SM4的128位密钥可能受此限制。
- 解决:对于JDK 8u151及以上版本,默认已解除限制。如果使用旧版本,需要手动下载并替换
local_policy.jar和US_export_policy.jar两个JAR包到$JAVA_HOME/jre/lib/security/目录下。更简单的方法是升级JDK。
问题2:解密后得到乱码,但长度似乎正确
- 原因:字符编码不一致。前端使用
TextEncoder(通常是UTF-8)将字符串转为字节,后端解密后可能使用了错误的字符集(如GBK)来还原字符串。 - 解决:确保前后端在所有字符串与字节数组转换的地方都明确指定UTF-8。Java端使用
new String(decryptedBytes, StandardCharsets.UTF_8)。
问题3:在Android或特定Java环境中无法找到SM4算法
- 原因:Bouncy Castle Provider未正确注册,或者注册的优先级不够高。
- 解决:
- 确认依赖已引入。
- 在调用加解密代码前,确保执行了
Security.addProvider(new BouncyCastleProvider())。 - 可以在
Cipher.getInstance时强制指定提供者:Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC")。但更推荐在程序启动时全局注册。
问题4:使用Hex格式密钥时加解密失败
- 原因:
sm-crypto的encrypt方法接受Hex字符串作为密钥,但Java的SecretKeySpec需要字节数组。如果你将Hex字符串直接getBytes(),就错了。 - 解决:需要将Hex字符串解码为字节数组。
// 错误的做法 // byte[] keyBytes = hexKeyString.getBytes(StandardCharsets.UTF_8); // 正确的做法 import org.bouncycastle.util.encoders.Hex; byte[] keyBytes = Hex.decode(hexKeyString); // 使用BC库的Hex解码
折腾完这一整套,最大的体会就是:密码学互通,细节决定成败。它不像调用一个普通的API,参数对了就能返回结果。它要求前后端工程师对算法、编码、数据格式有着完全一致的理解和实现。最好的办法就是在一开始就定好一份详细的“加密通信协议”,把算法、模式、填充、密钥派生方法、IV生成与传递方式、数据编码格式全部白纸黑字写清楚,双方都严格按照协议实现。这样能节省大量无效的联调时间。希望这篇长文能成为你国密SM4互通之路上的一个实用路书,帮你把坑都填平。
