Asset Bundle 签名:保护本地资源文件不被篡改的方案

各位开发者、安全工程师们,下午好!

今天,我们将深入探讨一个在游戏开发和应用分发领域至关重要的话题:如何保护我们的本地资源文件不被篡改。具体来说,我们将聚焦于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带来了诸多便利,但它们作为本地文件,天然地存在以下安全漏洞:

  1. 本地可访问性: Asset Bundle通常存储在设备的本地文件系统上(如Application.persistentDataPath),这使得它们对用户是可访问的。
  2. 标准文件操作工具: 任何用户都可以使用文件浏览器、命令行工具甚至十六进制编辑器来打开和修改这些文件。
  3. 缺乏内置完整性检查: Unity本身在加载Asset Bundle时,主要关注其内部格式是否正确,而非内容是否被恶意篡改。它会尝试加载文件,如果格式损坏可能报错,但如果内容被巧妙地修改,使其仍然符合Unity的解析预期,那么篡改就可能成功。
  4. 恶意工具的存在: 存在专门针对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 核心概念:哈希与非对称加密的结合

数字签名的工作原理可以概括为以下两个关键步骤:

  1. 哈希(Hashing): 首先,对原始数据(在这里就是Asset Bundle文件)计算一个固定长度的“指纹”或“摘要”,称为哈希值(Hash Value)或消息摘要(Message Digest)。

    • 特性:
      • 单向性: 从哈希值无法逆推出原始数据。
      • 雪崩效应: 原始数据哪怕只有微小的改动,也会导致哈希值发生巨大变化。
      • 固定长度: 无论输入数据多大,输出的哈希值长度固定。
      • 抗碰撞性: 极难找到两个不同的输入数据产生相同的哈希值(强抗碰撞性)。
    • 常用算法: SHA-256 (Secure Hash Algorithm 256), SHA-512。
    • 作用: 哈希值代表了数据的“身份”,任何对数据的篡改都会改变其哈希值。
  2. 非对称加密(Asymmetric Encryption): 接着,使用签名者的私钥对这个哈希值进行加密。加密后的哈希值就是数字签名。

    • 公钥/私钥对: 非对称加密使用一对密钥:私钥(Private Key)和公钥(Public Key)。
      • 私钥: 签名者自己持有,绝不公开,用于签名。
      • 公钥: 可以公开分发,用于验证签名。
    • 特性:
      • 私钥加密的数据只能用对应的公钥解密。
      • 公钥加密的数据只能用对应的私钥解密(尽管数字签名中通常是私钥加密哈希,公钥解密哈希)。
    • 常用算法: RSA, ECDSA (Elliptic Curve Digital Signature Algorithm)。
    • 作用: 私钥加密哈希值,证明了只有持有私钥的实体(即我们自己)才能生成这个签名,从而保证了数据的真实性。

2.2 签名过程(Build Server端):

  1. 原始数据(Asset Bundle)
  2. 哈希算法 计算哈希值 H(AssetBundle)
  3. 签名者的私钥H(AssetBundle) 进行加密
  4. 生成 数字签名 S (Encrypt_PrivateKey(H(AssetBundle)))
  5. 数字签名 S 附加到 原始数据(Asset Bundle) 上,形成带签名的Asset Bundle

2.3 验证过程(Game Client端):

  1. 接收到 带签名的Asset Bundle
  2. 数字签名 S 从Asset Bundle中分离出来
  3. 使用 签名者的公钥数字签名 S 进行解密,得到原始哈希值 H_original (Decrypt_PublicKey(S))
  4. Asset Bundle的原始内容(不包含签名部分) 重新计算哈希值 H_computed
  5. 比较 H_originalH_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 核心原则

  1. 完整性: 签名必须覆盖Asset Bundle的 所有有效内容。任何对Asset Bundle文件(除了签名本身)的修改都必须导致验证失败。
  2. 可解析性: 签名数据必须以一种明确的方式存储,以便客户端能够准确地提取出签名和原始数据。
  3. 兼容性: 尽可能减少对Unity Asset Bundle文件结构的侵入性修改,以避免潜在的兼容性问题。

4.2 签名内容的确定

最简单也是最可靠的方案是:对整个Asset Bundle文件的二进制内容进行哈希并签名。

  • 优点:
    • 简单直接,不易出错。
    • 任何对文件的修改(包括头部、元数据、内容数据)都会被检测到。
  • 缺点:
    • 如果签名数据本身被附加到文件末尾,那么在计算哈希时,需要将这部分签名数据排除掉。

4.3 签名数据的存储方案

