launcher: refuse to launch on invalid manifest signature

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.
This commit is contained in:
Jan Nedbal
2026-04-14 11:17:12 +02:00
parent e1268e7cce
commit 99bdf855a0
2 changed files with 42 additions and 5 deletions

View File

@@ -0,0 +1,15 @@
namespace Metin2Launcher.Manifest;
/// <summary>
/// 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.
/// </summary>
public sealed class ManifestSignatureException : Exception
{
public ManifestSignatureException(string message) : base(message) { }
}

View File

@@ -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)