Skip to content

等保3加密

一、什么是等级保护?

网络安全等级保护是指对国家重要信息、法人和其他组织及公民的专有信息以及公开信息和存储、传输、处理这些信息的信息系统分等级实行安全保护。对信息系统中使用的信息安全产品实行按等级管理,对信息系统中发生的信息安全事件分等级响应、处置。

简单来说,就是国家制定的一套网络安全考试大纲,根据信息系统的重要性和被破坏后可能带来的危害程度,将其划分为不同的安全保护等级,并对应不同的保护要求。

二、等级保护的五个级别

根据2019年发布的《信息安全技术网络安全等级保护基本要求》(GB/T22239-2019),等保分为五个级别:

第一级(自主保护级):适用于一般系统,系统遭到破坏后,会对公民、法人和其他组织的合法权益造成损害。本级需要用户自主进行保护。

第二级(指导保护级):适用于一般系统,但系统遭到破坏后,会对公民、法人和其他组织的合法权益造成严重损害,或者对社会秩序和公共利益造成损害。本级在主管部门的指导下进行保护。

第三级(监督保护级):这是我们重点关注的级别。适用于涉及国家安全、社会秩序、公共利益的重要系统。系统遭到破坏后,会对社会秩序和公共利益造成严重损害,或者对国家安全造成损害。本级需要接受国家监督机构的强制监督和检查。

第四级(强制保护级):适用于涉及国家安全、社会秩序、公共利益的高度重要系统。系统遭到破坏后,会对社会秩序和公共利益造成特别严重损害,或者对国家安全造成严重损害。

第五级(专控保护级):适用于涉及国家安全的极端重要系统。系统遭到破坏后,会对国家安全造成特别严重损害。

alt text

三、大致的思路

以前端请求java 为例子。
1、登录接口,返回公钥。同时会也在token里面封装signKey字段。
2、前端生成sm4的32为hex的秘钥,然后用公钥加密(sm2的公钥加密算法),放在请求头encryptkey这个字段里边。
3、appSecretKey、token里面的signKey、时间戳、随机数、排序等一顿操作算出来sm3,也就是摘要。
4、把真实请求的json 加密,通过前端生成sm4的32为hex的秘钥(sm4的算法为:sm4/gcm/12 字节 IV/128bit TAG/AAD = GM_SM4_GCM/密文格式:ivHex:cipherHex/编码:UTF-8 / Hex 小写),
5、最终报文:

body:{
	"encryptData":"bb6b2eb22d0d856d046e504e:040df7f7589a52f810bde30b4b68bb9898081b1b53f27efa810cea994de15cd77cfd0db9ae4f9e98fa30e25b4685271ab61f0e601fae8f55b7c68b2d71de68549b3b1370de276f926788bc7d07879ae94e8386d5d0cca2da2708471990d28afb50abeb0bcb0e2765dfa57b48c1e8d44d699363682fc9e7e4f051dec37e53889bcba25767eb02   "
}
header:
sign:ce8f720217fc7f8d4b11948a59279953d02153d97cb24e844b2e24988b9af296
timestamp:1775210007906
nonce:d6U6HJesMJGVRujQ
encryptkey(前端生成sm4的32为hex的秘钥,然后用公钥加密):04e0bb36c2b7b02892004c231331cd5554cf221cbf455719a74da2188d70c25eb51597b6ca0ccd704753537bb34a4621a7e23ebbe9169c6375a348b5ee099dabc691def45b46e26fdb9aada27bbf476d07dba15e0817209079e5bed83478157b67d0fa12fbf6e1fb1cc0f06da3c9c5192fa900492ce7e6557e4a3d72d82be7c40f

alt text

四、具体代码示例

js
import Axios, { type AxiosInstance, type AxiosRequestConfig, type CustomParamsSerializer } from "axios";
import type { PureHttpError, RequestMethods, PureHttpResponse, PureHttpRequestConfig } from "./types.d";
import { stringify } from "qs";
import NProgress from "../progress";
import { getToken, formatToken, removeToken } from "@/utils/auth";
import { ElNotification, ElMessageBox } from "element-plus";
import router from "@/router";
import { jwtDecode } from "jwt-decode";

import { cloneDeep } from "lodash";
import settings from "@/settings";
import {
  generateSm4Key,
  sm2Encrypt,
  sm4GcmEncrypt,
  sm4GcmDecrypt,
  getNowTimestamp,
  generateNonce,
  hmacSm3Sign
} from "@/utils/SM4-GCM";
import { useAppStore } from "@/store/modules/app";

