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.
This commit is contained in:
102
scripts/sign-manifest.py
Executable file
102
scripts/sign-manifest.py
Executable file
@@ -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: <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())
|
||||
Reference in New Issue
Block a user