261 lines
13 KiB
Python
261 lines
13 KiB
Python
#!/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)
|