launcher: add manifest loader and ed25519 verification
This commit is contained in:
82
src/Metin2Launcher/Manifest/Manifest.cs
Normal file
82
src/Metin2Launcher/Manifest/Manifest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/Metin2Launcher/Manifest/ManifestLoader.cs
Normal file
71
src/Metin2Launcher/Manifest/ManifestLoader.cs
Normal 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);
|
||||
51
src/Metin2Launcher/Manifest/SignatureVerifier.cs
Normal file
51
src/Metin2Launcher/Manifest/SignatureVerifier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
127
tests/Metin2Launcher.Tests/ManifestLoaderTests.cs
Normal file
127
tests/Metin2Launcher.Tests/ManifestLoaderTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
74
tests/Metin2Launcher.Tests/SignatureVerifierTests.cs
Normal file
74
tests/Metin2Launcher.Tests/SignatureVerifierTests.cs
Normal 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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user