major fixes test

This commit is contained in:
2026-09-09 12:30:40 +03:00
parent b184ea76f4
commit bcd200b1e8
9 changed files with 1785 additions and 226 deletions
+151 -20
View File
@@ -1,42 +1,108 @@
import os
import json
import gzip
import shutil
import hashlib
import argparse
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# ============================================================
# CONFIGURATION - edit these values for your game/project
# CONFIGURATION - loaded from config.json, with hardcoded fallbacks below
# for anyone running this script without a config.json present (e.g. it
# was copied out on its own). Edit config.json, not this file - see
# README/quickstart.py for the intended workflow.
# ============================================================
MANIFEST_FILENAME = "manifest.json"
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
def _load_config():
if not os.path.exists(_CONFIG_PATH):
return {}
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _read_private_signing_key(cfg):
"""
The private signing key lives in a standalone .txt file next to the
scripts (see generate_key.py's write_private_key_file), NOT inside
config.json - config.json only stores which filename to look for
(signing_keys.private_key_file), so the secret itself never ends up
in a file that's likely to get committed, zipped, or shared alongside
the rest of the project's settings.
Returns the hex key string, or None if the file doesn't exist or has
no usable key in it (signing is then disabled - see SIGNING_PRIVATE_KEY
below and the "unsigned manifest" warning where it's used).
"""
filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt")
key_path = os.path.join(_SCRIPT_DIR, filename)
if not os.path.exists(key_path):
return None
try:
with open(key_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("SIGNING_PRIVATE_KEY="):
return line.split("=", 1)[1].strip() or None
except OSError:
return None
return None
_CFG = _load_config()
_manifest_cfg = _CFG.get("manifest", {})
_ignore_cfg = _CFG.get("ignore", {})
MANIFEST_FILENAME = _manifest_cfg.get("manifest_filename", "manifest.json")
# Suffix used for the precompressed copy of each file, stored alongside the
# original (e.g. "texture.dds" -> "texture.dds.gz").
COMPRESSED_SUFFIX = ".gz"
COMPRESSED_SUFFIX = _manifest_cfg.get("compressed_suffix", ".gz")
# gzip compression level, 1 (fastest, larger) to 9 (slowest, smaller).
COMPRESSION_LEVEL = 6
COMPRESSION_LEVEL = _manifest_cfg.get("compression_level", 6)
# Files the updater must never touch (launcher itself, local settings, etc.)
# Ed25519 PRIVATE key used to sign manifest.json, hex-encoded (32 raw bytes
# = 64 hex chars). Generate a keypair with:
# python generate_key.py --signing-keypair --save
# This is the actual secret - keep it OFF the server and OFF any machine
# that ships to players. It only needs to exist wherever you run this
# script. The matching PUBLIC key goes into updater.py's SIGNING_PUBLIC_KEY
# instead of a shared secret, so decompiling updater.exe never exposes
# anything that could be used to forge a manifest (unlike a shared-secret
# HMAC key, which the client would also need to hold and could therefore
# leak from the exe).
# Read from a standalone .txt file (see _read_private_signing_key above),
# never from config.json - if that file is missing, signing is disabled
# (NOT recommended, see README).
SIGNING_PRIVATE_KEY = _read_private_signing_key(_CFG)
# Files the updater must never touch (launcher itself, local settings,
# self-update artifacts, etc.) - the self-update filenames are always
# ignored regardless of config.json, since scanning/hashing them as
# regular game files would never make sense. config.json's ignore.files
# is additive on top of this fixed set.
IGNORE_FILES = {
MANIFEST_FILENAME,
"updater.exe", # Your compiled updater executable
"Option.ini", # Example: graphics/sound settings
"WindowsInfo.ini", # Example: window layout settings
}
MANIFEST_FILENAME + ".sig",
"updater.exe", # Your compiled updater executable
"updater_manifest.json", # Self-update manifest (see generate_updater_manifest.py)
"updater_manifest.json.sig",
"updater.exe.new", # Leftover from an in-progress self-update, if generate_manifest.py
"updater.exe.update_helper.bat", # is ever run inside a live client folder rather than a build dir
} | set(_ignore_cfg.get("files", ["Option.ini", "WindowsInfo.ini"]))
# Folders the updater must never scan or update
IGNORE_FOLDERS = {
"screenshots",
"logs",
"replays",
}
IGNORE_FOLDERS = set(_ignore_cfg.get("folders", ["screenshots", "logs", "replays"]))
# File extensions to always skip (lowercase, without the dot doesn't matter, include the dot)
IGNORE_EXTENSIONS = {
".log",
}
IGNORE_EXTENSIONS = set(_ignore_cfg.get("extensions", [".log"]))
# ============================================================
# END CONFIGURATION
@@ -60,6 +126,25 @@ def get_md5(filepath):
return None
def is_safe_rel_path(rel_path_web):
"""
Defense in depth: refuses to put a path in the manifest that the
updater's own sanitizer would reject anyway (absolute path, ".."
segments, drive letters, backslashes). Should never trigger in
practice since paths come from os.walk() on a real folder, but if
it ever does, we want a loud failure here rather than a silently
unusable manifest entry on the client.
"""
if not rel_path_web or rel_path_web.startswith("/"):
return False
if ":" in rel_path_web or "\\" in rel_path_web:
return False
parts = rel_path_web.split("/")
if any(p in ("", ".", "..") for p in parts):
return False
return True
def compress_file(filepath, compressed_path):
"""
Compresses filepath into compressed_path with gzip. Every file is
@@ -95,6 +180,24 @@ def compress_file(filepath, compressed_path):
return compressed_size, compressed_hash
def sign_manifest(manifest_bytes):
"""
Returns a hex Ed25519 signature over the exact bytes written to
manifest.json, or None if SIGNING_PRIVATE_KEY isn't configured. The
updater verifies this signature with the matching PUBLIC key before
trusting anything in the manifest, so a compromised/spoofed server
can no longer hand out a manifest pointing files at arbitrary paths
or vouching for a tampered game.exe - forging a valid signature
requires this private key, which is never present in updater.exe or
on the server, only wherever manifests are generated.
"""
if not SIGNING_PRIVATE_KEY:
return None
private_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SIGNING_PRIVATE_KEY))
signature = private_key.sign(manifest_bytes)
return signature.hex()
def generate(client_dir):
if not os.path.exists(client_dir) or not os.path.isdir(client_dir):
print(f"Error: directory '{client_dir}' not found!")
@@ -102,11 +205,13 @@ def generate(client_dir):
manifest = {}
manifest_path = os.path.join(client_dir, MANIFEST_FILENAME)
sig_path = manifest_path + ".sig"
print(f"Scanning client folder: {client_dir}")
total_original = 0
total_saved = 0
skipped_unsafe = 0
for root, dirs, files in os.walk(client_dir):
# Filter out ignored folders (case-insensitive)
@@ -128,6 +233,11 @@ def generate(client_dir):
rel_path = os.path.relpath(filepath, client_dir)
rel_path_web = rel_path.replace("\\", "/")
if not is_safe_rel_path(rel_path_web):
print(f" WARNING: skipping unsafe path (should never happen): {rel_path_web}")
skipped_unsafe += 1
continue
file_hash = get_md5(filepath)
if not file_hash:
continue
@@ -152,10 +262,31 @@ def generate(client_dir):
manifest[rel_path_web] = entry
try:
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=4)
# Serialize once with sorted, fixed formatting so the byte sequence
# that gets hashed/signed is deterministic and matches exactly what
# gets written to disk (and what the updater will read back).
manifest_bytes = json.dumps(manifest, indent=4, sort_keys=True).encode("utf-8")
with open(manifest_path, "wb") as f:
f.write(manifest_bytes)
signature = sign_manifest(manifest_bytes)
if signature:
with open(sig_path, "w", encoding="utf-8") as f:
f.write(signature)
print(f"Manifest signed (Ed25519). Signature saved to: {sig_path}")
else:
print("WARNING: no private signing key found - manifest.json is UNSIGNED.")
print(" Run: python generate_key.py --signing-keypair --save")
print(" The updater will refuse unsigned manifests unless it")
print(" has REQUIRE_SIGNATURE = False, which is not recommended.")
if os.path.exists(sig_path):
os.remove(sig_path) # don't leave a stale signature lying around
print(f"Done! Manifest saved to: {manifest_path}")
print(f"Total files in manifest: {len(manifest)}")
if skipped_unsafe:
print(f"Skipped {skipped_unsafe} file(s) with unsafe paths.")
if total_original:
pct = (total_saved / total_original) * 100
print(f"Compression saved {total_saved:,} bytes of {total_original:,} ({pct:.1f}%)")