由于项目要求,前端需要使用国密算法对请求数据字段进行加密,再发送给后端。同样前端接收数据需要先解密数据后,才能拿到数据明文,所以选用 sm-crypto 库。sm-crypto 是一个基于国密算法(也称为商用密码)的工具库,用于进行加密、解密、签名和验证操作,以下是 sm-crypto 库提供的主要功能:
这里以第一次进入页面并点击登录来看整个流程是如何实现的。
首先当页面加载完成后,会首先请求一个无需加密处理的接口 A(是否需要加密可以在 axios 拦截器做特殊处理),该接口返回的数据是一个 sm2 公钥字符串 publicKey,将这个值存起来。
输入完用户密码,点击登录,发起一个 POST 请求(项目要求所有请求必须为 POST 对 body 加密),首先走 axios 拦截器:
const instance = axios.create({
timeout: 60000,
})
instance.interceptors.request.use(
(config) => {
let { url, data, headers } = config
let isFormData = data instanceof FormData
let reqData = data
let reqHeaders = headers
// hasUrlNeedEncrypt 方法传入url后会返回一个布尔值,
// 用于判断该url是否需要走加密流程(像第一次进页面获取sm2公钥的接口就不需要)
let needEncrypt = hasUrlNeedEncrypt(url)
if (!isFormData && needEncrypt) {
// 通过smEncrypt方法加密数据
reqData = smEncrypt(data)
// 加密接口需要的特殊 headers(看后端是否需要)
reqHeaders = {
...reqHeaders,
'X-NON-AUTH': 'XXXXX'
}
}
return {
data: reqData,
headers: reqHeaders,
...config,
}
},
(error) => {
return Promise.reject(error)
},
)
在 request 拦截器中,如果判断接口需要加密,则走 smEncrypt 这个加密方法,smEncrypt 这个方法做了以下这些事情:
import { sm2, sm3, sm4 } from 'sm-crypto'
const smEncrypt = (data) => {
// 获取接口A拿到的sm2公钥,用于最后加密
let publicKey = localStorage.getItem('publicKey')
// 传给后端的body数据
let originData = {
data: JSON.stringify(data),
}
// 获取签名秘钥,登录成功后拿到的数据会有一个signPrivateKey值,
// signPrivateKey值是经过sm4解密,所以需要调用sm4.decrypt进行解密,解密得到用于sm2签名的私钥,
// 登录接口是没有这个值的所以不进行sm2签名操作。
let signPrivateKey = localStorage.getItem('signPrivateKey') ?? ''
// 解密后的用于sm2签名私钥
let decryptPrivateKey = ''
if (signPrivateKey !== '') {
decryptPrivateKey = sm4.decrypt(
signPrivateKey,
'XXXX', // 解密密钥,前端代码写死
{
mode: 'cbc', // 使用 cbc 解密模式
iv: 'XXXX', // 初始向量,前端代码写死
},
)
}
// 进行sm2签名操作,将结果赋值给originData
if (decryptPrivateKey !== '') {
originData.sign = sm2.doSignature(JSON.stringify(data), privateKey, {
hash: true,
der: true,
})
}
// 最后进行data的sm2加密
let sm2EncryptData = sm2.doEncrypt(JSON.stringify(data), publicKey, 1)
return sm2EncryptData
}
代码中间部分的 sm4.decrypt 方法解密时 cbc 模式使用了一个初始向量(iv),后端加密时指定了 iv,解密时也需要相同的 iv。
代码最后的 sm2.doEncrypt 方法用于使用 SM2 公钥加密数据。该方法接受三个参数:
其中第三个参数 cipherMode 是可选的,如果不指定,则默认使用 01 表示使用 SM2 推荐的填充方式和随机数生成算法。cipherMode 参数可以是以下值之一:
以上就是整个加密的流程,下面是解密流程,首先还是看 response 拦截器:
instance.interceptors.response.use(
(response) => {
let { config, data } = response
let reqDecrypData = {}
// 如果是字符串加密数据 且 是需要加解密处理的请求URL 则进行解密
if (isString(data.resData) && hasUrlNeedEncrypt(config.url)) {
let { decryptResult, error } = smDecryptData(data.data, data.hash)
reqDecrypData = decryptResult
if (error) {
// 数据完整性被破坏处理
}
}
return { ...response, data: reqDecrypData }
},
(error) => {
Promise.reject(error)
},
)
然后是解密方法,接口接收两个参数,一个是加密后的数据,一个是数据哈希值:
export const smDecryptData = (resData, resHash) => {
// 数据完整性验证
let currentSM3Hash = sm3(resData)
let error = currentSM3Hash !== resHash
// 数据解密
let decryptResult = JSON.parse(
sm4.decrypt(
resData,
'XXXX', //解密秘钥,前端代码写死
{ mode: 'cbc', iv: 'XXXX' } //iv向量,前端代码写死
)
)
return {
decryptResult,
error
}
}
首先接口响应会返回一个加密数据和一个哈希值,拿加密数据进行 sm3 计算的结果对比获取的哈希值是否一致,不一致说明数据有误。然后在进行 sm4 解密处理,这里的解密秘钥和 iv 偏移量也是前端的固定字符串变量由前端进行保存。
到这里一次完整的加解密流程就完成了,其实本文中 sm4 的解密秘钥和iv向量直接写在前端代码中是也是不是绝对安全的,即使是进行秘钥字符串分割成不同的变量存储配合打包构建工具的代码混淆。