const sm4KeyMap = {};

function getRequestIdFromConfig(headers: Record<string, unknown> | undefined): string {
  if (!headers) return "";
  const h = headers as Record<string, string>;
  return h.requestId || h.requestid || h["Request-Id"] || "";
}

let isShowingTokenExpiredAlert = false;
const appSecretKey = "eca44a5a258c4c10a4b857f6111615f0";
const defaultConfig: AxiosRequestConfig = {
  timeout: 10000,
  headers: {
    Accept: "application/json, text/plain, */*",
    "Content-Type": "application/json",
    "X-Requested-With": "XMLHttpRequest"
  },
  paramsSerializer: {
    serialize: stringify as unknown as CustomParamsSerializer
  }
};

class PureHttp {
  constructor() {
    this.httpInterceptorsRequest();
    this.httpInterceptorsResponse();
  }
  private static requests = [];
  private static isRefreshing = false;
  private static initConfig: PureHttpRequestConfig = {};
  private static axiosInstance: AxiosInstance = Axios.create(defaultConfig);

  private static retryOriginalRequest(config: PureHttpRequestConfig) {
    return new Promise(resolve => {
      PureHttp.requests.push((token: string) => {
        config.headers["Authorization"] = formatToken(token);
        resolve(config);
      });
    });
  }

  private httpInterceptorsRequest(): void {
    PureHttp.axiosInstance.interceptors.request.use(
      async (config: PureHttpRequestConfig): Promise<any> => {
        NProgress.start();
        const method = config.method?.toLowerCase();
        const data = getToken();

        if (["get", "delete"].includes(method)) {
          const timestamp = new Date().getTime();
          const hasQuery = config.url?.includes("?");
          config.url = `${config.url}${hasQuery ? "&" : "?"}t=${timestamp}`;
        }

        if (config["ContentType"] === "multipart/form-data") {
          config.headers["Content-Type"] = "multipart/form-data";
        }

        if (typeof config.beforeRequestCallback === "function") {
          config.beforeRequestCallback(config);
          return config;
        }
        if (PureHttp.initConfig.beforeRequestCallback) {
          PureHttp.initConfig.beforeRequestCallback(config);
          return config;
        }

        const whiteList = ["/refresh-token", "/login"];
        if (whiteList.some(url => config.url?.endsWith(url))) {
          return config;
        }

        if (!data?.token) return config;
        config.headers["Authorization"] = formatToken(data.token);

        if (settings.enabled) {
          // 强制每个请求独立密钥
          const sm4Key = generateSm4Key();
          const requestId = "REQ_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
          config.headers.requestId = requestId;
          sm4KeyMap[requestId] = sm4Key;

          const encryptkey = sm2Encrypt(sm4Key, useAppStore().getPublicKeyHex);
          config.headers["encryptkey"] = encryptkey;

          const payload = jwtDecode(data.token);
          const signKey = payload.signKey;
          const ts = getNowTimestamp();
          const nonce = generateNonce();
          let signParams = {};

          if (method === "get" || method === "delete") {
            const urlObj = new URL(config.url, window.location.origin);
            urlObj.searchParams.forEach((val, key) => {
              signParams[key] = val;
            });
          } else if (method === "post" || method === "put") {
            if (config.data) {
              signParams = cloneDeep(config.data);
              const encrypted = sm4GcmEncrypt(JSON.stringify(config.data), sm4Key);
              config.data = { encryptData: encrypted };
            }
          }

          signParams.timestamp = ts;
          signParams.nonce = nonce;
          signParams.signKey = signKey;

          const signStr = Object.keys(signParams)
            .filter(k => signParams[k] != null)
            .sort()
            .map(k => {
              const value =
                Array.isArray(signParams[k]) || typeof signParams[k] === "object"
                  ? JSON.stringify(signParams[k])
                  : signParams[k];
              return `${k}=${value}`;
            })
            .join("&");

          const signResult = hmacSm3Sign(signStr, appSecretKey);
          config.headers["sign"] = signResult;
          config.headers["nonce"] = nonce;
          config.headers["timestamp"] = ts;
        }

        return config;
      },
      error => Promise.reject(error)
    );
  }

