#!/usr/bin/env python3 """ generate_key.py ---------------------------------- Генерирует ключи для simple-updater и умеет сразу сохранять их в config.json (см. --save), чтобы не копировать хэши руками между файлами. Без аргументов — печатает один 64-символьный hex access-key (используется в config.json's access_keys для маршрутизации клиентских папок). Это НЕ криптографический секрет в строгом смысле - он обязан жить внутри updater.exe, чтобы обновлятор работал, и его можно извлечь декомпиляцией. Он просто не даёт угадать URL клиентской папки наугад. С флагом --signing-keypair генерирует пару Ed25519-ключей для подписи манифестов: - приватный ключ - с --save сохраняется в отдельный текстовый файл (по умолчанию SIGNING_PRIVATE_KEY.txt, см. config.json's signing_keys.private_key_file) НЕ внутри config.json. Остаётся ТОЛЬКО на машине, где вы генерируете манифесты (generate_manifest.py / generate_updater_manifest.py). Никогда не попадает ни на сервер, ни в updater.exe, ни в config.json - именно чтобы его нельзя было случайно закоммитить или разослать вместе с остальными настройками. - публичный ключ - идёт в config.json (signing_keys.public) и вписывается в updater.py перед компиляцией exe. Его можно раскрывать сколько угодно: имея только публичный ключ, подделать подпись невозможно (в отличие от HMAC, где один и тот же секрет и подписывает, и проверяет - и потому достать его из exe означало бы получить возможность подделывать манифесты). Это меняет модель угроз по сравнению с HMAC: даже если updater.exe полностью декомпилирован, из него можно извлечь только публичный ключ, которым НИЧЕГО подписать нельзя - только проверить чужую подпись. Примеры: python generate_key.py --client Main --save python generate_key.py --signing-keypair --save """ import os import json import secrets import argparse from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) _CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json") def generate_access_key() -> str: return secrets.token_hex(32) # 64 hex chars def generate_signing_keypair(): """ Returns (private_hex, public_hex) - both raw 32-byte Ed25519 keys, hex-encoded. private_hex goes only in generate_manifest.py's config (kept secret, ideally outside version control). public_hex goes in updater.py and is safe to publish/decompile. """ private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() private_bytes = private_key.private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption(), ) public_bytes = public_key.public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) return private_bytes.hex(), public_bytes.hex() def _private_key_file_path(cfg): filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt") return os.path.join(_SCRIPT_DIR, filename), filename def write_private_key_file(path, private_hex): """ Writes the Ed25519 private signing key to a standalone .txt file next to the scripts, NOT into config.json. This key is the one real secret in the whole project - it must never end up in a file that gets zipped up, committed, or handed to anyone else, and config.json is exactly the kind of file that tends to get copied around wholesale (backed up, committed "just to be safe", sent to a teammate along with the rest of the project). A dedicated file makes the secret visually obvious and easy to .gitignore / delete on its own, separate from the non-secret settings in config.json. """ sep = "=" * 70 lines = [ sep, "СЕКРЕТНЫЙ КЛЮЧ - НЕ ДЕЛИТЕСЬ И НЕ КОММИТЬТЕ ЭТОТ ФАЙЛ", sep, "", "Это приватный Ed25519-ключ для подписи manifest.json /", "updater_manifest.json (generate_manifest.py, generate_updater_manifest.py,", "quickstart.py). Кто угодно с этим ключом может подписать поддельный", "манифест, который ваши игроки примут как настоящий, - то есть", "фактически получит возможность выполнить произвольный код на машинах", "всех игроков через игровые файлы или сам updater.exe.", "", "ЧТО С ЭТИМ ФАЙЛОМ ДЕЛАТЬ:", " - Держите его ТОЛЬКО на машине, где вы генерируете манифесты.", " - Никогда не кладите рядом с config.json в архив/бэкап/репозиторий,", " который может куда-то уехать целиком.", " - Добавьте имя файла в .gitignore, если используете git.", " - Если ключ больше не нужен на этой машине (например, манифесты", " теперь генерируются на другом сервере) - удалите файл. Он не", " нужен ни серверу (server.py), ни updater.exe - только скриптам", " генерации манифестов.", " - Если ключ утёк - сгенерируйте новую пару (generate_key.py", " --signing-keypair --save), заново сгенерируйте ВСЕ манифесты и", " пересоберите updater.exe с новым публичным ключом.", "", sep, "", ] banner = "\n".join(lines) + "\n" with open(path, "w", encoding="utf-8") as f: f.write(banner) f.write(f"SIGNING_PRIVATE_KEY={private_hex}\n") def read_private_key_file(path): """ Reads the hex key back out of a file written by write_private_key_file. Tolerates the human-readable banner around it - only the SIGNING_PRIVATE_KEY=... line matters. """ with open(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() return None def load_config(): if not os.path.exists(_CONFIG_PATH): raise SystemExit(f"config.json not found at {_CONFIG_PATH} - can't use --save without it.") with open(_CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) def save_config(cfg): """ Writes config.json back out, preserving key order/indentation style (indent=4) so diffs stay readable. Not atomic-write-protected like the runtime files elsewhere in this project - this is a one-off dev-time tool run interactively, not something that needs to survive a crash mid-write the way a live server/updater does. """ with open(_CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=4) f.write("\n") def save_access_key(client_name, key): cfg = load_config() cfg.setdefault("access_keys", {}) existing = cfg["access_keys"].get(client_name) if existing: print(f"WARNING: overwriting existing access key for '{client_name}' in config.json.") print(" Any already-built updater.exe with the old key will stop being able") print(" to reach the server until it's rebuilt with the new one.") cfg["access_keys"][client_name] = key cfg.setdefault("clients", {}) if client_name not in cfg["clients"]: # Keep clients/access_keys in sync - a key with no matching folder # entry would be silently skipped by server.py's startup check. default_folder = f"game_client_{client_name.lower()}" if client_name.lower() != "main" else "game_client" cfg["clients"][client_name] = default_folder print(f"Also added clients.{client_name} = \"{default_folder}\" (edit this in config.json if needed).") save_config(cfg) print(f"Saved access_keys.{client_name} to config.json.") def save_signing_keypair(private_hex, public_hex): """ Public half goes into config.json as usual - it's not a secret. Private half goes into a standalone .txt file NEXT TO config.json, never into config.json itself (see write_private_key_file's docstring for why). Prints a loud reminder either way, since this overwriting the private key file, if one already existed, invalidates every manifest signed with the old key. """ cfg = load_config() cfg.setdefault("signing_keys", {}) key_path, key_filename = _private_key_file_path(cfg) if cfg["signing_keys"].get("public") or os.path.exists(key_path): print("WARNING: overwriting existing signing keypair.") print(" Manifests signed with the old private key will fail verification") print(" against exes built with the new public key, and vice versa -") print(" regenerate all manifests and rebuild updater.exe after this.") cfg["signing_keys"]["public"] = public_hex cfg["signing_keys"].setdefault("private_key_file", key_filename) save_config(cfg) write_private_key_file(key_path, private_hex) print(f"Saved signing_keys.public to config.json.") print(f"Saved PRIVATE key to {key_filename} - read the warning inside that file.") print(f"{key_filename} is NOT part of config.json and should never be committed or shared.") if __name__ == "__main__": parser = argparse.ArgumentParser( description="Генератор ключей для simple-updater.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--signing-keypair", action="store_true", help=( "Сгенерировать пару Ed25519-ключей для подписи манифестов " "вместо access key. Печатает и приватный, и публичный ключ." ), ) parser.add_argument( "--client", metavar="NAME", help="Имя клиента (как 'Main' или 'Test') для сохранения access key под ним в config.json. Требуется вместе с --save, если не указан --signing-keypair.", ) parser.add_argument( "--save", action="store_true", help="Сохранить сгенерированный ключ прямо в config.json вместо/в дополнение к печати в консоль.", ) args = parser.parse_args() if args.signing_keypair: private_hex, public_hex = generate_signing_keypair() if args.save: save_signing_keypair(private_hex, public_hex) else: print("Приватный ключ (держите в секрете, только для генерации манифестов):") print(f" {private_hex}") print() print("Публичный ключ (безопасно зашивать в updater.exe):") print(f" {public_hex}") print() print("Без --save ключи никуда не сохраняются - скопируйте их сами,") print("или перезапустите с --save чтобы приватный ключ ушёл в отдельный") print(".txt файл, а публичный - в config.json.") else: key = generate_access_key() print(key) if args.save: if not args.client: raise SystemExit("--save для access key требует --client NAME (например --client Main).") save_access_key(args.client, key)