前端开发中RSA与AES加密组合应用所面临的挑战😎

899 阅读11分钟

本篇文章分享一下前端在做数据加解密过程中遇到的问题🤦🤦🤦。

这是我的版本:

image.png

在这篇文章中,有我在开发过程中前期准备工作遇到的问题。如果大家对加解密的流程搞不清楚可以看下面这篇文章

👉👉👉前端实现RSA和AES中遇到的问题

加解密的流程🙋

这是RSA+AES加解密第一步,全流程如下:

  1. 客户端获取服务端公钥证书

整体流程

  • 第一步🙀:客户端请求服务端的公钥证书
  • 服务端生成RSA密钥对并生成唯一标识
  • 把服务端公钥和唯一标识返回给客户端
  • 客户端接收服务端公钥,保存唯一标识
  • 客户端生成自己的RSA密钥对
  • 客户端用服务端公钥对自己的公钥加密
  • 客户端将唯一标识和密文传给服务器
  • 第二步😲:服务器解开客户端的密钥
  • 服务器生成最终的AES加密密钥
  • 服务器根据唯一标识从数据库取出客户端公钥
  • 服务器用客户端的公钥对AES加密密钥加密
  • 服务器把密文返回给客户端
  • 客户端用自己的私钥解密,得到最终的AES加密密钥
  • 以后要传输数据,用AES加密密钥对数据加解密
  • 在AES有效期内不用服务端再次生成密钥对,只用加密密钥加解密就可以了
  • 服务端检查AES密钥有效期,如果过期则给客户端返回错误码,要求客户端重新申请服务端公钥,重新走一遍流程。

RSA+AES加密流程图.jpg

RSA+AES.jpg

开发中遇到的问题🙃

我们在开发中,不是对数据全部进行加解密,而是对单独某一个接口的某数据进行加密,所以就不能再拦截器中进行全部数据加密,这样的话所有数据进行了加密,会造成不必要的麻烦😧。

我们在配置部分数据进行加密过程中也遇到很多问题,首先的是,我们在业务流程中发起的请求中,就需要得到最终的AES KEY,那么怎么在发送业务请求之前获取到秘钥呢,我是这样做的在拦截器中进行业务请求的拦截,需要判断仓库中是否有秘钥(AES KEY)如果没有,则发送请求AES KEY的请求。

但是这里就出现了一个问题,那就是我们发送获取AES KEY的请求也要走这个拦截器,拦截器也会去判断仓库中是否有秘钥,这样就导致了没有一个请求能够成功发送出去。就会出现一直循环,出现死循环的结果。我的解决方案是将请求加解密的方法独立出去,也就单独给他们配置一个拦截器,这样就使业务和加解密的方法走了两个拦截器,这样问题就解决了

下面的是业务拦截器:👇👇👇👇

import axios from 'axios'
import { useUserStore } from '@/stores/index'
import { GetAESKEY } from './encryption'

// 配置基地址
const request = axios.create({
  baseURL: '', // 任何的请求都会被拼上api
  timeout: 30000,
})

// 添加请求拦截器
request.interceptors.request.use(
  function (config) {
    const store = useUserStore()
    if (!store.AESKEY && config.url.indexOf('GetPublicKey') === -1) GetAESKEY() // 如果没有AES KEY 则需要再请求之前重新申请一遍
    config.headers['uniqueId'] = store.uniqueid
    return config
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error)
  }
)

// 添加响应拦截器
request.interceptors.response.use(
  function (response) {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    // 如果状态码是-401表示AES KEY失效,需要重新申请
    return response.data
  },
  function (error) {
    console.log('错误的状态码:', error)
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error)
  }
)

export default request

下面是加解密请求的拦截器👇👇👇👇

import axios from 'axios'
import { useUserStore } from '@/stores/index'

// 配置秘钥基地址

const http = axios.create({
  baseURL: '', // 开发数据 任何的请求都会被拼上api
  timeout: 30000,
})

// 添加请求秘钥拦截器
http.interceptors.request.use(
  function (config) {
    const store = useUserStore()
    if (config.url.indexOf('GetPublicKey') === -1) {
      config.headers['uniqueId'] = store.uniqueid
    } // 如果没有AES KEY 则需要再请求之前重新申请一遍
    return config
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error)
  }
)

