diff --git a/src/Metin2Launcher/Runtime/RuntimeKey.cs b/src/Metin2Launcher/Runtime/RuntimeKey.cs index 25a214d..fc49ef3 100644 --- a/src/Metin2Launcher/Runtime/RuntimeKey.cs +++ b/src/Metin2Launcher/Runtime/RuntimeKey.cs @@ -10,9 +10,9 @@ namespace Metin2Launcher.Runtime; /// through to the game executable via one of the /// mechanisms. /// -/// File layout (runtime-key.json, placed next to the manifest by the release -/// tool, never signed independently — its integrity comes from the signed -/// manifest that references it): +/// Tolerant of two JSON shapes seen in the wild: +/// +/// 1. Native launcher shape (canonical): /// /// { /// "key_id": "2026.04.14-1", @@ -20,32 +20,64 @@ namespace Metin2Launcher.Runtime; /// "sign_pubkey_hex": "<64 hex chars>" /// } /// +/// +/// 2. m2pack export-runtime-key --format json output, where +/// key_id is emitted as an integer and the public key field is +/// named sign_public_key_hex. Extra fields like version and +/// mapping_name are ignored. See +/// metin-server/m2pack-secure#3. /// public sealed class RuntimeKey { - [JsonPropertyName("key_id")] public string KeyId { get; set; } = ""; - - [JsonPropertyName("master_key_hex")] public string MasterKeyHex { get; set; } = ""; - - [JsonPropertyName("sign_pubkey_hex")] public string SignPubkeyHex { get; set; } = ""; - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNameCaseInsensitive = false, - }; - /// Parses a runtime-key.json blob. Throws on malformed or incomplete data. public static RuntimeKey Parse(ReadOnlySpan raw) { - var rk = JsonSerializer.Deserialize(raw, _opts) - ?? throw new InvalidDataException("runtime-key deserialized to null"); + // Go through JsonSerializer.Deserialize<JsonElement> rather than + // JsonDocument.Parse so malformed input throws JsonException (as the + // old path did) rather than its internal JsonReaderException subtype, + // which Assert.Throws<JsonException> in the tests won't match. + var root = JsonSerializer.Deserialize(raw); + if (root.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("runtime-key root must be a JSON object"); + + var rk = new RuntimeKey + { + KeyId = ReadKeyId(root), + MasterKeyHex = ReadString(root, "master_key_hex"), + SignPubkeyHex = ReadString(root, "sign_pubkey_hex", "sign_public_key_hex"), + }; Validate(rk); return rk; } + // key_id can be a plain string or an integer (m2pack CLI emits it as an + // int). Stringify numeric variants so callers always see a string. + private static string ReadKeyId(JsonElement root) + { + if (!root.TryGetProperty("key_id", out var el)) + return ""; + return el.ValueKind switch + { + JsonValueKind.String => el.GetString() ?? "", + JsonValueKind.Number => el.TryGetInt64(out var n) ? n.ToString() : el.GetRawText(), + _ => "", + }; + } + + private static string ReadString(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String) + return el.GetString() ?? ""; + } + return ""; + } + public static RuntimeKey Load(string path) { var bytes = File.ReadAllBytes(path); diff --git a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs index bb7ef70..98fbfe3 100644 --- a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs +++ b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs @@ -80,4 +80,27 @@ public class RuntimeKeyTests Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json"))); } + + // m2pack export-runtime-key emits key_id as an integer and the public + // key field as `sign_public_key_hex`, plus extra version/mapping_name + // fields that are Windows-shared-memory bookkeeping. RuntimeKey must + // tolerate that shape so release pipelines can pipe the CLI output + // directly. See metin-server/m2pack-secure#3. + [Fact] + public void Parse_accepts_m2pack_cli_shape() + { + var json = $$""" + { + "version": 1, + "mapping_name": "Local\\M2PackSharedKeys", + "key_id": 1, + "master_key_hex": "{{ValidHex64}}", + "sign_public_key_hex": "{{ValidHex64}}" + } + """; + var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Equal("1", rk.KeyId); + Assert.Equal(ValidHex64, rk.MasterKeyHex); + Assert.Equal(ValidHex64, rk.SignPubkeyHex); + } }