=> GuardHei

Illusory Walls Ahead

F+ or Deferred? Which one should I choose?


Here we go again!

Unity游戏开发中对本地文件的加密读写

概述

在实际项目中,总是需要将一些数据持久化在本地,比如游戏存档,偏好设置或是热更新的一些资源。对于其中一些需求如偏好设置来说,直接以明文的方式写入PlayerPrefs就可以了,但是对于存档这类的文件一般都是在写入在项目的Application.persistentDataPath路径下。然而直接明文读写的话可能会导致存档被玩家篡改,或者重要的信息被第三方获取到(比如与服务器通讯的秘钥),因此,对于敏感的文件,我们需要对它进行加密操作。

实现

二进制存储

第一种实现的方法非常简单,就是存储文件的时候不要以文本的形式写入本地,而是写一个二进制文件,并且更改文件后缀为一个自定义的后缀,如.interesting之类的。对于大部分玩家来说,一个不常见的二进制文件往往不能直接使用记事本读写软件打开,也就放弃了对存档之类的数据篡改。关键代码如下:

byte[] binaryContents = Encoding.UTF8.GetBytes("some contents");
string contents = Encoding.UTF8.GetString(binaryContents);

这段代码非常简单,Encoding里面提供不同的编码方式的工具类,这里可以根据需求选择字符串编码方式,一般使用UTF-8的编码,GetBytes(string str)返回一个byte[],即该字符串的二进制编码,而GetString(byte[] bytes)返回一个string,即将二进制数据按照指定编码转为字符串。

当然,项目中肯定会有很多处需要进行本地读写的地方,我们不可能每一处都添加这么几行代码,因此,我们需要设计一个CipherManager类来对文档加密读写操作进行统一封装与管理。

public static class CipherManager {

    public static byte[] ToBinary(string toBinary) {
        return Encoding.UTF8.GetBytes(toBinary);
    }

    public static string FromBinary(byte[] fromBinary) {
        return Encoding.UTF8.GetString(fromBinary);
    }

    public static void WriteToFile(string path, string toWrite) {
        File.WriteAllBytes(path, ToBinary(toWrite));
    }

    public static string ReadFromFile(string path) {
        return FromBinary(File.ReadAllBytes(path));
    }
}

这里我们简单使用一个static class创建一个单例管理类,当需要读写加密数据的时候直接调用WriteToFile(string path, string toWrite)ReadFromFile(string path)方法即可,将加密过程封装起来,以便复用。

事实上,不少游戏像侠盗猎车手3(Grand Theft Auto III)就使用了这一方法对部分数据进行了加密。GTA3游戏目录下的.dat文件,直接使用记事本是打不开的,当转换后缀名为.txt,并指定解码方式为UTF8就可以成功看到里面的文本内容。游戏里的各种道具的数值都是用这种方式存储的。

缺点

这种存储方法非常简单,但也有不小的问题。首先数据本身其实并没有进行加密,我们只是利用了操作系统默认通过后缀名判断文件格式的漏洞。事实上,对于目前的各种文本编辑器,它们对文件格式的判断是会读取二进制数据以判断是不是文本编码,因此如果转换为UTF-8Unicode之类的二进制格式,很有可能一下就被读出文本内容。如果有人对游戏的本地数据图谋不轨的话这种尝试是最基本的,因此,我们需要加入真正的加密。

AES(Rijndael)加密

不要被突然高大上起来的标题吓到了,这边博客只会简单谈谈什么是AES加密技术,只有实现嘛。。。只要知道C#提供了相关api就行了。

什么是AES?

AES,即Advanced Encrytion Standard,又称Rijndael加密法,是美国联邦政府选用的一种区块加密标准,由比利时密码学家Joan DaemenVincent Rijmen所设计(看名字应该能看出来😂)。总而言之,这是一种比较新的,比较安全的加密方式,虽然可以被破解,但是大部分破解都不是针对密码本身的,而是基于不安全的系统进行攻击,因此不足为虑。

AES算法需要我们提供一个byte[]格式的秘钥,字节长度必须为32的整数倍,且以128位为下限,265位为上限,但事实上我们只需要提供一个32位的秘钥就行了,因为C#自带的加密api会将不够位数的秘钥进行重复叠加来填充位数。