export default http

上面的代码我为什么需要再配置一个拦截器呢,其实如果你加解密的数据中没有额外请求头参数是不需要配置另外的一个拦截器的只需要配置基地址baseURL即可但是我的项目中需要再请求aes key的时候请求头携带uniqueId(唯一标识)所以我另外配置了一个拦截器。

但是在拦截器中我们就需要进行判断,判断请求是否是获取服务端公钥的请求🙍也就是下面这行代码:

if (config.url.indexOf('GetPublicKey') === -1) {
      config.headers['uniqueId'] = store.uniqueid
    } // 如果没有AES KEY 则需要再请求之前重新申请一遍

处理器这部分解决了还有另外一些问题,就是数据进行加解发送问题AES KEY的时效问题

获取最终的AES KEY

import CryptoJS from 'crypto-js'
import { JSEncrypt } from 'encryptlong'
import { GetPublicKey, GetCipherText, DataEncrtpt } from '@/api/encryption.js'
import { useUserStore } from '@/stores/index.js'

/**
 * RSA 公钥加密
 *
 * @param content 待加密数据
 * @param publicK1ey 公钥
 * @returns {string} 加密结果
 */
export function rsaEncrypt(content, publicKey) {
  var encrypt = new JSEncrypt()
  encrypt.setPublicKey(publicKey)
  return encrypt.encryptLong(content)
}

/**
 * RSA 私钥解密
 *
 * @param content 待解密数据
 * @param privateKey 私钥
 * @returns {string} 解密结果
 */
export function rsaDecrypt(content, privateKey) {
  var encrypt = new JSEncrypt()
  encrypt.setPrivateKey(privateKey)
  return encrypt.decrypt(content)
}

/**
 * AES加密
 *
 * @param content 待加密的内容
 * @param secretKey 密钥
 * @param iv 初始向量
 * @returns {string} 加密结果
 */
export function aesEncrypt(content, secretKey, iv) {
  return CryptoJS.AES.encrypt(content, CryptoJS.enc.Utf8.parse(secretKey), {
    iv: CryptoJS.enc.Utf8.parse(iv),
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  }).toString()
}

/**
 * AES解密
 *
 * @param content 待解密的内容
 * @param secretKey 密钥
 * @param iv 初始向量
 * @returns {string} 解密结果
 */
export function aesDecrypt(content, secretKey, iv) {
  return CryptoJS.AES.decrypt(content, CryptoJS.enc.Utf8.parse(secretKey), {
    iv: CryptoJS.enc.Utf8.parse(iv),
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  }).toString(CryptoJS.enc.Utf8)
}

// 获取AES KEY
export const GetAESKEY = async () => {
  const store = useUserStore()
  const rsa = new JSEncrypt()
  const res = await GetPublicKey() // pubKey公钥1
  store.uniqueid = res.data.data.uniqueId
  let pubKey2 = rsa.getKey().getPublicKey() // 客户端公钥
  let prikey2 = rsa.getKey().getPrivateKey() // 客户端私钥
  console.log('服务端公钥:', res.data.data.pubKey)
  console.log('唯一标识:', res.data.data.uniqueId)

  let encPubKey2 = rsaEncrypt(pubKey2, res.data.data.pubKey) // 加密客户端公钥
  const data = { encPubKey2 }
  const val = await GetCipherText(data)
  store.keyLimit = Date.now()
  store.encIv2 = rsaDecrypt(val.data.data.encIV2, prikey2)
  store.AESKEY = rsaDecrypt(val.data.data.encKey2, prikey2)
  console.log('最终的AES KEY是:', store.AESKEY)
}

其中在在获取AES KEY之前需要调用请求服务端公钥的方法,将服务端返回的服务端公钥存起来,同时这个接口还会返回唯一标识

数据进行加密

这个问题也是比较头疼的,因为并不是全部数据进行加密,只是部分数据加密,所以数据加密的方法不能写在拦截器中,而是在每一个需要请求接口的时候手动进行数据加密。🙁🙁🙁

下面是独立出来的数据加密的方法👇👇👇👇

/**
 * 数据进行加密
 * @param data 需要加密的数据
 * @returns { String } 加密后的数据
 */
