1. 项目概述为什么需要SM4加密传输最近在做一个前后端分离的项目后端是SpringBoot前端是Vue。项目里有个需求要求把用户在前端填写的敏感信息比如身份证号、手机号加密后再传给后端后端解密后处理处理完的结果再加密返回给前端。甲方爸爸特别强调不能用常见的AES得用国密算法SM4。这个需求听起来挺常见的但真做起来从算法选型、前后端加解密对齐到实际部署每一步都有不少细节要注意。今天我就把这个从零到一的完整实现过程包括我踩过的坑和总结的经验详细拆解一遍。无论你是刚接触国密算法的新手还是正在为前后端数据安全传输头疼的开发者这篇内容应该都能给你提供一份可以直接“抄作业”的实操指南。SM4算法是一种分组密码算法分组长度和密钥长度均为128位。和AES一样它也有ECB、CBC等不同的工作模式。在前后端分离架构下实现SM4加密传输核心目标是在不依赖HTTPS或作为HTTPS的补充的情况下确保敏感数据在传输过程中的机密性。这尤其适用于对数据安全有更高要求的内部系统、金融或政务相关应用场景。整个流程可以概括为前端Vue应用在发送数据前使用SM4算法和约定好的密钥对明文进行加密得到密文后端SpringBoot服务接收到密文后使用相同的SM4算法和密钥进行解密还原出明文进行处理反之后端返回敏感数据时也先加密前端再解密展示。2. 核心思路与方案选型背后的考量为什么是SM4而不是AES这往往是第一个要回答的问题。对于国内项目尤其是涉及政务、金融、国企等领域的系统使用国家密码管理局认定的国产商用密码算法国密算法常常是合规性要求。SM4就是国密算法中的对称加密算法其安全性和性能已经过充分验证。从技术实现角度看SM4和AES在API使用上非常相似都是分组加密都有ECB、CBC等模式所以对于开发者来说学习成本和迁移成本并不高。选择SM4既是满足特定监管要求也是对国产密码技术的支持。接下来是工作模式的选择。SM4常见的有ECB和CBC模式。ECB模式简单同一明文块加密后永远得到相同的密文块但缺乏扩散性如果数据有规律可能会在密文中暴露出模式安全性较弱一般不建议用于加密大量或有规律的数据。CBC模式引入了初始化向量使得即使相同的明文块加密后的密文也不同安全性更高是更推荐的选择。在我们的前后端传输场景中数据包通常不大但为了更高的安全性我决定采用CBC模式。这就需要前后端协商好一个共同的初始化向量。这个IV不需要像密钥那样绝对保密但为了安全每次通信最好使用不同的IV。一种常见的做法是前端随机生成一个IV将其和密文一起传输给后端例如将IV拼接在密文前后端先分离出IV再用它和密钥去解密。关于密钥管理这是一个比算法实现更关键的安全问题。绝对不能把密钥硬编码在前端代码里因为前端代码对用户是透明的这相当于把钥匙放在了门口。一个更安全的实践是后端在用户登录成功后动态生成一个临时的会话密钥或者叫“数据加密密钥”通过HTTPS通道安全地下发给前端。前端用这个临时密钥来加密本次会话的请求数据。这个临时密钥可以有过期时间从而进一步提升安全性。在本文的示例中为了聚焦于加解密本身的实现我们会假设一个前后端预先约定好的固定密钥但在实际生产环境中务必采用动态密钥分发的方案。最后是前后端加解密库的选型。后端Java生态中BouncyCastle是一个强大的密码学提供者完美支持SM4。前端JavaScript方面虽然原生不支持SM4但我们可以使用sm-crypto这个优秀的国密算法库。确保前后端使用相同的工作模式如CBC、填充方式如PKCS7和编码格式如Base64是实现互通的关键。3. 后端SpringBoot实现构建稳固的解密服务后端的任务是提供一个接收密文、解密处理、再加密返回的API。我们首先来搭建环境。3.1 环境准备与依赖引入创建一个标准的SpringBoot项目。在pom.xml中我们需要引入BouncyCastle的依赖来提供SM4算法实现。dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15on/artifactId version1.70/version !-- 请使用最新稳定版 -- /dependencyBouncyCastle是一个开源的密码学库它扩展了Java标准库的密码学功能使我们能够使用SM2、SM3、SM4等国密算法。3.2 核心工具类SM4加解密实现这是后端最核心的部分。我们将创建一个Sm4Util工具类封装加密和解密方法。这里采用CBC模式PKCS7填充在Java中PKCS7填充通常由BC库映射到PKCS5Padding因为块大小都是16字节。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.util.Base64; public class Sm4Util { // 算法名称SM4模式CBC填充PKCS7 (在Java中通常指定为PKCS5Padding) private static final String ALGORITHM_NAME SM4; private static final String TRANSFORMATION SM4/CBC/PKCS5Padding; private static final String PROVIDER_NAME BC; static { // 静态代码块确保BouncyCastle提供者被注册到JVM中 Security.addProvider(new BouncyCastleProvider()); } /** * SM4加密 (CBC模式) * param data 待加密的明文 * param key 密钥16字节长度128位 * param iv 初始化向量16字节长度 * return Base64编码后的密文 */ public static String encrypt(String data, String key, String iv) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, PROVIDER_NAME); SecretKeySpec secretKeySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); IvParameterSpec ivParameterSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * SM4解密 (CBC模式) * param encryptedData Base64编码的密文 * param key 密钥16字节长度128位 * param iv 初始化向量16字节长度 * return 解密后的明文 */ public static String decrypt(String encryptedData, String key, String iv) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, PROVIDER_NAME); SecretKeySpec secretKeySpec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); IvParameterSpec ivParameterSpec new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes Base64.getDecoder().decode(encryptedData); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } }注意密钥和IV的长度。SM4的密钥必须是16字节128位。IV在CBC模式下也必须是16字节。如果你的密钥字符串长度不是16字节需要设计一个密钥派生函数如使用SM3对密码进行哈希来生成固定长度的密钥但最简单的方式是直接使用一个16字符的字符串。iv同理。3.3 构建控制器与数据传输对象接下来我们创建一个REST控制器来接收前端的加密请求。为了清晰我们定义一个请求体对象EncryptedRequest和一个响应体对象EncryptedResponse。// EncryptedRequest.java Data // 使用Lombok注解简化getter/setter public class EncryptedRequest { private String data; // 前端传过来的密文 private String iv; // 前端传过来的IV如果采用IV随密文传输的方案 } // EncryptedResponse.java Data public class EncryptedResponse { private boolean success; private String data; // 加密后的响应数据 private String iv; // 本次响应使用的IV可选 private String message; }然后创建控制器SecureDataController。这里演示一个简单的场景前端加密一个用户信息JSON后端解密后处理例如打印日志然后再加密一个结果返回。RestController RequestMapping(/api/secure) public class SecureDataController { // 这里为了演示将密钥和IV硬编码。生产环境必须从安全配置中心获取 private static final String SECRET_KEY “0123456789abcdef”; // 16位密钥 private static final String FIXED_IV “1234567890abcdef”; // 16位IV (如果使用固定IV) PostMapping(/process) public EncryptedResponse processEncryptedData(RequestBody EncryptedRequest request) { EncryptedResponse response new EncryptedResponse(); try { // 1. 解密前端传来的数据 // 假设前端将IV和密文一起传来我们使用请求中的IV String decryptedData Sm4Util.decrypt(request.getData(), SECRET_KEY, request.getIv()); System.out.println(“解密后的数据” decryptedData); // 2. 业务逻辑处理 (这里只是示例将解密数据转为对象) // 假设解密后是一个JSON字符串例如{name:张三,idCard:110101...“} ObjectMapper mapper new ObjectMapper(); UserInfo userInfo mapper.readValue(decryptedData, UserInfo.class); // ... 这里执行你的业务逻辑比如保存到数据库 ... // 3. 构造返回给前端的明文结果 MapString, Object resultMap new HashMap(); resultMap.put(“status”, “success”); resultMap.put(“receivedName”, userInfo.getName()); resultMap.put(“processTime”, LocalDateTime.now().toString()); String plainResponse mapper.writeValueAsString(resultMap); // 4. 加密返回结果 // 注意返回的IV可以是新生成的也可以复用请求的IV。这里生成一个新的IV更安全。 String newIv generateRandomIv(); // 生成一个16字节的随机IV String encryptedResponseData Sm4Util.encrypt(plainResponse, SECRET_KEY, newIv); // 5. 设置响应 response.setSuccess(true); response.setData(encryptedResponseData); response.setIv(newIv); // 将新的IV返回给前端用于解密 response.setMessage(“处理成功”); } catch (Exception e) { e.printStackTrace(); response.setSuccess(false); response.setMessage(“数据处理失败” e.getMessage()); } return response; } // 生成一个随机的16字节IV (Base64编码后的字符串) private String generateRandomIv() { byte[] ivBytes new byte[16]; new SecureRandom().nextBytes(ivBytes); return Base64.getEncoder().encodeToString(ivBytes); } }实操心得IV的处理策略。上面的代码展示了两种IV处理方式。在processEncryptedData方法中解密时使用了前端传来的IVrequest.getIv()这要求前端在加密时生成IV并随密文一起传输。加密返回数据时我生成了一个新的随机IVgenerateRandomIv()并传给前端。这种做法更安全因为每次响应的IV都不同。你也可以约定一个固定的IV但随机IV能更好地抵抗重放攻击。关键点是加密和解密必须使用同一个IV。4. 前端Vue实现构建安全的加密请求层前端的工作是在数据发送前进行加密并在收到响应后解密。我们将使用sm-crypto库。4.1 安装依赖与工具函数封装首先在你的Vue项目中安装sm-crypto。npm install sm-crypto --save然后我们创建一个工具文件src/utils/sm4.js封装加密解密函数确保与后端参数对齐。import { sm4 } from ‘sm-crypto’; // 密钥必须为16字节的十六进制字符串。与后端保持一致。 const SECRET_KEY ‘0123456789abcdef’; // 注意sm-crypto要求密钥是16进制字符串 // 如果后端密钥是普通字符串需要先转成16进制例如 // const keyStr ‘0123456789abcdef’; // const SECRET_KEY Buffer.from(keyStr, ‘utf8’).toString(‘hex’); /** * SM4加密 (CBC模式) * param {string} plainText - 待加密的明文 * param {string} iv - 初始化向量16字节的十六进制字符串 * returns {string} Base64编码的密文 */ export function encrypt(plainText, iv) { // sm4.encrypt 参数 (明文数据 密钥 配置对象) // 配置对象{mode: ‘cbc’, iv: iv, output: ‘base64’} // 注意sm-crypto的encrypt方法默认输入输出都是16进制字符串通过output参数指定base64 const cipherText sm4.encrypt(plainText, SECRET_KEY, { mode: ‘cbc’, iv: iv, output: ‘base64’ // 指定输出为base64方便网络传输 }); return cipherText; } /** * SM4解密 (CBC模式) * param {string} cipherTextBase64 - Base64编码的密文 * param {string} iv - 初始化向量16字节的十六进制字符串 * returns {string} 解密后的明文 */ export function decrypt(cipherTextBase64, iv) { // sm4.decrypt 参数 (密文数据 密钥 配置对象) // 注意密文输入是base64所以需要指定input为‘base64’ const plainText sm4.decrypt(cipherTextBase64, SECRET_KEY, { mode: ‘cbc’, iv: iv, input: ‘base64’, // 指定输入为base64 output: ‘string’ // 指定输出为普通字符串 }); return plainText; } /** * 生成一个随机的16字节IV并返回其16进制字符串形式 * returns {string} 16字节的16进制IV */ export function generateRandomIv() { const array new Uint8Array(16); window.crypto.getRandomValues(array); // 使用Web Crypto API生成强随机数 return Array.from(array, byte byte.toString(16).padStart(2, ‘0’)).join(‘’); } /** * 将普通UTF-8字符串转换为16进制字符串用于处理非16进制格式的密钥 * param {string} str * returns {string} */ export function stringToHex(str) { return Buffer.from(str, ‘utf8’).toString(‘hex’); }踩坑记录密钥和IV的格式问题。这是前后端联调时最容易出错的地方sm-crypto库的sm4.encrypt/decrypt方法默认期望密钥和IV是16进制字符串。而后端我们的Sm4Util使用的是普通的UTF-8字符串字节。如果不统一加解密必然失败。有两种解决方案1) 前后端都使用16进制字符串作为密钥和IV推荐更标准。2) 前端在加密前将普通字符串密钥通过stringToHex函数转换。上面的工具函数中SECRET_KEY直接写成了16进制字符串。请确保后端也使用相同的16进制字符串或者将后端的密钥字符串转换为对应的字节。务必在项目启动时就统一好密钥和IV的格式与值。4.2 集成Axios拦截器实现自动加解密我们不想在每个请求里手动调用加密函数。更优雅的方式是使用Axios的请求和响应拦截器对特定接口的数据进行自动加解密。首先在src/utils/request.js或你的Axios实例配置文件中import axios from ‘axios’; import { encrypt, decrypt, generateRandomIv } from ‘/utils/sm4’; // 导入工具函数 // 创建axios实例 const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }); // 请求拦截器 service.interceptors.request.use( config { // 判断是否需要加密。可以给需要加密的请求加一个自定义标记例如 headers[‘X-Encrypt’] true // 或者根据URL路径判断。这里假设我们给config添加一个自定义属性_needEncrypt if (config._needEncrypt) { // 1. 生成随机IV const iv generateRandomIv(); // 将IV保存在config中供响应拦截器使用或者可以将其放入请求头 config._iv iv; // 2. 加密请求数据。假设数据在config.data中且是JSON对象 if (config.data typeof config.data ‘object’) { const plainText JSON.stringify(config.data); const encryptedData encrypt(plainText, iv); // 用加密后的数据替换原data。通常我们需要将IV和密文一起传给后端。 // 一种常见格式 { data: encryptedData, iv: iv } config.data { data: encryptedData, iv: iv }; // 同时可能需要修改Content-Type因为现在发送的是一个包含两个字段的对象 config.headers[‘Content-Type’] ‘application/json’; } } return config; }, error { return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response { const config response.config; const res response.data; // 假设后端返回的数据结构为 { success, data, iv, message } // 判断响应是否需要解密 if (config._needEncrypt res.success) { const encryptedResponseData res.data; const responseIv res.iv; // 后端返回的IV if (encryptedResponseData responseIv) { try { const decryptedText decrypt(encryptedResponseData, responseIv); // 将解密后的字符串解析为JSON对象替换掉原始的response.data response.data { ...res, data: JSON.parse(decryptedText) // 替换为解密后的业务数据 }; } catch (e) { console.error(‘响应解密失败’, e); // 可以在这里统一处理解密错误例如跳转到错误页 } } } return response; }, error { return Promise.reject(error); } ); export default service;4.3 在组件中发起加密请求现在在Vue组件中发起请求就非常简洁了。只需要在调用Axios时标记这个请求需要加密即可。template div form submit.prevent“submitForm” input v-model“form.name” placeholder“姓名” / input v-model“form.idCard” placeholder“身份证号” / button type“submit”提交加密传输/button /form div v-if“result”处理结果{{ result }}/div /div /template script import request from ‘/utils/request’; export default { data() { return { form: { name: ‘’, idCard: ‘’ }, result: null }; }, methods: { async submitForm() { try { // 发起请求通过自定义属性_needEncrypt标记需要加密 const response await request({ url: ‘/api/secure/process’, method: ‘post’, data: this.form, // 这是原始的明文数据 _needEncrypt: true // 关键标记此请求需要加密 }); if (response.data.success) { // response.data.data 已经是响应拦截器解密后的业务数据对象了 this.result response.data.data; console.log(‘解密后的响应数据’, this.result); } else { this.$message.error(response.data.message); } } catch (error) { console.error(‘请求失败’, error); this.$message.error(‘网络或服务异常’); } } } }; /script这样我们就实现了一个完整的、对开发者透明的SM4加密传输流程。前端开发者只需要关心业务数据加解密工作由拦截器自动完成。5. 联调测试、常见问题与性能优化前后端代码都写好了接下来就是联调和上线。这个阶段会遇到最多的问题。5.1 联调测试步骤与工具单元测试先行分别对后端的Sm4Util和前端的sm4.js工具函数进行单元测试使用相同的密钥、IV和明文确保各自加解密正确。可以写一个简单的测试用例比如明文是{“test”: “hello”}密钥是0123456789abcdef16进制IV是1234567890abcdef16进制看前后端加密结果是否一致解密后是否能还原。使用Postman测试后端接口暂时关闭前端拦截器用Postman直接调用后端接口。手动构造一个加密请求体。你可以先写一个简单的Java或Node.js脚本用相同的参数加密一段数据然后将密文和IV作为{data: “...”, “iv”: “...”}发给后端看能否正确解密并返回加密响应。这是隔离前后端问题的最有效方法。开启前端加密进行集成测试前后端服务启动在前端页面操作通过浏览器开发者工具的Network面板观察。你应该看到请求Payload是一个包含data和iv字段的对象data是长长的Base64密文。响应Payload也是一个包含data、iv、success等字段的对象其中的data也是密文。检查前端控制台看解密后的业务数据是否正确显示。5.2 常见问题排查表问题现象可能原因排查步骤与解决方案后端解密失败报javax.crypto.BadPaddingException1. 前后端密钥不一致。2. 前后端IV不一致。3. 密文在传输过程中被篡改或编码错误。4. 工作模式或填充方式不匹配。1.核对密钥和IV确保前后端使用的密钥和IV字符串完全一致包括长度和字符。特别注意sm-crypto要求16进制字符串而后端Java可能是普通字符串。使用System.out.println和console.log打印并对比。2.检查编码确保前端加密后输出Base64后端解密前对Base64进行解码。网络传输中确保Base64字符串没有换行或空格。3.确认算法参数前后端必须同时使用SM4/CBC/PKCS5PaddingJava和{mode: ‘cbc’, input/output: ‘base64’}JS。前端解密响应失败1. 用于解密的IV不对不是后端返回的那个IV。2. 响应密文格式错误或损坏。3. 前端解密函数配置错误。1. 检查响应拦截器确认是从响应体的正确字段如res.iv获取IV。2. 在响应拦截器的catch中打印错误和原始响应数据确认数据结构是否符合预期。3. 确认前端的decrypt函数参数顺序和配置对象正确。加解密结果前后端不一致除了上述密钥、IV、模式问题外还可能是因为数据本身格式。确保加密前的明文字符串完全一致。例如JSON字符串中的空格、缩进、属性顺序都可能影响最终的字符串值。建议在加密前对JSON对象使用JSON.stringify(obj)无空格进行序列化确保前后端序列化结果一致。性能问题感觉加密后请求变慢SM4加解密是CPU密集型操作对于大量数据或高并发场景会有性能开销。1.性能测试对一段1KB、10KB、100KB的数据进行加解密测量耗时。通常对于表单类小数据几KB开销在毫秒级可接受。2.选择性加密并非所有数据都需要加密。只对真正的敏感字段如身份证、手机、银行卡号进行加密其他非敏感字段明文传输。3.考虑HTTPS如果全链路已使用HTTPS且安全要求不是极端高可以评估是否仍需应用层加密避免过度设计。5.3 性能优化与安全增强建议选择性加密最重要不要盲目加密所有请求。只为包含敏感信息的请求如登录、支付、个人信息修改添加加密标记。这能显著减少不必要的性能损耗和服务端压力。可以在Axios拦截器中通过URL白名单或请求头标记来控制。密钥动态管理如前所述硬编码密钥是安全隐患。实现一个简单的密钥协商流程用户登录后后端生成一个随机的会话加密密钥通过HTTPS通道下发给前端可以放在登录接口的响应里。前端将该密钥存储在内存或安全的存储中如Vuex用于本次会话的后续数据加密。会话过期后密钥失效。使用Web Workers如果加密大量数据如上传加密文件加密操作可能会阻塞前端主线程导致页面卡顿。可以考虑使用Web Worker在后台线程进行加解密操作。服务端缓存解密结果对于一些频繁请求且数据不变的非敏感信息后端可以在解密后将其缓存起来避免重复的解密操作。但要注意缓存的安全性和有效性。监控与告警在服务端监控解密失败的频率。如果短时间内出现大量解密失败请求可能是遭到了攻击或前端版本不一致应及时告警。6. 项目总结与延伸思考实现一套完整的前后端SM4加密传输核心难点其实不在于算法调用本身而在于确保前后端环境在算法参数上的绝对一致以及设计一套安全且高效的密钥管理方案。通过拦截器我们将加解密的复杂性从业务代码中剥离让开发者能更专注于业务逻辑这是一个非常实用的架构模式。我个人在几个项目中实践下来最大的体会是联调阶段的“对齐”工作至关重要。建议在技术方案设计阶段就由后端或架构师出一份《加解密对接文档》明确写出以下内容并让前后端负责人确认算法标准SM4。工作模式CBC。填充方式PKCS7前端叫PKCS7Java叫PKCS5Padding但本质相同。密钥格式16字节明确是普通UTF-8字符串还是16进制字符串。强烈建议统一使用16进制字符串避免编码歧义。IV格式16字节是否随机生成如何传递放请求体/请求头。数据格式加密前明文是否为JSON字符串加密后密文是否Base64编码。接口协议请求/响应体中data、iv等字段的名称和结构。把这份文档作为联调的“宪法”能节省大量的排查时间。最后必须再次强调应用层的加密如本文的SM4不能替代传输层的加密HTTPS/SSL。它们的关系是互补的。HTTPS保证了数据在传输过程中不被窃听和篡改而应用层加密提供了“端到端”的安全即使数据到达后端服务器或中间经过的网关、日志系统在没有密钥的情况下也无法被解读这尤其适用于对内部数据安全有极高要求的场景。在实际项目中通常建议在HTTPS的基础上对核心敏感数据再进行一层应用层加密实现纵深防御。这个方案已经可以直接用于大多数需要国密加密的中后台系统。你可以根据自己项目的安全等级决定是否引入动态密钥、是否结合HTTPS证书绑定等更高级的特性。希望这篇详细的拆解能帮你避开我当年踩过的那些坑。