Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端数据加密请求流程实践 #13

Open
bouquetrender opened this issue Apr 19, 2023 · 0 comments
Open

前端数据加密请求流程实践 #13

bouquetrender opened this issue Apr 19, 2023 · 0 comments

Comments

@bouquetrender
Copy link
Owner

bouquetrender commented Apr 19, 2023

由于项目要求,前端需要使用国密算法对请求数据字段进行加密,再发送给后端。同样前端接收数据需要先解密数据后,才能拿到数据明文,所以选用 sm-crypto 库。sm-crypto 是一个基于国密算法(也称为商用密码)的工具库,用于进行加密、解密、签名和验证操作,以下是 sm-crypto 库提供的主要功能:

  • SM2 密钥对生成、加密、解密和签名验证
  • SM3 哈希算法
  • SM4 分组密码算法

这里以第一次进入页面并点击登录来看整个流程是如何实现的。

首先当页面加载完成后,会首先请求一个无需加密处理的接口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 公钥加密数据。该方法接受三个参数:

  • msg:需要加密的数据,可以是字符串或 Buffer 对象。
  • pubKey:SM2 公钥,可以是字符串或 Buffer 对象。如果是字符串,则需要使用 '04' 开头表示公钥的未压缩格式,或者使用 '02' 或 '03' 开头表示公钥的压缩格式。
  • cipherMode:加密模式,可以是字符串或 Number 类型,用于指定加密时使用的填充方式和随机数生成算法。

其中第三个参数 cipherMode 是可选的,如果不指定,则默认使用 "01" 表示使用 SM2 推荐的填充方式和随机数生成算法。cipherMode 参数可以是以下值之一:

  • "00":表示不使用填充,直接使用加密结果作为密文。此模式不够安全,不建议使用。
  • "01":表示使用 SM2 推荐的填充方式和随机数生成算法,即 ASN.1 编码的 SM2 加密算法。
  • "02":表示使用 NoPadding 填充方式和随机数生成算法。
  • "03":表示使用 PKCS1 填充方式和随机数生成算法。

以上就是整个加密的流程,下面是解密流程,首先还是看 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向量直接写在前端代码中是也是不是绝对安全的,即使是进行秘钥字符串分割成不同的变量存储配合打包构建工具的代码混淆。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant