Files
m2dev-client/scripts/sign-manifest.py
Jan Nedbal b7e4514677 scripts: add manifest signer
Companion to make-manifest.py that signs the output with an Ed25519
private key. Signs the literal manifest bytes — never re-serializes —
because the launcher verifies against exactly what the server delivers.
Warns if the private key file is readable beyond owner. Verified
end-to-end against the launcher's real public key and a tamper test.
2026-04-14 11:22:32 +02:00

103 lines
3.8 KiB
Python
Executable File

#!/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: <manifest>.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())