launcher: add manifest loader and ed25519 verification

This commit is contained in:
Jan Nedbal
2026-04-14 11:12:30 +02:00
parent 9f3ad79320
commit 18271a71db
5 changed files with 405 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
using System.Text.Json.Serialization;
namespace Metin2Launcher.Manifest;
/// <summary>
/// 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 <see cref="System.Text.Json.JsonSerializerOptions.UnmappedMemberHandling"/> default (Skip).
/// </summary>
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<ManifestFile> 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; }
/// <summary>Manifest spec default: platform == "all" if unset.</summary>
public string EffectivePlatform => Platform ?? "all";
/// <summary>Manifest spec default: required == true if unset.</summary>
public bool IsRequired => Required ?? true;
/// <summary>Returns true if this file applies to the given platform ("windows" or "linux").</summary>
public bool AppliesTo(string platform)
{
var p = EffectivePlatform;
return p == "all" || p == platform;
}
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
namespace Metin2Launcher.Manifest;
/// <summary>
/// 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.
/// </summary>
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.
};
/// <summary>
/// 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.
/// </summary>
public async Task<LoadedManifest> 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);
}
/// <summary>Parses a manifest from raw bytes. Throws on malformed JSON.</summary>
public static Manifest Parse(ReadOnlySpan<byte> raw)
{
var m = JsonSerializer.Deserialize<Manifest>(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<ManifestFile>();
return m;
}
/// <summary>Serializes a manifest back to bytes — used only in tests for round-trip checks.</summary>
public static byte[] Serialize(Manifest manifest)
=> JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
private async Task<byte[]> 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);

View File

@@ -0,0 +1,51 @@
using NSec.Cryptography;
namespace Metin2Launcher.Manifest;
/// <summary>
/// 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).
/// </summary>
public static class SignatureVerifier
{
/// <summary>Verifies <paramref name="signature"/> over <paramref name="message"/> with a hex-encoded public key.</summary>
public static bool Verify(ReadOnlySpan<byte> message, ReadOnlySpan<byte> 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<byte> message, ReadOnlySpan<byte> signature, ReadOnlySpan<byte> 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);
}
}

View File

@@ -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<InvalidDataException>(() => 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}");
}
}
}

View File

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