------ 国密算法在前后端真实项目中的落地实践
SM2 作为中国自主可控的椭圆曲线公钥密码算法,已逐步取代 RSA 成为标配。本文将手把手带你实现一套 Vue2 + Spring Boot 项目中真正的「双向」SM2 非对称加密通信机制,做到:
- 前端用公钥加密 → 后端私钥解密(保护敏感数据传输)
- 后端用私钥签名 → 前端公钥验签(防止响应数据被篡改)
- 真正可落地的完整代码(含密钥生成、Hex/C1C3C2 格式处理、异常处理)
一、SM2 算法与常见坑点回顾
| 项目 | 说明 |
|---|---|
| 密钥格式 | SM2 私钥一般 32 字节,公钥未压缩格式 65 字节(04 开头) |
| 常见编码格式 | C1C3C2(国密标准,默认)、C1C2C3(部分 js 库使用) |
| ASN.1 包装 | Java 默认使用 PKCS#8 私钥、X509 公钥,需要转原始 04+64 字节 |
| 随机数 k | 每次加密/签名必须不同,否则泄露私钥 |
二、后端 Spring Boot 实现(Java 17 + Bouncy Castle 1.78+)
1. 依赖
xml
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
</dependencies>
2. SM2 工具类(完整生产可用)
java
@Component
public class Sm2Util {
private static final String PUB_KEY_HEX = "04a3e2f1b1c5d7...你的公钥04开头130位hex...";
private static final String PRI_KEY_HEX = "1a2b3c4d5e6f...你的私钥64位hex...";
private static final ECPublicKey PUBLIC_KEY;
private static final ECPrivateKey PRIVATE_KEY;
static {
Security.addProvider(new BouncyCastleProvider());
PUBLIC_KEY = loadPublicKey(PUB_KEY_HEX);
PRIVATE_KEY = loadPrivateKey(PRI_KEY_HEX);
}
/** 公钥加密(返回 C1C3C2 格式 Hex) */
public static String encrypt(String plainText) {
try {
SM2Engine engine = new SM2Engine();
engine.init(true, new ParametersWithRandom(PUBLIC_KEY, SecureRandom.getInstanceStrong()));
byte[] data = plainText.getBytes(StandardCharsets.UTF_8);
byte[] c1c3c2 = engine.processBlock(data, 0, data.length);
return Hex.toHexString(c1c3c2);
} catch (Exception e) {
throw new RuntimeException("SM2 encrypt failed", e);
}
}
/** 私钥解密(接收 C1C3C2 Hex) */
public static String decrypt(String cipherHex) {
try {
SM2Engine engine = new SM2Engine();
engine.init(false, PRIVATE_KEY);
byte[] c1c3c2 = Hex.decode(cipherHex);
byte[] plain = engine.processBlock(c1c3c2, 0, c1c3c2.length);
return new String(plain, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("SM2 decrypt failed", e);
}
}
/** 私钥签名(返回 SM2 标准 r||s 64字节 Hex) */
public static String sign(String data) {
try {
SM2Signer signer = new SM2Signer();
signer.init(true, new ParametersWithRandom(PRIVATE_KEY, SecureRandom.getInstanceStrong()));
signer.update(data.getBytes(StandardCharsets.UTF_8), 0, data.length());
byte[] signature = signer.generateSignature(); // r||s 64字节
return Hex.toHexString(signature);
} catch (Exception e) {
throw new RuntimeException("SM2 sign failed", e);
}
}
/** 公钥验签 */
public static boolean verify(String data, String signatureHex) {
try {
SM2Signer signer = new SM2Signer();
signer.init(false, PUBLIC_KEY);
signer.update(data.getBytes(StandardCharsets.UTF_8), 0, data.length());
byte[] signature = Hex.decode(signatureHex);
return signer.verifySignature(signature);
} catch (Exception e) {
return false;
}
}
// 加载原始 Hex 公私钥
private static ECPublicKey loadPublicKey(String hex) {
byte[] pubBytes = Hex.decode(hex);
ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
ECPoint q = spec.getCurve().decodePoint(pubBytes);
return new ECPublicKeyImpl(q, getDomainParameters(spec));
}
private static ECPrivateKey loadPrivateKey(String hex) {
BigInteger d = new BigInteger(1, Hex.decode(hex));
ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
return new ECPrivateKeyImpl(d, getDomainParameters(spec));
}
private static ECDomainParameters getDomainParameters(ECNamedCurveParameterSpec spec) {
return new ECDomainParameters(spec.getCurve(), spec.getG(), spec.getN(), spec.getH());
}
}
3. Controller 示例
java
@RestController
@RequestMapping("/api/user")
public class UserController {
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody LoginDTO dto) {
// 1. 前端传过来的密码是 SM2 加密后的 Hex
String encryptedPwd = dto.getPassword();
String plainPwd = Sm2Util.decrypt(encryptedPwd);
// 2. 业务校验(这里简化)
if (!"123456".equals(plainPwd)) {
throw new BizException("密码错误");
}
// 3. 组装返回数据并签名
LoginVO vo = new LoginVO();
vo.setToken(JwtUtil.generate(userId));
vo.setUserId(userId);
String dataToSign = vo.getToken() + vo.getUserId(); // 建议签名业务关键字段
String signature = Sm2Util.sign(dataToSign);
vo.setSign(signature);
return Result.success(vo);
}
}
三、前端 Vue2 实现(gm-crypto-js + vue-cli)
1. 安装依赖
bash
npm install gm-crypto-js
# 或 yarn add gm-crypto-js
2. 封装 sm2.js
javascript
// src/utils/sm2.js
import { sm2 } from 'gm-crypto-js'
// 必须与后端一致:04 + 64字节未压缩公钥
const PUBLIC_KEY = '04a3e2f1b1c5d7...同后端公钥...'
const sm2Encrypt = (plainText) => {
return sm2.encrypt(plainText, PUBLIC_KEY, {
inputEncoding: 'utf8',
outputEncoding: 'hex',
// 关键:gm-crypto-js 默认 C1C2C3,这里要转成 C1C3C2 与 Java 一致
asn1: false
})
}
// gm-crypto-js 加密出来是 C1C2C3,需要转换为 C1C3C2
const toC1C3C2 = (c1c2c3Hex) => {
const hex = c1c2c3Hex.toUpperCase()
const c1 = hex.substr(0, 66) // 04 + 32*2
const c2 = hex.substr(-64) // 最后32字节
const c3 = hex.substr(66, 64) // 中间32字节
return (c1 + c3 + c2).toLowerCase()
}
export const encryptPassword = (pwd) => {
const c1c2c3 = sm2Encrypt(pwd)
return toC1C3C2(c1c2c3)
}
// 验签
export const verifySign = (data, signatureHex) => {
return sm2.verify(data, signatureHex, PUBLIC_KEY, {
inputEncoding: 'utf8'
})
}
3. 登录页面使用
vue
<script>
import { encryptPassword, verifySign } from '@/utils/sm2'
import axios from 'axios'
export default {
data() {
return {
form: { username: '', password: '' }
}
},
methods: {
async login() {
const encryptedPwd = encryptPassword(this.form.password)
const res = await axios.post('/api/user/login', {
username: this.form.username,
password: encryptedPwd // 明文绝不上网
})
const { token, userId, sign } = res.data.data
// 验签防止伪造响应
const dataToVerify = token + userId
const ok = verifySign(dataToVerify, sign)
if (!ok) {
this.$message.error('响应数据异常,请重试')
return
}
this.$store.commit('SET_TOKEN', token)
this.$router.push('/dashboard')
}
}
}
</script>
四、密钥生成(只执行一次)
java
// 生成一对 SM2 密钥(Java)
public static void generateKeyPair() {
ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
ECKeyPairGenerator generator = new ECKeyPairGenerator();
generator.init(new ECKeyGenerationParameters(new ECDomainParameters(spec.getCurve(), spec.getG(), spec.getN()), new SecureRandom()));
AsymmetricKeyParameter keyPair = generator.generateKeyPair();
ECPublicKey pub = (ECPublicKey) keyPair.getPublic();
ECPrivateKey pri = (ECPrivateKey) keyPair.getPrivate();
System.out.println("公钥: " + Hex.toHexString(pub.getQ().getEncoded(false))); // 04...
System.out.println("私钥: " + pri.getD().toString(16));
}
五、生产注意事项
- 公钥硬编码到前端,私钥必须放在服务器环境变量或配置中心
- 每次加密必须使用不同随机数 k(BouncyCastle 已自动处理)
- 建议对敏感接口全部启用双向 SM2(登录、支付、修改密码等)
- C1C3C2 与 C1C2C3 转换是最大坑点,已在上面代码解决
- 签名建议只签业务关键字段,防止重放攻击仍需配合 timestamp + nonce + JWT