Vue2与SpringBoot集成SM2国密算法实现前后端双向加密通信
1. 项目概述与背景
最近在做一个对数据安全要求比较高的内部管理系统,前端用的是Vue2,后端是SpringBoot。客户明确要求,所有敏感数据在传输过程中必须使用国密算法进行加密,点名要用SM2。一开始我也觉得头大,毕竟平时RSA、AES用得顺手,SM2这套体系接触不多。但真正上手后发现,只要把几个关键点打通,从零构建一套前后端SM2双向加密通信的机制,并没有想象中那么复杂。所谓“双向加密”,简单说就是前端用后端的公钥加密数据传给后端,后端用自己的私钥解密;反过来,后端也可以用前端的公钥加密响应数据,前端用自己的私钥解密。这样,即便传输链路被监听,看到的也是一堆乱码,有效防止了数据在传输过程中的泄露风险。这篇文章,我就把自己从零搭建这套系统的完整过程、踩过的坑以及核心代码实现,毫无保留地分享出来,无论你是前端Vue2开发者还是后端SpringBoot工程师,都能找到清晰的路径。
2. 核心思路与方案选型
2.1 为什么选择SM2而非RSA/AES?
在项目初期,团队内部有过讨论:既然要加密,用成熟的RSA非对称加密+AES对称加密组合不行吗?为什么非得用SM2?这里涉及几个核心考量。
首先,是合规性要求。在一些涉及金融、政务、关键基础设施的领域,使用国家密码管理局认定的商用密码算法(国密算法)是硬性规定。SM2属于国密标准中的非对称加密算法,相当于国际上的ECC(椭圆曲线密码学)。使用它,项目在验收和审计时能直接满足合规门槛。
其次,是安全性与性能的平衡。在同等安全强度下,SM2(基于ECC)所需的密钥长度(通常256位)远小于RSA(通常需要2048位甚至更长)。这意味着SM2的加密解密速度更快,生成的密文更短,对网络传输和存储更友好。对于需要高频次加密通信的前后端应用,性能优势明显。
最后,是生态支持。以前国密算法生态不完善,现在无论是前端JavaScript库(如sm-crypto),还是后端Java库(如Bouncy Castle、Hutool),对SM2的支持都已经相当成熟和稳定,集成成本大大降低。
基于以上三点,我们确定了技术栈:Vue2前端集成sm-crypto进行SM2加密解密,SpringBoot后端使用Hutool工具包中的国密算法模块进行处理。这套组合经过我们实测,在开发效率和运行稳定性上表现都不错。
2.2 双向加密通信流程设计
单向加密(例如只加密密码)很简单,后端生成一对密钥,把公钥给前端,前端加密后传回即可。但双向加密要求通信双方都持有自己的密钥对,并能获取对方的公钥。其核心流程设计如下:
- 密钥对生成与分发:后端启动时,在内存或安全的配置中心生成自己的SM2密钥对(公钥
serverPublicKey,私钥serverPrivateKey)。同时,前端(或由后端代为)生成自己的SM2密钥对(公钥clientPublicKey,私钥clientPrivateKey)。前端需要将clientPublicKey安全地发送给后端进行注册或存储。后端则需要将serverPublicKey下发给前端。 - 前端加密请求:前端在发送敏感请求(如登录、提交表单)前,使用后端的公钥
serverPublicKey对请求体(或特定字段)进行SM2加密,生成密文。 - 后端解密与处理:后端接收到加密请求后,使用自己的私钥
serverPrivateKey对密文进行解密,得到原始明文数据,再进行业务逻辑处理。 - 后端加密响应:后端在处理完业务后,如果需要返回敏感数据(如用户手机号、余额),则使用前端的公钥
clientPublicKey对该部分数据进行SM2加密。 - 前端解密响应:前端收到加密响应后,使用自己的私钥
clientPrivateKey进行解密,获取明文数据并渲染。
这个流程确保了请求和响应双向的敏感信息都得到了非对称加密保护。需要注意的是,SM2加密有长度限制,不适合直接加密超长文本。通常的做法是:前端用SM2加密一个随机生成的AES密钥(即“会话密钥”),再用这个AES密钥去加密实际的请求体。后端解密得到AES密钥后,再用它解密请求体。响应亦然。这种“SM2+AES”的混合加密模式兼具了非对称加密的安全性和对称加密的效率,是实际工程中的标准做法。本文为简化演示,先聚焦于纯SM2对短数据的加解密,理解了核心,混合模式便水到渠成。
3. 前端Vue2集成SM2加密
3.1 环境准备与库选型
前端我们使用Vue2框架。首先,需要选择一个可靠的SM2 JavaScript库。经过调研,sm-crypto这个库口碑不错,它纯JavaScript实现,不依赖任何原生模块,支持UMD、CommonJS、ES Module等多种引入方式,且专门针对国密算法进行了优化。
在项目根目录下,通过npm安装:
npm install sm-crypto --save安装完成后,你可以在package.json的dependencies中看到它。这里有个小坑需要注意:sm-crypto的主版本号更新可能带来API变化。我们项目锁定在0.3.2版本,这个版本稳定且API清晰。建议你也先锁定一个稳定版本,避免后续升级带来意外问题。
除了库本身,我们还需要一个地方来安全地管理前端的密钥对。绝对不要将私钥硬编码在源码里或提交到版本库。我们的做法是:在首次访问时,由前端动态生成密钥对,并将公钥通过初始化的安全通道(例如,在用户登录认证后建立的HTTPS连接中)发送给后端注册。私钥则保存在浏览器的sessionStorage中,生命周期仅为一次会话。这样即使关闭浏览器,私钥也会清除,相对安全。对于安全性要求更高的场景,可以考虑使用Web Crypto API配合后端来生成和托管密钥,但复杂度会显著增加。
3.2 核心工具类封装
为了在项目中优雅地使用SM2,我们封装一个独立的工具类sm2Utils.js,放在src/utils/目录下。
// src/utils/sm2Utils.js import { sm2 } from 'sm-crypto'; const Sm2Utils = { // SM2加密模式,推荐使用C1C3C2模式(对应sm-crypto的加密方式1) cipherMode: 1, /** * 生成SM2密钥对 * @returns {Object} {privateKey, publicKey} */ generateKeyPair() { // sm2.generateKeyPairHex() 返回的是16进制字符串格式的密钥对 const keyPair = sm2.generateKeyPairHex(); return { privateKey: keyPair.privateKey, // 64字节长度的16进制私钥 publicKey: keyPair.publicKey, // 130字节长度(04开头)的16进制公钥 }; }, /** * 使用公钥加密数据 * @param {String} plainText - 待加密的明文(字符串) * @param {String} publicKey - 公钥(16进制字符串) * @returns {String} 加密后的密文(16进制字符串) */ encrypt(plainText, publicKey) { // 注意:sm2.doEncrypt默认输出为16进制字符串,公钥需要是04开头的130位16进制 // cipherMode为1代表使用C1C3C2顺序的国标格式 const encryptData = sm2.doEncrypt(plainText, publicKey, this.cipherMode); return encryptData; }, /** * 使用私钥解密数据 * @param {String} cipherTextHex - 加密后的密文(16进制字符串) * @param {String} privateKey - 私钥(16进制字符串) * @returns {String} 解密后的明文 */ decrypt(cipherTextHex, privateKey) { // 解密,cipherMode需与加密时一致 const decryptData = sm2.doDecrypt(cipherTextHex, privateKey, this.cipherMode); return decryptData; }, /** * 验证公钥格式(简单校验) * @param {String} publicKey * @returns {Boolean} */ isValidPublicKey(publicKey) { return publicKey && publicKey.startsWith('04') && publicKey.length === 130; } }; export default Sm2Utils;封装这个工具类有几个好处:一是统一了加密模式和参数,避免在业务代码中散落着不同的配置;二是提供了密钥格式的简单校验;三是方便未来更换SM2实现库或调整策略,只需修改这个文件即可。
注意:
sm-crypto的doEncrypt和doDecrypt方法默认处理的是字符串。如果你的数据是对象,需要先JSON.stringify()成字符串再加密。解密后得到字符串,也需要JSON.parse()还原成对象。
3.3 在Vue组件中应用加密
假设我们有一个用户登录的场景。首先,在应用初始化(比如在根组件App.vue的created钩子或路由守卫中),我们需要生成并注册前端密钥对。
// 在某个全局初始化逻辑中,例如 src/main.js 或一个专门的auth模块 import Sm2Utils from '@/utils/sm2Utils'; import axios from 'axios'; // 1. 生成前端密钥对 const clientKeyPair = Sm2Utils.generateKeyPair(); // 将私钥存入sessionStorage,公钥准备发送给后端 sessionStorage.setItem('sm2_private_key', clientKeyPair.privateKey); const clientPublicKey = clientKeyPair.publicKey; // 2. 将前端公钥注册到后端 // 假设有一个专门的API接口 /api/registerClientPubKey axios.post('/api/registerClientPubKey', { clientId: 'unique_frontend_identifier', // 需要一个唯一标识,可以是用户ID或设备ID publicKey: clientPublicKey }).then(response => { console.log('前端公钥注册成功'); // 3. 获取后端的公钥并存储 const serverPublicKey = response.data.serverPublicKey; sessionStorage.setItem('sm2_server_public_key', serverPublicKey); }).catch(error => { console.error('密钥注册失败:', error); // 这里应有降级或错误处理逻辑,例如提示用户或使用备用方案 });接下来,在登录组件中,我们使用后端的公钥来加密密码。
<template> <div> <input v-model="username" placeholder="用户名"> <input v-model="password" type="password" placeholder="密码"> <button @click="handleLogin">登录</button> </div> </template> <script> import Sm2Utils from '@/utils/sm2Utils'; import axios from 'axios'; export default { data() { return { username: '', password: '' }; }, methods: { async handleLogin() { // 1. 获取后端公钥 const serverPublicKey = sessionStorage.getItem('sm2_server_public_key'); if (!serverPublicKey || !Sm2Utils.isValidPublicKey(serverPublicKey)) { this.$message.error('加密服务未就绪,请刷新页面'); return; } // 2. 构建登录请求数据对象 const loginData = { username: this.username, password: this.password, // 明文密码,即将被加密 timestamp: Date.now() }; // 3. 将整个数据对象转为字符串并加密 const plainText = JSON.stringify(loginData); let encryptedData; try { encryptedData = Sm2Utils.encrypt(plainText, serverPublicKey); } catch (error) { console.error('加密失败:', error); this.$message.error('数据加密失败'); return; } // 4. 发送加密后的数据 try { const response = await axios.post('/api/login', { encryptedData: encryptedData // 将密文作为字段发送 }); // 假设后端返回的敏感数据也是加密的 const encryptedUserInfo = response.data.encryptedUserInfo; if (encryptedUserInfo) { // 5. 使用前端私钥解密响应 const clientPrivateKey = sessionStorage.getItem('sm2_private_key'); const decryptedInfoStr = Sm2Utils.decrypt(encryptedUserInfo, clientPrivateKey); const userInfo = JSON.parse(decryptedInfoStr); console.log('登录成功,用户信息:', userInfo); // ... 后续处理,如存入Vuex、跳转页面等 } } catch (error) { console.error('登录请求失败:', error); // 处理错误,注意解密错误和后端业务错误要区分 if (error.response && error.response.data && error.response.data.errorCode === 'DECRYPT_FAILED') { this.$message.error('数据解密异常,请重试'); } else { this.$message.error(error.response?.data?.message || '登录失败'); } } } } }; </script>这段代码清晰地展示了双向加密的完整前端流程:注册密钥、加密请求、解密响应。在实际项目中,你可以将加密逻辑进一步封装到axios的请求拦截器中,实现自动加密所有POST/PUT请求的data,以及自动解密响应data中的特定加密字段,这样业务代码就无需关心加解密细节了。
4. 后端SpringBoot集成SM2解密与加密
4.1 引入国密算法支持库
SpringBoot后端我们选择使用Hutool工具包,它提供了对国密算法的友好封装,API简洁易懂。在pom.xml中添加依赖:
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.16</version> <!-- 请使用最新稳定版 --> </dependency> <!-- Hutool的加密模块依赖Bouncy Castle提供者 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.72</version> </dependency>Hutool的cn.hutool.crypto.asymmetric.SM2类封装了SM2的操作。Bouncy Castle是一个强大的密码学提供者,Hutool底层依赖它来实现国密算法。
接下来,我们需要一个配置类来初始化后端的SM2密钥对,并将其作为一个Bean注入Spring容器,方便全局使用。
import cn.hutool.core.codec.Base64; import cn.hutool.crypto.SecureUtil; import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.asymmetric.SM2; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.security.KeyPair; @Configuration public class Sm2Config { /** * 服务器SM2密钥对Bean。 * 实际生产环境,私钥应从安全的密钥管理系统(如KMS)获取,而非硬编码或放在配置文件中。 */ @Bean(name = "serverSm2KeyPair") public KeyPair serverSm2KeyPair() { // Hutool 工具类生成SM2密钥对 return SecureUtil.generateKeyPair("SM2"); } /** * 服务器SM2加密解密工具Bean。 * 使用上面生成的密钥对进行初始化。 */ @Bean(name = "serverSm2") public SM2 serverSm2(KeyPair serverSm2KeyPair) { // 初始化SM2对象,使用服务器密钥对 SM2 sm2 = new SM2(serverSm2KeyPair.getPrivate(), serverSm2KeyPair.getPublic()); // 设置使用标准C1C3C2密文顺序(与前端sm-crypto的mode 1对应) sm2.setMode(cn.hutool.crypto.asymmetric.SM2.Mode.C1C3C2); return sm2; } /** * 获取服务器公钥(Base64编码),用于下发给前端。 */ public String getServerPublicKeyBase64(KeyPair serverSm2KeyPair) { byte[] publicKeyBytes = serverSm2KeyPair.getPublic().getEncoded(); return Base64.encode(publicKeyBytes); } }这里有几个关键点:
- 密钥存储安全:示例中在内存生成密钥对。生产环境中,私钥是最高机密,绝不能写在代码或配置文件中。应该从硬件安全模块(HSM)、云密钥管理服务(KMS)或经过严格权限控制的配置中心动态获取。
- 模式对齐:
sm2.setMode(SM2.Mode.C1C3C2)这行至关重要,它必须与前端的sm-crypto库使用的加密模式(我们之前设置的cipherMode: 1)保持一致,否则解密会失败。 - 公钥格式:
getServerPublicKeyBase64方法将公钥编码为Base64字符串,方便通过网络传输。前端sm-crypto需要的是16进制(Hex)格式,所以后端下发时可能需要转换,或者前端接收Base64后再转Hex。为了简化,我们可以在后端直接生成16进制公钥字符串。Hutool的SM2对象可以通过sm2.getPublicKeyBase64()或sm2.getPublicKey()获取不同格式的公钥信息,但要注意其getPublicKey()返回的是PublicKey对象,不是Hex字符串。一个更直接的方法是使用Hutool的KeyUtil来获取Hex:
import cn.hutool.crypto.KeyUtil; // ... String publicKeyHex = KeyUtil.getKeyString(serverSm2KeyPair.getPublic()); // 获取16进制公钥字符串4.2 设计加密通信API接口
我们设计两个核心接口:
GET /api/serverPublicKey:用于前端获取后端公钥。POST /api/login:处理前端加密后的登录请求,并返回加密后的响应。
首先,创建一个控制器AuthController:
import cn.hutool.core.codec.Base64; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.asymmetric.SM2; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.web.bind.annotation.*; import java.security.KeyPair; import java.security.PublicKey; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @RestController @RequestMapping("/api") @Slf4j public class AuthController { @Autowired @Qualifier("serverSm2") private SM2 serverSm2; // 服务器SM2工具 @Autowired @Qualifier("serverSm2KeyPair") private KeyPair serverSm2KeyPair; // 内存存储客户端公钥,key为客户端ID。生产环境应使用Redis等持久化存储。 private final Map<String, String> clientPublicKeyStore = new ConcurrentHashMap<>(); /** * 获取服务器SM2公钥 (Hex格式) */ @GetMapping("/serverPublicKey") public Map<String, String> getServerPublicKey() { // 使用Hutool的KeyUtil直接获取16进制字符串 String publicKeyHex = cn.hutool.crypto.KeyUtil.getKeyString(serverSm2KeyPair.getPublic()); Map<String, String> result = new HashMap<>(); result.put("serverPublicKey", publicKeyHex); // 也可以同时返回Base64格式,供不同需求的前端使用 result.put("serverPublicKeyBase64", Base64.encode(serverSm2KeyPair.getPublic().getEncoded())); return result; } /** * 注册/更新客户端公钥 * @param request 包含clientId和publicKey(Hex) */ @PostMapping("/registerClientPubKey") public Map<String, Object> registerClientPubKey(@RequestBody Map<String, String> request) { String clientId = request.get("clientId"); String publicKeyHex = request.get("publicKey"); if (StrUtil.isBlank(clientId) || StrUtil.isBlank(publicKeyHex)) { throw new IllegalArgumentException("客户端ID和公钥不能为空"); } // 简单校验公钥格式(是否以04开头,长度130) if (!publicKeyHex.startsWith("04") || publicKeyHex.length() != 130) { throw new IllegalArgumentException("无效的SM2公钥格式"); } clientPublicKeyStore.put(clientId, publicKeyHex); log.info("客户端[{}]公钥注册成功", clientId); Map<String, Object> result = new HashMap<>(); result.put("success", true); result.put("message", "公钥注册成功"); // 同时返回服务器公钥,方便前端一次调用完成所有初始化 result.put("serverPublicKey", cn.hutool.crypto.KeyUtil.getKeyString(serverSm2KeyPair.getPublic())); return result; } /** * 处理加密的登录请求 * @param request 包含encryptedData字段(Hex格式密文) * @return 响应,敏感数据被加密 */ @PostMapping("/login") public Map<String, Object> handleEncryptedLogin(@RequestBody Map<String, String> request) { String encryptedDataHex = request.get("encryptedData"); if (StrUtil.isBlank(encryptedDataHex)) { throw new IllegalArgumentException("加密数据为空"); } String decryptedJsonStr; try { // 关键步骤:使用服务器私钥解密 // SM2.decrypt方法接收密文(Hex或Base64)和密钥类型,返回解密后的字符串 decryptedJsonStr = serverSm2.decryptStr(encryptedDataHex, KeyType.PrivateKey); log.debug("解密后的数据: {}", decryptedJsonStr); } catch (Exception e) { log.error("SM2解密失败,密文: {}", encryptedDataHex, e); throw new RuntimeException("数据解密失败,请检查加密参数或密钥", e); } // 将解密后的JSON字符串解析为对象 JSONObject loginData = JSONUtil.parseObj(decryptedJsonStr); String username = loginData.getStr("username"); String password = loginData.getStr("password"); Long timestamp = loginData.getLong("timestamp"); // TODO: 此处进行实际的用户认证逻辑,如查询数据库、校验密码等 boolean authSuccess = "admin".equals(username) && "123456".equals(password); // 示例逻辑 if (!authSuccess) { throw new RuntimeException("用户名或密码错误"); } // 模拟获取到的敏感用户信息 Map<String, Object> sensitiveUserInfo = new HashMap<>(); sensitiveUserInfo.put("userId", 1001); sensitiveUserInfo.put("phone", "13800138000"); sensitiveUserInfo.put("email", "user@example.com"); sensitiveUserInfo.put("balance", 9999.99); // 准备响应数据 Map<String, Object> response = new HashMap<>(); response.put("code", 200); response.put("message", "登录成功"); // 对敏感信息进行加密 String clientId = "unique_frontend_identifier"; // 实际应从解密后的数据或会话中获取 String clientPublicKeyHex = clientPublicKeyStore.get(clientId); if (StrUtil.isNotBlank(clientPublicKeyHex)) { try { // 使用客户端的公钥加密敏感信息 // 注意:需要创建一个新的SM2实例,使用客户端的公钥 SM2 clientSm2 = new SM2(null, clientPublicKeyHex); // 仅用公钥初始化,用于加密 clientSm2.setMode(cn.hutool.crypto.asymmetric.SM2.Mode.C1C3C2); String sensitiveInfoJson = JSONUtil.toJsonStr(sensitiveUserInfo); String encryptedSensitiveInfo = clientSm2.encryptHex(sensitiveInfoJson, KeyType.PublicKey); response.put("encryptedUserInfo", encryptedSensitiveInfo); log.info("已对用户[{}]的敏感信息进行加密返回", username); } catch (Exception e) { log.error("使用客户端公钥加密失败,clientId: {}", clientId, e); // 加密失败,可以选择不返回敏感信息或返回错误 response.put("encryptedUserInfo", null); response.put("warning", "部分敏感信息因加密失败未返回"); } } else { log.warn("未找到客户端[{}]的公钥,无法加密返回敏感信息", clientId); response.put("warning", "客户端公钥未注册,敏感信息以明文返回(仅用于调试)"); response.put("userInfo", sensitiveUserInfo); // 生产环境绝不允许这样做 } return response; } }这个控制器实现了完整的双向加密逻辑:
getServerPublicKey接口提供后端公钥。registerClientPubKey接口接收并存储前端公钥,同时返回后端公钥,优化了交互流程。handleEncryptedLogin是核心:它先用服务器私钥解密请求,处理业务后,再用之前注册的客户端公钥加密敏感响应数据。
重要提示:示例中将客户端公钥存储在内存
ConcurrentHashMap中,这仅适用于单机开发和测试。在生产环境中,必须使用外部集中存储如Redis,并设置合理的过期时间(如与用户会话绑定)。同时,客户端ID(clientId)的生成和传递需要安全的设计,防止被篡冒。
4.3 全局异常处理与日志
加解密过程可能出错(如密文格式错误、密钥不匹配),我们需要一个全局异常处理器来统一处理,并返回友好的错误信息,避免泄露系统内部细节。
import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.HashMap; import java.util.Map; @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public Map<String, Object> handleException(Exception e) { log.error("系统异常: ", e); Map<String, Object> result = new HashMap<>(); result.put("code", 500); result.put("message", "系统内部错误"); // 可以根据异常类型返回更具体的错误码 if (e instanceof IllegalArgumentException) { result.put("code", 400); result.put("message", "请求参数错误: " + e.getMessage()); } else if (e.getCause() != null && e.getCause().getMessage().contains("decryption")) { // 捕获解密相关的异常 result.put("code", 400); result.put("message", "数据解密失败,请检查加密数据或联系管理员"); result.put("errorCode", "DECRYPT_FAILED"); // 前端可根据此错误码做特定处理 } // 生产环境不应将详细异常信息返回给前端 // result.put("detail", e.getMessage()); return result; } }这样,当前端加密数据错误或密钥有问题导致后端解密失败时,前端会收到一个包含errorCode: "DECRYPT_FAILED"的响应,从而可以给用户更明确的提示,而不是笼统的“系统错误”。
5. 联调测试与问题排查
5.1 完整流程测试步骤
理论通了,代码写了,能不能跑通还得看联调。下面是我总结的联调检查清单:
环境检查:
- 前端确认
sm-crypto库已正确安装,版本稳定。 - 后端确认
hutool-all和bcprov-jdk15to18依赖已引入,无版本冲突。 - 确保SpringBoot应用已成功启动,无Bean创建错误。
- 前端确认
密钥获取与注册:
- 调用
GET /api/serverPublicKey,确认能正确返回130位以04开头的16进制公钥字符串。 - 前端生成密钥对后,调用
POST /api/registerClientPubKey,将clientId和clientPublicKey发送给后端。检查后端日志,确认公钥被成功存储。
- 调用
加密请求测试:
- 在前端登录页面输入信息,点击登录,通过浏览器开发者工具的
Network面板查看发出的请求。 - 请求体应该是一个JSON对象,其中
encryptedData字段是一长串16进制字符(密文),而不是明文的用户名密码。 - 观察后端控制台日志,应该能看到类似
解密后的数据: {"username":"...","password":"...","timestamp":...}的日志输出。如果看不到,说明解密可能已失败,被全局异常处理器拦截了。
- 在前端登录页面输入信息,点击登录,通过浏览器开发者工具的
解密响应测试:
- 登录成功后,查看网络响应。响应中应该包含
encryptedUserInfo字段(也是一串16进制密文)。 - 在前端代码的
then回调中打印解密后的userInfo,应该能看到明文的用户敏感信息。
- 登录成功后,查看网络响应。响应中应该包含
5.2 常见问题与解决方案实录
在实际搭建过程中,我遇到了不少坑,这里把典型问题和解决方案记录下来:
问题1:后端解密失败,报错“Invalid point encoding 77”或类似异常。
- 排查思路:这几乎都是前后端加密解密模式不匹配导致的。
- 解决方案:
- 确认模式:前端
sm-crypto的doEncrypt第三个参数(cipherMode)必须设置为1(C1C3C2)。后端Hutool的SM2对象必须调用setMode(SM2.Mode.C1C3C2)。两者必须严格一致。 - 确认公钥格式:前端用于加密的公钥字符串,必须是后端通过
KeyUtil.getKeyString(publicKey)生成的130位16进制字符串(以04开头)。如果后端传了Base64,前端需要先将其解码为16进制。可以使用sm-crypto自带的sm2.getPublicKeyFromHex()或sm2.getPublicKeyFromBase64()进行验证和转换。 - 确认密文传递:前端发送给后端的
encryptedData,必须是sm2.doEncrypt返回的原始16进制字符串,不要做额外的Base64编码。后端直接用这个字符串进行decryptStr。
- 确认模式:前端
问题2:前端加密时抛出“publicKey length error”错误。
- 排查思路:公钥字符串格式或长度不正确。
- 解决方案:
- 检查从后端获取的公钥字符串。一个有效的SM2公钥(未压缩)16进制表示应该是130个字符(
04+ 64字节X坐标 + 64字节Y坐标)。 - 确保字符串中没有换行符、空格或其他不可见字符。可以在控制台打印其
length属性进行验证。 - 如果后端提供的是Base64,需要使用
atob()(浏览器环境)或Buffer.from(str, 'base64').toString('hex')(Node环境)将其转换为16进制字符串。
- 检查从后端获取的公钥字符串。一个有效的SM2公钥(未压缩)16进制表示应该是130个字符(
问题3:后端使用客户端公钥加密时失败,报“Invalid point encoding”或空指针。
- 排查思路:存储的客户端公钥格式错误或为空。
- 解决方案:
- 在
registerClientPubKey接口中,增加严格的公钥格式校验(如检查长度130、以04开头)。 - 在加密响应前,先检查
clientPublicKeyStore中是否存在对应clientId的公钥,且不为空。 - 打印出用于加密的客户端公钥字符串,确认其格式正确。
- 在
问题4:性能问题,加密大量数据或高并发时响应慢。
- 排查思路:SM2非对称加密本身比对称加密慢,且加密长度有限。
- 解决方案:
- 采用混合加密:这是终极解决方案。流程改为:
- 前端每次会话随机生成一个AES密钥(
sessionKey)。 - 前端用后端的SM2公钥加密这个
sessionKey,得到encryptedSessionKey。 - 前端用
sessionKey通过AES加密实际的请求数据requestData,得到encryptedData。 - 前端将
encryptedSessionKey和encryptedData一起发送给后端。 - 后端用SM2私钥解密
encryptedSessionKey得到sessionKey。 - 后端用
sessionKey解密encryptedData得到明文。 - 响应数据同理,后端用前端的SM2公钥加密一个新的AES密钥,或者复用请求中的
sessionKey(需确保安全)来加密响应体。
- 前端每次会话随机生成一个AES密钥(
- 仅加密关键字段:不要加密整个大的JSON请求体。只加密真正敏感的字段(如密码、身份证号、手机号),其他非敏感字段(如用户名、时间戳)明文传输。这能显著减少加解密的数据量。
- 采用混合加密:这是终极解决方案。流程改为:
问题5:前端刷新页面后,之前注册的客户端公钥丢失,导致后端无法加密返回数据。
- 排查思路:前端密钥对存储在
sessionStorage中,刷新页面后,新的前端实例生成了新的密钥对,但后端存储的还是旧的公钥。 - 解决方案:
- 方案A(推荐):将前端密钥对与用户身份绑定。在用户登录成功后,再将前端生成的公钥发送给后端进行注册/更新,并将
clientId设置为用户ID。这样,每次登录后公钥都是最新的。 - 方案B:使用更持久的存储,如
localStorage,但要注意清理机制,避免不同设备或浏览器间混淆。 - 方案C:后端不存储公钥。改为在每次需要加密响应时,让前端在请求中附带自己的公钥。但这会增加每次请求的数据量,且需要防范公钥被篡改。
- 方案A(推荐):将前端密钥对与用户身份绑定。在用户登录成功后,再将前端生成的公钥发送给后端进行注册/更新,并将
6. 安全加固与生产环境考量
到这一步,一个基本的双向加密通信demo已经跑通了。但要真正上线,还需要考虑更多安全性和工程化的问题。
1. 密钥生命周期管理
- 后端私钥:必须从安全的密钥管理系统动态获取,定期轮换。代码中不应出现私钥明文。
- 前端密钥对:考虑定期更新(如每天或每次登录生成新的)。旧的公钥需要在后端有失效机制。
- 客户端公钥存储:使用Redis等带过期时间的存储,过期时间与用户会话或前端密钥更新周期同步。
2. 防重放攻击目前的实现中,虽然加了timestamp,但并未验证。攻击者可以截获加密后的请求包,直接重放给服务器。解决方法是在加密数据中加入随机数(nonce)或序列号,后端维护一个短时间内已使用过的nonce缓存,拒绝重复的请求。
3. 完整性与抗篡改SM2算法本身不提供消息完整性验证。虽然加密后篡改密文会导致解密失败(概率极大),但更严谨的做法是,在加密前对明文数据计算SM3(国密哈希算法)摘要,并将摘要一起加密。解密后重新计算摘要并比对,确保数据在加密后未被篡改。
4. 使用HTTPS(TLS)这一点至关重要!本文实现的SM2双向加密,是在应用层对业务数据进行的额外保护。它绝不能替代传输层的HTTPS。HTTPS提供了通道加密、服务器身份认证和消息完整性保护,是防范中间人攻击的基础。SM2加密是建立在HTTPS安全通道之上的第二道防线,主要用于防止HTTPS终端解密后(或在某些内部网络环境中),数据在应用系统内部传递过程中的泄露风险。
5. 监控与审计在日志中,不要记录完整的密文或解密后的敏感信息(如密码)。但可以记录加解密操作的成功/失败次数、客户端ID、操作时间等,用于监控异常行为和事后审计。
搭建这套系统花了我不少时间,主要是前期在模式对齐和密钥格式处理上踩了坑。一旦把C1C3C2这个模式前后端统一好,把公钥的16进制字符串格式理清楚,后面就顺畅了。对于性能要求高的场景,一定要上SM2+AES的混合加密,这是经过验证的最佳实践。最后再强调一遍,这套东西是锦上添花,HTTPS才是那个雪中送炭必须有的基础,千万别本末倒置。
