Vue与Spring Boot集成国密SM2:前后端非对称加密通信实战指南
1. 项目概述与核心价值
最近在做一个前后端分离的项目,前端用的是Vue,后端是Spring Boot,客户对数据安全这块提了硬性要求,点名要用国密算法。说实话,一开始听到“国密SM2”这几个字,我和团队里不少小伙伴都有点懵,毕竟平时RSA、AES用惯了,对国密这套体系接触不多。但需求就是命令,硬着头皮也得啃下来。折腾了小半个月,从查文档、找库、联调测试到最终上线,踩了不少坑,也积累了一些实战心得。今天就把Vue前端配合Spring Boot后端,完整实现国密SM2非对称加密通信的这套方案梳理出来,希望能给遇到同样需求的同行们铺个路。
简单来说,SM2是国家密码管理局发布的椭圆曲线公钥密码算法标准,属于非对称加密,对标的是国际上的RSA和ECC。它在相同安全强度下,密钥长度更短(256位就相当于RSA 2048位的强度),运算速度更快,而且是我们自己的标准,在金融、政务这些对安全自主可控要求高的场景里是硬性要求。我们这个项目就是典型的B/S架构,前端Vue收集用户敏感数据(比如身份证号、银行卡号),需要先加密再传给后端Spring Boot,后端解密后处理。整个过程要保证数据在传输过程中的机密性,防止被窃听。
2. 技术选型与前期准备
2.1 为什么选择SM2而非RSA?
客户要求是一方面,技术上的优势也是我们最终决定投入时间研究SM2的原因。除了前面提到的密钥短、效率高,SM2在签名算法上也有改进,安全性更有保障。RSA算法这些年被挑战的比较多,而基于椭圆曲线的SM2在理论上更抗攻击。当然,最大的推动力还是合规性。在很多行业项目中,使用国密算法不是“加分项”,而是“入场券”。所以,如果你的项目涉及敏感数据且面向国内市场,提前布局国密是很有必要的。
2.2 前端加密库选型: sm-crypto
前端JavaScript环境里实现SM2,经过一番调研,社区里比较成熟、star数高的库是 sm-crypto 。它纯JavaScript实现,不依赖任何原生模块,在浏览器和Node.js环境都能跑,对Vue这种现代前端框架集成起来非常方便。它的API设计得也比较清晰,加密、解密、签名、验签功能都齐全。我们不需要从底层理解椭圆曲线的数学原理,直接调用封装好的方法就行,这对前端开发来说大大降低了门槛。
注意 :市面上也有一些其他带“国密”字样的JS库,但有些可能未经充分审计或已停止维护。选择
sm-crypto主要是看中其活跃的社区和相对完整的文档,在Github上能查到 issues 和更新记录,用起来更放心。
2.3 后端加密库选型: Bouncy Castle 国密支持包
后端的Java生态里,JDK标准库默认并不支持国密算法。这时候就需要引入强大的第三方密码学提供者——Bouncy Castle。但是,普通的Bouncy Castle(BC)发行版对国密算法的支持是有限的或者不完整的。我们需要一个专门适配了国密算法的BC扩展包。经过对比,我们选择了 org.bouncycastle:bcprov-jdk15to18 这个常用版本,并搭配一个国密补丁包,或者直接使用国内一些厂商维护的、已经集成了国密算法的BC版本。这里有个关键点:前后端使用的椭圆曲线参数必须一致,否则无法互通。国密SM2使用的是固定的曲线参数 sm2p256v1 ,在选型时必须确保后端库使用的也是同一套标准参数。
2.4 环境与依赖确认
在开始敲代码之前,先把环境捋清楚:
-
前端 (Vue项目) :
- 项目基于 Vue 2/3 或 Vue CLI 创建均可。
- 通过 npm 或 yarn 安装
sm-crypto:npm install sm-crypto --save - 确保你的项目能处理异步操作和Base64编码,这些是现代前端项目的标配。
-
后端 (Spring Boot项目) :
- 版本建议 Spring Boot 2.x 或 3.x。
- 在
pom.xml中引入 Bouncy Castle 的国密支持依赖。这里以使用一个整合好的依赖为例(具体groupId和artifactId可能需要根据你选择的实际国密BC包调整,以下为示例):<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.72</version> <!-- 请使用最新稳定版 --> </dependency> <!-- 可能需要额外的国密算法包,例如 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-crypto</artifactId> <version>5.8.25</version> <!-- Hutool工具包内置了国密支持,封装得较好 --> </dependency> - 我们最终部分功能选择了Hutool工具包,因为它对国密SM2、SM3、SM4进行了友好封装,API简单,减少了直接操作BC底层API的复杂度。当然,你也可以选择直接使用纯BC API,控制更精细,但代码量会多一些。
3. 核心流程与密钥管理设计
3.1 非对称加密通信流程
我们的场景是典型的“前端加密,后端解密”。流程如下:
- 后端启动时,生成一对SM2密钥:公钥(Public Key)和私钥(Private Key)。私钥绝对保密,存放在后端服务器安全位置(如配置文件、环境变量或密钥管理服务), 绝不能 发给前端。公钥则可以安全地暴露给前端。
- 前端从后端接口(例如
/api/sm2/public-key)获取到SM2公钥(一般是Base64或16进制字符串格式)。 - 前端在提交敏感表单数据时,使用获取到的公钥,通过
sm-crypto对明文数据(如JSON字符串)进行加密,得到密文。 - 前端将密文作为请求体(如
{“encryptedData”: “xxxx...”})发送给后端对应的API。 - 后端接收到密文后,使用保管的私钥进行解密,还原出原始明文数据,再进行后续业务处理。
这个流程保证了即使网络请求被截获,攻击者因为没有私钥也无法解密数据内容。
3.2 密钥的生成、存储与分发
密钥管理是安全的核心,这里详细说一下我们的做法:
-
生成 :后端使用选定的国密库(如Hutool的
SM2类)生成密钥对。生成后,将公钥和私钥分别转换为Base64编码的字符串,方便存储和传输。// 示例:使用Hutool生成SM2密钥对 import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.asymmetric.SM2; import java.util.Base64; SM2 sm2 = new SM2(); // 默认使用国密标准曲线参数 // 获取私钥和公钥的Base64字符串 String privateKeyBase64 = sm2.getPrivateKeyBase64(); String publicKeyBase64 = sm2.getPublicKeyBase64(); System.out.println("私钥(Base64): " + privateKeyBase64); System.out.println("公钥(Base64): " + publicKeyBase64); -
存储 :
- 私钥 :这是命根子。我们坚决不把它写在项目的
application.yml或代码里。生产环境中,我们将其存储在服务器的环境变量中,或者更专业的做法是使用Hashicorp Vault、阿里云KMS等密钥管理服务。Spring Boot可以通过@Value(“${sm2.private-key}”)从环境变量中注入。 - 公钥 :可以存储在配置文件中,或者每次服务启动时动态生成并缓存。我们采用的是后者,并将公钥通过一个安全的、无需认证的接口暴露给前端。因为公钥本身就是可以公开的,所以这个接口不需要担心泄露问题。
- 私钥 :这是命根子。我们坚决不把它写在项目的
-
分发 :我们专门写了一个
PublicKeyController,提供一个GET /public-key接口。前端在应用初始化时(比如在Vue的App.vue的created或mounted钩子中),调用这个接口获取公钥字符串,并保存在前端的内存或状态管理(如Vuex、Pinia)中,供整个应用在加密时使用。
实操心得 :密钥千万不能写死在代码里!我们吃过亏,早期测试时图省事,把密钥硬编码在常量类里,结果在代码审计时被严重警告。即使是在测试环境,也要养成从环境变量读取的好习惯。另外,可以考虑定期(如每季度)更换密钥对,但要做好新旧密钥的平滑过渡,避免服务中断。
4. 前端Vue实现细节
4.1 安装、引入与封装工具类
首先,在Vue项目中安装 sm-crypto :
npm install sm-crypto --save
# 或
yarn add sm-crypto
我们不建议在每个需要加密的Vue组件里直接 import smCrypto from ‘sm-crypto’ 然后写加密逻辑。更好的做法是封装一个专用的工具模块(如 utils/sm2Encrypt.js ),实现关注点分离,也便于统一维护和更新。
// utils/sm2Encrypt.js
import { sm2 } from 'sm-crypto';
// 这里存储从后端获取的公钥
let publicKey = '';
/**
* 设置公钥(通常在应用初始化时调用)
* @param {string} key - Base64格式的公钥字符串
*/
export function setPublicKey(key) {
// 后端传来的可能是Base64,sm-crypto通常需要16进制字符串
// 这里假设后端传的是Base64,我们需要转换
// 注意:实际转换取决于后端返回的格式,需与后端约定一致
publicKey = `04${Buffer.from(key, 'base64').toString('hex').substring(2)}`;
// 更常见的做法是后端直接返回16进制公钥(04开头),前端直接使用
// publicKey = key;
}
/**
* 使用SM2公钥加密明文
* @param {string} plainText - 需要加密的原始文本
* @param {string} cipherMode - 加密模式,默认 'C1C3C2'
* @returns {string} 加密后的密文(16进制字符串)
*/
export function encrypt(plainText, cipherMode = 'C1C3C2') {
if (!publicKey) {
throw new Error('SM2公钥未初始化,请先调用 setPublicKey 方法。');
}
// sm2.doEncrypt 默认输出16进制字符串
const encryptedData = sm2.doEncrypt(plainText, publicKey, cipherMode);
return encryptedData;
}
/**
* 注意:前端通常只加密,不解密。解密由后端私钥完成。
*/
4.2 在应用生命周期中获取并设置公钥
在应用入口(如 main.js 或根组件 App.vue ),调用后端接口获取公钥并设置到工具类中。
// App.vue 或专门的初始化脚本中
import { setPublicKey } from '@/utils/sm2Encrypt';
import axios from 'axios'; // 假设使用axios
export default {
name: 'App',
created() {
this.fetchPublicKey();
},
methods: {
async fetchPublicKey() {
try {
const response = await axios.get('/api/sm2/public-key');
// 假设后端返回格式为 { code: 200, data: { publicKey: '...' } }
if (response.data.code === 200) {
setPublicKey(response.data.data.publicKey);
console.log('SM2公钥初始化成功');
} else {
console.error('获取公钥失败:', response.data.message);
}
} catch (error) {
console.error('获取公钥接口异常:', error);
// 这里可以根据业务需求决定是否阻止应用运行
}
}
}
}
4.3 在表单提交时加密数据
现在,在任何一个需要提交敏感数据的组件里,就可以方便地使用加密功能了。
<template>
<div>
<form @submit.prevent="handleSubmit">
<input v-model="formData.idCard" placeholder="请输入身份证号" />
<input v-model="formData.bankCard" placeholder="请输入银行卡号" />
<button type="submit">提交</button>
</form>
</div>
</template>
<script>
import { encrypt } from '@/utils/sm2Encrypt';
import axios from 'axios';
export default {
data() {
return {
formData: {
idCard: '',
bankCard: ''
}
};
},
methods: {
async handleSubmit() {
// 1. 将表单数据转为JSON字符串
const plainText = JSON.stringify(this.formData);
// 2. 使用SM2加密
let encryptedHex;
try {
encryptedHex = encrypt(plainText);
console.log('加密结果(16进制):', encryptedHex);
} catch (error) {
console.error('数据加密失败:', error);
alert('数据加密失败,请检查公钥配置或联系管理员。');
return;
}
// 3. 将密文发送给后端
try {
const response = await axios.post('/api/secure/submit', {
encryptedData: encryptedHex
// 可以同时传递其他非敏感字段,如业务类型
// bizType: 'user_register'
});
if (response.data.code === 200) {
alert('提交成功!');
// 处理成功逻辑...
} else {
alert(`提交失败: ${response.data.message}`);
}
} catch (error) {
console.error('请求发送失败:', error);
alert('网络请求异常,请重试。');
}
}
}
};
</script>
注意事项 :
sm-crypto的doEncrypt方法默认输出是16进制字符串。有些后端库可能期望接收Base64格式的密文。这里需要前后端对齐,要么前端加密后转Base64再传,要么后端适配16进制解密。我们选择传16进制,因为sm-crypto直接输出就是16进制,减少一次转换。 务必与后端同事确认好密文的格式 ,这是联调时最常见的坑。
5. 后端Spring Boot实现细节
5.1 配置国密算法提供者
首先,我们需要在应用启动时,将支持国密的Bouncy Castle提供者注册到Java安全体系中。可以写一个配置类来完成。
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import java.security.Security;
@Configuration
public class CryptoConfig {
@PostConstruct
public void init() {
// 防止重复添加
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
System.out.println("BouncyCastle Provider (国密支持) 注册成功。");
}
}
}
5.2 密钥对管理服务
我们创建一个服务来管理SM2密钥对的生成、获取和私钥的解密操作。
import cn.hutool.core.codec.Base64;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.SM2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
@Service
@Slf4j
public class Sm2Service {
/**
* 从环境变量或配置中心读取Base64编码的私钥
* 生产环境务必使用外部配置!
*/
@Value("${sm2.private-key:}")
private String privateKeyBase64Config;
/**
* SM2实例,持有密钥对
*/
private SM2 sm2;
/**
* 初始化SM2实例
* 如果配置了私钥,则使用配置的密钥对;否则生成新的。
*/
@PostConstruct
public void init() {
if (privateKeyBase64Config != null && !privateKeyBase64Config.trim().isEmpty()) {
try {
// 使用配置的私钥(和对应的公钥)创建SM2实例
// 注意:这里需要根据你存储密钥对的方式调整
// 假设我们存储了完整的密钥对信息,Hutool的SM2可以重建
// 更常见的做法是存储私钥,公钥可以从私钥推导。这里简化处理。
// 实际情况可能需要从文件或KMS加载完整的密钥对对象。
log.info("从配置加载SM2密钥对...");
// 示例:如果配置的是PEM格式或特定字符串,需要解析
// 这里假设配置的就是Hutool SM2生成的Base64私钥字符串
sm2 = new SM2(privateKeyBase64Config, null); // 仅私钥,公钥自动推导
} catch (Exception e) {
log.error("加载配置的SM2私钥失败,将生成新密钥对。", e);
generateNewKeyPair();
}
} else {
log.warn("未配置SM2私钥,将生成新的临时密钥对。生产环境请务必配置!");
generateNewKeyPair();
}
log.info("SM2服务初始化完成。公钥Base64: {}", this.getPublicKeyBase64());
}
/**
* 生成新的SM2密钥对
*/
private void generateNewKeyPair() {
this.sm2 = new SM2(); // Hutool默认使用国密标准曲线 sm2p256v1
// 可以将新生成的密钥对输出到日志(仅限测试环境!)
log.info("生成新的SM2密钥对 - 私钥: {}, 公钥: {}",
sm2.getPrivateKeyBase64(),
sm2.getPublicKeyBase64());
}
/**
* 获取Base64编码的公钥,供前端使用
* @return 公钥Base64字符串
*/
public String getPublicKeyBase64() {
return sm2.getPublicKeyBase64();
}
/**
* 使用私钥解密数据
* @param encryptedHex 前端传来的16进制格式密文
* @return 解密后的原始明文
*/
public String decrypt(String encryptedHex) {
try {
// Hutool的SM2解密方法默认支持16进制密文输入
// 注意:加密模式需与前端一致,默认是 C1C3C2
byte[] decryptedBytes = sm2.decrypt(encryptedHex, KeyType.PrivateKey);
return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("SM2解密失败,密文: {}", encryptedHex, e);
throw new RuntimeException("数据解密失败,请检查密文格式或密钥是否正确。", e);
}
}
}
5.3 提供公钥的控制器
创建一个简单的REST接口,让前端能获取到公钥。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/sm2")
public class PublicKeyController {
@Autowired
private Sm2Service sm2Service;
@GetMapping("/public-key")
public Map<String, Object> getPublicKey() {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "success");
Map<String, String> data = new HashMap<>();
data.put("publicKey", sm2Service.getPublicKeyBase64());
result.put("data", data);
return result;
}
}
5.4 接收加密数据并解密的控制器
最后,创建处理业务请求的控制器,它接收前端发来的密文,解密后再处理业务逻辑。
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/secure")
@Slf4j
public class SecureDataController {
@Autowired
private Sm2Service sm2Service;
@PostMapping("/submit")
public Map<String, Object> handleEncryptedData(@RequestBody Map<String, String> requestBody) {
Map<String, Object> response = new HashMap<>();
try {
// 1. 获取密文
String encryptedHex = requestBody.get("encryptedData");
if (encryptedHex == null || encryptedHex.isEmpty()) {
response.put("code", 400);
response.put("message", "请求参数缺失: encryptedData");
return response;
}
// 2. 使用SM2服务解密
String decryptedText = sm2Service.decrypt(encryptedHex);
log.info("解密成功,明文数据: {}", decryptedText); // 生产环境切勿日志记录敏感明文!
// 3. 将解密后的JSON字符串解析为对象
JSONObject formData = JSON.parseObject(decryptedText);
String idCard = formData.getString("idCard");
String bankCard = formData.getString("bankCard");
// 4. 此处编写你的业务逻辑,例如数据验证、存储等
// ...
// 5. 返回成功响应
response.put("code", 200);
response.put("message", "数据处理成功");
// 可以返回处理后的业务数据(非敏感信息)
// response.put("data", someResult);
} catch (RuntimeException e) {
log.error("处理加密数据时发生解密或业务错误", e);
response.put("code", 500);
response.put("message", "服务器内部错误: " + e.getMessage());
} catch (Exception e) {
log.error("处理加密数据时发生未知错误", e);
response.put("code", 500);
response.put("message", "系统异常");
}
return response;
}
}
6. 联调测试、常见问题与优化
6.1 完整联调测试步骤
- 启动后端 :确保Spring Boot应用成功启动,Bouncy Castle提供者注册成功,SM2密钥对生成或加载成功。
- 获取公钥 :使用Postman或浏览器访问
GET http://localhost:8080/api/sm2/public-key,确认能正确返回公钥字符串。记录下这个公钥。 - 前端配置 :在Vue项目中,手动或在初始化时代码里,将上一步获取的公钥设置到
sm2Encrypt.js工具类中。 - 前端加密测试 :在浏览器控制台,尝试调用封装好的
encrypt方法,对一个测试字符串(如“Hello SM2”)进行加密,观察输出的16进制密文是否为一长串有规律的字符。 - 后端解密测试 :将上一步前端加密得到的密文,通过Postman构造一个POST请求,发送到
POST http://localhost:8080/api/secure/submit,请求体为{“encryptedData”: “你的密文”}。观察后端日志,看是否能成功解密出“Hello SM2”,并返回成功响应。 - 集成测试 :在前端Vue页面真实提交表单,通过浏览器开发者工具的Network面板查看发送的请求,确认请求体中是密文。同时查看后端应用日志,确认解密和业务逻辑执行成功。
6.2 常见问题与排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
前端加密时报错: Invalid public key |
公钥格式不正确。 sm-crypto 需要的公钥是16进制格式,且可能要求包含 04 前缀。 |
1. 检查从后端获取的公钥字符串。2. 确认前端 setPublicKey 方法中的格式转换逻辑是否正确。3. 最简单的方法:让后端直接返回16进制格式的公钥字符串(以 04 开头),前端无需转换直接使用。 |
后端解密失败,抛出异常如 Invalid point encoding 或 Unable to process key |
1. 前后端使用的椭圆曲线参数不一致。2. 密文格式不符。3. 私钥与加密公钥不匹配。 | 1. 确认曲线 :确保前后端库都使用国标 sm2p256v1 曲线。2. 确认密文格式 :前端加密模式( C1C3C2 )与后端解密期望的模式必须一致。Hutool默认也是 C1C3C2 。3. 确认密钥配对 :确保后端解密的私钥,就是生成提供给前端那个公钥所对应的私钥。重启服务后密钥对是否变化?4. 检查密文传输 :确保网络传输中密文没有被截断或修改。 |
| 加解密过程很慢 | 加密的数据块太大。SM2作为非对称加密,适合加密小数据(如密钥、敏感字段),不适合加密大文件。 | 对于大数据,应采用混合加密:1. 前端随机生成一个对称密钥(如AES密钥)。2. 用SM2公钥加密这个对称密钥。3. 用对称密钥加密大数据。4. 将加密后的对称密钥和加密后的大数据一起传给后端。后端先用SM2私钥解出对称密钥,再用对称密钥解密数据。 |
| 后端无法加载Bouncy Castle Provider | 依赖冲突或版本问题。 | 1. 检查 pom.xml ,排除其他依赖引入的老版本BC。2. 确认引入的BC版本支持国密算法。3. 在 CryptoConfig 的 init 方法中打印所有已注册的Provider,看BC是否在其中。 |
| 生产环境私钥泄露风险 | 私钥硬编码在代码或配置文件中。 | 1. 立即移除 代码中的硬编码私钥。2. 将私钥存入服务器环境变量。3. 使用Spring Cloud Config、Apollo等配置中心,并开启加密。4. 强烈建议 使用专业的密钥管理服务(KMS),私钥根本不落地到应用服务器。 |
6.3 性能与安全优化建议
- 公钥缓存 :前端获取公钥后,可以将其存储在
localStorage或sessionStorage中,并设置合理的过期时间,避免每次页面加载都请求接口。后端公钥一般不变,除非密钥轮换。 - HTTPS是基础 :SM2保证了数据本身的机密性,但传输过程仍需HTTPS(TLS)来防止中间人攻击、篡改和重放。 绝对不能因为用了SM2加密就省略HTTPS 。
- 数据签名防篡改 (可选但推荐):上述流程只保证了机密性。为了确保数据在传输过程中未被篡改,可以考虑加入SM2签名机制。前端用另一对密钥(或同一对)的私钥对数据(或数据的摘要)签名,后端用公钥验签。这样实现了“加密+签名”,同时满足机密性、完整性和不可否认性。
- 密钥轮换 :制定密钥轮换策略,定期更换SM2密钥对。轮换时需要有一个重叠期,新旧公钥同时有效,前端逐步升级,确保服务不间断。
- 异常监控与告警 :在后端的解密服务中,对频繁的解密失败请求进行监控和告警,这可能是攻击者在进行盲测或密钥已泄露的迹象。
这套方案从零到一跑通后,你会发现国密集成并没有想象中那么困难。核心在于理解非对称加密的流程,选对经过验证的库,并仔细处理好前后端之间密钥格式、数据格式的约定。最深的体会就是, 联调阶段“对齐”二字值千金 ——公钥格式、加密模式、密文编码,任何一个细节不一致都会导致失败。希望这篇长文能帮你避开我们踩过的那些坑,顺利在Vue+SpringBoot项目中驾驭国密SM2加密。
更多推荐
所有评论(0)