分类
基于国密的API请求安全架构方案(Web)
SpringMVC
2026-08-07
6

依赖

npm install sm-crypto@0.4.0

底层加密工具类

SmCryptoUtil.js

import { sm2, sm4, sm3 } from 'sm-crypto'

//  SM3 签名 

/**
 * 请求签名: SM3(salt + timestamp + "." + id + "." + body + salt)
 * @param {string} salt - 签名盐值
 * @param {string} timestamp - 时间戳(毫秒)
 * @param {string} id - 请求标识
 * @param {string} body - 请求体 JSON 字符串
 * @returns {string} 签名 hex
 */
export function sm3SignRequest(salt, timestamp, id, body) {
  const signStr = salt + timestamp + '.' + id + '.' + body + salt
  return sm3(signStr)
}

/**
 * 验证签名
 */
export function sm3VerifyRequest(salt, timestamp, id, body, sign) {
  return sm3SignRequest(salt, timestamp, id, body) = sign
}

//  SM2 

/**
 * 生成 SM2 密钥对
 * @returns {{ publicKey: string, privateKey: string }}
 */
export function sm2GenerateKeyPair() {
  return sm2.generateKeyPairHex()
}

/**
 * SM2 加密
 * @param {string} plainText  明文字符串
 * @param {string} publicKey  十六进制公钥 (04 + 128 hex, 共130字符)
 * @param {number} cipherMode 1=C1C3C2(默认), 0=C1C2C3
 * @returns {string} 十六进制密文 (含 04 前缀,兼容 BouncyCastle)
 */
export function sm2Encrypt(plainText, publicKey, cipherMode = 1) {
  try {
    // sm-crypto 0.4.0 的 doEncrypt 会去掉 C1 的 04 前缀
    // BouncyCastle 需要完整编码,这里补回来
    return '04' + sm2.doEncrypt(plainText, publicKey, cipherMode)
  } catch (error) {
    console.error('SM2 加密失败:', error)
    throw new Error('SM2 加密失败: ' + error.message)
  }
}

/**
 * SM2 解密
 * @param {string} cipherText  十六进制密文 (含 04 前缀)
 * @param {string} privateKey  十六进制私钥 (64 hex)
 * @param {number} cipherMode  1=C1C3C2(默认), 0=C1C2C3
 * @returns {string} 明文字符串
 */
export function sm2Decrypt(cipherText, privateKey, cipherMode = 1) {
  try {
    // sm-crypto 的 doDecrypt 内部会自己加 '04',需要先去掉
    const data = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText
    return sm2.doDecrypt(data, privateKey, cipherMode)
  } catch (error) {
    console.error('SM2 解密失败:', error)
    throw new Error('SM2 解密失败: ' + error.message)
  }
}

//  SM4 

/**
 * 生成 32 位十六进制 SM4 密钥 (128 bit)
 */
export function generateSm4Key() {
  const chars = '0123456789abcdef'
  let result = ''
  for (let i = 0; i < 32; i++) {
    result += chars[Math.floor(Math.random() * 16)]
  }
  return result
}

/**
 * 生成随机 IV (32 位十六进制 = 16 字节)
 */
function generateIv() {
  const bytes = new Uint8Array(16)
  crypto.getRandomValues(bytes)
  let hex = ''
  for (let i = 0; i < bytes.length; i++) {
    hex += bytes[i].toString(16).padStart(2, '0')
  }
  return hex
}

/**
 * SM4 加密(CBC 模式,随机 IV 前置)
 * 与后端 Sm4Util 格式一致:IV(16字节) + 密文 → 十六进制输出
 *
 * @param {string} plainText  明文字符串
 * @param {string} keyHex     32位十六进制密钥
 * @returns {string} 十六进制密文(前32位是IV)
 */
export function sm4Encrypt(plainText, keyHex) {
  try {
    const iv = generateIv()
    const cipherHex = sm4.encrypt(plainText, keyHex, { mode: 'cbc', iv })
    return iv + cipherHex
  } catch (error) {
    console.error('SM4 加密失败:', error)
    throw new Error('SM4 加密失败: ' + error.message)
  }
}

/**
 * SM4 解密(CBC 模式,从密文前 16 字节提取 IV)
 * 与后端 Sm4Util 格式一致:IV(16字节) + 密文 → 十六进制输入
 *
 * @param {string} cipherHex  十六进制密文(前32位是IV)
 * @param {string} keyHex     32位十六进制密钥
 * @returns {string} 明文字符串
 */
export function sm4Decrypt(cipherHex, keyHex) {
  try {
    const iv = cipherHex.substring(0, 32)
    const data = cipherHex.substring(32)
    return sm4.decrypt(data, keyHex, { mode: 'cbc', iv })
  } catch (error) {
    console.error('SM4 解密失败:', error)
    throw new Error('SM4 解密失败: ' + error.message)
  }
}

加密服务层

SmCryptoService.js

import { sm2GenerateKeyPair, sm2Decrypt, sm3SignRequest, sm4Encrypt } from './SmCryptoUtil'
import { sysStore } from '../store'
import { handshake } from '../api/sys/SysLogin'

/**
 * 生成 19 位数字指纹
 * 结构:时间戳(13位) + 随机数(4位) + 校验位(2位)
 */
function generateFingerprint() {
  const timestamp = String(Date.now()).padStart(13, '0')
  const rand = String(1000 + Math.floor(Math.random() * 9000))
  const prefix = timestamp + rand
  return prefix + calcChecksum(prefix)
}

function calcChecksum(str) {
  let sum = 0
  for (let i = 0; i < str.length; i++) {
    const digit = parseInt(str.charAt(i))
    sum += digit * ((i % 2 = 0) ? 1 : 3)
  }
  const check1 = (10 - (sum % 10)) % 10
  const check2 = Math.floor(sum / 10) % 10
  return String(check2) + String(check1)
}