export const dataEncryption = (data) => {
  const store = useUserStore()
  return aesEncrypt(data, store.AESKEY, store.encIv2)
}

这里加密的方法是使用仓库中的的AES KEY 和仓库中的加密的向量encIv2进行数据加密。 注意这里有一个坑,加密的数据必须是字符串,不可以是对象数组或者数字,否则加密都会失败

数据加密后发送问题

这里就简单的使用登录请求的业务来展示一下数据加密并放在业务请求参数中。

const getLogin = async () => {
  // 进行数据加密:
  const loginParams = {
    type: type.value,
    mobile: dataEncryption(form.value.mobile),
    verifyCode: dataEncryption(form.value.verifyCode),
    verifyCodeId: dataEncryption(verifyCodeid.value),
  }
  let res
  if (type.value === 0) {
    // 手机号密码登录
    loginParams.password = dataEncryption(sm3(form.value.password))
    console.log('加密的密码:', loginParams.password)
    res = await login(loginParams)
    if (res.code === 1) {
      ElMessage.success('登录成功 !')
      emit('cancelLogin', false)
    } else if (res.code === -2007) {
      ElMessage.warning('手机号已注册,但未设置密码,请用短信验证码登录 !')
    } else if (res.code !== -500) {
      ElMessage.warning('出错了,请联系工作人员!')
    } else {
      ElMessage.warning(res.msg)
    }
    console.log(res)
  } else if (type.value === 1) {
    // 手机号验证码登录
    loginParams.mobileCode = dataEncryption(form.value.mobileCode)
    loginParams.smsId = dataEncryption(smsid.value)
    res = await login(loginParams)
    if (res.code === 1) {
      ElMessage.success('登录成功 !')
      emit('cancelLogin', false)
    } else if (res.code === -2007) {
      ElMessage.warning('手机号已注册,但未设置密码,请用短信验证码登录 !')
    } else if (res.code !== -500) {
      ElMessage.warning('出错了,请联系工作人员!')
    } else {
      ElMessage.warning(res.msg)
    }
  }

  if (res && res.data) {
    store.nickName = res.data.nickName
    store.useravatar = res.data.avatar
    store.userId = res.data.userId
  }
}

上面的代码中因为我需要判断是手机密码登录还是短信验证码登录,所以判断了type的值。因为我的项目中密码比较重要,所以在使用对称加解密和非对称加解密之前还需要使用SM3进行密码加密,以保证程序足够安全。

SM3加密

SM3加密也很简单🤤🤤🤤

  1. 首先需要下载对应的库

yarn add sm-crypto或者npm install sm-crypto --save

2.然后引用,并使用即可

<script setup>
import { sm3 } from 'sm-crypto';
import { ref, onMounted } from 'vue'

onMounted(() => {
    console.log('使用sm3进行数据加密后的结果是:',sm3('12345678'))
})
<script>

你就可以看到这个加密的数据了

image.png

AES KEY的时效问题

这个问题,也是很让人头疼的,我们团队一次又一次的尝试废弃了好多方案😬😬😬。 终于经过不断地尝试,不断地实验,确定了下面这个方案🤤🤤🤤

在我们请求AES KEY的方法中,我们首先会去请求服务端公钥,我们在服务端公钥时候,记录下当前的时间戳,然后在进行需要加密的业务请求时候也会记录一个时间戳,然后进行对比,如果时间戳,小于AES KEY的时效,我们就重新请求。但是怎么解决客户第一次进入这个请求,并没有发送请求获取AES KEY从而没办法记录时间戳呢?我们可以在业务拦截器的第一步进行判断,判断仓库中是否有AES KEY也就是我们说的秘钥,如果没有则发起一个请求.

拦截器中的代码上面有,大家可以往上边翻一翻

  1. 首先是在请求服务端公钥的时候记录时间戳,并存放到仓库中
export const GetAESKEY = async () => {
  const store = useUserStore()
  const res = await GetPublicKey() // pubKey公钥1  
  store.aesTimeLimit = res.data.data.aesTimeLimit
  store.uniqueid = res.data.data.uniqueId
  store.keyLimit = Date.now() // 存放时间戳,用于进行判断时效
}
  1. 为了方便代码的复用,我将判断时效的代码抽离成了函数