重新设计过的CipherManager类实现如下:

public static class CipherManager {

	readonly static byte[] KEY = Encoding.UTF8.GetBytes("guardheiguardheiguardheiguardhei");

	static RijndaelManaged rij;
	static ICryptoTransform encryptor;
	static ICryptoTransform decryptor;

	static CipherManager() {
		rij = new RijndaelManaged();
		rij.Key = KEY;
		rij.Mode = CipherMode.ECB;
		rij.Padding = PaddingMode.PKCS7;
		encryptor = rij.CreateEncryptor();
		decryptor = rij.CreateDecryptor();
	}

	public static string Encrypt(string toEncrypt) {
		byte[] toEncryptArray = Encoding.UTF8.GetBytes(toEncrypt);
		byte[] encryptedArray = encryptor.TransformFinalBlock(toEncryptArray, 0, toEncryptArray.Length);
		return Convert.ToBase64String(encryptedArray);
	}

	public static string Decrypt(string toDecrypt) {
		byte[] toDecryptArray = Convert.FromBase64String(toDecrypt);
		byte[] decryptedArray = decryptor.TransformFinalBlock(toDecryptArray, 0, toDecryptArray.Length);
		return Encoding.UTF8.GetString(decryptedArray);
	}

	public static void EncryptFile(string path) {
		string text = File.ReadAllText(path);
		File.WriteAllText(path, Encrypt(text));
	}

	public static string DecryptFile(string path) {
		return Decrypt(File.ReadAllText(path));
	}
}

是不是看上去复杂了很多?不要急,慢慢来看。  一开始我们首先需要创建一个秘钥KEY,这里我们通过获取传入的字符串的二进制数组实现的。我们需要保证字符串的字节长度是32的位数(注意不是字符串的长度,而时候字节长度!),对于UTF-8编码来说,一个英文字符占一个字节,一个中文字节占两个字节,对于Unicode编码来说,任意字符都占两个字节。

创建好秘钥后,我们声明一个静态的加密管理对象rijRijndaelManaged类的实例,以及两个密码变换实例encryptordecryptor。加密管理对象比较好理解,它管理着加密的各种参数,其中Key就是一个byte[]型秘钥,mode是指加密模式,padding是补位方式。

秘钥我们已经在前面解释过了,那么加密模式和补位方式又是啥?

加密模式可以理解为统一加密标准的不同实现。这是因为现实应用中,不同的项目需求的加密数据量是不同,数据格式也是不定的,加密硬件也会有差异,为了更好的兼顾安全性与性能,针对不同的加密需求,选择不同的加密模式。常见的加密模式如下:

  1. ECBElectronic Code Book)电子密码本模式 比较简单的模式,可以进行并行运算且不会传递误差,但更加容易被主动攻击,所以适用于比较简短的消息
  2. CBCCipher Block Chaining)加密快链模式 不要被名字吓到,这玩意儿和区块链(Block Chaining)没有什么关系。这种模式比ECB更安全,适合加密长度较长的消息,但是会传递误差
  3. CFBCipher Feedback Mode)加密反馈模式 比ECB安全,更适合加密流数据,虽然加密不能并行运算但解密可以,同时会传递误差
  4. OFBOutput Feedback Mode)输出反馈模式 比ECB要安全但没有CBCCFB那么安全,也适合加密流数据,但是不能进行并行运算,且传递误差
  5. CTRCounter)计数模式 这种模式不需要实现解密算法,只要实现加密算法即可,因此最为简单。同时因为允许并行计算与比重足够大的预处理,可以以“空间换时间”提高加密效率,还不会传递误差,有着不低的安全性。但是如果密文传输过程中丢失字节会导致后续字节无法正确解密(这主要发生在网络通讯中)

补位方式说起来就有点复杂,具体说来加密模式分为块加密(ECBCBC)和流加密(CFBOFB)。块加密,又称分组加密,就是将待加密的数据分组,即分割成一个个固定大小的数据块,对每一个块进行加密。然而不可能待加密的数据大小正好可以被分割到每一个块里且充分的填充每一个块,因此我们需要对最后一个块里没有填充完的部分填入“补位数据”,而填充方式就是“补位方式”。流加密是不需要这一点的。

