各位开发者、安全工程师们,下午好!
今天,我们将深入探讨一个在游戏开发和应用分发领域至关重要的话题:如何保护我们的本地资源文件不被篡改。具体来说,我们将聚焦于Unity Asset Bundle的签名机制,这是一种利用数字签名技术来确保Asset Bundle文件完整性和真实性的强大方案。
在现代游戏中,Asset Bundle以其高效的资源管理和动态加载能力,成为了不可或缺的一部分。然而,将重要的游戏数据和逻辑以Asset Bundle的形式存储在本地,也带来了显而易见的安全风险:这些文件可能被恶意用户篡改,从而导致作弊、盗版,甚至更严重的系统破坏。想象一下,如果一个玩家可以轻易修改Asset Bundle中的角色属性、物品价格或关卡配置,游戏的平衡性和经济系统将荡然无存。
传统的加密手段固然能保护数据的机密性,防止未经授权的访问者读取其内容。但加密本身并不能保证数据的完整性——一个被加密的文件,仍然可能在传输或存储过程中被恶意修改,而接收方在解密后并不会立即知道内容已被篡改。这就是数字签名技术大显身手的地方。数字签名不仅能验证文件的来源(真实性),还能确保文件自签名以来未被任何第三方修改(完整性)。
本次讲座的目标是,详细阐述Asset Bundle签名从理论到实践的全过程。我们将从数字签名的基本原理讲起,深入探讨签名方案的设计,并通过详尽的C#代码示例,演示如何在构建服务器端对Asset Bundle进行签名,以及在游戏客户端如何高效、安全地验证这些签名。最终,我们将讨论一些高级考量和最佳实践,以构建一个既安全又健壮的资源保护体系。
1. Asset Bundle及其面临的篡改风险
在深入签名机制之前,我们首先需要理解Asset Bundle是什么,以及它们为何如此容易受到攻击。
1.1 什么是Asset Bundle?
Asset Bundle是Unity引擎提供的一种用于打包和加载资源(如模型、纹理、音频、场景等)的机制。它们允许开发者将游戏资源分离出主构建,以便在运行时按需下载和加载。这带来了诸多优势:
- 减小初始包体大小: 玩家无需下载所有游戏内容,只下载必需部分。
- 热更新能力: 无需发布新的应用版本即可更新游戏内容。
- 资源按需加载: 优化内存使用,只加载当前需要的资源。
- 多平台兼容性: 为不同平台构建特定的Asset Bundle。
一个Asset Bundle本质上是一个压缩文件,内部包含一个或多个Unity资源文件,以及一些元数据。其文件格式是Unity私有的,但其结构并非完全不透明。
1.2 Asset Bundle的漏洞:为何容易被篡改?
尽管Asset Bundle带来了诸多便利,但它们作为本地文件,天然地存在以下安全漏洞:
- 本地可访问性: Asset Bundle通常存储在设备的本地文件系统上(如
Application.persistentDataPath),这使得它们对用户是可访问的。 - 标准文件操作工具: 任何用户都可以使用文件浏览器、命令行工具甚至十六进制编辑器来打开和修改这些文件。
- 缺乏内置完整性检查: Unity本身在加载Asset Bundle时,主要关注其内部格式是否正确,而非内容是否被恶意篡改。它会尝试加载文件,如果格式损坏可能报错,但如果内容被巧妙地修改,使其仍然符合Unity的解析预期,那么篡改就可能成功。
- 恶意工具的存在: 存在专门针对Unity Asset Bundle的解包、修改和重新打包工具,这些工具使得篡改变得更加容易和自动化。
1.3 篡改的潜在后果
Asset Bundle的篡改可能导致一系列严重的问题:
- 游戏内作弊:
- 修改角色属性(生命值、攻击力、移动速度)。
- 修改物品数据(稀有度、价格、效果)。
- 解锁付费内容或道具。
- 修改关卡参数,使游戏变得异常简单或困难。
- 盗版和私服: 恶意用户可能通过修改Asset Bundle来绕过授权验证,甚至构建非官方的游戏服务器。
- 游戏崩溃和不稳定: 不正确的篡改可能导致游戏运行时崩溃,影响玩家体验。
- 注入恶意内容: 理论上,攻击者可以注入恶意代码或不当内容,虽然Asset Bundle主要承载数据而非执行代码,但在某些特定场景下,结合其他漏洞可能产生危害。
1.4 为什么仅加密不足以解决问题?
一些开发者可能会考虑对Asset Bundle进行对称加密,例如使用AES。加密确实可以隐藏Asset Bundle的真实内容,防止未经授权的读取。然而,它并不能阻止篡改:
- 一个攻击者可以在不知道密钥的情况下,对加密后的Asset Bundle进行任意字节的修改。
- 当游戏客户端使用正确的密钥解密这个被篡改的Asset Bundle时,解密过程通常会成功(除非篡改导致解密算法本身失败),但解密出的数据将是毫无意义的乱码或被破坏的内容。游戏可能会崩溃,或者加载错误的数据。
- 重要的是,客户端无法知道这些错误是由于原始文件损坏,还是被恶意篡改造成的。加密只保证了机密性,不保证完整性。
为了确保Asset Bundle的完整性和真实性,我们必须引入数字签名。
2. 数字签名原理:保障完整性与真实性的基石
数字签名是一种基于公钥密码学(Asymmetric Cryptography)的技术,旨在解决数据在传输或存储过程中的完整性、真实性和不可否认性问题。它与对称加密技术有着本质的区别,是保护Asset Bundle不被篡改的核心。
2.1 核心概念:哈希与非对称加密的结合
数字签名的工作原理可以概括为以下两个关键步骤:
-
哈希(Hashing): 首先,对原始数据(在这里就是Asset Bundle文件)计算一个固定长度的“指纹”或“摘要”,称为哈希值(Hash Value)或消息摘要(Message Digest)。
- 特性:
- 单向性: 从哈希值无法逆推出原始数据。
- 雪崩效应: 原始数据哪怕只有微小的改动,也会导致哈希值发生巨大变化。
- 固定长度: 无论输入数据多大,输出的哈希值长度固定。
- 抗碰撞性: 极难找到两个不同的输入数据产生相同的哈希值(强抗碰撞性)。
- 常用算法: SHA-256 (Secure Hash Algorithm 256), SHA-512。
- 作用: 哈希值代表了数据的“身份”,任何对数据的篡改都会改变其哈希值。
- 特性:
-
非对称加密(Asymmetric Encryption): 接着,使用签名者的私钥对这个哈希值进行加密。加密后的哈希值就是数字签名。
- 公钥/私钥对: 非对称加密使用一对密钥:私钥(Private Key)和公钥(Public Key)。
- 私钥: 签名者自己持有,绝不公开,用于签名。
- 公钥: 可以公开分发,用于验证签名。
- 特性:
- 私钥加密的数据只能用对应的公钥解密。
- 公钥加密的数据只能用对应的私钥解密(尽管数字签名中通常是私钥加密哈希,公钥解密哈希)。
- 常用算法: RSA, ECDSA (Elliptic Curve Digital Signature Algorithm)。
- 作用: 私钥加密哈希值,证明了只有持有私钥的实体(即我们自己)才能生成这个签名,从而保证了数据的真实性。
- 公钥/私钥对: 非对称加密使用一对密钥:私钥(Private Key)和公钥(Public Key)。
2.2 签名过程(Build Server端):
- 原始数据(Asset Bundle)
- 哈希算法 计算哈希值
H(AssetBundle) - 签名者的私钥 对
H(AssetBundle)进行加密 - 生成 数字签名 S (
Encrypt_PrivateKey(H(AssetBundle))) - 将 数字签名 S 附加到 原始数据(Asset Bundle) 上,形成带签名的Asset Bundle
2.3 验证过程(Game Client端):
- 接收到 带签名的Asset Bundle
- 将 数字签名 S 从Asset Bundle中分离出来
- 使用 签名者的公钥 对 数字签名 S 进行解密,得到原始哈希值
H_original(Decrypt_PublicKey(S)) - 对 Asset Bundle的原始内容(不包含签名部分) 重新计算哈希值
H_computed - 比较
H_original和H_computed:- 如果两者 相等:验证成功,Asset Bundle内容未被篡改,且确实由持有私钥的我们所签署。
- 如果两者 不相等:验证失败,Asset Bundle已被篡改,或签名不正确/伪造。
2.4 数字签名的关键属性
- 数据完整性(Integrity): 任何对Asset Bundle内容的修改都会改变其哈希值,从而导致验证失败。
- 数据真实性/来源认证(Authenticity): 只有持有正确私钥的实体才能生成一个有效的签名。客户端通过公钥验证,确信Asset Bundle来自可信的源。
- 不可否认性(Non-repudiation): (虽然对于本地文件不太常用,但在网络通信中很重要)签名者无法否认自己签署了某个数据。
2.5 数字签名与对称加密的对比
| 特性 | 对称加密 (AES) | 非对称加密 (RSA/ECDSA) + 哈希 (SHA256) (即数字签名) |
|---|---|---|
| 主要目的 | 保护数据机密性 (Confidentiality) | 保护数据完整性 (Integrity) 和真实性 (Authenticity) |
| 密钥 | 单个共享密钥 (加密/解密都用同一个) | 一对密钥:私钥 (签名), 公钥 (验证) |
| 安全性 | 防止未经授权的读取 | 防止数据被篡改,验证数据来源 |
| 性能 | 通常更快 | 通常比对称加密慢,尤其是在处理大量数据时 |
| 应用场景 | 存储敏感数据,网络传输加密 | 软件更新验证,数字证书,文件完整性校验 |
| Asset Bundle | 防止内容被直接查看 | 防止内容被篡改,确保来自官方 |
可以看到,数字签名和加密是互补的。如果既要保护Asset Bundle的机密性,又要保护其完整性,那么通常会结合使用:先对Asset Bundle进行对称加密,然后对 加密后的数据 进行数字签名。这样,即使加密后的数据被篡改,签名也能检测出来。今天我们主要聚焦于完整性保护,所以只讨论签名。
3. 选择合适的加密算法与库
在实现Asset Bundle签名方案时,选择恰当的哈希算法、非对称加密算法以及相应的编程库是至关重要的一步。这将直接影响方案的安全性和性能。
3.1 哈希算法
哈希算法的选择相对直接,我们需要选择一个具有强抗碰撞性且被广泛认可的算法。
- SHA-256 (Secure Hash Algorithm 256):
- 推荐: 目前业界标准,提供256位的哈希值。安全性高,计算速度适中。对于大多数Asset Bundle签名场景来说,SHA-256是足够安全的。
- 优势: 广泛支持,性能与安全性平衡。
- SHA-512:
- 提供512位的哈希值,安全性更高。
- 考虑: 计算量通常比SHA-256略大,但对于现代CPU来说,差异可能不显著。如果对安全性有极致要求,可以选择。
- MD5/SHA-1:
- 不推荐: MD5和SHA-1已被证明存在碰撞漏洞,不应再用于安全性要求高的场景,特别是数字签名。
结论: 强烈推荐使用 SHA-256。
3.2 非对称加密算法
非对称加密算法用于对哈希值进行加密(签名)和解密(验证)。
- RSA (Rivest-Shamir-Adleman):
- 最常用: 历史悠久,安全性经过广泛验证。是目前最常用的非对称加密算法之一。
- 密钥长度: 至少应使用2048位密钥。更高的安全性可以考虑3072位或4096位。密钥长度越长,安全性越高,但签名和验证的速度也越慢。对于Asset Bundle签名,2048位通常是很好的平衡点。
- 填充模式 (Padding Scheme): 在RSA签名中,通常会使用PKCS#1 v1.5或PSS (Probabilistic Signature Scheme) 填充。PKCS#1 v1.5是传统标准,PSS提供更高的安全性保障,但更复杂。对于.NET的
RSACryptoServiceProvider,默认的RSASignaturePadding.Pkcs1是安全且常用的。 - 优势: 广泛支持,易于理解和实现。
- 劣势: 相对于椭圆曲线算法,相同安全强度下密钥和签名长度更长,计算速度稍慢。
- ECDSA (Elliptic Curve Digital Signature Algorithm):
- 现代选择: 基于椭圆曲线密码学,提供与RSA相同安全强度下更短的密钥、更小的签名和更快的计算速度。在移动设备和性能敏感的场景中越来越受欢迎。
- 密钥长度: 通常使用256位或384位的曲线。
- 优势: 性能更高,签名更小。
- 劣势: 实现相对复杂,对曲线参数的选择有要求。
结论: 对于大多数Unity项目,RSA 2048位 是一个成熟、安全且易于实现的方案。如果对性能或签名大小有极高要求,并且团队对椭圆曲线密码学有一定了解,可以考虑ECDSA。本次讲座我们将以RSA为例进行实现。
3.3 密钥管理:核心中的核心
无论选择哪种算法,密钥管理都是整个签名方案中 最关键 的环节。
- 私钥 (Private Key):
- 绝对保密: 私钥是签名的“核心”,一旦泄露,攻击者就可以伪造任何官方签名。
- 存储位置: 必须存储在一个高度安全的环境中。
- 生产环境: 专业的硬件安全模块 (HSM – Hardware Security Module) 是最佳选择。
- 开发/小型项目: 专用的安全服务器、加密文件系统、受严格访问控制的机器。
- 访问控制: 只有构建系统或授权的自动化脚本才能访问私钥。
- 永不分发: 私钥绝不能随游戏客户端发布,即使是加密形式也不行。
- 公钥 (Public Key):
- 公开分发: 公钥用于验证,可以安全地嵌入到游戏客户端中。
- 存储位置:
- 硬编码: 作为字符串或
TextAsset嵌入到Unity项目中。 - 远程获取: 在游戏启动时从可信服务器获取。这提供了更高的灵活性(如密钥轮换),但增加了启动时的网络依赖和攻击面(需要验证远程服务器的真实性,通常通过TLS)。
- 硬编码: 作为字符串或
- 混淆: 虽然公钥公开无妨,但为了增加逆向工程的难度,可以对其进行简单的混淆(如异或加密、Base64编码等),但这并非安全措施,只是“加了一层薄纱”。
密钥轮换策略: 即使密钥保管得再好,也应考虑定期轮换私钥。当轮换时,需要发布包含新公钥的游戏客户端版本。旧的公钥可以保留一段时间,以兼容尚未更新的旧版本Asset Bundle。
3.4 C#/.NET 提供的密码学库
C#和.NET框架提供了强大的内置密码学支持,主要通过 System.Security.Cryptography 命名空间。这使得我们在Unity中实现数字签名变得相对容易。
- 哈希:
System.Security.Cryptography.SHA256(或SHA512) - RSA:
System.Security.Cryptography.RSACryptoServiceProvider - PEM/PKCS8/X.509支持: .NET Core 2.1+ 和 .NET 5+ 提供了更丰富的PEM/PKCS8密钥格式支持。对于旧版.NET Framework或Unity(通常基于Mono),
RSACryptoServiceProvider默认使用XML格式导入导出密钥。如果需要处理PEM格式,可能需要借助第三方库如Bouncy Castle,或者手动解析PEM格式转换为XML。本次讲解将使用XML格式,因为它在Unity中通过RSACryptoServiceProvider直接支持。
第三方库:
- Bouncy Castle: 一个非常全面的加密库,支持更广泛的算法、密钥格式和高级功能。如果内置的.NET库无法满足需求,Bouncy Castle是一个不错的选择。然而,它会增加项目的依赖和包体大小。对于Asset Bundle签名这种相对标准的需求,.NET内置库通常足够。
结论: 我们将主要使用System.Security.Cryptography命名空间下的类来实现签名和验证。
4. 设计Asset Bundle签名方案
在开始编写代码之前,我们需要明确签名方案的具体细节:签名的内容是什么?签名数据如何存储在Asset Bundle中?客户端如何解析和验证?
4.1 核心原则
- 完整性: 签名必须覆盖Asset Bundle的 所有有效内容。任何对Asset Bundle文件(除了签名本身)的修改都必须导致验证失败。
- 可解析性: 签名数据必须以一种明确的方式存储,以便客户端能够准确地提取出签名和原始数据。
- 兼容性: 尽可能减少对Unity Asset Bundle文件结构的侵入性修改,以避免潜在的兼容性问题。
4.2 签名内容的确定
最简单也是最可靠的方案是:对整个Asset Bundle文件的二进制内容进行哈希并签名。
- 优点:
- 简单直接,不易出错。
- 任何对文件的修改(包括头部、元数据、内容数据)都会被检测到。
- 缺点:
- 如果签名数据本身被附加到文件末尾,那么在计算哈希时,需要将这部分签名数据排除掉。
4.3 签名数据的存储方案
有几种方式可以存储数字签名:
- 独立签名文件 (.sig): 将签名存储在与Asset Bundle同名的独立文件中(例如
mybundle.bundle和mybundle.bundle.sig)。- 优点: 不修改Asset Bundle文件本身,结构清晰。
- 缺点: 增加了文件管理复杂性,需要同时分发两个文件。攻击者可能只替换其中一个文件。
- 在自定义头部/尾部嵌入: 在Asset Bundle文件内部添加一个自定义头部或尾部结构来存储签名。
- 优点: 单一文件,不易被分离。
- 缺点: 需要更复杂的解析逻辑,可能需要理解和修改Asset Bundle的内部格式(虽然我们会在文件末尾添加,但仍需谨慎)。
- 附加到文件末尾: 将签名数据直接追加到Asset Bundle文件的末尾。这是最常用且相对简单的方案。
推荐方案:附加到文件末尾。
为了让客户端能够正确解析,我们需要在签名数据之后再附加一个表示签名长度的字段。
具体存储结构:
[Asset Bundle 原始内容]
[数字签名 (Signature)]
[签名长度 (Signature Length)]
- Asset Bundle 原始内容: 这是Unity构建出来的Asset Bundle的原始二进制数据。
- 数字签名: 使用私钥对Asset Bundle原始内容的哈希值进行加密后生成的二进制数据。RSA 2048位密钥生成的签名通常是256字节(2048位 / 8)。
- 签名长度: 一个固定长度的整数,表示前面“数字签名”的字节长度。例如,使用4字节(
int)来存储这个长度。
客户端解析流程:
- 读取文件的最后4个字节,解析出签名长度
L。 - 从文件末尾向前
L + 4个字节处开始,读取L个字节作为数字签名S。 - 文件的剩余部分(从文件开头到
文件总长度 - L - 4)就是Asset Bundle的原始内容AB_Original。 - 对
AB_Original计算哈希值H_computed。 - 使用公钥验证
S是否是AB_Original哈希值的有效签名。
这种方案简单、高效,且对Asset Bundle的内部结构影响最小。
4.4 签名方案小结
| 环节 | 细节 |
|---|---|
| 哈希算法 | SHA-256 |
| 签名算法 | RSA 2048位 (PKCS#1 v1.5 padding) |
| 签名内容 | 整个Asset Bundle文件的二进制内容 |
| 存储方式 | 附加到Asset Bundle文件末尾 |
| 存储结构 | [AB_Content] [Signature] [Signature_Len] |
| 私钥 | 仅在构建服务器端使用,绝不泄露 |
| 公钥 | 嵌入到游戏客户端中,用于验证 |
接下来,我们将进入具体的代码实现环节。
5. 实现:签名过程(Build Server端)
在构建服务器端,我们需要一个脚本来完成Asset Bundle的生成、哈希计算、签名以及将签名附加到文件末尾的整个过程。这个脚本通常是一个Unity Editor脚本,可以在构建Asset Bundle后自动执行。
5.1 准备:密钥生成
首先,我们需要生成RSA公钥和私钥对。私钥将用于签名,公钥将嵌入到客户端用于验证。这个过程只需要执行一次,或者在需要轮换密钥时执行。
为了方便起见,我们将私钥和公钥都保存为XML格式的字符串。私钥XML包含私有和公共部分,而公钥XML只包含公共部分。
// File: Assets/Editor/SigningTools.cs
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class SigningTools
{
private const string PrivateKeyFileName = "rsa_private_key.xml";
private const string PublicKeyFileName = "rsa_public_key.xml";
private const int RsaKeySize = 2048; // RSA密钥长度,建议2048位或更高
/// <summary>
/// 生成RSA密钥对(公钥和私钥)并保存到文件。
/// 私钥包含私有信息,绝不能泄露。
/// 公钥可以公开。
/// </summary>
[MenuItem("Tools/Signing/Generate RSA Key Pair")]
public static void GenerateRsaKeyPair()
{
using (var rsa = new RSACryptoServiceProvider(RsaKeySize))
{
try
{
// 导出包含私钥的XML字符串
string privateKeyXml = rsa.ToXmlString(true);
string privateKeyPath = Path.Combine(Application.dataPath, "Editor", PrivateKeyFileName);
File.WriteAllText(privateKeyPath, privateKeyXml);
Debug.Log($"RSA Private Key generated and saved to: {privateKeyPath}");
// 导出只包含公钥的XML字符串
string publicKeyXml = rsa.ToXmlString(false);
string publicKeyPath = Path.Combine(Application.dataPath, "Editor", PublicKeyFileName);
File.WriteAllText(publicKeyPath, publicKeyXml);
Debug.Log($"RSA Public Key generated and saved to: {publicKeyPath}");
AssetDatabase.Refresh();
}
catch (CryptographicException e)
{
Debug.LogError($"Error generating RSA key pair: {e.Message}");
}
}
}
/// <summary>
/// 从文件加载私钥XML字符串。
/// </summary>
public static string LoadPrivateKeyXml()
{
string privateKeyPath = Path.Combine(Application.dataPath, "Editor", PrivateKeyFileName);
if (!File.Exists(privateKeyPath))
{
Debug.LogError($"Private key file not found: {privateKeyPath}. Please generate it first.");
return null;
}
return File.ReadAllText(privateKeyPath);
}
/// <summary>
/// 从文件加载公钥XML字符串。
/// </summary>
public static string LoadPublicKeyXml()
{
string publicKeyPath = Path.Combine(Application.dataPath, "Editor", PublicKeyFileName);
if (!File.Exists(publicKeyPath))
{
Debug.LogError($"Public key file not found: {publicKeyPath}. Please generate it first.");
return null;
}
return File.ReadAllText(publicKeyPath);
}
}
重要提示:
rsa_private_key.xml文件包含了私钥的敏感信息。在实际生产环境中,这个文件绝对不能直接放在版本控制系统(如Git)中,也不能随意分发。它应该被存储在一个受限访问的安全位置,例如构建服务器的加密目录,并且只在签名Asset Bundle时临时加载。rsa_public_key.xml可以随项目分发,但为了方便,通常会将其内容复制到一个TextAsset或直接硬编码到客户端代码中。
5.2 签名Asset Bundle的Editor脚本
接下来,我们编写一个Editor脚本,它将在Asset Bundle构建完成后,遍历所有生成的Asset Bundle文件,并对它们进行签名。
// File: Assets/Editor/AssetBundleSigner.cs
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class AssetBundleSigner
{
private const string AssetBundlesOutputPath = "AssetBundles"; // Asset Bundle的输出目录
private const int SignatureLengthFieldSize = 4; // 存储签名长度的字节数 (int)
/// <summary>
/// 构建Asset Bundles并自动进行签名。
/// </summary>
[MenuItem("Tools/AssetBundle/Build AssetBundles and Sign")]
public static void BuildAndSignAssetBundles()
{
string outputPath = Path.Combine(Application.dataPath, AssetBundlesOutputPath);
if (!Directory.Exists(outputPath))
{
Directory.CreateDirectory(outputPath);
}
// 1. 获取私钥
string privateKeyXml = SigningTools.LoadPrivateKeyXml();
if (string.IsNullOrEmpty(privateKeyXml))
{
Debug.LogError("Private key not loaded. Aborting Asset Bundle signing.");
return;
}
// 2. 构建Asset Bundles
AssetBundleManifest manifest = BuildPipeline.BuildAssetBundles(
outputPath,
BuildAssetBundleOptions.None,
EditorUserBuildSettings.activeBuildTarget
);
if (manifest == null)
{
Debug.LogError("Failed to build Asset Bundles.");
return;
}
Debug.Log("Asset Bundles built successfully. Starting signing process...");
// 3. 遍历并签名所有生成的Asset Bundles
string[] assetBundleNames = manifest.GetAllAssetBundles();
foreach (string abName in assetBundleNames)
{
string abPath = Path.Combine(outputPath, abName);
if (File.Exists(abPath))
{
SignAssetBundleFile(abPath, privateKeyXml);
}
else
{
Debug.LogWarning($"Asset Bundle file not found: {abPath}. Skipping signing.");
}
}
Debug.Log("Asset Bundle signing process completed.");
AssetDatabase.Refresh();
}
/// <summary>
/// 对指定的Asset Bundle文件进行签名。
/// </summary>
/// <param name="assetBundleFilePath">Asset Bundle文件的完整路径。</param>
/// <param name="privateKeyXml">RSA私钥的XML字符串。</param>
public static void SignAssetBundleFile(string assetBundleFilePath, string privateKeyXml)
{
Debug.Log($"Signing Asset Bundle: {assetBundleFilePath}");
try
{
byte[] fileContent;
using (var fs = new FileStream(assetBundleFilePath, FileMode.Open, FileAccess.Read))
{
fileContent = new byte[fs.Length];
fs.Read(fileContent, 0, (int)fs.Length);
}
// 1. 计算Asset Bundle内容的SHA256哈希值
byte[] hash;
using (SHA256 sha256 = SHA256.Create())
{
hash = sha256.ComputeHash(fileContent);
}
// 2. 使用私钥对哈希值进行签名
byte[] signature;
using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider())
{
rsa.FromXmlString(privateKeyXml); // 导入私钥
// 使用PKCS#1 v1.5填充模式签名
signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
// 3. 将签名和签名长度附加到Asset Bundle文件末尾
using (var fs = new FileStream(assetBundleFilePath, FileMode.Append, FileAccess.Write))
{
fs.Write(signature, 0, signature.Length);
byte[] signatureLengthBytes = BitConverter.GetBytes(signature.Length);
fs.Write(signatureLengthBytes, 0, signatureLengthBytes.Length);
}
Debug.Log($"Successfully signed Asset Bundle: {assetBundleFilePath}. Signature length: {signature.Length} bytes.");
}
catch (Exception e)
{
Debug.LogError($"Error signing Asset Bundle {assetBundleFilePath}: {e.Message}");
}
}
}
代码解释:
BuildAndSignAssetBundles(): 这是一个Unity Editor菜单项函数。- 首先调用
SigningTools.LoadPrivateKeyXml()加载私钥。 - 然后调用
BuildPipeline.BuildAssetBundles()生成标准的Asset Bundle。 - 最后,遍历
manifest.GetAllAssetBundles()返回的所有Asset Bundle名称,对每个文件调用SignAssetBundleFile()进行签名。
- 首先调用
SignAssetBundleFile(string assetBundleFilePath, string privateKeyXml): 这是签名的核心逻辑。- 读取文件内容: 将整个Asset Bundle文件的内容读入
byte[] fileContent。 - 计算哈希: 使用
SHA256.Create().ComputeHash(fileContent)计算出内容的SHA256哈希值。 - 签名哈希:
- 创建一个
RSACryptoServiceProvider实例。 rsa.FromXmlString(privateKeyXml)导入我们预先生成的私钥。rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)执行签名操作。HashAlgorithmName.SHA256指定了哈希算法,RSASignaturePadding.Pkcs1指定了填充模式。
- 创建一个
- 附加签名和长度:
- 以
FileMode.Append模式打开文件,将生成的signature字节数组写入文件末尾。 - 使用
BitConverter.GetBytes(signature.Length)将签名的长度转换为4字节的byte[],并紧接着签名数据写入文件。这个长度信息是客户端验证时用来反向解析的关键。
- 以
- 读取文件内容: 将整个Asset Bundle文件的内容读入
现在,你可以在Unity Editor中点击 Tools/AssetBundle/Build AssetBundles and Sign 来构建并签名你的Asset Bundles了。签名后的Asset Bundle文件会比原始文件稍大,多出的部分就是数字签名和其长度信息。
6. 实现:验证过程(Game Client端)
在游戏客户端,我们需要一个运行时脚本来加载公钥,然后当需要加载Asset Bundle时,对文件进行验证。只有验证通过的Asset Bundle才会被加载和使用。
6.1 准备:嵌入公钥
为了让客户端能够验证签名,公钥必须嵌入到客户端应用中。最简单的方式是将其作为TextAsset资源添加到Unity项目中,或者直接硬编码为字符串。
- 复制公钥文件: 将之前生成的
rsa_public_key.xml文件从Assets/Editor目录复制到Assets/Resources或Assets下的其他任意可打包的目录。 - 创建TextAsset: 在Unity Editor中,右键点击
rsa_public_key.xml文件,选择Create -> TextAsset,或直接将其拖入项目,Unity会自动将其识别为TextAsset。例如,命名为RSAPublicKey。
6.2 客户端验证脚本
现在,我们编写一个运行时脚本来处理Asset Bundle的加载和验证。
// File: Assets/Scripts/AssetBundleVerifier.cs
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
public class AssetBundleVerifier : MonoBehaviour
{
// 将公钥TextAsset拖拽到此字段
public TextAsset rsaPublicKeyXmlAsset;
private RSACryptoServiceProvider rsaVerifier;
private bool isPublicKeyLoaded = false;
private const int SignatureLengthFieldSize = 4; // 存储签名长度的字节数 (int)
void Awake()
{
LoadPublicKey();
}
/// <summary>
/// 加载RSA公钥。在游戏启动时执行一次。
/// </summary>
private void LoadPublicKey()
{
if (rsaPublicKeyXmlAsset == null || string.IsNullOrEmpty(rsaPublicKeyXmlAsset.text))
{
Debug.LogError("RSA Public Key TextAsset is missing or empty. Cannot verify Asset Bundles.");
isPublicKeyLoaded = false;
return;
}
try
{
rsaVerifier = new RSACryptoServiceProvider();
rsaVerifier.FromXmlString(rsaPublicKeyXmlAsset.text); // 导入公钥
isPublicKeyLoaded = true;
Debug.Log("RSA Public Key loaded successfully.");
}
catch (CryptographicException e)
{
Debug.LogError($"Error loading RSA Public Key: {e.Message}");
isPublicKeyLoaded = false;
}
}
/// <summary>
/// 验证并加载指定路径的Asset Bundle文件。
/// </summary>
/// <param name="assetBundleFilePath">Asset Bundle文件的完整路径。</param>
/// <returns>验证成功则返回AssetBundle对象,否则返回null。</returns>
public AssetBundle VerifyAndLoadAssetBundle(string assetBundleFilePath)
{
if (!isPublicKeyLoaded)
{
Debug.LogError("Public key not loaded. Cannot verify Asset Bundle.");
return null;
}
if (!File.Exists(assetBundleFilePath))
{
Debug.LogError($"Asset Bundle file not found: {assetBundleFilePath}");
return null;
}
try
{
using (var fs = new FileStream(assetBundleFilePath, FileMode.Open, FileAccess.Read))
{
long fileLength = fs.Length;
if (fileLength < SignatureLengthFieldSize)
{
Debug.LogError($"File {assetBundleFilePath} is too short to contain a signature. File length: {fileLength}");
return null;
}
// 1. 读取签名长度
byte[] signatureLengthBytes = new byte[SignatureLengthFieldSize];
fs.Seek(-SignatureLengthFieldSize, SeekOrigin.End); // 从文件末尾向前移动4字节
fs.Read(signatureLengthBytes, 0, SignatureLengthFieldSize);
int signatureLength = BitConverter.ToInt32(signatureLengthBytes, 0);
if (signatureLength <= 0 || signatureLength > fileLength - SignatureLengthFieldSize)
{
Debug.LogError($"Invalid signature length ({signatureLength}) found in file {assetBundleFilePath}. File length: {fileLength}.");
return null;
}
// 2. 读取数字签名
byte[] signature = new byte[signatureLength];
fs.Seek(-(SignatureLengthFieldSize + signatureLength), SeekOrigin.End); // 从文件末尾向前移动 (签名长度 + 4) 字节
fs.Read(signature, 0, signatureLength);
// 3. 读取原始Asset Bundle内容(不包含签名和签名长度字段)
long originalContentLength = fileLength - SignatureLengthFieldSize - signatureLength;
if (originalContentLength <= 0)
{
Debug.LogError($"Original Asset Bundle content length is invalid or zero for {assetBundleFilePath}.");
return null;
}
byte[] originalContent = new byte[originalContentLength];
fs.Seek(0, SeekOrigin.Begin); // 回到文件开头
fs.Read(originalContent, 0, (int)originalContentLength);
// 4. 计算原始Asset Bundle内容的SHA256哈希值
byte[] computedHash;
using (SHA256 sha256 = SHA256.Create())
{
computedHash = sha256.ComputeHash(originalContent);
}
// 5. 使用公钥验证签名
if (rsaVerifier.VerifyHash(computedHash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
Debug.Log($"Asset Bundle {assetBundleFilePath} verification successful. Loading...");
// 验证成功,现在可以安全地加载Asset Bundle
return AssetBundle.LoadFromMemory(originalContent);
}
else
{
Debug.LogError($"Asset Bundle {assetBundleFilePath} verification FAILED: Signature mismatch or content tampered!");
// 验证失败,拒绝加载,可以考虑删除文件或报警
return null;
}
}
}
catch (Exception e)
{
Debug.LogError($"Error during Asset Bundle verification or loading for {assetBundleFilePath}: {e.Message}");
return null;
}
}
// 示例用法:
/*
public string assetBundleToLoad = "mybundle"; // 假设要加载的Asset Bundle名称
void Start()
{
string abPath = Path.Combine(Application.dataPath, "AssetBundles", assetBundleToLoad);
AssetBundle ab = VerifyAndLoadAssetBundle(abPath);
if (ab != null)
{
Debug.Log($"Asset Bundle '{assetBundleToLoad}' loaded successfully.");
// 在这里可以使用加载的Asset Bundle
// Example: var myGameObject = ab.LoadAsset<GameObject>("MyPrefab");
ab.Unload(false); // 使用完后记得卸载
}
else
{
Debug.LogError($"Failed to load asset bundle '{assetBundleToLoad}'.");
}
}
*/
}
代码解释:
rsaPublicKeyXmlAsset: 这是一个public TextAsset字段,你需要在Unity Editor中将你导入的rsa_public_key.xml文件拖拽到这个字段上。LoadPublicKey(): 在Awake中调用,用于在游戏启动时加载公钥。- 从
TextAsset中读取XML字符串。 - 使用
rsaVerifier.FromXmlString(rsaPublicKeyXmlAsset.text)导入公钥。这只需要执行一次。
- 从
VerifyAndLoadAssetBundle(string assetBundleFilePath): 这是验证和加载的核心函数。- 文件存在性检查: 确保文件存在。
- 文件流操作: 使用
FileStream以FileMode.Open和FileAccess.Read模式打开Asset Bundle文件。 - 读取签名长度:
fs.Seek(-SignatureLengthFieldSize, SeekOrigin.End):将文件指针移动到文件末尾的前4个字节处。fs.Read(signatureLengthBytes, 0, SignatureLengthFieldSize):读取这4个字节。BitConverter.ToInt32(signatureLengthBytes, 0):将字节数组转换为整数,得到signatureLength。
- 读取数字签名:
fs.Seek(-(SignatureLengthFieldSize + signatureLength), SeekOrigin.End):将文件指针移动到签名数据的起始位置。fs.Read(signature, 0, signatureLength):读取签名数据。
- 读取原始Asset Bundle内容:
- 计算
originalContentLength = fileLength - SignatureLengthFieldSize - signatureLength,这是原始Asset Bundle内容的实际长度。 - 创建一个
byte[] originalContent来存储这部分内容。 fs.Seek(0, SeekOrigin.Begin):将文件指针移回文件开头。fs.Read(originalContent, 0, (int)originalContentLength):读取原始内容。
- 计算
- 计算哈希: 对
originalContent计算SHA256哈希值,得到computedHash。 - 验证签名:
rsaVerifier.VerifyHash(computedHash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1):使用加载的公钥,传入计算出的哈希值和从文件中读取的签名,进行验证。- 如果返回
true,则表示签名有效,Asset Bundle未被篡改。此时,可以使用AssetBundle.LoadFromMemory(originalContent)从内存加载原始Asset Bundle内容。 - 如果返回
false,则验证失败,通常意味着文件已被篡改,拒绝加载。
通过以上步骤,你就实现了一个完整的Asset Bundle签名和验证系统。当游戏客户端尝试加载Asset Bundle时,它会首先执行严格的数字签名验证。如果验证失败,则会拒绝加载,从而有效地阻止了恶意篡改的Asset Bundle进入游戏。
7. 高级考量与最佳实践
实现基本的签名和验证机制只是第一步。为了构建一个真正健壮和安全的系统,还需要考虑更多高级因素和遵循最佳实践。
7.1 密钥管理深度剖析
- 私钥的极致安全:
- 硬件安全模块 (HSM): 在大型或高安全要求的项目中,应使用HSM来存储和操作私钥。HSM是专门的硬件设备,私钥永远不会离开设备,所有签名操作都在HSM内部完成。
- 专用构建服务器: 私钥应仅存在于受严格物理和网络访问控制的专用构建服务器上。
- 最小权限原则: 只有执行签名的自动化进程或极少数授权人员才能访问私钥。
- 加密存储: 即使在服务器上,私钥也应以加密形式存储,并在使用时通过安全方式(如环境变量、安全凭证管理系统)提供解密密钥。
- 审计日志: 记录所有私钥访问和签名操作。
- 公钥的保护:
- 硬编码与远程获取的权衡:
- 硬编码: 最简单,但密钥轮换需要发布新客户端版本。
- 远程获取: 客户端从服务器获取公钥。这提供了更大的灵活性,可以在不更新客户端的情况下轮换公钥。但需要确保获取公钥的通道是安全的(HTTPS/TLS),并且客户端能验证服务器的真实性,防止中间人攻击。同时,如果公钥服务器被攻击,也可能导致问题。通常可以结合使用:客户端内置一个“根公钥”,用于验证从服务器获取的“更新公钥”。
- 客户端代码混淆: 虽然公钥本身是公开的,但通过代码混淆、字符串加密等手段,可以增加攻击者从客户端二进制文件中提取公钥的难度。这并非安全措施,只是增加逆向工程的成本。
- 硬编码与远程获取的权衡:
- 密钥轮换策略:
- 定期轮换私钥是良好安全实践,可以限制单个密钥泄露的潜在影响。
- 实施多密钥支持:客户端应能识别并验证由不同私钥签名的Asset Bundle。例如,维护一个公钥列表,尝试用列表中的每个公钥进行验证。这在密钥轮换期间特别有用,可以兼容使用旧密钥签名的Asset Bundle。
7.2 性能优化
-
异步验证: 对于大型Asset Bundle,读取文件、计算哈希和验证签名可能需要一定时间,尤其是在移动设备上。应将这些操作放到后台线程中进行,避免阻塞主线程,影响游戏体验。
// 异步加载和验证示例伪代码 public IEnumerator VerifyAndLoadAssetBundleAsync(string assetBundleFilePath, Action<AssetBundle> onComplete) { // ... (文件存在性检查等) byte[] originalContent = null; bool isValid = false; // 在新线程中执行CPU密集型操作 yield return new WaitForBackgroundThread(() => { try { using (var fs = new FileStream(assetBundleFilePath, FileMode.Open, FileAccess.Read)) { // ... (读取签名长度、签名、原始内容) // ... (计算哈希) // ... (rsaVerifier.VerifyHash) // 设置 isValid 和 originalContent } } catch (Exception e) { Debug.LogError(e.Message); } }); if (isValid && originalContent != null) { AssetBundle ab = AssetBundle.LoadFromMemory(originalContent); // Unity API必须在主线程调用 onComplete?.Invoke(ab); } else { onComplete?.Invoke(null); } }(
WaitForBackgroundThread需要自定义实现,例如使用Task.Run或Thread,并在主线程回调) - 缓存验证结果: 一旦一个Asset Bundle被验证为有效,其验证状态可以在内存中缓存。如果文件没有被修改,下次加载时可以跳过耗时的验证步骤。但需要注意:
- 缓存必须与文件路径和版本关联。
- 如果文件被外部修改,缓存会失效。
- 对于每次启动游戏都可能被清除的临时文件,缓存意义不大。
- 分块哈希: 对于超大文件,可以考虑分块读取和哈希,减少内存占用。但最终的签名仍然是对整个文件的哈希。
7.3 错误处理与用户体验
- 明确的错误报告: 当验证失败时,记录详细的日志(但不要泄露敏感信息)。可以向服务器发送遥测数据,报告可疑的篡改尝试。
- 用户反馈: 对于被篡改的Asset Bundle,游戏应提供友好的错误提示,例如“资源文件已损坏,请重新下载”或“检测到文件异常,游戏无法启动”。
- 自动修复: 在检测到Asset Bundle被篡改后,可以尝试自动从服务器重新下载该Asset Bundle,而不是简单地报错。
7.4 客户端验证逻辑的自身保护
数字签名保护了Asset Bundle文件,但客户端中执行验证逻辑的代码本身也可能成为攻击目标。一个熟练的攻击者可能会逆向工程游戏客户端,并修改或绕过验证函数。
- 代码混淆: 使用ProGuard (Android), IL2CPP + Unity Linker (Unity), Dotfuscator 等工具对C#代码进行混淆,增加逆向工程的难度。
- 反篡改工具: 使用第三方反作弊或反篡改SDK,这些工具通常包含运行时内存保护、完整性校验、检测调试器等功能。
- 关键逻辑分散: 将验证逻辑分散到多个不相关的代码模块中,使得攻击者更难找到并修改所有相关点。
- 运行时完整性检查: 在运行时,定期校验关键代码段的哈希值,确保它们没有被修改。但这本身又是一个可以被绕过的问题。
- 远程验证: 对于游戏的核心逻辑或关键数据,除了本地验证,还可以通过与服务器通信进行二次验证。例如,客户端上传Asset Bundle的哈希值到服务器,由服务器进行验证。
7.5 结合加密的方案
如果除了完整性,还需要保护Asset Bundle的机密性(防止内容被直接查看):
- 加密 Asset Bundle: 使用对称加密算法(如AES-256)对Asset Bundle的原始内容进行加密。
- 签名加密后的数据: 对加密后的Asset Bundle数据计算哈希,然后用私钥签名。
- 客户端解密并验证: 客户端首先验证签名,确认加密数据未被篡改。如果验证通过,再使用对称密钥解密。
关键点: 签名必须是对 加密后的数据 进行的,而不是对原始数据。这样才能保证加密数据的完整性。对称密钥的保护也至关重要,它通常通过某种形式的密钥派生或嵌入在客户端代码中(并进行混淆)。
7.6 版本管理与Delta更新
- 签名方案版本: 如果未来需要更新签名算法或存储格式,应在Asset Bundle中包含一个“签名版本”字段,以便客户端能够根据版本选择正确的解析和验证逻辑。
- Asset Bundle版本: 正常的Asset Bundle版本管理(如Unity Asset Bundle Manifest中的哈希)仍然重要,它用于确定是否需要下载新版本。
- Delta更新: 如果使用增量更新(Delta Patching)来减少下载量,那么在生成补丁后,通常需要对 整个更新后的Asset Bundle文件 重新进行签名。因为增量更新会改变文件的二进制内容,旧的签名将失效。
7.7 网络传输安全
如果Asset Bundle是通过网络下载的,那么传输层安全(TLS/HTTPS)是必不可少的。数字签名可以保护本地存储的Asset Bundle,但TLS可以防止下载过程中的中间人攻击和数据窃听。两者是互补的,不能互相替代。即使有了TLS,本地文件的数字签名仍然重要,因为它防范的是 下载完成后 的本地篡改。
7.8 局限性
尽管数字签名是强大的工具,但它并非万无一失:
- 并非反作弊银弹: 数字签名主要保护Asset Bundle的完整性。但攻击者仍然可能通过修改客户端内存、注入代码或使用其他手段绕过游戏逻辑进行作弊。
- 私钥安全是瓶颈: 如果私钥被盗,攻击者可以签署他们自己的恶意Asset Bundle,并通过验证。
- 性能开销: 尤其是在资源密集型操作中,哈希计算和签名验证会带来一定的性能开销。
8. 展望与总结
数字签名是保护本地Asset Bundle资源免受篡改的强大且必要的机制。通过结合哈希算法和非对称加密,我们能够有效地确保游戏资源的完整性和真实性,从而打击作弊、维护游戏平衡并提升玩家体验。尽管实现过程涉及密钥管理、性能考量和客户端安全等多个层面,但投入这些努力是构建一个安全、健壮游戏生态系统的关键一步。
本讲座详细阐述了从密钥生成、Asset Bundle签名到客户端验证的完整流程,并提供了具体的C#代码示例。同时,我们也探讨了诸如密钥管理、性能优化、与加密结合以及客户端自身保护等高级主题。希望这些内容能为各位在实际项目中实施Asset Bundle签名方案提供坚实的理论基础和实践指导。