众所周知,现在网络通信的时候,协议之间一般都会进行加密,以防第三方抓包等进行破解。再不济也会进行异或混淆。在这里我就记录一下自己的一个网络框架『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)】】

由于协议结构是个人琢磨的,若有不足还请指正。

其操作顺序为:

  1. 首先判断是否压缩:数据包大小超出压缩上限
  2. 若需要压缩,则将原数据压缩成一个新的比特数组
  3. 创建一个包括 MD5 标志、压缩标志和目前数据包长度 大小的新数组
  4. 数组第 0 位写入 MD5 校验标志及压缩标志 (之所以写入 MD5 标志位,是因为 MD5 校验做成了可通过配置进行开启或关闭)
  5. 写入实际数据
  6. 写入 MD5 校验数据 (如果有的话)
  7. RSA 加密:写入 RSA 标志位,写入加密数据 (同样可通过配置开启或关闭)
  8. 异或混淆

解密顺序则通过相反顺序操作进行。

  1. 异或混淆解密
  2. 判断是否 RSA 加密,并进行解密
  3. 取出 MD5 标志、取出压缩标志
  4. 取出老的 MD5 码
  5. 取出实际数据
  6. 判断是否进行 MD5 校验,若有则计算实际数据 MD5,并与老的 MD5 码对比
  7. 判断是否压缩、解压缩

代码如下:

s
/// <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

主要操作是:

  1. 判断需要加密的内容,字节数是否大于 117Byte,如果小于,直接加密返回即可。
  2. 若数据大于 117Byte,则根据 总量 / 117 并向上取整,获取分块数量。
    之所以向上取整,是因为如果有余数,那么代表按照 117Byte 进行分割会有剩余字节,最后一个数据块必然是小于 117Byte 的。
  3. 根据得出需要分块的数量 N,创建加密后总数据数组 (size=N*maxSzie) 容器。
  4. 进行 for 循环,不断加密不断填充,完成加密。

代码如下:

s
/// <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);
}

由于分块之后,最后一块的数据内容不规则,因此需要特殊处理一下最后一块的数据。

解密方式则通过类似操作进行,分块解密,然后统一合并,形成最终明文数据:

s
/// <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 方法生成一对公钥及私钥,进行简单的通信测试
(正常情况应该生成两对,服务器及客户端相对配置,这里为了方便就直接用一对进行测试了,双方都使用同一对公钥私钥进行加解密)

内容能够正常传输,未发现问题,说明加解密正常。

效果