diff --git a/scripts/sign-manifest.py b/scripts/sign-manifest.py new file mode 100755 index 00000000..63948a6d --- /dev/null +++ b/scripts/sign-manifest.py @@ -0,0 +1,102 @@ +#!/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())