SM4前后台加密实战:前端JS加密与后端Java解密全指南
2026/9/7 9:31:56 网站建设 项目流程

简介:面向需要在前端页面与后端服务之间实现国密 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 src="https://cdn.jsdelivr.net/npm/crypto-js@4.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> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.78.1</version> </dependency>

如果项目里已经有Hutool,也可以直接用Hutool的SmUtil,它底层也是BouncyCastle,但API更友好。

<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-crypto</artifactId> <version>5.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格式的密钥字符串,比如"0123456789abcdeF0123456789abcdeF"(32位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/PKCS7Padding:JDK没有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. 直接可用的完整示例

为了方便快速跑通整个流程,这里整理了一份最小可用示例的调用关系。前端用Vite+Vue3演示,后端用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本身不难,难的是把细节抠清楚,希望这份示例能帮你少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询