  private httpInterceptorsResponse(): void {
    const instance = PureHttp.axiosInstance;
    instance.interceptors.response.use(
      (response: PureHttpResponse) => {
        NProgress.done();
        const data = response.data || {};
        let realData = data;
        if (data.encryptData && settings.enabled) {
          try {
            const requestId = getRequestIdFromConfig(response.config.headers as unknown as Record<string, unknown>);
            const key = sm4KeyMap[requestId];
            if (!key) {
              console.error("[http] 解密失败:找不到 requestId 对应的 SM4 密钥", { requestId });
              throw new Error("缺少会话密钥,请重试请求");
            }
            const decrypted = sm4GcmDecrypt(data.encryptData, key);
            realData = JSON.parse(decrypted);
            delete sm4KeyMap[requestId];
          } catch (e) {
            console.error("解密失败:", e);
            throw e;
          }
        }
        if (realData?.code === 401) {
          if (isShowingTokenExpiredAlert) return Promise.reject(realData?.msg);
          isShowingTokenExpiredAlert = true;
          ElMessageBox.confirm("登录已过期,请重新登录", "提示", {
            showCancelButton: false,
            confirmButtonText: "确定",
            type: "warning"
          })
            .then(() => {
              removeToken();
              router.replace("/login");
            })
            .finally(() => {
              isShowingTokenExpiredAlert = false;
            });
          return Promise.reject(realData?.msg);
        }
        if (realData.code !== 200 && !(response.data instanceof Blob)) {
          ElNotification({ message: realData?.msg, type: "error", duration: 1500 });
          return Promise.reject(realData?.msg);
        }
        return response.config.url?.includes("/auditReport/export") ? response : settings.enabled ? realData : data;
      },
      (error: PureHttpError) => {
        NProgress.done();
        return Promise.reject(error);
      }
    );
  }

  public request<T>(
    method: RequestMethods,
    url: string,
    param?: AxiosRequestConfig,
    axiosConfig?: PureHttpRequestConfig
  ): Promise<T> {
    const config = { method, url, ...param, ...axiosConfig } as PureHttpRequestConfig;
    return new Promise((resolve, reject) => {
      PureHttp.axiosInstance.request(config).then(resolve).catch(reject);
    });
  }

  public post<T, P>(url: string, params?: P, config?: PureHttpRequestConfig): Promise<T> {
    return this.request<T>("post", url, { data: params }, config);
  }
  public get<T, P>(url: string, params?: P, config?: PureHttpRequestConfig): Promise<T> {
    return this.request<T>("get", url, { params: params }, config);
  }
  public delete<T, P>(url: string, params?: P, config?: PureHttpRequestConfig): Promise<T> {
    return this.request<T>("delete", url, { params: params }, config);
  }
  public put<T, P>(url: string, params?: P, config?: PureHttpRequestConfig): Promise<T> {
    return this.request<T>("put", url, { data: params }, config);
  }
  public getTime<T, P>(url: string, params?: P, config?: PureHttpRequestConfig): Promise<T> {
    return this.request<T>("get", url, { params, timeout: 3000000 }, config);
  }
}

export const http = new PureHttp();

工具文件

js
import { sm4 } from "sm-crypto-v2";
import { sm2, sm3 } from "sm-crypto";

// ========== 1. 对称加密(前端使用) ==========
const GCM_IV_LENGTH = 12; // 必须12字节
const AAD_STR = "GM_SM4_GCM";

const GCM_TAG_LENGTH = 16; // 128bit = 16字节
/**
 * 前端 SM4-GCM 加密(与 sm-crypto-v2 一致)
 * @param {string} plainText 明文
 * @param {string} keyBase64 16字节SM4密钥(base64)
 * @returns {string} ivBase64:cipherWithTagBase64
 */
export function sm4GcmEncrypt(plainText, keyBase64) {
  // 1. Base64 密钥 → Uint8Array(与 decrypt 侧一致)
  const key = base64ToUint8(keyBase64);

  // 2. 生成 12字节 IV
  const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LENGTH));

  // 3. 明文、AAD 转字节(必须用 associatedData,additionalData 会被库忽略)
  const data = new TextEncoder().encode(plainText);
  const aad = new TextEncoder().encode(AAD_STR);

  // 4. GCM:output:"array" 且未设 outputTag 时只返回密文不含 tag,会导致与后端/tag 校验不一致
  const enc = sm4.encrypt(data, key, {
    mode: "gcm",
    iv,
    associatedData: aad,
    output: "array",
    outputTag: true
  }) as { output: Uint8Array; tag: Uint8Array };

  const cipherWithTag = concatUint8(enc.output, enc.tag);

  const ivBase64 = uint8ToBase64(iv);
  const cipherWithTagBase64 = uint8ToBase64(cipherWithTag);

  return `${ivBase64}:${cipherWithTagBase64}`;
}

