runtime: tolerate m2pack export-runtime-key JSON shape

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<JsonException>).

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) <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
Jan Nedbal
2026-04-15 13:03:47 +02:00
parent ac0034fc51
commit db59f4963c
2 changed files with 70 additions and 15 deletions

View File

@@ -10,9 +10,9 @@ namespace Metin2Launcher.Runtime;
/// through to the game executable via one of the <see cref="IRuntimeKeyDelivery"/>
/// 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):
/// <code>
/// {
/// "key_id": "2026.04.14-1",
@@ -20,32 +20,64 @@ namespace Metin2Launcher.Runtime;
/// "sign_pubkey_hex": "&lt;64 hex chars&gt;"
/// }
/// </code>
///
/// 2. <c>m2pack export-runtime-key --format json</c> output, where
/// <c>key_id</c> is emitted as an integer and the public key field is
/// named <c>sign_public_key_hex</c>. Extra fields like <c>version</c> and
/// <c>mapping_name</c> are ignored. See
/// <c>metin-server/m2pack-secure#3</c>.
/// </summary>
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,
};
/// <summary>Parses a runtime-key.json blob. Throws on malformed or incomplete data.</summary>
public static RuntimeKey Parse(ReadOnlySpan<byte> raw)
{
var rk = JsonSerializer.Deserialize<RuntimeKey>(raw, _opts)
?? throw new InvalidDataException("runtime-key deserialized to null");
// Go through JsonSerializer.Deserialize&lt;JsonElement&gt; rather than
// JsonDocument.Parse so malformed input throws JsonException (as the
// old path did) rather than its internal JsonReaderException subtype,
// which Assert.Throws&lt;JsonException&gt; in the tests won't match.
var root = JsonSerializer.Deserialize<JsonElement>(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);

View File

@@ -80,4 +80,27 @@ public class RuntimeKeyTests
Assert.Throws<System.Text.Json.JsonException>(() =>
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);
}
}