有几种方式可以存储数字签名:

  1. 独立签名文件 (.sig): 将签名存储在与Asset Bundle同名的独立文件中(例如mybundle.bundlemybundle.bundle.sig)。
    • 优点: 不修改Asset Bundle文件本身,结构清晰。
    • 缺点: 增加了文件管理复杂性,需要同时分发两个文件。攻击者可能只替换其中一个文件。
  2. 在自定义头部/尾部嵌入: 在Asset Bundle文件内部添加一个自定义头部或尾部结构来存储签名。
    • 优点: 单一文件,不易被分离。
    • 缺点: 需要更复杂的解析逻辑,可能需要理解和修改Asset Bundle的内部格式(虽然我们会在文件末尾添加,但仍需谨慎)。
  3. 附加到文件末尾: 将签名数据直接追加到Asset Bundle文件的末尾。这是最常用且相对简单的方案。

推荐方案:附加到文件末尾。

为了让客户端能够正确解析,我们需要在签名数据之后再附加一个表示签名长度的字段。

具体存储结构:

[Asset Bundle 原始内容]
[数字签名 (Signature)]
[签名长度 (Signature Length)]
  • Asset Bundle 原始内容: 这是Unity构建出来的Asset Bundle的原始二进制数据。
  • 数字签名: 使用私钥对Asset Bundle原始内容的哈希值进行加密后生成的二进制数据。RSA 2048位密钥生成的签名通常是256字节(2048位 / 8)。
  • 签名长度: 一个固定长度的整数,表示前面“数字签名”的字节长度。例如,使用4字节(int)来存储这个长度。

客户端解析流程:

  1. 读取文件的最后4个字节,解析出签名长度 L
  2. 从文件末尾向前 L + 4 个字节处开始,读取 L 个字节作为数字签名 S
  3. 文件的剩余部分(从文件开头到 文件总长度 - L - 4)就是Asset Bundle的原始内容 AB_Original
  4. AB_Original 计算哈希值 H_computed
  5. 使用公钥验证 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}");
        }
    }
}

代码解释:

  1. BuildAndSignAssetBundles() 这是一个Unity Editor菜单项函数。
    • 首先调用SigningTools.LoadPrivateKeyXml()加载私钥。
    • 然后调用BuildPipeline.BuildAssetBundles()生成标准的Asset Bundle。
    • 最后,遍历manifest.GetAllAssetBundles()返回的所有Asset Bundle名称,对每个文件调用SignAssetBundleFile()进行签名。
  2. 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[],并紧接着签名数据写入文件。这个长度信息是客户端验证时用来反向解析的关键。

现在,你可以在Unity Editor中点击 Tools/AssetBundle/Build AssetBundles and Sign 来构建并签名你的Asset Bundles了。签名后的Asset Bundle文件会比原始文件稍大,多出的部分就是数字签名和其长度信息。

6. 实现:验证过程(Game Client端)

在游戏客户端,我们需要一个运行时脚本来加载公钥,然后当需要加载Asset Bundle时,对文件进行验证。只有验证通过的Asset Bundle才会被加载和使用。

6.1 准备:嵌入公钥

为了让客户端能够验证签名,公钥必须嵌入到客户端应用中。最简单的方式是将其作为TextAsset资源添加到Unity项目中,或者直接硬编码为字符串。

  1. 复制公钥文件: 将之前生成的rsa_public_key.xml文件从Assets/Editor目录复制到Assets/ResourcesAssets下的其他任意可打包的目录。
  2. 创建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}'.");
        }
    }
    */
}

代码解释:

  1. rsaPublicKeyXmlAsset 这是一个public TextAsset字段,你需要在Unity Editor中将你导入的rsa_public_key.xml文件拖拽到这个字段上。
  2. LoadPublicKey()Awake中调用,用于在游戏启动时加载公钥。
    • TextAsset中读取XML字符串。
    • 使用rsaVerifier.FromXmlString(rsaPublicKeyXmlAsset.text)导入公钥。这只需要执行一次。
  3. VerifyAndLoadAssetBundle(string assetBundleFilePath) 这是验证和加载的核心函数。
    • 文件存在性检查: 确保文件存在。
    • 文件流操作: 使用FileStreamFileMode.OpenFileAccess.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.RunThread,并在主线程回调)

  • 缓存验证结果: 一旦一个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的机密性(防止内容被直接查看):

  1. 加密 Asset Bundle: 使用对称加密算法(如AES-256)对Asset Bundle的原始内容进行加密。
  2. 签名加密后的数据: 对加密后的Asset Bundle数据计算哈希,然后用私钥签名。
  3. 客户端解密并验证: 客户端首先验证签名,确认加密数据未被篡改。如果验证通过,再使用对称密钥解密。

关键点: 签名必须是对 加密后的数据 进行的,而不是对原始数据。这样才能保证加密数据的完整性。对称密钥的保护也至关重要,它通常通过某种形式的密钥派生或嵌入在客户端代码中(并进行混淆)。

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签名方案提供坚实的理论基础和实践指导。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注