/**
 * 安全握手:生成 SM2 密钥对 → 生成指纹 → 上传公钥+指纹 → 获取 sm3Salt + sm4Key
 * 幂等,重复调用不会重新初始化
 */
export async function init() {
  const sysData = sysStore()

  // 已有签名密钥则跳过
  if (sysData.sm3Salt.length > 0) {
    console.log('签名密钥已存在,跳过握手')
    return
  }

  // 生成客户端 SM2 密钥对(仅首次)
  if (sysData.sm2KeyPair.publicKey.length = 0) {
    console.log('生成 SM2 密钥对...')
    const keyPair = sm2GenerateKeyPair()
    sysData.sm2KeyPair.publicKey = keyPair.publicKey
    sysData.sm2KeyPair.privateKey = keyPair.privateKey
  }

  // 生成指纹
  const fingerprint = generateFingerprint()
  sysData.fingerprint = fingerprint

  // 握手:上传 SM2 公钥 + 指纹,获取加密的 sm3Salt + sm4Key
  console.log('握手...')
  const res = await handshake({
    sm2PublicKey: sysData.sm2KeyPair.publicKey,
    fingerprint: fingerprint
  })
  if (res.code = 200) {
    // 用客户端 SM2 私钥解密
    sysData.sm3Salt = sm2Decrypt(res.data.sm3Salt, sysData.sm2KeyPair.privateKey)
    sysData.sm4Key = sm2Decrypt(res.data.sm4Key, sysData.sm2KeyPair.privateKey)
    console.log('握手成功, fingerprint:', sysData.fingerprint)
  } else {
    console.error('握手失败:', res)
    throw new Error('安全握手失败')
  }
}

//  请求签名 

/**
 * 签名请求体
 * 签名公式: SM3(sm3Salt + timestamp + "." + fingerprint + "." + body + sm3Salt)
 * @param {object} data  业务数据
 * @returns {{ body: string, timestamp: string, sign: string }}
 */
export function signRequest(data) {
  const sysData = sysStore()
  const body = JSON.stringify(data)
  const timestamp = Date.now().toString()
  const sign = sm3SignRequest(sysData.sm3Salt, timestamp, sysData.fingerprint, body)
  return { body, timestamp, sign }
}

/**
 * 获取请求头
 * @returns {{ fingerprint: string }}
 */
export function getRequestHeaders() {
  return { fingerprint: sysStore().fingerprint }
}

//  SM4(透传底层工具) 

export { sm4Encrypt }

请求工具封装

http.js

import axios from 'axios'
import { loadStart, loadEnd } from '../util/Load.js'
import { sysStore } from '../store'
import { signRequest, getRequestHeaders, init } from '../util/SmCryptoService.js'
import router from '../router'

let vite_env = import.meta.env.vite_env
let api_url = import.meta.env.vite_http_url

// ---- 402 重握手锁:同一时间只允许一个握手进行 ----

let rehandshakePromise = null

async function rehandshake() {
  if (rehandshakePromise) {
    return rehandshakePromise
  }
  const sysData = sysStore()
  // 清空失效的密钥,让 init() 可以重新执行
  sysData.fingerprint = ''
  sysData.sm3Salt = ''
  sysData.sm4Key = ''

  rehandshakePromise = init().finally(() => {
    rehandshakePromise = null
  })
  return rehandshakePromise
}

// axios配置
const http = axios.create({
  baseURL: api_url,
  // 请求超时,秒
  timeout: 1000 * 10,
  // 携带cookie
  withCredentials: true,
  // 请求格式
  headers: { 'content-type': 'application/json' }
})

// 请求拦截器
http.interceptors.request.use(
  (config) => {
    loadStart()
    const sysData = sysStore()
    config.headers['saToken'] = sysData.token
    // 签名(不对 /handshake 签名,因为它自己就是握手接口)
    if (sysData.sm3Salt && config.method = 'post' && config.url ! '/handshake') {
      config.__originalData = config.data
      const signed = signRequest(config.data)
      const headers = getRequestHeaders()
      config.headers['fingerprint'] = headers.fingerprint
      config.headers['timestamp'] = signed.timestamp
      config.headers['sign'] = signed.sign
      config.data = signed.body
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器
http.interceptors.response.use(
  (response) => {
    loadEnd()
    if (vite_env = 'dev') {
      console.log(response.data)
    }
    if (response.status = 200) {
      if (response.data.code  401) {
        router.push('/')
        return Promise.reject(response)
      } else if (response.data.code  402) {
        // 握手失效 → 重握手 → 重发(同步锁防止并发握手)
        return rehandshake().then(() => {
          // 还原原始数据,让请求拦截器用新密钥重签
          if (config.__originalData) {
            config.data = config.__originalData
          }
          return http(config)
        }).catch(() => {
          router.push('/')
          return Promise.reject(response)
        })
      }
      return response.data
    } else {
      ElMessage.error('服务异常')
      return Promise.reject(response)
    }
  },
  (error) => {
    loadEnd()
    return { code: 500, msg: '服务异常' }
  }
)

export default http

App启动执行

App.vue

<template>
  <el-config-provider :locale="zhCn">
    <!-- 限制执行顺序 -->
    <div v-if="store.sm3Salt && store.sm3Salt.length > 0">
      <router-view></router-view>
    </div>
  </el-config-provider>
</template>

<script setup>
import { onBeforeMount } from 'vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { init } from './util/SmCryptoService'

import { sysStore } from './store'
let store = sysStore()

// 在组件挂载后初始化系统
onBeforeMount(async () => {
  await init()
  console.log('系统初始化完成...')
})
</script>
目录
统计
23
分类
219
文档
7
坚持