
简介面向需要在前端页面与后端服务之间实现国密 SM4 加密的开发者这份完整示例源码提供了从前台到后台的加解密实现同时给出 ECB 与 CBC 两种分组模式覆盖了常见业务场景下接口敏感数据的加密传输需求。ECB 模式适合数据量较小、结构简单的场景CBC 模式通过初始向量进一步提升安全性开发者可根据实际业务选择合适的模式。资源包共 2 个文件由 zip 源码压缩包与 html 演示页面组成整体仅 13KB体积小巧zip 内存放可直接运行的前后端代码html 页面用于直观展示加解密效果与调用方式解压后即可快速对照验证。代码经过实测可用前端加密、后端解密的关键逻辑清晰并配有可操作示例大幅降低了国密算法的接入门槛。目前已有 157 人学习使用适合具有一定 Web 开发基础、需要为系统引入国密加密的初中级开发者也可作为技术团队快速集成时的参考帮助减少前后台加密对接中的踩坑时间。1. 前后台加密的整体设计思路接手前后端分离项目的时候凡是涉及登录、交易、身份信息的请求直接明文传输都是埋雷。你可能会想“我有HTTPS怕什么”HTTPS解决的是传输链路被窃听的问题但到了前端日志、代理层、网关、运维抓包这些环节敏感数据照样是裸露的。所以前后台加密不是可选项而是对数据安全有要求时的必选项。SM4是国家商用密码算法中的对称分组加密标准分组长度128位密钥长度128位算法公开、性能优异在国内的金融、政务、企业信息化系统中已经被大量采用。选择一个好的加密算法只是第一步前后台各管哪一段、密钥怎么放、密文怎么传、padding怎么对齐这些细节才是决定方案能不能落地的关键。本文就以一套完整可运行的SM4前后台加解密方案为例把前端JavaScript加密、后端Java解密、联调踩坑这三个环节完整梳理一遍并提供可直接拿去改的示例源码。适合正在做前后端分离项目、被国密合规要求追着跑、或者单纯想让接口数据更安全的开发同学参考。1.1 前后台各自应该承担什么很多第一次做前后台加密的同学容易走入一个误区认为加密就是把后端代码改成“收到密文再解密”前端随便找个库把明文变成密文发过去就完事。实际情况远没有这么简单先看两个最核心的职责划分问题。第一前端只负责“加密传输”不负责“永久保密”。前端的密钥一定存在于浏览器内存或JS代码中这是无法绝对保密的。所以前端的加密目标是把数据在传输路径上保护起来防止中间环节如代理服务器、网关日志、运营商链路直接看到明文而不是构建一套不可破解的密码体系。第二后端负责“密钥持有”和“统一解密”。真正的核心密钥、解密逻辑、权限校验都应该放在后端。后端收到密文后解密再把解密后的数据用于业务处理。如果业务系统内部有多个服务之间调用服务间的鉴权和加密最好用另一套独立的内部密钥不要和对外接口的密钥混用。基于这个思路设计上建议把密钥分两层外层是前端加密使用的“对外传输密钥”可以定期轮换内层是服务间调用的“内部密钥”基本不动。对外传输密钥即使被拿到也只能解开浏览器到后端这一段的密文不会影响内部数据链路的安全性。1.2 为什么选择SM4而不是AES这是一个避不开的比较问题。AES在国外生态里非常成熟JDK原生支持网上资料一抓一大把。但SM4在国密合规、自主可控方面有天然优势很多项目尤其是金融、政务、国企有明确的国密算法合规要求。从技术指标上看SM4与AES-128的安全性都在同一水平线上SM4的分组长度是128位密钥长度也是128位采用32轮非线性迭代结构AES-128密钥长度也是128位但分组迭代轮数是10轮。两者在抗差分攻击、线性攻击方面都有充分的安全论证实际使用中性能差异也非常小。从生态支持上看SM4现在也很完善前端有crypto-js支持SM4后端Java可以通过BouncyCastle或者Hutool轻松调用OpenSSL从1.1.1开始也原生支持SM4。所以选SM4并不会让你在代码层面多受折磨反而能在合规审查时省掉一大堆麻烦。本文示例采用的就是SM4-CBC模式这是一种安全性更高的分组模式配合PKCS7填充可以处理任意长度的明文相比ECB模式最大的优势是相同的明文分组在不同位置会得到不同的密文分组不会暴露数据的重复模式。1.3 密钥管理能跑通不算本事能安全才是前后台加密实现起来不算难真正难的是密钥管理。这里必须强调一个原则生产环境千万不要把密钥硬编码在前端JS里长期使用。示例代码为了方便演示会把密钥写死在配置里但你在实际项目中至少要保证三点。第一前端密钥可以内置但要与后端配置隔离并且支持远程动态获取和定期轮换。第二即使前端密钥泄露也必须通过其他机制保障系统安全比如后端增加频率限制、设备指纹、验证码等。第三密钥长度必须是16字节128位SM4算法密钥就是128位多一位少一位都会直接报错这点后面联调的时候会反复遇到。我之前遇到一个项目同事把SM4密钥写了一个32位的字符串前端加密用crypto-js还好因为库会自动截断但后端Java的SecretKeySpec直接抛异常因为SM4只允许16字节密钥。这种“两端默认行为不一致”的问题就是后面要重点讲的典型坑。2. 前端JavaScript实现SM4加密2.1 依赖库的选择与引入前端实现SM4加密最常用的是crypto-js库。老版本crypto-js没有内置SM4支持从4.1.1版本开始crypto-js支持了SM4可以用npm直接安装。npm install crypto-js如果你用的是Vue或React项目在需要加密的模块里引入即可import CryptoJS from crypto-js;如果你是传统HTML页面也可以通过CDN引入script srchttps://cdn.jsdelivr.net/npm/crypto-js4.2.0/crypto-js.js/script这里有一个细节需要特别注意crypto-js的SM4底层实现的加密结果默认是Hex格式的字符串。但很多后端示例默认把SM4密文转成了Base64字符串。这两种编码格式一旦没对齐前端加密出来的东西后端怎么解都不对。所以前端处理密文时一定要确认好格式下面会在代码里展示怎么统一处理。2.2 前端加解密完整示例代码下面这段代码是直接在业务中验证过的SM4-CBC加解密实现密钥和IV这里先写死用于演示实际项目建议通过配置接口下发。import CryptoJS from crypto-js; // 密钥和IV必须是16字节也就是16个ASCII字符 const SM4_KEY 0123456789abcdeF; const SM4_IV fedcba9876543210; /** * SM4加密 * param {string} plainText 明文 * returns {string} Base64编码的密文 */ export function sm4Encrypt(plainText) { const key CryptoJS.enc.Utf8.parse(SM4_KEY); const iv CryptoJS.enc.Utf8.parse(SM4_IV); const encrypted CryptoJS.SM4.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 默认返回的是Hex字符串这里统一转成Base64方便后端处理 const hexStr encrypted.ciphertext.toString(); const wordArray CryptoJS.enc.Hex.parse(hexStr); return CryptoJS.enc.Base64.stringify(wordArray); } /** * SM4解密 * param {string} base64CipherText Base64编码的密文 * returns {string} 明文 */ export function sm4Decrypt(base64CipherText) { const key CryptoJS.enc.Utf8.parse(SM4_KEY); const iv CryptoJS.enc.Utf8.parse(SM4_IV); // 先转成WordArray再调用解密方法 const cipherWordArray CryptoJS.enc.Base64.parse(base64CipherText); const decrypted CryptoJS.SM4.decrypt( { ciphertext: cipherWordArray }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); }加密后的输出如果你打印出来应该是类似xJkZLdBhRrY5xG2uB0nF1A这样的Base64字符串。我特意在代码里加了把默认Hex转成Base64的逻辑原因就是后端Java那边用Base64会更顺手后面会解释。2.3 前端编码格式的几个隐藏坑第一个坑是密钥和IV的字节数。SM4要求密钥128位也就是16字节IV同样必须是16字节。很多中文字符串看起来是16个字符实际上UTF-8编码后超过16字节比如一二三四五六七八九十是10个汉字UTF-8编码后是30字节直接拿去parse再传给SM4就会出问题。所以密钥和IV建议只用ASCII字符组成例如数字加字母的16位组合。第二个坑是密文的Hex和Base64格式。crypto-js的SM4.encrypt返回对象你直接toString()拿到的是Hex但Java端如果用Base64解码两边的字节就完全对不上。常见的“前端加密正常后端解密成功但结果是乱码”现象八成就是格式不统一。第三个坑是加密前的空值和类型问题。JS是弱类型语言加密一个数字类型的值比如sm4Encrypt(12345)CryptoJS内部转字符串没问题但如果传对象、数组或者值是null/undefined加密结果就可能和预期不一致甚至直接报错。所以在调用加密方法前建议先String(plainText)或者JSON.stringify(object)统一转成字符串再加密。3. 后端Java实现SM4解密3.1 依赖引入与BouncyCastle注册Java原生JDK不支持SM4算法需要借助BouncyCastle或者使用封装好的Hutool工具类。先说BouncyCastle的方案这是最通用的做法。dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.78.1/version /dependency如果项目里已经有Hutool也可以直接用Hutool的SmUtil它底层也是BouncyCastle但API更友好。dependency groupIdcn.hutool/groupId artifactIdhutool-crypto/artifactId version5.8.25/version /dependency两种方式选哪一种如果你的项目是Spring Boot或者工具类已经引入了Hutool用Hutool最省事如果只在某个模块用不想引入大包直接引用bcprov就够了。下面示例以BouncyCastle为主毕竟这是最“纯粹”的方案可移植性最强。3.2 Java后端加解密工具类来看一个完整的SM4工具类同时包含加密和解密方法。import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.Security; import java.util.Base64; public class Sm4Util { private static final String ALGORITHM SM4; private static final String TRANSFORMATION SM4/CBC/PKCS7Padding; private static final String KEY 0123456789abcdeF; private static final String IV fedcba9876543210; static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) null) { Security.addProvider(new BouncyCastleProvider()); } } public static String encrypt(String plainText) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public static String decrypt(String cipherTextBase64) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherTextBase64)); return new String(decrypted, StandardCharsets.UTF_8); } }代码里有两个关键点。第一Cipher.getInstance(TRANSFORMATION)指定了算法、模式和填充方式。SM4/CBC/PKCS7Padding对应前端crypto-js里的mode: CBC, padding: Pkcs7两边必须完全一致少一个字母都会报NoSuchAlgorithmException。第二SecretKeySpec的构造函数里密钥字节数组长度必须是16字节UTF-8编码的英文数字字符串正好满足这个条件。有的教程会用Hex.decodeHex(key)把十六进制字符串转成字节那要求key是32个十六进制字符这两种写法不要混用。密钥和IV这里我建议统一用16字节的ASCII字符串。如果用Hutool的SmUtil.sm4(keyBytes)keyBytes也必须是16字节规则一致。3.3 与前端联调时的核心要点前后台联调时最核心的一点编码格式要完全对齐。前文已经提过前端默认输出Hex后端示例默认用Base64两边必须约定好。本文示例中前端做了Base64转换后端也用Base64解码这样就能正常联调。第二个要点是字符集。前端加密时如果传入的是中文必须保证加密前是UTF-8编码。crypto-js内部默认用UTF-8处理字符串Java端解密后用new String(decrypted, StandardCharsets.UTF_8)还原两边都是UTF-8就没有问题。如果项目里为了兼容老系统用了GBK就会乱码这时候要统一改成UTF-8或者加密前手动转码。第三个要点是解密结果的容错处理。网上有很多现成代码解密失败时直接抛异常这会在业务层造成不太友好的体验。建议在实际项目中捕获解密异常返回明确的错误码比如“数据解密失败”或“请求参数有误”避免把底层异常直接抛给前端。4. 联调中常见问题与排查技巧4.1 密文长度不一致解密出来是乱码这种情况绝大多数是Hex和Base64混用了。判断方法很简单把前端加密出来的字符串打印到控制台如果是纯0-9a-f组成的字符串说明是Hex如果包含/和末位的就是Base64。后端的解码方式要和这个格式严格对应。如果你想让调试更轻松建议在一个测试页面里同时写加密和解密先用前端的解密方法解自己加密出来的密文确认前端逻辑没问题再联调后端。这样能把问题快速锁定在前端还是后端。4.2 后端报InvalidAlgorithmParameterException或者BadPaddingException这类异常大多是密钥或IV长度不对。SM4密钥明文要求16字节即使你的密钥字符串是“1234567890abcdef123456”也会因为超过16字节而报错。另外crypto-js解析Key的方式是CryptoJS.enc.Utf8.parse(key)Java端是KEY.getBytes(StandardCharsets.UTF_8)只要两边字符串一样字节就是一样的。但如果你用了Hex格式的密钥字符串比如0123456789abcdeF0123456789abcdeF32位hex前端用Utf8.parse会把每个字符当一字节密钥变成32字节后端如果用Hex.decodeHex就会得到16字节两边秘钥不一致自然解密失败。解决办法是统一下游约定要么都用ASCII字符串加Utf8.parse要么都用Hex字符串加Hex解码千万不要混。4.3 在线工具验证联调过程中强烈建议用一个在线的SM4加解密工具来辅助排查。把密钥、IV、明文输入进去选择SM4-CBC/PKCS7生成密文然后拿这个密文去测试你的前端或者后端代码。如果在线工具能解出来的内容你的代码解不出来说明是代码细节问题如果在线工具也解不出来先检查密钥和IV的字节长度再检查模式选择。这里推荐一个验证思路先用自己写好的工具类走一遍“加密-解密”闭环再用在线工具分别验证前端加密结果和后端解密结果。这样可以快速定位是加密端出错还是解密端出错是格式不对还是模式不匹配。4.4 其他高频坑位清单NoSuchAlgorithmException: SM4/CBC/PKCS7PaddingJDK没有BouncyCastle或者注册代码没执行。检查Security.addProvider是否在静态块中被正确调用。密文是undefined表示加密时传入了undefined或非法对象先检查前端入参。解密时抛IllegalBlockSizeException密文被截断或传输过程中被URL编码改写了。前端传参时记得对Base64字符串做encodeURIComponent处理。同一段明文每次加密结果不同这是CBC模式使用随机IV的正常现象。如果IV固定同一明文加密结果相同如果使用随机IV则每次结果不同。后端解密时需要把IV和密文一起接收否则解不出来。5. 方案的扩展与安全加固建议5.1 配合SM3做完整性校验对称加密只解决机密性问题解决不了篡改问题。攻击者虽然不知道密钥无法解出明文但如果他截获了密文仍然可能把密文原样重放或篡改虽然概率很低但业务上需要防范。所以建议在SM4之外再叠加一个SM3摘要对明文或者关键参数计算摘要随密文一起传给后端后端解密后重新计算摘要并比对。如果摘要不一致直接拒绝请求。SM3是国密哈希算法输出256位摘要。在Java中用Hutool的SmUtil.sm3(plainText)即可生成前端也可以用crypto-js的SM3计算。这样等于给整个加密链路加了一道完整性保险。5.2 密钥隔离与动态轮换前端内置密钥原则上只能作为应急方案或低安全场景使用。安全要求高的系统建议采用“动态密钥SM2非对称加密”的实现方式后端生成临时SM4密钥用前端公钥SM2加密后下发前端用SM2私钥解密拿到SM4密钥再用这个会话密钥加密业务数据。这样每个会话的SM4密钥都不相同即使某一个会话被攻破也不会影响历史数据。如果暂时做不到这一层至少要让密钥做到可配置化比如放在后端的配置中心前端通过一个专门的接口拉取密钥并且支持定时轮换更新。轮换时要预留一个过渡期旧密钥在新密钥生效后保留一段时间避免正在处理中的请求因为密钥切换而失败。5.3 传输层与应用层加密的关系应用层做SM4加密并不等于传输层可以放弃HTTPS。HTTPS保护的是整个链路的传输安全包括请求头、URL参数、Cookie等敏感信息。SM4加密保护的只是你主动加密的请求体或特定字段。所以这两者应该是叠加关系而不是二选一。生产环境务必保证HTTPS是标配SM4是纵深防御的补充层。另外一个容易被忽视的点是日志安全。即使你在传输层和应用层都做了加密如果后端在打印请求参数日志时把解密后的明文打印出来数据照样会通过日志渠道泄露。建议统一采用脱敏组件对日志中的手机号、身份证号、密码、加密密钥等信息做脱敏或者在日志配置里直接禁止打印请求响应体。6. 直接可用的完整示例为了方便快速跑通整个流程这里整理了一份最小可用示例的调用关系。前端用ViteVue3演示后端用Spring Boot的Controller演示接收密文并解密。6.1 前端发送加密请求import { sm4Encrypt } from /utils/sm4; const requestData { username: admin, password: 123456 }; axios.post(/api/login, { data: sm4Encrypt(JSON.stringify(requestData)) }).then(res { console.log(login success, res.data); });这里先把业务对象序列化成JSON字符串再做SM4加密最后放在请求体的data字段里传给后端。千万不要直接把对象传给加密函数上面已经说过这可能引发不一致问题。6.2 后端接收并解密RestController RequestMapping(/api) public class LoginController { PostMapping(/login) public Result login(RequestBody LoginRequest request) { try { String plainText Sm4Util.decrypt(request.getData()); JSONObject json JSONObject.parseObject(plainText); String username json.getString(username); String password json.getString(password); // 业务逻辑校验用户名密码 return Result.success(); } catch (Exception e) { return Result.error(数据解密失败); } } }后端的LoginRequest只需要一个data字段接收前端传过来的密文。解密后的JSON字符串再交给业务逻辑处理。这种模式的好处是业务侧对加密逻辑无感知接口入参统一是密文很多中间件和网关也能更统一地做安全处理。6.3 完整项目的目录结构参考如果你要在一个实际工程里集成这套方案目录结构大概是这样的src ├── main │ ├── java │ │ └── com │ │ └── demo │ │ ├── controller │ │ │ └── LoginController.java │ │ ├── util │ │ │ └── Sm4Util.java │ │ └── Sm4Application.java │ └── resources │ └── application.yml前端目录src ├── api │ └── login.js ├── utils │ └── sm4.js └── views └── Login.vue整体结构不复杂核心就两个文件前端sm4.js、后端Sm4Util.java。只要这两个文件的密钥、IV、模式、编码格式保持一致前后台加密链路就能立刻跑通。最后分享一个我个人的习惯不论项目多急前端JS和后端Java的加解密代码写好之后一定先各写一个单元测试/自测页面用同一段明文跑一遍加密-解密闭环再去做联调。只有两端各自闭环没问题联调才可能一次性通过。别问我为什么强调这句话问就是当年拿半天时间排查一个IV大小写不一致问题的教训。SM4本身不难难的是把细节抠清楚希望这份示例能帮你少走点弯路。本文还有配套的精品资源点击获取