#!/usr/bin/env python3 """Sign a manifest.json with the launcher's Ed25519 private key. Produces a detached signature at the same path with a ``.sig`` suffix. The signature is over the **literal bytes** of the manifest file — do not re-serialize the JSON before signing, or the launcher will refuse the result. Usage: sign-manifest.py --manifest /path/to/manifest.json \\ --key ~/.config/metin/launcher-signing-key \\ [--out manifest.json.sig] The private key file is 32 raw bytes (no PEM header, no encryption). Create it with: python3 -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey; from cryptography.hazmat.primitives import serialization; p=Ed25519PrivateKey.generate(); open("key","wb").write(p.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())); print(p.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw).hex())' Keep the file ``chmod 600`` on the release machine. Never commit it to any repo and never store it in CI secrets unless the release flow is moved off the CI runner entirely. """ from __future__ import annotations import argparse import os import sys from pathlib import Path try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization except ImportError: print("error: this script requires the 'cryptography' package " "(pip install cryptography, or dnf install python3-cryptography)", file=sys.stderr) raise SystemExit(1) def load_private_key(key_path: Path) -> Ed25519PrivateKey: raw = key_path.read_bytes() if len(raw) != 32: raise SystemExit( f"error: private key at {key_path} is {len(raw)} bytes, expected 32 " f"(a raw Ed25519 seed, not a PEM file)" ) return Ed25519PrivateKey.from_private_bytes(raw) def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--manifest", required=True, type=Path, help="Path to manifest.json to sign") parser.add_argument("--key", required=True, type=Path, help="Path to the raw 32-byte Ed25519 private key") parser.add_argument("--out", type=Path, help="Signature output path (default: .sig)") args = parser.parse_args() manifest_path: Path = args.manifest if not manifest_path.is_file(): print(f"error: manifest not found: {manifest_path}", file=sys.stderr) return 1 key_path: Path = args.key if not key_path.is_file(): print(f"error: private key not found: {key_path}", file=sys.stderr) return 1 key_mode = key_path.stat().st_mode & 0o777 if key_mode & 0o077: print( f"warning: private key {key_path} is readable by group or world " f"(mode {oct(key_mode)}). chmod 600 recommended.", file=sys.stderr, ) private_key = load_private_key(key_path) manifest_bytes = manifest_path.read_bytes() signature = private_key.sign(manifest_bytes) assert len(signature) == 64, "Ed25519 signatures are always 64 bytes" out_path: Path = args.out or manifest_path.with_suffix(manifest_path.suffix + ".sig") out_path.write_bytes(signature) os.chmod(out_path, 0o644) public_hex = private_key.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ).hex() print( f"signed {manifest_path} -> {out_path} " f"(64 bytes, verify with public key {public_hex})", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main())