using System; using System.IO; using System.Runtime.CompilerServices; using System.Security.Cryptography; using Xunit; using XWorld.PublishTool; using XWorld.Server.Host; namespace XWorld.Server.Host.Tests { /// /// 设计§7:服务端加载时签名验证测试(TDD RED→GREEN)。 /// 验证 GameModuleLoader 在配置公钥时执行签名+md5 校验,未配置时跳过(向后兼容)。 /// public class GameModuleSignatureTests : IDisposable { private readonly string _tempRoot; private readonly string _gameDir; // {tempRoot}/rps/1/ public GameModuleSignatureTests() { _tempRoot = Path.Combine(Path.GetTempPath(), "sig_test_" + Guid.NewGuid().ToString("N")); _gameDir = Path.Combine(_tempRoot, "rps", "1"); Directory.CreateDirectory(_gameDir); // 从已构建的 TestGames/rps/1 复制 3 个文件 string src = Path.Combine(TestGames.Root, "rps", "1"); foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" }) File.Copy(Path.Combine(src, name), Path.Combine(_gameDir, name), overwrite: true); } public void Dispose() { if (Directory.Exists(_tempRoot)) { // Windows 下 ALC 加载的 DLL 可能还被锁住;先做 GC 等待释放,再删除 for (int i = 0; i < 5; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); } try { Directory.Delete(_tempRoot, true); } catch (UnauthorizedAccessException) { /* Windows 文件锁:测试结束后 GC 会最终释放;不影响测试结果 */ } } } // 生成签名 fixture 并写入 files.txt + files.txt.sig private static (string pubPem, string privPem) GenerateKeyPair() { using var rsa = RSA.Create(2048); return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem()); } private void WriteSignature(string privPem) { var fm = new FilesManifest(); foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" }) { string p = Path.Combine(_gameDir, name); byte[] b = File.ReadAllBytes(p); fm.Add(name, Md5Util.OfBytes(b), b.Length); } File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render()); File.WriteAllBytes(Path.Combine(_gameDir, "files.txt.sig"), ManifestSigner.Sign(fm.Digest(), privPem)); } [Fact] public void Load_WithValidSignature_Succeeds() { var (pubPem, privPem) = GenerateKeyPair(); WriteSignature(privPem); var module = new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1); Assert.NotNull(module); Assert.Equal("rps", module.GameId); Assert.Equal(1, module.Version); var room = module.CreateRoom(); Assert.NotNull(room); // 卸载 ALC,释放 Windows 文件锁,以便 Dispose() 能清理临时目录 room = null; module.Unload(); GC.Collect(); GC.WaitForPendingFinalizers(); } [Fact] public void Load_TamperedDll_Rejected() { var (pubPem, privPem) = GenerateKeyPair(); WriteSignature(privPem); // 篡改 RPS.Core.dll 的一个字节,md5 随之改变 string coreDll = Path.Combine(_gameDir, "RPS.Core.dll"); byte[] bytes = File.ReadAllBytes(coreDll); bytes[0] ^= 0xFF; // 翻转首字节 File.WriteAllBytes(coreDll, bytes); var ex = Assert.ThrowsAny(() => new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1)); Assert.Contains("md5", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Load_WrongKey_Rejected() { var (_, privPem) = GenerateKeyPair(); WriteSignature(privPem); // 用另一组密钥的公钥验证 → 签名不符 var (wrongPubPem, _) = GenerateKeyPair(); var ex = Assert.ThrowsAny(() => new GameModuleLoader(wrongPubPem).Load(_tempRoot, "rps", 1)); Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Load_MissingSignature_WhenKeyConfigured_Rejected() { var (pubPem, _) = GenerateKeyPair(); // 故意不写 files.txt.sig(签名文件缺失) var ex = Assert.ThrowsAny(() => new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1)); Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Load_NoKeyConfigured_SkipsVerification() { // 不配置公钥,使用无签名的原始 TestGames/rps/1 目录 // 向后兼容:无公钥时跳过验证,正常加载 var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1); Assert.NotNull(module); Assert.Equal("rps", module.GameId); var room = module.CreateRoom(); Assert.NotNull(room); } [Fact] public void Load_TamperedGameJson_Rejected() { // 构建签名 fixture(与其他测试相同方式) var (pubPem, privPem) = GenerateKeyPair(); WriteSignature(privPem); // 篡改 game.json:追加一个空格,改变其 md5,使清单 md5 校验失败 File.AppendAllText(Path.Combine(_gameDir, "game.json"), " "); Assert.ThrowsAny(() => new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1)); } [Fact] public void Load_TamperedManifestWithoutResign_Rejected() { // 构建签名 fixture var (pubPem, privPem) = GenerateKeyPair(); WriteSignature(privPem); // 攻击者篡改 RPS.Core.dll 一字节,并重算其 md5 写回 files.txt, // 但没有私钥无法重签 files.txt.sig → 验签时发现 Digest 与 sig 不符 → 拒绝 string coreDll = Path.Combine(_gameDir, "RPS.Core.dll"); byte[] bytes = File.ReadAllBytes(coreDll); bytes[0] ^= 0xFF; File.WriteAllBytes(coreDll, bytes); // 重算被篡改 DLL 的 md5,重写 files.txt(files.txt.sig 保持不变) string newMd5 = Md5Util.OfFile(coreDll); var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(_gameDir, "files.txt"))); fm.Entries["RPS.Core.dll"].Md5 = newMd5; File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render()); Assert.ThrowsAny(() => new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1)); } } }