/**
 * SM4-GCM 解密
 * @param encryptedBase64
 * @param sm4KeyBase64
 * @returns
 */
export function sm4GcmDecrypt(encryptedBase64, sm4KeyBase64) {
  try {
    const [ivBase64, cipherWithTagBase64] = String(encryptedBase64 || "").split(":");
    const iv = base64ToUint8(ivBase64);
    const cipherWithTag = base64ToUint8(cipherWithTagBase64);
    const key = base64ToUint8(sm4KeyBase64);
    const aad = new TextEncoder().encode(AAD_STR);

    const tag = cipherWithTag.slice(-GCM_TAG_LENGTH);
    const cipherText = cipherWithTag.slice(0, -GCM_TAG_LENGTH);

    const plainBytes = sm4.decrypt(cipherText, key, {
      mode: "gcm",
      iv,
      tag,
      associatedData: aad,
      output: "array"
    });

    return new TextDecoder().decode(plainBytes as Uint8Array);
  } catch (e) {
    console.error("解密失败:", e);
    throw e;
  }
}

/**
 * 生成 SM4 16字节密钥
 * @returns Base64
 */
export function generateSm4Key() {
  const randomBytes = crypto.getRandomValues(new Uint8Array(16));
  return btoa(String.fromCharCode(...randomBytes));
}

// ========== 2. 公钥加密(前端使用) ==========
// @param text 明文字符串
// @param publicKey  SM2公钥
// @return 加密后的十六进制字符串
export function sm2Encrypt(text, publicKeyBase64) {
  // 1. 把【Base64公钥】转成 Hex公钥(sm2 加密必须用 Hex 公钥)
  const publicKeyHex = base64ToHex(publicKeyBase64);

  // 2. SM2 加密(C1C2C3 格式)
  const hexCipher = sm2.doEncrypt(text, publicKeyHex, 1);

  // 3. 拼接 04 前缀(后端通用格式)
  const fullHex = "04" + hexCipher;

  // 4. 转成最终的 Base64 密文返回
  return hexToBase64(fullHex);
}

// ===================== 工具函数 =====================
// Base64 → Hex
function base64ToHex(base64) {
  const uint8Array = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
  return Array.from(uint8Array, b => b.toString(16).padStart(2, "0")).join("");
}

function base64ToUint8(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function concatUint8(a: Uint8Array, b: Uint8Array): Uint8Array {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

/** 大数组安全 Base64(避免 ... 展开参数长度限制) */
function uint8ToBase64(uint8: Uint8Array): string {
  let binary = "";
  for (let i = 0; i < uint8.length; i++) {
    binary += String.fromCharCode(uint8[i]);
  }
  return btoa(binary);
}

export function getNowTimestamp() {
  return Date.now();
}

export function generateNonce(length = 16) {
  const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return result;
}

export function hmacSm3Sign(text: string, key: string): string {
  const encoder = new TextEncoder();
  const keyBytes = encoder.encode(key);
  const textBytes = encoder.encode(text);
  const blockSize = 64;

  let keyPadded = new Uint8Array(blockSize);
  if (keyBytes.length > blockSize) {
    const keyHash = hexToBytes(sm3(keyBytes));
    keyPadded.set(keyHash);
  } else {
    keyPadded.set(keyBytes);
  }

  const ipad = new Uint8Array(blockSize).fill(0x36);
  const opad = new Uint8Array(blockSize).fill(0x5c);
  const ikey = xor(keyPadded, ipad);
  const okey = xor(keyPadded, opad);
  const inner = sm3(concat(ikey, textBytes));
  const outer = sm3(concat(okey, hexToBytes(inner)));
  return hexToBase64(outer.toLowerCase());
}

function hexToBase64(hex: string): string {
  const bytes = new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
  return btoa(String.fromCodePoint(...bytes));
}

function xor(a: Uint8Array, b: Uint8Array): Uint8Array {
  const r = new Uint8Array(a.length);
  for (let i = 0; i < a.length; i++) r[i] = a[i] ^ b[i];
  return r;
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
  const r = new Uint8Array(a.length + b.length);
  r.set(a);
  r.set(b, a.length);
  return r;
}
function hexToBytes(hex: string): Uint8Array {
  const b = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  return b;
}