diff --git a/src/Metin2Launcher/Manifest/Manifest.cs b/src/Metin2Launcher/Manifest/Manifest.cs new file mode 100644 index 0000000..955195e --- /dev/null +++ b/src/Metin2Launcher/Manifest/Manifest.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; + +namespace Metin2Launcher.Manifest; + +/// +/// DTOs matching docs/update-manifest.md. +/// Field order in the serialized JSON is governed by the canonical ordering rules; +/// we only parse here (and optionally round-trip for tests), we do not sign. +/// Unknown fields are ignored by default (Skip). +/// +public sealed class Manifest +{ + [JsonPropertyName("version")] + public string Version { get; set; } = ""; + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("previous")] + public string? Previous { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("min_launcher_version")] + public string? MinLauncherVersion { get; set; } + + [JsonPropertyName("launcher")] + public ManifestLauncherEntry Launcher { get; set; } = new(); + + [JsonPropertyName("files")] + public List Files { get; set; } = new(); +} + +public sealed class ManifestLauncherEntry +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = ""; + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("platform")] + public string? Platform { get; set; } +} + +public sealed class ManifestFile +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = ""; + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("required")] + public bool? Required { get; set; } + + [JsonPropertyName("executable")] + public bool? Executable { get; set; } + + /// Manifest spec default: platform == "all" if unset. + public string EffectivePlatform => Platform ?? "all"; + + /// Manifest spec default: required == true if unset. + public bool IsRequired => Required ?? true; + + /// Returns true if this file applies to the given platform ("windows" or "linux"). + public bool AppliesTo(string platform) + { + var p = EffectivePlatform; + return p == "all" || p == platform; + } +} diff --git a/src/Metin2Launcher/Manifest/ManifestLoader.cs b/src/Metin2Launcher/Manifest/ManifestLoader.cs new file mode 100644 index 0000000..cdcc56f --- /dev/null +++ b/src/Metin2Launcher/Manifest/ManifestLoader.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace Metin2Launcher.Manifest; + +/// +/// Fetches a manifest (and its signature) over HTTP and parses it. +/// +/// Important invariant: we keep the raw byte slice of the manifest body so the +/// signature verifier can operate on the exact bytes the server delivered. +/// Never re-serialize before verifying — the signature is over literal bytes. +/// +public sealed class ManifestLoader +{ + private readonly HttpClient _http; + + public ManifestLoader(HttpClient http) + { + _http = http; + } + + public static JsonSerializerOptions JsonOptions { get; } = new() + { + PropertyNameCaseInsensitive = false, + WriteIndented = true, + // Field order in our DTOs is already source-ordered; serializer writes them in declaration order. + }; + + /// + /// Downloads manifest.json and manifest.json.sig, returns raw bytes and parsed DTO. + /// The caller is responsible for verifying the signature before trusting the parsed manifest. + /// + public async Task FetchAsync( + string manifestUrl, + string signatureUrl, + CancellationToken ct) + { + var manifestBytes = await GetBytesAsync(manifestUrl, ct).ConfigureAwait(false); + var sigBytes = await GetBytesAsync(signatureUrl, ct).ConfigureAwait(false); + + var manifest = Parse(manifestBytes); + return new LoadedManifest(manifestBytes, sigBytes, manifest); + } + + /// Parses a manifest from raw bytes. Throws on malformed JSON. + public static Manifest Parse(ReadOnlySpan raw) + { + var m = JsonSerializer.Deserialize(raw, JsonOptions); + if (m is null) + throw new InvalidDataException("Manifest JSON deserialized to null"); + if (string.IsNullOrEmpty(m.Version)) + throw new InvalidDataException("Manifest is missing required field 'version'"); + if (m.Launcher is null || string.IsNullOrEmpty(m.Launcher.Sha256)) + throw new InvalidDataException("Manifest is missing required field 'launcher'"); + m.Files ??= new List(); + return m; + } + + /// Serializes a manifest back to bytes — used only in tests for round-trip checks. + public static byte[] Serialize(Manifest manifest) + => JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions); + + private async Task GetBytesAsync(string url, CancellationToken ct) + { + using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseContentRead, ct) + .ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + } +} + +public sealed record LoadedManifest(byte[] RawBytes, byte[] Signature, Manifest Manifest); diff --git a/src/Metin2Launcher/Manifest/SignatureVerifier.cs b/src/Metin2Launcher/Manifest/SignatureVerifier.cs new file mode 100644 index 0000000..263a33c --- /dev/null +++ b/src/Metin2Launcher/Manifest/SignatureVerifier.cs @@ -0,0 +1,51 @@ +using NSec.Cryptography; + +namespace Metin2Launcher.Manifest; + +/// +/// Ed25519 detached-signature verification via NSec.Cryptography (libsodium). +/// +/// .NET 8's BCL does not implement Ed25519, so we rely on NSec. The public key +/// is a 32-byte raw Ed25519 key supplied as hex (see LauncherConfig.PublicKeyHex). +/// +public static class SignatureVerifier +{ + /// Verifies over with a hex-encoded public key. + public static bool Verify(ReadOnlySpan message, ReadOnlySpan signature, string publicKeyHex) + { + if (string.IsNullOrEmpty(publicKeyHex)) + return false; + + byte[] pubBytes; + try + { + pubBytes = Convert.FromHexString(publicKeyHex); + } + catch (FormatException) + { + return false; + } + + return Verify(message, signature, pubBytes); + } + + public static bool Verify(ReadOnlySpan message, ReadOnlySpan signature, ReadOnlySpan publicKey) + { + if (publicKey.Length != 32) + return false; + if (signature.Length != 64) + return false; + + var algo = SignatureAlgorithm.Ed25519; + PublicKey key; + try + { + key = PublicKey.Import(algo, publicKey, KeyBlobFormat.RawPublicKey); + } + catch + { + return false; + } + return algo.Verify(key, message, signature); + } +} diff --git a/tests/Metin2Launcher.Tests/ManifestLoaderTests.cs b/tests/Metin2Launcher.Tests/ManifestLoaderTests.cs new file mode 100644 index 0000000..1b9f700 --- /dev/null +++ b/tests/Metin2Launcher.Tests/ManifestLoaderTests.cs @@ -0,0 +1,127 @@ +using System.Text; +using Metin2Launcher.Manifest; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class ManifestLoaderTests +{ + private const string SampleJson = """ +{ + "version": "2026.04.14-1", + "created_at": "2026-04-14T14:00:00Z", + "previous": "2026.04.13-3", + "notes": "example", + "launcher": { + "path": "Metin2Launcher.exe", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 15728640, + "platform": "windows" + }, + "files": [ + { + "path": "Metin2.exe", + "sha256": "2653e87ecd8ba305b70a96098788478e8b60c125ec83bcd1307486eafb5c5289", + "size": 27982848, + "platform": "windows" + }, + { + "path": "pack/item.pck", + "sha256": "7aa9d46724a921fecf5af14c10372d0d03922e92a4cace4b5c15c451416f36b7", + "size": 128547328 + }, + { + "path": "pack/optional.pck", + "sha256": "3b9dfe45317a14fcb70a138c1b9d57d984fe130e833c4341deaaff93d615ac67", + "size": 4587520, + "required": false + } + ] +} +"""; + + [Fact] + public void Parse_reads_all_fields() + { + var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(SampleJson)); + Assert.Equal("2026.04.14-1", m.Version); + Assert.Equal("2026-04-14T14:00:00Z", m.CreatedAt); + Assert.Equal("2026.04.13-3", m.Previous); + Assert.Equal("example", m.Notes); + Assert.Equal("Metin2Launcher.exe", m.Launcher.Path); + Assert.Equal(3, m.Files.Count); + Assert.Equal("Metin2.exe", m.Files[0].Path); + Assert.True(m.Files[0].IsRequired); + Assert.False(m.Files[2].IsRequired); + Assert.Equal("all", m.Files[1].EffectivePlatform); + Assert.True(m.Files[1].AppliesTo("windows")); + Assert.True(m.Files[1].AppliesTo("linux")); + Assert.True(m.Files[0].AppliesTo("windows")); + Assert.False(m.Files[0].AppliesTo("linux")); + } + + [Fact] + public void Roundtrip_parse_serialize_parse_is_stable() + { + var raw = Encoding.UTF8.GetBytes(SampleJson); + var first = ManifestLoader.Parse(raw); + var bytes2 = ManifestLoader.Serialize(first); + var second = ManifestLoader.Parse(bytes2); + Assert.Equal(first.Version, second.Version); + Assert.Equal(first.Files.Count, second.Files.Count); + for (int i = 0; i < first.Files.Count; i++) + { + Assert.Equal(first.Files[i].Path, second.Files[i].Path); + Assert.Equal(first.Files[i].Sha256, second.Files[i].Sha256); + Assert.Equal(first.Files[i].Size, second.Files[i].Size); + Assert.Equal(first.Files[i].IsRequired, second.Files[i].IsRequired); + } + } + + [Fact] + public void Parse_ignores_unknown_optional_fields() + { + const string withExtra = """ +{ + "version": "2026.04.14-1", + "created_at": "2026-04-14T14:00:00Z", + "future_field": {"nested": 1}, + "launcher": { + "path": "Metin2Launcher.exe", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 100 + }, + "files": [] +} +"""; + var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(withExtra)); + Assert.Equal("2026.04.14-1", m.Version); + Assert.Empty(m.Files); + } + + [Fact] + public void Parse_rejects_missing_version() + { + const string bad = """ +{ + "created_at": "2026-04-14T14:00:00Z", + "launcher": {"path": "x", "sha256": "y", "size": 1}, + "files": [] +} +"""; + Assert.Throws(() => ManifestLoader.Parse(Encoding.UTF8.GetBytes(bad))); + } + + [Fact] + public void Files_are_sorted_by_path_in_sample() + { + // The spec mandates lexicographic sort; assert our sample is consistent with that. + var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(SampleJson)); + for (int i = 1; i < m.Files.Count; i++) + { + Assert.True( + string.CompareOrdinal(m.Files[i - 1].Path, m.Files[i].Path) < 0, + $"file list not sorted: {m.Files[i - 1].Path} >= {m.Files[i].Path}"); + } + } +} diff --git a/tests/Metin2Launcher.Tests/SignatureVerifierTests.cs b/tests/Metin2Launcher.Tests/SignatureVerifierTests.cs new file mode 100644 index 0000000..fcc4cc2 --- /dev/null +++ b/tests/Metin2Launcher.Tests/SignatureVerifierTests.cs @@ -0,0 +1,74 @@ +using System.Text; +using Metin2Launcher.Manifest; +using NSec.Cryptography; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class SignatureVerifierTests +{ + private static (byte[] pub, byte[] sig, byte[] msg) MakeSignedMessage(string text) + { + var algo = SignatureAlgorithm.Ed25519; + using var key = Key.Create(algo, new KeyCreationParameters + { + ExportPolicy = KeyExportPolicies.AllowPlaintextExport, + }); + var pub = key.PublicKey.Export(KeyBlobFormat.RawPublicKey); + var msg = Encoding.UTF8.GetBytes(text); + var sig = algo.Sign(key, msg); + return (pub, sig, msg); + } + + [Fact] + public void Verify_returns_true_for_valid_signature() + { + var (pub, sig, msg) = MakeSignedMessage("hello metin2"); + Assert.True(SignatureVerifier.Verify(msg, sig, pub)); + Assert.True(SignatureVerifier.Verify(msg, sig, Convert.ToHexString(pub))); + } + + [Fact] + public void Verify_returns_false_for_tampered_message() + { + var (pub, sig, msg) = MakeSignedMessage("hello metin2"); + msg[0] ^= 0x01; + Assert.False(SignatureVerifier.Verify(msg, sig, pub)); + } + + [Fact] + public void Verify_returns_false_for_tampered_signature() + { + var (pub, sig, msg) = MakeSignedMessage("hello metin2"); + sig[0] ^= 0x01; + Assert.False(SignatureVerifier.Verify(msg, sig, pub)); + } + + [Fact] + public void Verify_returns_false_for_wrong_public_key() + { + var (_, sig, msg) = MakeSignedMessage("hello metin2"); + var algo = SignatureAlgorithm.Ed25519; + using var other = Key.Create(algo, new KeyCreationParameters + { + ExportPolicy = KeyExportPolicies.AllowPlaintextExport, + }); + var wrongPub = other.PublicKey.Export(KeyBlobFormat.RawPublicKey); + Assert.False(SignatureVerifier.Verify(msg, sig, wrongPub)); + } + + [Fact] + public void Verify_returns_false_for_malformed_hex_key() + { + var (_, sig, msg) = MakeSignedMessage("hello"); + Assert.False(SignatureVerifier.Verify(msg, sig, "not-hex")); + Assert.False(SignatureVerifier.Verify(msg, sig, "")); + } + + [Fact] + public void Verify_returns_false_for_wrong_key_length() + { + var (_, sig, msg) = MakeSignedMessage("hello"); + Assert.False(SignatureVerifier.Verify(msg, sig, new byte[16])); + } +}