304 lines
12 KiB
Python
304 lines
12 KiB
Python
import os
|
|
import json
|
|
import gzip
|
|
import hashlib
|
|
import argparse
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
# ============================================================
|
|
# 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.
|
|
# ============================================================
|
|
|
|
_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 = _manifest_cfg.get("compressed_suffix", ".gz")
|
|
|
|
# gzip compression level, 1 (fastest, larger) to 9 (slowest, smaller).
|
|
COMPRESSION_LEVEL = _manifest_cfg.get("compression_level", 6)
|
|
|
|
# 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,
|
|
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 = 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 = set(_ignore_cfg.get("extensions", [".log"]))
|
|
|
|
# ============================================================
|
|
# END CONFIGURATION
|
|
# ============================================================
|
|
|
|
|
|
def get_md5_bytes(data):
|
|
"""Computes the MD5 hash of an in-memory bytes object."""
|
|
return hashlib.md5(data).hexdigest()
|
|
|
|
|
|
def get_md5(filepath):
|
|
"""Computes the MD5 hash of a file on disk."""
|
|
hash_md5 = hashlib.md5()
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
hash_md5.update(chunk)
|
|
return hash_md5.hexdigest()
|
|
except IOError:
|
|
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
|
|
compressed unconditionally - if the result isn't actually smaller
|
|
(common for already-compressed formats like .jpg/.mp3/.zip), the
|
|
compressed copy is discarded and the manifest entry omits "compressed",
|
|
so the server/updater fall back to serving the original file untouched.
|
|
|
|
Uses mtime=0 and a GzipFile with no filename so the compressed output
|
|
is byte-identical across runs when the input hasn't changed - otherwise
|
|
gzip embeds the source mtime in its header and compressed_hash would
|
|
change on every manifest regeneration even with no real content change.
|
|
|
|
Returns (compressed_size, compressed_hash) or (None, None) if the
|
|
compressed copy wasn't kept.
|
|
"""
|
|
original_size = os.path.getsize(filepath)
|
|
|
|
with open(filepath, "rb") as f_in:
|
|
data = f_in.read()
|
|
|
|
with open(compressed_path, "wb") as raw_out:
|
|
with gzip.GzipFile(fileobj=raw_out, mode="wb", compresslevel=COMPRESSION_LEVEL, mtime=0) as f_out:
|
|
f_out.write(data)
|
|
|
|
compressed_size = os.path.getsize(compressed_path)
|
|
|
|
if compressed_size >= original_size:
|
|
os.remove(compressed_path)
|
|
return None, None
|
|
|
|
compressed_hash = get_md5(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!")
|
|
return
|
|
|
|
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)
|
|
dirs[:] = [d for d in dirs if d.lower() not in IGNORE_FOLDERS]
|
|
|
|
for file in files:
|
|
if file in IGNORE_FILES:
|
|
continue
|
|
if any(file.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
|
|
continue
|
|
# Skip stray leftover compressed copies from a previous run so
|
|
# we never treat "texture.dds.gz" as a distributable file itself.
|
|
if file.endswith(COMPRESSED_SUFFIX):
|
|
continue
|
|
|
|
filepath = os.path.join(root, file)
|
|
|
|
# Path relative to the client folder (this is what the updater expects)
|
|
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
|
|
|
|
entry = {
|
|
"hash": file_hash,
|
|
"size": os.path.getsize(filepath),
|
|
}
|
|
|
|
compressed_path = filepath + COMPRESSED_SUFFIX
|
|
compressed_size, compressed_hash = compress_file(filepath, compressed_path)
|
|
|
|
if compressed_hash:
|
|
entry["compressed"] = True
|
|
entry["compressed_size"] = compressed_size
|
|
entry["compressed_hash"] = compressed_hash
|
|
total_saved += entry["size"] - compressed_size
|
|
else:
|
|
entry["compressed"] = False
|
|
|
|
total_original += entry["size"]
|
|
manifest[rel_path_web] = entry
|
|
|
|
try:
|
|
# 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}%)")
|
|
except IOError as e:
|
|
print(f"Error saving manifest: {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Universal manifest generator for game updaters.")
|
|
parser.add_argument("client_path", help="Path to the client files folder")
|
|
|
|
args = parser.parse_args()
|
|
|
|
generate(args.client_path)
|