更新记录
1.0.0(2020-05-21) 下载此版本
项目初建
平台兼容性
项目相关
使用场景
- 如果只是为了防止用户数据泄露,有条件用https,那不要犹豫,赶快买个证书。
- 但是https也有局限性,加密层位于http层(应用层)和tcp层(传输层)之间, 所以抓到的http层的数据并没有加密。
单独加密的弊端
- 单独用RSA非对称加密的话,客户端解密的时候需要用到私匙,这样无异于裸奔,使得整个加密毫无意义,除非你客户端只加密不解密,服务器直接返回明文,但这样就不是双向加密了
- 单独用AES对称加密的话,加密解密用同一个密匙,密匙就在客户端放着,也是裸奔
AES + RSA 加密思路
- 在启动APP时,本地随机生成AES密匙,不做持久化存储。
- 通信的时候,将AES密匙通过RSA加密发送给服务器,将通信内容用过AES加密发送给服务器,这样服务器通过RSA解密得到AES密匙,再通过AES解密得到通信明文内容。
- 返回数据的时候,服务器通过AES加密返回密文,客户端用过AES解密得到明文。这样,抓包是无法获取AES密匙的,AES密匙只存在于本地内存中
- 总结来说,就是通过AES加密通信内容,RSA加密AES密匙
目录结构
- js_sdk/encryption/crypto-js // AES加密库
- js_sdk/encryption/jsencrypt // RSA加密库
- js_sdk/encryption/utils.js // 封装好的 AES, RSA加密方法
使用方法(这里只是提供一种参考方法)
- 导入插件
- Vuex的modules模块里,初始化AES密匙,存做全局变量,例如我创建了 /store/modules/app.js 模块(对Vuex不甚明白的,看Vuex官方文档)
import { initAesKey, rsaEncrypt } from '@/js_sdk/encryption/utils'; const state = { publicKey: null, // RSA加密公匙 aesKey: null, // AES加密密匙 aesEncryptKey: null, // AES加密密匙的RSA加密字符串 } const mutations = { SET_PUBLICKEY: (state, publicKey) => { state.publicKey = publicKey }, SET_AESKEY: (state, aesKey) => { state.aesKey = aesKey }, SET_AESENCRYPTKEY: (state, aesEncryptKey) => { state.aesEncryptKey = aesEncryptKey }, } const actions = { // 初始化请求,获取RSA公匙 async initPublicKey({ commit }) { // 这里的AppApi是我事先封装好的网络请求,用来向服务器获取公匙的,你要替换成你的网络请求 const res = await AppApi.getPublicKey() if (res.code) { // 存下RSA公匙 commit('SET_PUBLICKEY', res.data.key) // 随机生成AES密匙,并存下来 commit('SET_AESKEY', initAesKey()) // 将AES密匙进行RSA加密,并存下来 commit('SET_AESENCRYPTKEY', rsaEncrypt(state.aesKey, state.publicKey)) } return res; } } export default { namespaced: true, state, mutations, actions }
- 在main.js中调用app.js里的initPublicKey方法
// 初始化通信公匙 store.dispatch('app/initPublicKey').then((res) => { // 获取公匙完成,开始加载网页 app.$mount() })
- 完成2,3两步,现在你的APP在启动之时,即初始化了publicKey,aesKey,aesEncryptKey三个变量,接着我们来到http拦截器(如果你没有用到http拦截器,强烈建议你去插件市场搜索一款)
- 这里是我的http拦截器代码
import { aesEncrypt, aesDecrypt } from '@/js_sdk/encryption/utils'; /** * 全局配置 * 只能配置 静态数据 * `content-type` 默认为 application/json * `header` 中`content-type`设置特殊参数 或 配置其他会导致触发 跨域 问题,出现跨域会直接进入响应拦截器的catch函数中 */ export const config = { header: { // 这里要注意,请求内容设置为 "text/plain",不要是其他的例如键值对类型 contentType: "text/plain" } } /** * 全局 请求拦截器, 支持添加多个拦截器 * 例如: 配置token、添加一些默认的参数 * * `return config` 继续发送请求 * `return false` 会停止发送请求,不会进入错误数据拦截,也不会进入请求对象中的catch函数中 * `return Promise.reject('xxxxx')` 停止发送请求, 会错误数据拦截,也会进入catch函数中 * * @param {Object} config 发送请求的配置数据 */ globalInterceptor.request.use( config => { if(Vue.prototype.$store.getters.aesEncryptKey) { // 将AES密匙(RSA加密后)放入请求头中 config['header']['aes-key'] = Vue.prototype.$store.getters.aesEncryptKey; if(!config['data']) { config['data'] = {}; } // 将时间戳放入请求内容里面 config['data']['timetoken'] = Vue.prototype.$store.getters.timestamp; // 将请求内容字符串化,进行AES加密 let data = aesEncrypt(JSON.stringify(config['data']), Vue.prototype.$store.getters.aesKey); // 将请求内容替换成加密后的字符串,如果你的请求内容是键值对类型,这里会出错的 config['data'] = data; } return config; }, err => { console.error("is global fail request interceptor: ", err); return false; } ); /** * 全局 响应拦截器, 支持添加多个拦截器 * 例如: 根据状态码选择性拦截、过滤转换数据 * * `return res` 继续返回数据 * `return false` 停止返回数据,不会进入错误数据拦截,也不会进入catch函数中 * `return Promise.reject('xxxxx')` 返回错误信息, 会错误数据拦截,也会进入catch函数中 * * @param {Object} res 请求返回的数据 * @param {Object} config 发送请求的配置数据 * @return {Object|Boolean|Promise<reject>} */ globalInterceptor.response.use( (res, config) => { if (res.statusCode == 200) { const code = parseInt(res.data.code); if(code) { // 如果有code,说明返回的是明文,这种请求也就是在初始化获取RSA公匙的那一次才会有 return res.data; }else{ // AES解密 let content = aesDecrypt(res.data, Vue.prototype.$store.getters.aesKey); // 明文json化,返回 return JSON.parse(content) } } else { uni.showModal({ title: "请求错误", showCancel: false, content: "errMsg:" + res.errMsg + "\nstatusCode:" + res.statusCode }) return Promise.reject(res, config); } }, (err, config) => { // 请求错误, 有可能服务器没开, 有可能是跨域问题, 情况非常多, 不可能一一进行逻辑处理, 只能弹窗提示 uni.showModal({ title: "请求异常", content: err.errMsg, showCancel: false }) return Promise.reject(err); } );
- 以上就是js端的代码,仅作参考,请根据自己代码进行调整
- 后端代码,这里暂时只提供php代码作为参考,以下是thinkphp6.0框架的模块中间件代码
<?php namespace app\api\middleware; use app\common\model\db\WebConfig; use app\Request; class Encryption { /** * 处理请求 * @param $request * @param \Closure $next * @return mixed|\think\response\Json */ public function handle(Request $request, \Closure $next) { $aesKey = $request->getAesKey(); if ($aesKey && $aesKey != 'null') { // 获取RSA私匙 $privateKey = WebConfig::getRSAPrivateKey(); // $encrypted为需要解密的数据(如果加密的时候用了base64,这里则需要解码),$decrypted为解密后的数据,$privateKey同上,为私钥密钥 openssl_private_decrypt(base64_decode($aesKey), $decrypted, $privateKey); // 解密之后的字符串 $decrypted ,为aes密匙 $request->aesKey = $decrypted; $content = $request->getContent(); // aes解密 $_content = aesDecrypt($content, $decrypted); // 转json $contentArr = json_decode(trim($_content), true); if ($contentArr) { foreach ($contentArr as $k => $v) { $request->$k = $v; } } if ($request->timetoken) { // 这里的时间,请自己根据实际情况做调整,毕竟timetoken这是客户端时间,客户端时间很有可能不是准的,我是在客户端弄了socket连接通过心跳来校准时间 if (time() - $request->timetoken > 10 || time() - $request->timetoken < -5) { // 时间异常 return json(config(TimeException)); } } else { return json(config(TimeException)); } return $next($request); } else { // 没有AES密匙,要么非法,要么当前是 getPublicKey 方法 $pathinfo = $request->pathinfo(); $urls = explode('/', $pathinfo); $controller = $urls[0]; $action = $urls[1]; if ($controller == 'web' && $action == 'getPublicKey') { // 如果当前是在行获取公匙的路由,那么可以放行 return $next($request); } else { return json(config(AESNo)); } } } }
- php的RES加密解密方法如下
/** * AES解密 * @param $content * @param $key * @return false|string */ function aesDecrypt($content, $key) { return openssl_decrypt($content, 'AES-256-CBC', $key, OPENSSL_ZERO_PADDING, 'ZZWBKJ_ZHIHUAWEI'); } /** * AES加密 * @param $content * @param $key * @return false|string */ function aesEncrypt($content, $key) { return openssl_encrypt($content, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, 'ZZWBKJ_ZHIHUAWEI'); }
- thinkphp6的统一返回管理
/** * 返回给前端数据标准化接口 * @param $data * @param $msg * @param $code * @param $key * @return false|string|\think\response\Json */ function apidata($data, $msg, $code, $key) { $content = [ "data" => $data, "msg" => $msg, "code" => $code, ]; if ($key) { return base64_encode(aesEncrypt(json_encode($content), $key)); } else { return json($content); } }
发散思维
- 我这里是AES密匙这一个应用周期里是重复使用的,服务器重复进行解密获取密匙,其实可以在服务器把AES密匙缓存起来,通过token对比,来获取密匙,这样可以减少服务端的工作量
- 当然,你也可以一次请求用一次密匙,每次请求都随机生成一个AES密匙,加密发送,这样增加了客户端的工作量
- AES密匙只存在于本地的内存中,理论上来说,只有用户打开控制台进行变量打印才能知道该密匙,或者黑客控制了该电脑从内存里读出密匙,不要对AES密匙进行本地持久化缓存处理,这样既没必要也增加了AES密匙泄漏的风险
一些坑
- 主要坑在前后端的加密解密上,js加密php解密,php加密js再解密,因为事先没接触这些,走了很多弯路,以下列出一些对应关系
- AES加密的参数mode,在php与js端要保持一致,我这里用的CBC
- AES加密的padding值,js的CryptoJS.pad.ZeroPadding对应php的OPENSSL_ZERO_PADDING,js的CryptoJS.pad.Pkcs7对应php的OPENSSL_RAW_DATA
- AES加密方法,当密匙长度为32时为‘AES-256-CBC’,16位时则是‘AES-128-CBC’
致谢
- request网络请求,拦截器用的就是这里面的
- jsencrypt RSA 加密/解密,js_sdk/encryption/jsencrypt文件夹,RSA加密库,就是用的该项目