众所周知,现在网络通信的时候,协议之间一般都会进行加密,以防第三方抓包等进行破解。再不济也会进行异或混淆。在这里我就记录一下自己的一个网络框架『CrySimpleNet』进行协议加密的结构。
# 0x01
CrySimpleNet 开发的初衷,主要是当年接手公司项目维护之后,意图实现一套『远程打包』。
比如,在自己的机器上,使用一个客户端,点击对应选项,就可以自动在打包机上调用 Unity3D 进行相应操作:如自动合并 SVN 打热更、更新项目出包等等。
这个网络框架的目的是:使得打包者可以直接调用客户端,通知 Windows 打包机进行各项操作,Android 打包就简单,直接全是 Windows 打包机操作就行了,最多不过在让 Unity 导出项目之后,需要再额外调用 Android Studio 进行最终打包。
不过 IOS 项目打包则需要在导出 Xcode 工程后,将工程数据传输到 Mac 打包机调用 Xcode 进行最终打包。此不多言。
不过后面在实现网络方面功能、然后测试了框架文件传输效果后,被告知打包机不能被直连:因为担心网络安全;因此这个计划就放弃了。
后期打包主要操作和工具还是做到了 UnityEditor 中。
还好这个框架在实现的时候,就考虑了重用性,核心库不管是放 Unity,还是放 WPF 都可以用。现在用不上,以后说不准哪里还能用到。
毕竟自己实现的东西,跟网上随便找的框架,用起来可不一样。
放了这么些时日,想起当初对协议传输加密方面,只简单实现了 异或混淆、压缩等操作,加密性应该算是比较低的。包括数据包完整性校验也没做,于是花了点时间,将其补上了。
# 0x02
加密结构:异或加密【RSA 加密标志 (1)|RSA 加密【MD5 校验位 + 压缩位 | 实际数据 | MD5 校验数据 (16 位或者 0)】】
由于协议结构是个人琢磨的,若有不足还请指正。
其操作顺序为:
- 首先判断是否压缩:数据包大小超出压缩上限
- 若需要压缩,则将原数据压缩成一个新的比特数组
- 创建一个包括 MD5 标志、压缩标志和目前数据包长度 大小的新数组
- 数组第 0 位写入 MD5 校验标志及压缩标志 (之所以写入 MD5 标志位,是因为 MD5 校验做成了可通过配置进行开启或关闭)
- 写入实际数据
- 写入 MD5 校验数据 (如果有的话)
- RSA 加密:写入 RSA 标志位,写入加密数据 (同样可通过配置开启或关闭)
- 异或混淆
解密顺序则通过相反顺序操作进行。
- 异或混淆解密
- 判断是否 RSA 加密,并进行解密
- 取出 MD5 标志、取出压缩标志
- 取出老的 MD5 码
- 取出实际数据
- 判断是否进行 MD5 校验,若有则计算实际数据 MD5,并与老的 MD5 码对比
- 判断是否压缩、解压缩
代码如下:
/// <summary> | |
/// 加密 | |
/// 加密结构:异或加密【RSA 加密标志 (1)|RSA 加密【MD5 校验位 + 压缩位 | 实际数据 | MD5 校验数据 (16 位或者 0)】】 | |
/// </summary> | |
/// <param name="bytes"></param> | |
/// <param name="compress"> 是否压缩 & lt;/param> | |
/// <param name="rsaEncrypt"> 是否 rsa 加密 & lt;/param> | |
/// <returns></returns> | |
public static byte[] ByteEncryption(byte[] bytes, bool compress = true, bool rsaEncrypt = false) | |
{ | |
byte[] compressBytes = bytes; | |
// 压缩 | |
if (compress) | |
compressBytes = Compress(compressBytes); | |
// 写入标志 | |
// 注:MD5 加密,则位数为 16 位 MD5+MD5 标志位 @压缩标志位 | |
// 否则为 MD5 标志位 @压缩标志位 | |
byte[] tempBytes = new byte[(Config.IsProtoMD5Verification ? compressBytes.Length + 16 : compressBytes.Length) + 1]; | |
int index = 0; | |
//byte 类型标志位最大支持 7 个:byte b = 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4 | 1 << 5 | 1 << 6 | 1 << 7=254; | |
int flagByte = 0; | |
// 写入 MD5 校验位 | |
flagByte |= (Config.IsProtoMD5Verification ? 1 : 0) << 1; | |
// 写入压缩位 | |
flagByte |= (compress ? 1 : 0) << 2; | |
tempBytes[index++] = (byte)flagByte; | |
// 写入实际数据 | |
compressBytes.CopyTo(tempBytes, index); | |
index += compressBytes.Length; | |
// 写入 MD5 校验数据 | |
if (Config.IsProtoMD5Verification) | |
{ | |
CalcMD5(compressBytes).CopyTo(tempBytes, index); | |
index += 16; | |
} | |
// 内容 加密 | |
tempBytes = EncryptRawContent(tempBytes, rsaEncrypt); | |
// 混淆 | |
for (int i = 0; i < tempBytes.Length; i++) | |
{ | |
tempBytes[i] ^= Config.XorKey; | |
} | |
return tempBytes; | |
} | |
/// <summary> | |
/// 解密 | |
/// </summary> | |
/// <param name="bytes"></param> | |
/// <returns></returns> | |
public static byte[] ByteDecrypt(byte[] bytes) | |
{ | |
// 解混 | |
for (int i = 0; i < bytes.Length; i++) | |
{ | |
bytes[i] ^= Config.XorKey; | |
} | |
// 内容解密 | |
bytes = DecryptRawContent(bytes); | |
int index = 0; | |
// 读取标志位信息 | |
byte flagByte = bytes[index++]; | |
// 读取 MD5 校验标志 | |
bool isMd5Verify = (flagByte & 1 << 1) != 0; | |
// 读取压缩标志 | |
bool isCompress = (flagByte & 1 << 2) != 0; | |
//MD5 校验数据保存在最后,这里如果有的话就要删掉 | |
int contentLength = bytes.Length - index; | |
contentLength = isMd5Verify ? contentLength - 16 : contentLength; | |
byte[] rawBytes = new byte[contentLength]; | |
Array.Copy(bytes, index, rawBytes, 0, contentLength); | |
index += contentLength; | |
if (isMd5Verify) | |
{ | |
byte[] md5Bytes = new byte[16]; | |
Array.Copy(bytes, index, md5Bytes, 0, md5Bytes.Length); | |
byte[] nowMd5 = CalcMD5(rawBytes); | |
for (int i = 0; i < md5Bytes.Length; i++) | |
{ | |
if (md5Bytes[i] != nowMd5[i]) | |
{ | |
Log.Error($"MD5校验失败!原:{md5Bytes.ToString()} ==> 现{nowMd5}"); | |
return null; | |
} | |
} | |
index += 16; | |
} | |
byte[] oringinByte = rawBytes; | |
// 解压缩 | |
if (isCompress) | |
oringinByte = Decompress(oringinByte); | |
return oringinByte; | |
} |
以上便是整个加密结构。
# 0x03
加密结构中,加解密顺序操作即可,并无难点。需要注意的是 RSA 加密的问题,这里我是单独抽出了一个方法,方便排版和处理。
因为 RSA 加密需要秘钥,一般来说,加密者使用公钥,接收者使用私钥,成对进行。
因此在个人的设计中,需要有两组秘钥:
- 服务器公钥 - 客户端私钥
- 服务器私钥 - 客户端公钥
分别用于客户端与服务器相对发送、接收的加密、解密。
但是,RSA 加密明文长度是有限制的:根据资料显示,一个公钥加密的明文长度,等于秘钥长度减 11 字节。
其中 11 字节是 RSA 算法需要占用的空间。
即,若秘钥长度为 1024 位,则明文长度最大支持 1024/8-11=117 字节 (1 字节 = 8 位,秘钥单位是位)
同时,明文加密后的长度,一定会等于秘钥长度。
因此采取了分块解密的方式。
假设秘钥长度为 1024 位,则
- 数据块一块最多占用 maxSzie=1024/8=128Byte
- 明文一块最多占用 maxCap=maxSzie-11=117Byte
主要操作是:
- 判断需要加密的内容,字节数是否大于 117Byte,如果小于,直接加密返回即可。
- 若数据大于 117Byte,则根据 总量 / 117 并向上取整,获取分块数量。
之所以向上取整,是因为如果有余数,那么代表按照 117Byte 进行分割会有剩余字节,最后一个数据块必然是小于 117Byte 的。 - 根据得出需要分块的数量 N,创建加密后总数据数组 (size=N*maxSzie) 容器。
- 进行 for 循环,不断加密不断填充,完成加密。
代码如下:
/// <summary> | |
/// RSA 加密 | |
/// </summary> | |
/// <param name="val"></param> | |
/// <returns></returns> | |
public static byte[] EncryptRSA(byte[] val) | |
{ | |
if (_rsaEncrypt == null) | |
{ | |
_rsaEncrypt = new RSACryptoServiceProvider(); | |
_rsaEncrypt.FromXmlString(Config.RSAPublicKey); | |
} | |
//Rsa 加密最大允许秘钥长度 - 11 的加密内容 | |
// 例如 1024/8=128 个字节 - 11=117 字节 | |
// 因此需要分割加密 | |
int maxSize = _rsaEncrypt.KeySize / 8; | |
int maxCap = maxSize - 11; | |
if (val.Length > maxCap) | |
{ | |
int num = (int)Math.Ceiling(val.Length / (float)maxCap); | |
// 最终数据 | |
byte[] finalBytes = new byte[num * maxSize]; | |
// 每一片的实际数据 | |
byte[] piceRawBytes; | |
for (int i = 0; i < num; i++) | |
{ | |
int index = i * maxCap; | |
piceRawBytes = new byte[val.Length - index > maxCap ? maxCap : val.Length - index]; | |
Array.Copy(val, index, piceRawBytes, 0, piceRawBytes.Length); | |
byte[] bytes = _rsaEncrypt.Encrypt(piceRawBytes, false); | |
bytes.CopyTo(finalBytes, i * maxSize); | |
} | |
return finalBytes; | |
} | |
return _rsaEncrypt.Encrypt(val, false); | |
} |
由于分块之后,最后一块的数据内容不规则,因此需要特殊处理一下最后一块的数据。
解密方式则通过类似操作进行,分块解密,然后统一合并,形成最终明文数据:
/// <summary> | |
/// RSA 解密 | |
/// </summary> | |
/// <param name="val"></param> | |
/// <returns></returns> | |
public static byte[] DecryptRSA(byte[] val) | |
{ | |
if (_rsaDecrypt == null) | |
{ | |
_rsaDecrypt = new RSACryptoServiceProvider(); | |
_rsaDecrypt.FromXmlString(Config.RSAPrivateKey); | |
} | |
//Rsa 加密最大允许秘钥长度 - 11 的加密内容 | |
// 例如 1024/8=128 个字节 - 11=117 字节 | |
// 因此需要分割解密 | |
int maxSize = _rsaDecrypt.KeySize / 8; | |
int maxCap = maxSize - 11; | |
// 大于秘钥最大支持长度,估计是分割过的内容 | |
if (val.Length > maxSize) | |
{ | |
int num = val.Length / maxSize; | |
using (MemoryStream stream = new MemoryStream()) | |
{ | |
byte[] lockBytes = new byte[maxSize]; | |
for (int i = 0; i < num; i++) | |
{ | |
Array.Copy(val, i * maxSize, lockBytes, 0, lockBytes.Length); | |
byte[] unlockBytes = _rsaDecrypt.Decrypt(lockBytes, false); | |
stream.Write(unlockBytes, 0, unlockBytes.Length); | |
} | |
return stream.ToArray(); | |
} | |
} | |
return _rsaDecrypt.Decrypt(val, false); | |
} |
不可否认的是,RSA 加密及分块加解密这种方式肯定也会给性能带来影响。
不过现在还是以安全为准,而且结构在这儿,就算更换加密方式也是很简单的。
增加数据 MD5 校验及 RSA 加密后,调用 GenerateRsaKey 方法生成一对公钥及私钥,进行简单的通信测试
(正常情况应该生成两对,服务器及客户端相对配置,这里为了方便就直接用一对进行测试了,双方都使用同一对公钥私钥进行加解密)
内容能够正常传输,未发现问题,说明加解密正常。