
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 CastleBC密码库。它是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 srchttps://unpkg.com/sm-cryptolatest/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 groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.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 00112233445566778899aabbccddee15字节。长文本先将一段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里有转换工具检查算法参数确认两端都是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时需要使用IvParameterSpeccipher.init(mode, secretKeySpec, new IvParameterSpec(ivBytes))。安全提醒同一个密钥下绝对不要重复使用相同的IV。6.2 处理非Hex格式的输入文本、Base64实际业务中数据可能不是Hex格式。你需要一个统一的预处理层。场景加密普通文本字符串约定编码两端统一使用UTF-8编码将字符串转换为字节数组。转换步骤加密前文本 - UTF-8字节数组 - Hex字符串 - 调用上述加密函数。解密后获取Hex明文 - Hex字符串转字节数组 - UTF-8解码 - 文本。工具函数示例JavaScriptfunction 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)并做好错误处理和日志记录这将大大提升代码的可用性和可维护性。记住密码学实现无小事细节决定成败充分的测试是保证稳定互通的唯一途径。