Files
AIC-Project/Server/Gateway.Tests/ServerLoopSignatureEnforcementTests.cs
2026-07-10 10:24:29 +08:00

166 lines
7.5 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.PublishTool;
using XWorld.Server.Host;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
/// <summary>
/// Integration tests: ServerLoop with publicKeyPem enforces signature verification end-to-end.
/// TDD: these tests are written RED-first, then made GREEN by wiring publicKeyPem through
/// ServerLoop ctor → GameModuleLoader.
/// </summary>
public class ServerLoopSignatureEnforcementTests : IDisposable
{
private readonly string _tempRoot;
private readonly string _gameDir; // {tempRoot}/rps/1/
public ServerLoopSignatureEnforcementTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "sig_enforcement_" + Guid.NewGuid().ToString("N"));
_gameDir = Path.Combine(_tempRoot, "rps", "1");
Directory.CreateDirectory(_gameDir);
// Copy rps/1 files from the shared TestGames root (built by build-test-games.sh)
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)) return;
// Unload ALCs and wait for Windows file locks to release
for (int i = 0; i < 5; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
try { Directory.Delete(_tempRoot, true); }
catch (UnauthorizedAccessException) { /* Windows file lock: GC will release eventually */ }
}
private static (string pubPem, string privPem) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem());
}
/// <summary>
/// Write files.txt + files.txt.sig into _gameDir using the given private key.
/// </summary>
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));
}
// --- helpers mirroring ServerLoopMatchmakingTests ---
private static byte[] RpsJoinFrame()
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode()));
private static List<Frame> Frames(FakeConnection c)
{
var list = new List<Frame>();
foreach (var bytes in c.Sent)
list.Add(FrameCodec.Decode(bytes));
return list;
}
private static bool HasMatchFound(FakeConnection c) =>
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
private static bool HasError(FakeConnection c) =>
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
/// <summary>
/// Signed module + correct public key → 2 players receive MatchFound (verification passes).
/// </summary>
[Fact]
public void SignedModule_WithCorrectKey_PlayersGetMatchFound()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// Large matchTimeoutTicks so the 2 humans match by player-count before any AI fill
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-ok]"),
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
// One DrainAndTick: both players enqueued → matchmaker sees count=2 >= playerCount=2
// → forms match → CreateRoomForSeats → GameModuleLoader.Load (verifies signature: OK)
// → room created → both players receive MatchFound
loop.DrainAndTick(1f);
Assert.True(HasMatchFound(c1), "c1 should receive MatchFound when signature is valid");
Assert.True(HasMatchFound(c2), "c2 should receive MatchFound when signature is valid");
}
/// <summary>
/// Tampered module + correct public key → signature verification fails during CreateRoom
/// → CreateRoomForSeats catches and sends Error to players → neither gets MatchFound.
/// </summary>
[Fact]
public void TamperedModule_WithKey_PlayersGetErrorNotMatchFound()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// Tamper RPS.Core.dll: flip first byte → md5 changes → manifest md5 check fails
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF;
File.WriteAllBytes(coreDll, bytes);
// Large matchTimeoutTicks so 2 humans match before AI fill
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-bad]"),
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
// One DrainAndTick: match forms → CreateRoomForSeats → GameModuleLoader.Load
// (detects tampered dll via md5 mismatch) → throws → caught by CreateRoomForSeats
// → sends Error (code=1) to both real players
loop.DrainAndTick(1f);
// Primary invariant: no MatchFound (room was never created)
Assert.False(HasMatchFound(c1), "c1 must NOT receive MatchFound for a tampered module");
Assert.False(HasMatchFound(c2), "c2 must NOT receive MatchFound for a tampered module");
// Secondary invariant: at least one player receives an Error frame
// (CreateRoomForSeats sends Error to all realPids on load failure)
Assert.True(HasError(c1) || HasError(c2),
"At least one player should receive an Error frame when module load fails");
}
}
}