From 99bdf855a0adf7590cdf745309c8cb5ea5edb2e2 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 11:17:12 +0200 Subject: [PATCH] launcher: refuse to launch on invalid manifest signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous flow threw InvalidOperationException on signature failure, which was caught by the outer catch(Exception) and silently fell through to the offline launch branch — the exact bypass the design doc forbids ('server is lying' is more dangerous than 'server is down'). Introduce ManifestSignatureException and handle it in a dedicated catch above the generic fallback, exiting with code 2 without launching the game. --- .../Manifest/ManifestSignatureException.cs | 15 +++++++++ src/Metin2Launcher/Program.cs | 32 ++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/Metin2Launcher/Manifest/ManifestSignatureException.cs diff --git a/src/Metin2Launcher/Manifest/ManifestSignatureException.cs b/src/Metin2Launcher/Manifest/ManifestSignatureException.cs new file mode 100644 index 0000000..db19d4e --- /dev/null +++ b/src/Metin2Launcher/Manifest/ManifestSignatureException.cs @@ -0,0 +1,15 @@ +namespace Metin2Launcher.Manifest; + +/// +/// Thrown when the update manifest's Ed25519 signature does not verify. +/// +/// This is the one failure mode where the launcher must NOT fall back to an offline +/// launch: a signature failure means something between us and the trusted signing key +/// is lying, and silently launching the old client would mask an active attack. The +/// entry point catches this distinctly from generic update failures and refuses to +/// start the game. +/// +public sealed class ManifestSignatureException : Exception +{ + public ManifestSignatureException(string message) : base(message) { } +} diff --git a/src/Metin2Launcher/Program.cs b/src/Metin2Launcher/Program.cs index a1bd518..0721994 100644 --- a/src/Metin2Launcher/Program.cs +++ b/src/Metin2Launcher/Program.cs @@ -33,6 +33,13 @@ public static class Program { updatedOk = await RunUpdateAsync(clientRoot, stagingDir, ct).ConfigureAwait(false); } + catch (ManifestSignatureException ex) + { + // Signature failure is the one case where we do NOT fall back silently. + // Something between us and the trusted key is lying; refuse to launch. + Log.Error("manifest signature verification FAILED — refusing to launch", ex); + return 2; + } catch (OperationCanceledException) { Log.Warn("update cancelled / timed out"); @@ -82,9 +89,8 @@ public static class Program if (!SignatureVerifier.Verify(loaded.RawBytes, loaded.Signature, LauncherConfig.PublicKeyHex)) { - // Signature failure is the one case where we do NOT fall back silently. - Log.Error("manifest signature verification FAILED — refusing to launch"); - throw new InvalidOperationException("manifest signature invalid"); + throw new ManifestSignatureException( + "Ed25519 signature over manifest.json did not verify against the launcher's public key"); } var manifest = loaded.Manifest; @@ -99,8 +105,24 @@ public static class Program foreach (var file in applicable) { ct.ThrowIfCancellationRequested(); - var finalPath = Path.Combine(clientRoot, file.Path.Replace('/', Path.DirectorySeparatorChar)); - var stagingPath = Path.Combine(stagingDir, file.Path.Replace('/', Path.DirectorySeparatorChar)); + + string finalPath; + string stagingPath; + try + { + finalPath = PathSafety.ResolveInside(clientRoot, file.Path); + stagingPath = PathSafety.ResolveInside(stagingDir, file.Path); + } + catch (UnsafePathException ex) + { + if (file.IsRequired) + { + Log.Error($"required file rejected by path safety: {ex.Message}"); + return false; + } + Log.Warn($"optional file rejected by path safety: {ex.Message}"); + continue; + } var localHash = FileHasher.HashFileOrNull(finalPath); if (localHash == file.Sha256)