// 判断当前的AES KEY是否过期
export const isExpire = async () => {
  const store = useUserStore()
  const time = Date.now() - store.keyLimit
  const diffInMillisecs = Math.abs(time)
  console.log('仓库中的时间:', store.aesTimeLimit)
  const aesTimeLimit = store.aesTimeLimit * 1000 - 300
  if (diffInMillisecs > aesTimeLimit) {
    await GetAESKEY()
  }
}

在调用函数的时候先获取了当前额时间戳,然后进行判断。

  1. 函数的调用时机

因为我们要进行数据加密,所以上面的函数需要在数据加密之前进行调用,为了保证在重新请求回来新的秘钥,并更新后在进行数据加密,我们需要使用es6中新提出的async 和 await将函数标记成异步函数等待这个函数执行完毕

// 发送手机验证码
const send = async () => {
  if (!form.value.mobile) {
    ElMessage.error('请填写手机号后获取验证码!')
  } else if (/^1[3-9]\d{9}$/.test(form.value.mobile) == false) {
    ElMessage.error('请填写正确的手机号后获取验证码!')
  } else {
    console.log('点击了发送手机验证码')
    await isExpire() // 这里等待判断时效函数执行完后,才会执行下面的业务请求逻辑函数
    getMobileCode()
  }
}

注意我们在进行数据加密的时候,会遇到这样的问题:

image.png

core.js:335 Uncaught (in promise) RangeError: Invalid array length

首先我们需要先排除自己代码的问题(比如创建 Array 实例时使用了一个无效的长度值,或者操作数组的时候将数组长度设置为无效值等等...)

如果确定了自己写的代码没有任何问题,那么问题就是出现在数据加密的位置,原因是数据加密的时候,那个方法不能加密数字,如果直接加密数字的话,就会出现这个报错。

想要解决也很简单,你需要将数字转换为字符串后再加密就可以了,后端也是将数据解密后,将字符串转换为数字。

image.png

数据解密🉑

加密的流程跑通了,下面就是整个步骤的最后一步,那就是数据解密了,数据解密的流程通过了才算是真的解决了这个大问题。想要数据解密,一个一个数据解密是比较浪费时间和人力的,所以我们需要一个函数,自己通过递归调用循环等等来实现对全部数据进行解密。

/**
 * AES解密
 *
 * @param content 待解密的内容
 * @param secretKey 密钥
 * @param iv 初始向量
 * @returns {string} 解密结果
 */
export function aesDecrypt(content) {
  const store = useUserStore();
  return CryptoJS.AES.decrypt(content, CryptoJS.enc.Utf8.parse(store.AESKEY), {
    iv: CryptoJS.enc.Utf8.parse(store.encIv2),
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  }).toString(CryptoJS.enc.Utf8);
}

// 解密对象
const decodeObject = (obj) => {
  for (let key in obj) {
    if (typeof obj[key] === 'string') {
      // 解密字符串
      obj[key] = aesDecrypt(obj[key]);
    } else if (typeof obj[key] === 'object') {
      // 如果是数组或对象,递归处理
      obj[key] = Array.isArray(obj[key]) ? decodeArray(obj[key]) : decodeObject(obj[key]);
    }
  }
  return obj;
};
// 解密数组
const decodeArray = (arr) => {
  return arr.map((item) => {
    if (typeof item === 'string') {
      // 解密字符串
      return aesDecrypt(item);
    } else if (typeof item === 'object') {
      // 如果是对象或数组,递归处理
      return Array.isArray(item) ? decodeArray(item) : decodeObject(item);
    } else {
      // 其他类型的值保持不变
      return item;
    }
  });
};
// 数据解密
export const decodeData = (data) => {
  if (typeof data === 'string') {
    return aesDecrypt(data);
  }
  if (Array.isArray(data)) {
    return decodeArray(data);
  } else if (typeof data === 'object' && data !== null) {
    return decodeObject(data);
  } else {
    // 如果不是对象或数组,直接返回原数据
    return data;
  }
};

当我们进行数据解密的时候只需要调用一次decodeData这个函数,然后把需要解密的数据当成参数传递进去就可以了。

因为项目还在开发,开发过程中一定还会遇到问题,我会随时更新😎😎😎😎😎