前言
大家好,借着中秋放假明日又要上班的这个晚上,平常又没空,趁这个时间点就决定来一篇。
我们都知道微信小程序的服务端API上,官方可谓是做足了心思,对用户的数据进行加密,虽然对我们开发者来说似乎是一种麻烦,但是从长远角度来看,是十分有必要的,用户隐私高于一切。
那么在小程序的开发过程中与后端对接接口时是否有想过这样的问题呢?
- HTTPS真的百分百安全吗?
- 数据被成功抓包后安全吗?
- 数据被篡改后还有效吗?
- 请求被重放后安全吗?
世界上没有绝对安全的系统,但我们可以让它被破解的成本变高。
本篇文章专业性并不高,如果存在错误请大家为我指出来,以免误导别人,谢谢!
以下我们将小程序端称为C端,服务端称为S端,服务端代码是Node.js,仅供参考,但原理都一样,后端可以是其它语言。
思考
围绕着以上的问题,探究一下问题的答案?
本部分只对问题做思考,具体实现请参考下面的实现部分。
- HTTPS真的百分百安全吗?只能说是相对安全,当然,在微信小程序的沙箱环境里,HTTPS通信会更加安全,否则官方可能会要求我们对请求加密了对吧。但百密一疏,C端是否存在漏洞,假设C端安全了难道S端就安全嘛?细思恐极,退一万步讲,百分百安全是不存在的,由于篇幅问题相关漏洞大家可以搜索探究。
- 假设数据被中间人成功抓包,如果数据是明文传输,那么将导致数据泄露,因此对数据进行加密是必要的,但应该如何加密呢?如何做到密钥安全?C端和S端如何进行数据的加解密?RSA和AES加密应该使用哪种加密明文?如何充分发挥RSA和AES两个加密算法的特长?
- 假设数据被篡改,如果因为请求数据被篡改而导致严重后果,那么很大程度上其实是代码设计有问题,正常设计中安全应被摆在重要的位置,至少不应该出现购买某样商品时是通过C端发送商品价格给S端调起支付那样(只需篡改商品价格即可支付极少的钱购买商品),如果代码设计并不存在严重问题,那么数据被篡改也是不可忽视的,我们需要进行数据签名,让不同的数据拥有唯一的MD5哈希值,如果数据被篡改,通过哈希值即可判断数据是否被篡改,当然也可能会有人问,既然数据被篡改,那攻击者会不会重新生成一个哈希值代替?是的。但是我们有接下来会说到的key,key会作为签名的数据变量之一,由于攻击者并不知道key值因此无法重新签名,key值建议不是固定值而是周期性更换的随机值,例如随着用户的登录态而产生并随之抹除。
- 假设请求被重放,大部分时候由于数据被加密和防篡改处理过,攻击者并无法直接获得数据或篡改,但如果通过劫持到的登录认证请求的原始数据并重新发起该请求,则攻击者将可能获得重要的认证数据,使得系统将攻击者作为正常用户处理,后续的请求攻击者仍能伪装成正常的用户进行后续攻击。
期望效果
在开始实现之前先看一下完成后的效果,以下是截取了Network中其中一个GET请求发送的数据:
实际上发送的数据是如下图所示:
然后是该请求返回的数据:
实际上返回的数据是如下图所示:
整个流程:
实现
由于整个流程在C端的实现上顺序反过来的,因此下面的步骤也将是反向而行。
工具库下载
工欲善其事,必先利其器!这三个库是经过修改压缩的,支持在小程序上使用并且体积可观(总共69.1KB,如果不涉及密码哈希处理只需要前两个库,体积只有65.3KB),接下来的实现操作将会使用到,建议大家可以根据实际情况对功能进行二次封装:
CryptoJS.js:点击下载
RSA.js:点击下载
SHA256.js(可选):点击下载
在线的各类加解密工具(可选,可以收藏起来,平时测试挺有用):点击访问
请求数据防重放
要防范请求重放攻击,首先需要了解Unix时间戳 timestamp概念,和时间戳不一样的是它的单位是秒,事实上这个需求也只需要秒级即可。除此之外还将用到另一个值:nonce,它是一个随机产生并只能被使用一次的值,长度自定,请求越频繁长度需要越长(降低同一时间产生相同nonce的几率),C端发送请求时需要将timestamp和生成的nonce加入发送的参数中。那么,如何将两者结合呢?我这为防重放的目标下了一个简单的定义。
同样的请求只能发生一次,且请求必须在规定时间内发出。
何为同样的请求?不是指两次发送的参数一致就是一样的,而是连timestamp和nonce也一样才算是同样的请求。
那么S端如何确认其是同样的请求呢?
S端每次接收到一个请求,都会将该请求的nonce存入缓存并保持60秒(这个阈值不一定是60秒,可以根据实际需要定义),时间过后该值将被移除,建议S端采用Redis存储nonce,这样可省去检测和移除nonce的代码。如果S端发现当前请求的nonce存在于已存储的nonce之中,则此请求发生重复,那么timestamp有何用?
如果只使用nonce我们只能保证该请求60秒内不会重复,但60秒后依然任人宰割,这不是要的结果。所以timestamp将用来限制时间,S端时间戳减去C端发送请求的时间戳,得到的差值为N秒,如果N秒大于60秒则此请求过期,那么则可以保证,60秒内因为nonce相同而被判为请求重放,60秒后因为时间差超过而被判为请求已过期,因此确保了请求不会被重放。。
以下展示三种情况:
C端时间戳:1568487720 //2019/9/15 03:02:00
C端NONCE:5rKbMs2Fm3
C端发送请求 -> S端接收请求
S端时间戳:1568487722 //2019/9/15 03:02:02
CS端时间差:1568487722 - 1568487720 = 2
C端NONCE是否存在于缓存:false
【重放校验通过】
C端时间戳:1568487722 //2019/9/15 03:05:22
C端NONCE:IzFEs52bAC
C端发送请求 -> S端接收请求
S端时间戳:1568487922 //2019/9/15 03:02:00
CS端时间差:1568487922 - 1568487722 = 200
C端NONCE是否存在于缓存:false
【重放校验不通过,请求已过期,因为时间差超过60秒】
C端时间戳:1568487720 //2019/9/15 03:02:00
C端NONCE:IxwPHQU0nA
C端发送请求 -> S端接收请求
S端时间戳:1568487722 //2019/9/15 03:02:02
CS端时间差:1568487722 - 1568487720 = 2
C端NONCE是否存在于缓存:true
【重放校验不通过,此请求为重放请求,因为nonce已经存在,此请求已经完成,不可重复】
timestamp和nonce将作为参数参与下面部分的签名。
请求数据防篡改
C端数据签名
首先通过对参数按照参数名进行字典排序(调过一些第三方API的朋友应该明白),假设当前需要传输的参数如下:
{
"c": 123,
"b": 456,
"a": 789,
"timestamp": 1568487720,
"nonce": "5rKbMs2Fm3"
}
进行字典排序,参数名顺序应为:
const keys = Object.keys(data); //获得参数名数组
keys.sort(); //字典排序
console.log(key); //["a", "b", "c", "nonce", "timestamp"];
参数字典排序后应和参数一起拼接为字符串,至于使用什么拼接符就要与S端商量了,如果参数值是一个数组或一个对象(如c为[1,2,3])那么可以将数据值转为JSON字符串再拼接。以上参数拼接后字符串如下:
a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720
下一步是计算拼接字符串的MD5哈希值了嘛?
不是。因为这样拼接的字符串很容易被攻击者伪造签名并篡改数据,这样就失去了签名的意义了。也就是说缺了一个key值。key值又从何而来?上面思考部分有提到建议从登录后发放,并且这个key与该用户的登录态绑定,登录态有效期间,将使用这个key进行请求的签名与验签,至于如何鉴别用户,我们会在最终发送给S端的参数加入一个sessionId作为登录态唯一标识,这里不加是因为这部分数据是需要参与后续的加密的,而sessionId不参与加密。
但是可能又会有一个问题,登录前没有key怎么实现的登录请求?事实上,登录请求并不怕篡改,因为攻击者自己也不知道账号密码,所以无需提供key用于登录请求的签名。
提到登录密码这个需要注意一点,密码不能明文传输,请计算哈希值后传输,S端比对账户密码哈希值即可确认是否正确,同样S端非特殊情况也不能明文存储密码,建议SHA-1或更高级的SHA-256计算后的值,MD5值可能被使用彩虹表(一种为各种常见密码建立的MD5映射表)破解。SHA256计算的库已在上面工具库下载提供。
拼接上我们登录时随机生成的key:
a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB
接下来计算32位MD5哈希值
为什么上面说MD5会被破解而这里却用MD5计算?因为此处计算MD5的目的并不是为了隐藏明文数据,而只是用于数据校验
此处引入了CryptoJS.js
const CryptoJS = require('./CryptoJS');
const signStr = "a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB";
const sign = CryptoJS.MD5(signStr).toString(); //a42af0962de99e698d27030c5c9d3b0e
这么一来我们的数据签名阶段就完成了,然后需要把签名加入参数之中,将和参数一起传输,需要注意的是传输参数无需在意参数名排序。以下是当前的参数处理结果:
{
"c": 123,
"b": 456,
"a": 789,
"timestamp": 1568487720,
"nonce": "5rKbMs2Fm3",
"sign": "a42af0962de99e698d27030c5c9d3b0e"
}
其实从上面这两部分内容来看,会发现防重放和防篡改是相辅相成的,就像两兄弟一样,少了谁都干不好这件事。
S端验证签名
既然有签名那必定也有验签,验签流程其实就是重复C端的前面流程并比对CS两端得出的签名值是否一致。S端取得请求数据后(假设数据未加密,暂时不讨论解密),将除了sign之外的参数名进行字典排序,sign用一个临时变量存下,然后排好序的参数和C端一样拼接得到字符串。
接下来根据上面部分提到的未加密参数sessionId获得用户登录态并获取到该状态的临时key拼接到字符串末尾,接下来进行MD5计算即可得到S端方获得的签名,此时与请求中携带的sign比较是否一致则可确定签名是否有效,如果不一致返回签名错误。具体流程请参考以下:
C端发送请求 -> S端接收请求
//请求参数为data
const _sign = data.sign;
//排序并拼接除sign外的参数
let signStr = a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720
const sessionId = ...; //用户的sessionId
const sessionData = getUserSessionData(sessionId);//根据sessionId查询用户登录态sessionData并取出key,并拼接到字符串尾部
const key = sessionData.key
signStr += key;
//MD5计算并比对,代码仅供参考
const sign = crypto.md5(signStr);
if(sign !== _sign) {
//签名错误!
}
请求数据加解密
有了上面部分的参数处理铺垫后,接下来就该开始本文章最核心的加解密,在这之前我们先了解AES和RSA两种加密算法。了解概念后我们再来思考一下两者的一些特性。
AES加解密需要密钥,除某些模式外还需要提供初始化向量,可使用密钥解出明文,是对称加密算法。
RSA加解密需要一对密钥,分别为公钥和私钥,公钥加密,私钥解密,是非对称加密算法。
两者在加密长文本性能上AES占优势。
根据这些让我们发现,他们可以形成互补关系,RSA加解密安全性高但长文本处理性能不及AES。AES加解密长文本性能优于RSA但需要明文密钥和向量加解密,密钥的安全性成问题。
那么在C端生成随机的AES密钥和向量,使用密钥和向量使用aes-128-cbc加密模式(也可以根据实际需要采用其它的模式)加密真正需要传输的参数(参数则是经过防重放+防篡改处理的参数)得到encryptedData,然后将该密钥使用RSA公钥加密得到encryptedKey,下面我顺便把AES加密的向量也一起加密了得到encryptedIV,这样就完美的互补了对方的缺点,既能够较快的完成数据加密又能保证密钥安全性,两全其美。
整个个加解密流程如下图所示:
下面的四个小章节将逐一描述流程实现:
C端加密数据
C端加密后的数据应如下(并非固定格式,根据自己需要定制):
{
"sessionId": "xxxxxxxx",
"encryptedData": "xxxxxx",
"encryptedKey": "xxxxxx",
"encryptedIV": "xxxxxxx"
}
但RSA公钥又是怎么发放到C端的呢?答案是在登录认证的时候服务器下发的,登录成功时服务器会创建RSA密钥对并把公钥发放给C端,私钥存在服务器上该用户的登录态数据中。流程如下所示:
C端发起登录请求 -> S端接收登录请求
S端登录认证是否通过 true
S端生成RSA密钥对 - publicKey , privateKey
S端查询相关用户信息
S端生成登录态信息 -> 向登录态信息存入privateKey私钥,登录态信息类似如下
"xxxxxx": {
id: "xxxxx",
authData: {
key: "xxxxxx",
privateKey: "xxxxxx"
}
}
S端返回登录态唯一标识sessionId和publicKey公钥以及相关用户信息 -> C端
C端存储登录态信息于本地,后续请求将使用服务器提供的公钥进行加密
其中privateKey就是该用户当前登录态所使用的解密私钥,C端通过公钥加密后的AES密钥数据只能用该私钥解密。
如果希望登录阶段的请求也加密,那么可以手动生成一个RSA密钥对,然后客户端放置一个固定的公钥,服务器也使用一个固定的私钥进行登录阶段的加解密。
具体实现如下:
此处引入了CryptoJS.js和RSA.js
const CryptoJS = require('./CryptoJS');
const RSA = require('./RSA.js');
//假设当前已登录成功并获得S端下发的RSA公钥且已存入本地存储
//createRandomStr为生成随机大小写英文和数字的字符串
const aesKey = createRandomStr(16); //生成AES128位密钥 16字节=128位
const aesIV = createRandomStr(16); //生成初始化向量IV
const raw = JSON.stringfly({
"c": 123,
"b": 456,
"a": 789,
"timestamp": 1568487720,
"nonce": "5rKbMs2Fm3",
"sign": "a42af0962de99e698d27030c5c9d3b0e"
});
const encryptedData = CryptoJS.AES.encrypt(data: raw, key: aesKey, {
iv: aesIV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}); //使用CBC模式和Pkcs7填充加密
const authData = wx.getStorageSync("authData"); //读取本地存的RSA公钥
RSA.setPublicKey(authData.publicKey); //设置RSA公钥
const encryptedKey = RSA.encrypt(aesKey); //RSA加密AES加密密钥
const encryptedIV = RSA.encrypt(aesIV); //RSA加密AES加密初始化向量,是否加密向量可由自己决定
//最后的处理结果
const result = {
sessionId: authData.sessionId,
encryptedData,
encryptedKey,
encryptedIV
};
S端解密数据
const crypto = require('crypto');
const cryptojs = require('crypto-js');
const {
sessionId,
encryptedData,
encryptedKey,
encryptedIV
} = requestData;
const authData = getUserSessionData(sessionId); //获取用户登录态数据
const privateKey = authData.privateKey;
const aesKey = crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING
}, encryptedKey); //使用私钥解密得到AES密钥
const aesIV = crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING
}, encryptedIV); //使用私钥解密得到AES iv向量
const key = cryptojs.enc.Base64.parse(aesKey);
const iv = cryptojs.enc.Utf8.parse(aesIV);
const decryptedData = cryptojs.AES.decrypt(encryptedData, key, {
iv,
mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7
}); //采用与加密统一的模式和填充进行解密
const {
"c": 123,
"b": 456,
"a": 789,
"timestamp": 1568487720,
"nonce": "5rKbMs2Fm3",
"sign": "a42af0962de99e698d27030c5c9d3b0e"
} = decryptedData; //至此解密得到C端传来的数据
S端加密数据
当S端处理完C端的请求后应加密响应数据,那么加密响应数据应该使用什么密钥呢?既然C端已经将加密的密钥发送过来了,那么干脆将C端使用的AES密钥拿来加密响应数据就可以了。加密的数据传回C端后,C端只需使用该请求所使用的AES加密密钥进行解密即可。响应的加密数据如下:
const cryptojs = require('crypto-js');
const responseData = JSON.stringify(...); //此为S端需要返回给C端的数据
const aesKey = ...; //此为之前C端用来加密数据的AES密钥
const aesIV = createRandomStr(16); //生成初始化向量IV
const encryptedData = cryptojs.AES.encrypt(data, aesKey, {
iv: aesIV,
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7
}); //加密响应数据
const encryptedResponse = {
"encryptedData": encryptedData,
"iv": aesIV
}; //得到加密后的响应数据并返回给C端
C端解密数据
C端接收到S端的响应数据后应对加密的数据进行解密,此次解密就是单纯的AES解密了,使用发起请求时用于加密数据的AES密钥配合响应数据的iv向量对encryptedData进行解密,得到解密后的数据即为S端真正的响应数据。实现过程如下:
此处引入了CryptoJS.js
const CryptoJS = require('./CryptoJS');
const key = ...; //之前用于加密请求参数的AES加密密钥
const { encryptedData, iv } = responseData;
const aesIV = CryptoJS.enc.Utf8.parse(iv);
const aesKey = CryptoJS.enc.Utf8.parse(key);
const decryptedData = CryptoJS.AES.decrypt(encryptedData, aesKey, {
iv: aesIV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}); //AES解密响应数据
const {
...
} = decryptedData; //得到解密后的响应数据
结语
第一次写这么长的文章,可能存在大量纰漏,如果大佬发现问题欢迎指出(`・ω・´)我会马上修改。
也许小程序的运行环境会比我想象中的更安全
也许HTTPS也会比我想象中的更安全
也许Web服务器引擎也比我想象中的更安全
但,安全不总是先行一步的吗?
小白nb!
楼主给力先点个赞,我用RSA的方式 ,加密对象过长该怎末办呀。目前我就报了这样的错误,求解!
被反编译了,还能起作用吗?
大佬牛逼!
学习了,不过现在跟着做加密之后解不出来了。。。
如果登录起作用,只需要https就可以加密了,如果被中间人监听,那么https无效,靠登录下发RSA公钥就是有问题的
大佬有没有使用过RSA公钥解密的?
"为什么上面说MD5会被破解而这里却用MD5计算?因为此处计算MD5的目的并不是为了隐藏明文数据,而只是用于数据校验"
----------
数据校验也不建议使用MD5计算,目前已经可以实现不同数据生成相同的MD5值