From db59f4963c2bf596a5d02c33120f31342acecc06 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 13:03:47 +0200 Subject: [PATCH] runtime: tolerate m2pack export-runtime-key JSON shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical launcher runtime-key.json shape is { key_id: string, master_key_hex, sign_pubkey_hex } but `m2pack export-runtime-key --format json` emits { version, mapping_name, key_id: int, master_key_hex, sign_public_key_hex } because its JSON is really a dump of the Windows shared-memory struct. Parsing the CLI output with the old strict deserializer throws JsonException on key_id (int != string) and silently drops the public key field (name mismatch), after which Validate() rejects the key as not 64 hex chars and the m2pack release fails to boot with "runtime master key with key_id=1 required for 'pack/root.m2p'". Hit this tonight during the 2026.04.15-m2pack-v2 release and worked around it by hand-writing runtime-key.json. Fix: parse into a JsonElement and extract fields tolerantly — key_id accepts either a JSON string or a JSON number (stringified), and the pubkey field is looked up under both "sign_pubkey_hex" and "sign_public_key_hex". Added a test covering the m2pack CLI shape end to end. Also kept the malformed-input path on JsonSerializer.Deserialize so it still throws JsonException (JsonDocument.Parse throws its internal subtype which breaks Assert.Throws). Tracked separately as metin-server/m2pack-secure#3 — the m2pack side should also align its JSON to the canonical shape; this commit is the client-side belt to the server-side suspenders. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Runtime/RuntimeKey.cs | 62 ++++++++++++++----- tests/Metin2Launcher.Tests/RuntimeKeyTests.cs | 23 +++++++ 2 files changed, 70 insertions(+), 15 deletions(-) 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); + } }