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 { /// /// 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. /// 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()); } /// /// Write files.txt + files.txt.sig into _gameDir using the given private key. /// 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 Frames(FakeConnection c) { var list = new List(); 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); /// /// Signed module + correct public key → 2 players receive MatchFound (verification passes). /// [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"); } /// /// Tampered module + correct public key → signature verification fails during CreateRoom /// → CreateRoomForSeats catches and sends Error to players → neither gets MatchFound. /// [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"); } } }