183 lines
7.0 KiB
C#
183 lines
7.0 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// 设计§7:服务端加载时签名验证测试(TDD RED→GREEN)。
|
||
/// 验证 GameModuleLoader 在配置公钥时执行签名+md5 校验,未配置时跳过(向后兼容)。
|
||
/// </summary>
|
||
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<Exception>(() =>
|
||
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<Exception>(() =>
|
||
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<Exception>(() =>
|
||
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<Exception>(() =>
|
||
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<Exception>(() =>
|
||
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
|
||
}
|
||
}
|
||
}
|