SM4 ECB跨语言加解密:JavaScript与Java互通实现详解
1. 项目概述:为什么需要前后端统一的SM4 ECB加密?
最近在做一个涉及敏感数据传输的项目,前端是JavaScript,后端是Java。安全审计明确要求使用国密算法SM4,并且为了简化流程,初步选定ECB模式。这听起来是个标准需求,对吧?但真动起手来,我发现坑还真不少。网上搜到的代码片段要么是纯前端的,要么是纯后端的,要么就是加密解密结果对不上,让人头疼。
SM4作为国家密码管理局认定的商用密码算法,其安全性和国产化属性在金融、政务等领域应用越来越广。ECB(电子密码本)模式虽然因为其“相同明文块产生相同密文块”的特性,在安全性上不如CBC等模式,常用于加密密钥等短数据,但其实现简单、无需初始化向量(IV)、支持并行计算的特点,使其在特定场景下仍有不可替代的价值。比如,对一些已经经过安全封装、长度固定的令牌(Token)或关键标识进行二次加密。
这个项目的核心挑战,不在于理解SM4或ECB的原理,而在于实现跨语言(JS和Java)的加密解密结果完全一致。这涉及到编码、字节处理、填充方式等一系列细节的严格对齐。今天,我就把趟过的坑和最终验证通过的完整方案分享出来,包括可运行的代码和必须注意的“魔鬼细节”。
2. 核心思路与方案选型:对齐是唯一准则
要实现JS和Java的加解密互通,核心就两个字:对齐。任何微小的不一致都会导致失败。我们的方案必须确保以下五个方面在两端完全一致:
2.1 算法与模式对齐
这很明确:SM4算法,ECB模式。ECB模式不需要初始化向量(IV),这减少了一个需要对齐的变量,但也意味着我们必须更关注填充(Padding)方式。
2.2 密钥处理对齐
SM4的密钥是128位(16字节)。无论前端后端,我们接收的密钥通常是一个字符串(比如32位的十六进制字符串或一个密码文本)。我们必须确保将这个字符串转换成完全相同的16字节数组。
- 关键决策:密钥来源。为了最大程度减少干扰,我们约定:加解密使用的密钥是一个32位的十六进制字符串(例如:
0123456789abcdeffedcba9876543210)。这样,它本身就明确代表了16字节的二进制数据,避免了从文本密码通过摘要算法(如MD5、SHA-256)派生密钥时可能产生的额外不一致。
2.3 数据编码与填充对齐
这是最容易出错的地方。
- 明文/密文格式:我们要求输入和输出都是十六进制(Hex)字符串。这便于网络传输、日志记录和调试。在内存中,加解密操作的对象是二进制字节数组。
- 填充(Padding)方式:SM4是分组密码,块大小是128位(16字节)。当明文长度不是16字节的整数倍时,必须进行填充。PKCS#7/PKCS#5填充是标准且互通性最好的选择。它的规则是:缺N个字节,就填充N个值为N的字节。例如,一个15字节的数据,填充1个
0x01;一个14字节的数据,填充2个0x02,以此类推。Java的Cipher类默认使用PKCS5Padding(在16字节块大小下等同于PKCS#7),而我们需要在前端JS中实现完全相同的逻辑。
2.4 代码库选型
- Java端:优先使用
Bouncy Castle(BC)密码库。它是Java领域功能最全的密码学提供者,对国密算法支持良好。JDK标准库本身不包含SM4。 - JavaScript端:选择
sm-crypto库。这是一个专门为国密算法(SM2, SM3, SM4)实现的、经过验证的JavaScript库,API清晰,支持ECB和CBC模式。
2.5 工作流程设计
整个加解密流程可以抽象为以下步骤,必须确保JS和Java的每一步都镜像一致:
- 输入:接收十六进制字符串格式的密钥和明文(加密时)/密文(解密时)。
- 解码:将十六进制字符串转换为二进制字节数组(
Uint8Array或byte[])。 - 配置密码器:初始化SM4密码器,设定为ECB模式、PKCS#7填充。
- 执行操作:进行加密或解密,得到二进制结果字节数组。
- 编码输出:将结果字节数组编码回十六进制字符串。
3. 核心细节解析与实操要点
3.1 十六进制字符串与字节数组的转换
这是所有数据交互的基础,必须零误差。
JavaScript实现要点:sm-crypto库的sm4.encrypt和sm4.decrypt方法默认接受并返回十六进制字符串。这省去了我们手动转换的麻烦。但我们需要理解其内部过程:当你传入一个十六进制明文字符串,库会先将其解析为字节数组,加密后再将字节数组编码为十六进制字符串输出。解密过程反之。
一个常见的坑:如果你的明文不是纯十六进制字符串,而是普通文本(如“HelloWorld”),你需要先将其转换为UTF-8编码的字节数组,再转换为十六进制字符串,或者直接寻找库是否支持文本输入。为了简化,我们全程使用十六进制格式。
Java实现要点:Java中需要手动处理转换。我们可以使用Hex类(来自BC库或Apache Commons Codec)来完成。
import org.bouncycastle.util.encoders.Hex; // 十六进制字符串 -> 字节数组 String hexStr = "0123456789abcdef"; byte[] bytes = Hex.decode(hexStr); // 使用BC的Hex解码 // 字节数组 -> 十六进制字符串 byte[] resultBytes = ...; String resultHex = Hex.toHexString(resultBytes); // 使用BC的Hex编码注意:确保编解码时字母大小写一致。通常统一使用小写。
Hex.toHexString默认输出小写。
3.2 PKCS#7填充的手动实现(理解原理)
虽然库会处理填充,但理解其原理对调试至关重要。假设我们要加密的数据字节数组是dataBytes,长度是dataLen。
- 计算需要填充的字节数
padLen = 16 - (dataLen % 16)。如果dataLen % 16 == 0,则padLen = 16(填充一整块)。 - 创建一个新的字节数组
paddedData,长度为dataLen + padLen。 - 将原数据
dataBytes拷贝到paddedData的前dataLen位。 - 从
paddedData[dataLen]开始,到数组结束,全部填充为(byte)padLen的值。
例如,数据[0x01, 0x02, 0x03](3字节),padLen = 13,填充后的数组为[0x01, 0x02, 0x03, 0x0d, 0x0d, 0x0d, ... (共13个0x0d)]。
解密后,需要去除填充:读取最后一个字节的值padLen,然后验证最后padLen个字节的值是否都等于padLen,如果验证通过,则截取前总长度 - padLen个字节即为原始明文。
3.3 ECB模式的特点与安全提醒
务必再次强调:ECB模式不适合加密有重复模式的长文本数据。因为它缺乏扩散性,相同的明文块会产生相同的密文块。攻击者可能通过分析密文块的模式来推断部分明文信息。
适用场景:
- 加密随机生成的、长度固定的密钥或令牌。
- 加密已经由其他模式(如CBC)加密过的数据(即加密模式嵌套)。
- 对安全性要求不高,但需要极高性能或简化性的内部场景。
如果你的数据不是短且随机的,强烈建议使用CBC模式并安全管理IV。本项目聚焦ECB的互通实现,但选择需谨慎。
4. 完整代码实现与逐行解析
下面提供经过验证可互通的JavaScript和Java代码。
4.1 JavaScript前端实现(基于sm-crypto)
首先,安装sm-crypto库:
npm install sm-crypto --save # 或直接在HTML中引入 <script src="https://unpkg.com/sm-crypto@latest/dist/sm-crypto.min.js"></script>实现加密解密函数:
// 导入sm-crypto库,如果是在Node环境或使用了模块打包 const sm4 = require('sm-crypto').sm4; // 如果在浏览器直接引入script标签,则sm4是全局变量 /** * 使用SM4 ECB模式加密 * @param {string} plainHex - 十六进制字符串格式的明文 * @param {string} keyHex - 32位十六进制字符串格式的密钥 * @returns {string} 十六进制字符串格式的密文 */ function sm4EncryptECB(plainHex, keyHex) { // sm-crypto的encrypt方法默认:输入输出都是hex,模式是ECB,填充是PKCS#7 // 第一个参数:明文数据(Hex/String) // 第二个参数:密钥(Hex) // 第三个参数:模式('ecb'或'cbc') // 返回值:密文(Hex String) try { const cipherHex = sm4.encrypt(plainHex, keyHex, {mode: 'ecb'}); return cipherHex; } catch (error) { console.error('SM4加密失败:', error); throw new Error(`加密失败: ${error.message}`); } } /** * 使用SM4 ECB模式解密 * @param {string} cipherHex - 十六进制字符串格式的密文 * @param {string} keyHex - 32位十六进制字符串格式的密钥 * @returns {string} 十六进制字符串格式的明文 */ function sm4DecryptECB(cipherHex, keyHex) { // sm-crypto的decrypt方法参数与encrypt一致 try { const plainHex = sm4.decrypt(cipherHex, keyHex, {mode: 'ecb'}); return plainHex; } catch (error) { console.error('SM4解密失败:', error); throw new Error(`解密失败: ${error.message}`); } } // ============= 测试用例 ============= const testKeyHex = '0123456789abcdeffedcba9876543210'; // 128位密钥 const testPlainHex = '00112233445566778899aabbccddeeff'; // 刚好16字节的明文 console.log('密钥(Hex):', testKeyHex); console.log('明文(Hex):', testPlainHex); const encrypted = sm4EncryptECB(testPlainHex, testKeyHex); console.log('加密结果(Hex):', encrypted); const decrypted = sm4DecryptECB(encrypted, testKeyHex); console.log('解密结果(Hex):', decrypted); console.log('解密是否等于明文?', decrypted === testPlainHex);代码解析与注意点:
sm-crypto的API非常简洁,encrypt/decrypt默认处理十六进制字符串,并支持通过选项{mode: 'ecb'}指定模式。- 密钥
keyHex必须是32个字符的十六进制字符串(0-9, a-f),代表16字节。如果长度不对,库会抛出错误。 - 明文
plainHex的长度必须是16字节的整数倍吗?不是。库会自动处理PKCS#7填充。你可以传入任意长度的十六进制字符串(偶数个字符,因为每两个字符代表一字节)。 - 错误处理很重要。加密解密过程可能因为数据格式错误、密钥错误等原因失败,需要用try-catch包裹。
4.2 Java后端实现(基于Bouncy Castle)
首先,在项目中添加Bouncy Castle依赖。以Maven为例:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.74</version> <!-- 请使用最新版本 --> </dependency>实现工具类:
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.util.encoders.Hex; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.security.Security; public class Sm4EcbUtils { static { // 静态代码块,确保Bouncy Castle提供者被注册 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } // 算法名称:SM4/ECB/PKCS5Padding // 在16字节块大小下,PKCS5Padding等同于PKCS7Padding private static final String ALGORITHM_NAME = "SM4"; private static final String ALGORITHM_NAME_ECB_PADDING = "SM4/ECB/PKCS5Padding"; /** * SM4 ECB模式加密 * @param plainHex 十六进制字符串格式的明文 * @param keyHex 32位十六进制字符串格式的密钥 * @return 十六进制字符串格式的密文 */ public static String encrypt(String plainHex, String keyHex) throws Exception { // 1. 将十六进制密钥字符串转换为字节数组 byte[] keyBytes = Hex.decode(keyHex); if (keyBytes.length != 16) { throw new IllegalArgumentException("密钥长度必须为16字节(128位),请提供32位Hex字符串"); } // 2. 将十六进制明文字符串转换为字节数组 byte[] plainBytes = Hex.decode(plainHex); // 3. 创建密钥规范 SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, ALGORITHM_NAME); // 4. 获取并初始化Cipher实例 // 使用Bouncy Castle的提供者 Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING, BouncyCastleProvider.PROVIDER_NAME); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); // 5. 执行加密 byte[] cipherBytes = cipher.doFinal(plainBytes); // 6. 将密文字节数组转换为十六进制字符串 return Hex.toHexString(cipherBytes); } /** * SM4 ECB模式解密 * @param cipherHex 十六进制字符串格式的密文 * @param keyHex 32位十六进制字符串格式的密钥 * @return 十六进制字符串格式的明文 */ public static String decrypt(String cipherHex, String keyHex) throws Exception { // 1. 将十六进制密钥字符串转换为字节数组 byte[] keyBytes = Hex.decode(keyHex); if (keyBytes.length != 16) { throw new IllegalArgumentException("密钥长度必须为16字节(128位),请提供32位Hex字符串"); } // 2. 将十六进制密文字符串转换为字节数组 byte[] cipherBytes = Hex.decode(cipherHex); // 3. 创建密钥规范 SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, ALGORITHM_NAME); // 4. 获取并初始化Cipher实例 Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING, BouncyCastleProvider.PROVIDER_NAME); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec); // 5. 执行解密 byte[] plainBytes = cipher.doFinal(cipherBytes); // 6. 将明文字节数组转换为十六进制字符串 return Hex.toHexString(plainBytes); } public static void main(String[] args) { try { String testKeyHex = "0123456789abcdeffedcba9876543210"; String testPlainHex = "00112233445566778899aabbccddeeff"; System.out.println("密钥(Hex): " + testKeyHex); System.out.println("明文(Hex): " + testPlainHex); String encrypted = encrypt(testPlainHex, testKeyHex); System.out.println("加密结果(Hex): " + encrypted); String decrypted = decrypt(encrypted, testKeyHex); System.out.println("解密结果(Hex): " + decrypted); System.out.println("解密是否等于明文? " + testPlainHex.equals(decrypted)); } catch (Exception e) { e.printStackTrace(); } } }代码解析与注意点:
- 提供者注册:必须在操作前通过
Security.addProvider注册Bouncy Castle提供者,或者在Cipher.getInstance时显式指定提供者名称(如代码所示)。后者更明确。 - 算法字符串:
"SM4/ECB/PKCS5Padding"是BC库识别的标准名称。它指明了算法、模式和填充。 - 密钥检查:我们主动检查了密钥字节数组的长度是否为16,这是一个好习惯,可以提前发现参数错误。
- 异常处理:
doFinal方法可能抛出BadPaddingException等异常,在正式环境中应妥善处理,例如记录日志并返回统一的错误响应。
4.3 互通性验证测试
分别运行上述JS和Java的测试代码。你会发现,使用相同的密钥和明文,两者加密产生的密文完全一致,并且都能正确解密对方产生的密文。这是项目成功的标志。
你可以尝试更多测试用例:
- 短数据:
plainHex = "616263"(对应文本"abc"的UTF-8编码的Hex)。 - 非16倍数长数据:
plainHex = "00112233445566778899aabbccddee"(15字节)。 - 长文本:先将一段UTF-8文本(如"Hello, 世界!")通过
Buffer.from(str, 'utf8').toString('hex')(Node.js)或new TextEncoder().encode(str)转Hex(浏览器)得到plainHex,再进行加解密。
5. 常见问题、排查技巧与实战心得
即使代码看起来一样,在实际集成中你可能还是会遇到问题。下面是我踩过的一些坑和解决方法。
5.1 密文对不上?逐层排查法
当JS和Java加密结果不一致时,不要慌,按照以下步骤隔离问题:
- 检查输入一致性:这是最常被忽略的。确保两端传入的
keyHex和plainHex字符串完全一样,包括大小写。最好在函数入口打印或日志记录这两个值的长度和内容。一个空格、一个换行符的差异都会导致结果不同。 - 验证密钥和明文字节:在两端分别将Hex字符串解码成字节数组,并打印数组长度和内容。确保字节级一致。
- Java:
System.out.println(Arrays.toString(Hex.decode(keyHex))); - JavaScript:
console.log(Array.from(sm4.utils.hexToBytes(keyHex)));(sm-crypto的utils里有转换工具)
- Java:
- 检查算法参数:确认两端都是
SM4、ECB模式、PKCS#7填充。Java的PKCS5Padding在这里是等价的。 - 单独测试填充:构造一个非16倍数的明文,分别在两端手动实现PKCS#7填充,比较填充后的字节数组是否一致。这能排除填充逻辑的差异。
- 使用标准测试向量:在网上搜索SM4 ECB的官方或社区测试向量(一组确定的密钥、明文和密文)。用它们分别测试你的JS和Java代码,看是否能通过。这能直接判断你的核心加解密逻辑是否正确。
5.2 Java端报错:No such provider: BC 或 NoSuchAlgorithmException
- 原因:Bouncy Castle提供者未正确注册或加载。
- 解决:
- 确保
bcprov-jdk15to18.jar在项目的classpath中。 - 确认静态代码块或初始化代码成功执行了
Security.addProvider(new BouncyCastleProvider())。 - 尝试使用显式提供者的方式获取Cipher实例(如我们的代码所示):
Cipher.getInstance("SM4/ECB/PKCS5Padding", "BC")或Cipher.getInstance("SM4/ECB/PKCS5Padding", BouncyCastleProvider.PROVIDER_NAME)。
- 确保
5.3 解密时报错:Given final block not properly padded
- 原因:密文损坏、密钥错误、或者加密解密使用的模式/填充不一致。
- 排查:
- 检查传输过程中密文Hex字符串是否被截断或修改。网络传输时,确保使用纯文本模式或正确编码。
- 确认解密使用的密钥与加密时完全一致。
- 最关键的一点:确认Java和JS两端使用的是相同的填充模式。如果一端是
PKCS5Padding,另一端是NoPadding(无填充),但数据长度又不是16的倍数,解密时必然失败。
5.4 性能与优化建议
- 密钥和Cipher对象复用:在Java服务端,如果频繁进行加解密操作,不要每次调用都
Cipher.getInstance()和init()。可以考虑使用ThreadLocal缓存初始化好的Cipher对象,但要注意线程安全。对于固定密钥的场景,可以缓存SecretKeySpec。 - JS端数据量:在浏览器中进行大量数据加密可能会阻塞主线程。对于大数据量,考虑使用Web Worker在后台线程处理。
- Hex编码开销:Hex编码会使数据体积膨胀一倍(1字节变2字符)。如果传输效率是瓶颈,且通信双方都支持,可以考虑使用Base64编码(体积增加约33%)。但需要同时修改JS和Java的编解码部分。
sm-crypto也支持Base64输入输出。
5.5 安全强化实践
虽然本项目是ECB模式,但安全原则不容忽视:
- 密钥管理:绝对不要将密钥硬编码在前端代码中。前端加密使用的密钥应由后端在会话开始时动态下发(通过安全通道如HTTPS),并且可以定期更换。更安全的做法是,前端只用于展示,所有加解密操作由后端API完成。
- 升级到CBC或GCM模式:对于真实敏感数据,尽快评估并迁移到CBC(需要安全生成和管理IV)或GCM(同时提供加密和认证)模式。
sm-crypto和Bouncy Castle都支持这些模式,但互通实现需要额外对齐IV或Nonce。 - 完整性校验:ECB和CBC模式只提供机密性,不提供完整性。考虑结合HMAC-SM3等算法对密文进行签名,防止密文被篡改。
6. 从ECB扩展到其他模式与高级话题
当你掌握了ECB模式的互通之后,解决其他模式的问题就有了坚实的基础。核心思路不变:对齐所有参数。
6.1 实现SM4 CBC模式互通
CBC模式需要增加一个初始化向量(IV),它也是一个16字节(128位)的数据块。实现互通的关键点:
- IV的生成与传递:IV不需要保密,但必须不可预测。通常由加密方随机生成,并随密文一起传递给解密方。IV也需要以十六进制字符串格式对齐。
- 代码改动:
- JavaScript (
sm-crypto):encrypt和decrypt方法需要增加iv参数。{mode: 'cbc', iv: ivHex}。 - Java (Bouncy Castle):算法字符串改为
"SM4/CBC/PKCS5Padding"。初始化Cipher时,需要使用IvParameterSpec:cipher.init(mode, secretKeySpec, new IvParameterSpec(ivBytes))。
- JavaScript (
- 安全提醒:同一个密钥下,绝对不要重复使用相同的IV。
6.2 处理非Hex格式的输入(文本、Base64)
实际业务中,数据可能不是Hex格式。你需要一个统一的预处理层。
场景:加密普通文本字符串
- 约定编码:两端统一使用UTF-8编码将字符串转换为字节数组。
- 转换步骤:
- 加密前:文本 -> UTF-8字节数组 -> Hex字符串 -> 调用上述加密函数。
- 解密后:获取Hex明文 -> Hex字符串转字节数组 -> UTF-8解码 -> 文本。
- 工具函数示例(JavaScript):
function strToHex(str) { return Array.from(new TextEncoder().encode(str)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } function hexToStr(hex) { const bytes = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); return new TextDecoder().decode(bytes); } // 使用 const textPlain = '你好,Hello123!'; const plainHexForEncrypt = strToHex(textPlain); const encryptedHex = sm4EncryptECB(plainHexForEncrypt, keyHex); // ... 传输encryptedHex ... // 解密后 const decryptedHex = sm4DecryptECB(encryptedHex, keyHex); const finalText = hexToStr(decryptedHex); - Java端对应实现:使用
String.getBytes(StandardCharsets.UTF_8)和new String(bytes, StandardCharsets.UTF_8)进行转换。
6.3 在线调试与验证工具
在开发过程中,可以使用一些在线工具进行交叉验证,但切勿用于生产环境真实密钥和数据的测试!
- 寻找在线的SM4加密解密工具。
- 用你的JS代码加密一段数据,然后用在线工具(如果支持SM4 ECB)使用相同密钥解密,看结果是否正确。这可以帮助你快速定位问题是出在加密端还是解密端。
最后,封装一个好的加解密工具类或模块,对外提供清晰的接口(如encryptText(keyHex, text),decryptToText(keyHex, cipherHex)),并做好错误处理和日志记录,这将大大提升代码的可用性和可维护性。记住,密码学实现无小事,细节决定成败,充分的测试是保证稳定互通的唯一途径。