一般来说ECB的加密模式就可以满足我们的需求了,补位方式选择PKCS7即可(不要在意细节啦),如果有特殊需求的情自行尝试。

接下来说说ICryptoTransform的两个实例:encryptordecryptor。在加密与解密的过程中,AES算法对传入的数据进行了种种变换而进行加密/解密。也就是说算法的具体实现是由它们决定的,抓重点就是这个实例可以给我们提供如何变换数据的方法,因此它们被当作加密器和解密器。可以看到我们并不是直接new了两个对象,而是初始化完rij对象的相关属性后使用CreateEncrytor()CreateDecrytor()方法获取的。

到了这一步,我们可以谈一谈加密的步骤了,可以看到在Encrypt(string toEncrypt)方法中,我们先获取待加密文本的UTF-8格式的字节数据,然后通过encryptor.TransformFinalBlock(byte[] buffer, int offset, int length)加密得到加密后的字节数据。TransformFinalBlock这个名字听起来怪怪的,其实就是是获得变换到最后的块数据,讲白了就是加密完成的数据。三个参数分别是待加密的字节数据,偏移(从第几位开始后加密,从头开始当然是0啦)和需要加密的字节的长度(加密全部的所以直接buffer.length就可以啦)。有些人可能会奇怪最后一行代码为什么会使用一个Convert.ToBase64String(byte[] inArray)返回一个string。这里我们来稍微说下Base64这个东西。

Base64是一种常见的用于传递8位字节码的编码标准,是一种用64个可打印的字符来表示二进制数据的方法,可以在HTTP下传递较长的标识信息,其他的应用如迅雷的专用下载链接其实就是在正常下载地址的前后分别添加字符串AAZZ,再对新字符串进行Base64编码。直白点说,我们可以用Base64对字符串和二进制数据再进行一层简单的加密。想想看如果直接把存档存成二进制文件,破解者可能直接会考虑从此入手寻找AES的破解方法,而如果先用文本包一层奇怪的外表,破解者如果不清楚我们使用了Base64的话可能都无从下手。

解密的步骤基本就是加密的逆向,最后返回一个能够被我们的游戏解析的数据信息,没有必要多说。

最后再封装两个对文件进行加密解密的方法,这样,一个简单的加密管理类就完成了(所以说确实不用把AES加密方式理解的十分透彻,会用就行啦!)。

缺点

AES是比较主流的商业加密方法,像之前说的,本身并没有特别大的缺陷,但是有一个问题值得开发者思考,那就是:

Unity打包的项目不加密脚本代码啊!!!

所以说如果我们把加密的秘钥用字符串直接硬编码在代码里的话,有概率被用Unity解包器给揪出来,不过如果我们选择il2cpp这种发布方式的话,理论上会好一下,不容易被解。实在不行就撺掇公司去买个商业级的代码加密方案吧😂。

Recent Articles

Unity游戏开发优化 —— 通用对象池与复用(一)

概述 游戏开发的一个大坑就是优化。这边博客主要讨论什么是对象池缓存技术以及如何基于Unity设计一个通用的对象池缓存框架。为什么要用对象池?引入对象池的概念是为了减少内存碎片的产生与降低创建实例时的cpu消耗,一般适用于各种粒子特效,或是射击游戏里的子弹之类的大量重复出现的游戏物体。什么是内存碎片?当一个实例的所有引用都被赋予null时,这个实例就变成了内存垃圾,即将被系统清理。当它被GC清理掉后,内存里就会留下一条空的可以使用的内存。然而问题来了,假设这段空余内存有4个...…

UnityContinue
Earlier Articles

Unity中自定义Coroutine的yield return返回条件

概述 当游戏开发涉及到人工智能设计的部分时,判断人物动画/寻路等这类持续一段时间而不是单帧的操作何时结束是一个能扰乱代码质量的地方。一种最简单的方法是在update()中每帧进行检验,但这种方式会使用大量的if else结构,使得代码混论难懂,耦合高,同时即使该帧没有操作正在进行也需要一个if else判断是否需要检测操作进度,消耗cpu性能。如果我们能使用coroutine协程来解决这个问题,进度检测代码就可以和主更新方法分离开了来了。然而如果仍然使用if else判断动作是否结束,...…

UnityContinue