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]));
+ }
+}