#!/usr/bin/env python3 """Freigabe eines Releases für den Update-Helfer: Schlüssel anlegen und eine Version signieren. python scripts/release_grant.py key --out # einmalig: Schlüssel current und next python scripts/release_grant.py sign v0.12.0 --key --expect-digest sha256: [--upload] `sign` fragt den Digest bei ghcr.io ab und signiert nur, wenn er dem Digest aus dem Lauf von `release.yml` gleicht (`--trust-registry` verzichtet auf diesen Vergleich). Es baut die Freigabe, lässt sie von der Prüfung des Helfers gegenlesen und legt `freigabe.json` und `freigabe.json.sig` ab; mit `--upload` hängt `gh` beide an den Entwurf des Releases. Läuft nur auf Nicos Rechner, nie in der CI: der private Schlüssel verlässt ihn nicht. """ from __future__ import annotations import argparse import getpass import hashlib import json import os import re import subprocess import sys import time import urllib.parse import urllib.request from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "deploy" / "updater")) from nodvard_deck_updater import ed25519, policy, trust REGISTRY = "ghcr.io" REPOSITORY = policy.REPOSITORY.removeprefix(f"{REGISTRY}/") GRANT_NAME = "freigabe.json" SIG_NAME = "freigabe.json.sig" VALID_S = 365 * 24 * 3600 TIMEOUT_S = 15 MAX_BODY_BYTES = 1024 * 1024 MIN_PASSWORD = 12 ACCEPT = "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json" TAG_RE = re.compile(rf"v({policy.VERSION_RE.pattern})", re.ASCII) class GrantError(Exception): """Abgelehnt; die Meldung sagt warum und was zu tun ist (Exit-Code 1).""" def _crypto(): try: from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 as crypto_ed25519 except ImportError: raise GrantError("Dafür fehlt das Paket „cryptography“ (pip install cryptography).") from None return serialization, crypto_ed25519 def version_of(tag: str) -> str: match = TAG_RE.fullmatch(tag) if match is None: raise GrantError(f"„{tag}“ ist kein Tag einer fertigen Version (etwa v0.12.0); Vorabversionen bekommen keine " "Freigabe.") return match.group(1) def _get(url: str, headers: dict[str, str]) -> tuple[bytes, dict[str, str]]: if urllib.parse.urlsplit(url).hostname != REGISTRY: raise GrantError(f"Nur {REGISTRY} wird gefragt, nicht {url}.") request = urllib.request.Request(url, headers={"User-Agent": "nodvard-release", **headers}) with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response: body = response.read(MAX_BODY_BYTES + 1) found = {key.lower(): value for key, value in response.headers.items()} if len(body) > MAX_BODY_BYTES: raise GrantError("Antwort von ghcr.io zu groß.") return body, found def registry_digest(version: str) -> str: """Der Digest hinter `ghcr.io/nodvard/deck:`, berechnet wie in der Update-Suche von Nodvard Deck.""" query = urllib.parse.urlencode({"scope": f"repository:{REPOSITORY}:pull", "service": REGISTRY}) try: raw, _ = _get(f"https://{REGISTRY}/token?{query}", {}) token = json.loads(raw).get("token") body, headers = _get(f"https://{REGISTRY}/v2/{REPOSITORY}/manifests/{version}", {"Authorization": f"Bearer {token}", "Accept": ACCEPT}) except (OSError, ValueError, AttributeError) as exc: raise GrantError(f"ghcr.io kennt {REPOSITORY}:{version} nicht oder antwortet nicht ({exc}). Ist das Paket " "öffentlich und das Image schon gebaut?") from None digest = "sha256:" + hashlib.sha256(body).hexdigest() named = headers.get("docker-content-digest") if named is not None and named != digest: raise GrantError(f"ghcr.io nennt {named}, das Manifest hat aber {digest}. Nichts signiert.") return digest def expected_digest(value: str | None, trust_registry: bool) -> str | None: """Der Digest aus `--expect-digest`; ohne ihn geht es nur mit `--trust-registry`.""" if trust_registry: if value is not None: raise GrantError("--expect-digest und --trust-registry schließen sich aus.") return None if value is None: raise GrantError("Den erwarteten Digest mit --expect-digest sha256:… angeben. Er steht im Lauf von " "release.yml, Job image, Schritt „Bauen und veroeffentlichen“, Abschnitt „Digest“.") if not policy.is_digest(value): raise GrantError(f"„{value}“ ist kein Digest. Erwartet: sha256: und 64 Hex-Zeichen in Kleinbuchstaben.") return value def checked_digest(version: str, expected: str | None) -> str: """Der Digest von ghcr.io; mit `expected` nur, wenn beide gleich sind.""" digest = registry_digest(version) if expected is not None and digest != expected: raise GrantError(f"ghcr.io liefert für {version} {digest}, aus release.yml erwartet war {expected}. Nichts " f"signiert. Erst prüfen, ob der erwartete Wert aus dem Lauf zu v{version} stammt (bei " "mehreren Versuchen aus dem letzten). Stimmt er, zeigt ghcr.io auf ein Image, das dieser Lauf " "nicht gebaut hat: nicht signieren, sondern herausfinden, wer das Tag zuletzt gesetzt hat.") return digest def build_grant(version: str, digest: str, key_id: str, now: int) -> dict[str, object]: return {"fmt": policy.GRANT_FORMAT, "repo": policy.REPOSITORY, "version": version, "digest": digest, "key_id": key_id, "issued_at": now, "expires_at": now + VALID_S} def load_private_key(path: Path, password: bytes): serialization, crypto_ed25519 = _crypto() try: key = serialization.load_pem_private_key(path.read_bytes(), password=password) except (OSError, ValueError, TypeError) as exc: raise GrantError(f"Schlüssel {path} ließ sich nicht öffnen (falsches Passwort?): {exc}") from None if not isinstance(key, crypto_ed25519.Ed25519PrivateKey): raise GrantError(f"{path} ist kein Ed25519-Schlüssel.") return key def _raw_public(key) -> bytes: serialization, _ = _crypto() return key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) def sign(tag: str, key, *, digest: str, now: int, keys: tuple[trust.TrustedKey, ...] = trust.TRUSTED_KEYS, ) -> tuple[bytes, str]: """Freigabe und Signatur für `tag`; vorher muss sie die Prüfung des Helfers mit `keys` bestehen.""" version = version_of(tag) public = _raw_public(key) key_id = trust.key_id(public) if trust.find(key_id, keys) is None: raise GrantError(f"Der Schlüssel {key_id} steht nicht in deploy/updater/nodvard_deck_updater/trust.py; der " "Update-Helfer würde die Freigabe ablehnen.") grant = build_grant(version, digest, key_id, now) data = policy.dumps(grant) sig = key.sign(data).hex() request = {"v": 2, "id": "6f1c2b0e-9a4d-4c1e-8f00-1b2c3d4e5f60", "action": "update", "version": version, "created_at": now, "digest": digest, "grant": grant, "sig": sig} try: policy.parse_request(policy.dumps(request), now=now, keys=keys) except policy.Refusal as exc: raise GrantError(f"Die Prüfung des Helfers lehnt die Freigabe ab ({exc.code}). Nichts geschrieben.") from None if not ed25519.verify(public, data, bytes.fromhex(sig)): raise GrantError("Gegenprobe der Signatur fehlgeschlagen. Nichts geschrieben.") return data, sig def write_files(out: Path, data: bytes, sig: str) -> tuple[Path, Path]: out.mkdir(parents=True, exist_ok=True) grant_path, sig_path = out / GRANT_NAME, out / SIG_NAME grant_path.write_bytes(data) sig_path.write_text(sig + "\n", encoding="ascii") return grant_path, sig_path def upload(tag: str, files: tuple[Path, Path]) -> None: command = ["gh", "release", "upload", tag, *map(str, files), "--clobber", "-R", REPOSITORY] command_text = subprocess.list2cmdline(command) try: done = subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace") except FileNotFoundError: raise GrantError("„gh“ fehlt. Die beiden Dateien von Hand an den Entwurf hängen.\n" f"Befehl: {command_text}") from None if done.returncode != 0: raise GrantError(f"Hochladen gescheitert (gh {done.returncode}). Die Dateien liegen in {files[0].parent}.\n" f"Befehl: {command_text}\nAusgabe von gh:\n{done.stdout}") if done.stdout: print(done.stdout, end="") def make_keys(out: Path, password: bytes) -> list[str]: """Legt je einen Schlüssel für `trust.ROLES` verschlüsselt an und gibt die Zeilen für `trust.py` zurück.""" serialization, crypto_ed25519 = _crypto() if len(password) < MIN_PASSWORD: raise GrantError(f"Das Passwort braucht mindestens {MIN_PASSWORD} Zeichen.") paths = {role: out / f"release-ed25519-{role}.pem" for role in trust.ROLES} taken = [str(path) for path in paths.values() if path.exists()] if taken: raise GrantError(f"Gibt es schon, nichts überschrieben: {', '.join(taken)}") out.mkdir(parents=True, exist_ok=True) lines = [] for role, path in paths.items(): key = crypto_ed25519.Ed25519PrivateKey.generate() pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.BestAvailableEncryption(password)) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as fh: fh.write(pem) public = _raw_public(key) lines.append(f' TrustedKey("{role}", bytes.fromhex("{public.hex()}")), # {trust.key_id(public)}') return lines def _password(prompt: str, *, twice: bool = False) -> bytes: first = getpass.getpass(prompt) if twice and getpass.getpass("Noch einmal: ") != first: raise GrantError("Die Passwörter sind verschieden.") return first.encode("utf-8") def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) key_cmd = sub.add_parser("key", help="Schlüssel current und next anlegen (einmalig)") key_cmd.add_argument("--out", type=Path, required=True) sign_cmd = sub.add_parser("sign", help="eine Version freigeben") sign_cmd.add_argument("tag") sign_cmd.add_argument("--key", type=Path, required=True) sign_cmd.add_argument("--out", type=Path, default=Path.cwd()) sign_cmd.add_argument("--expect-digest", metavar="sha256:…", help="Digest aus dem Lauf von release.yml (Job image)") sign_cmd.add_argument("--trust-registry", action="store_true", help="ohne Vergleich signieren, was ghcr.io gerade liefert") sign_cmd.add_argument("--upload", action="store_true", help="beide Dateien mit gh an den Entwurf hängen") args = parser.parse_args(argv) try: if args.command == "key": lines = make_keys(args.out, _password("Passwort für die Schlüssel: ", twice=True)) print("Schlüssel angelegt. In trust.py bei TRUSTED_KEYS eintragen (nur diese öffentlichen Zeilen):") print("\n".join(lines)) print("Die Datei mit „next“ getrennt aufbewahren, nie in Nextcloud oder im Repository.") return 0 version = version_of(args.tag) expected = expected_digest(args.expect_digest, args.trust_registry) digest = checked_digest(version, expected) if expected is None: print(f"Ohne Vergleich: ghcr.io liefert für {version} {digest}. Nur weitermachen, wenn das der Digest aus " "dem Lauf von release.yml ist.", file=sys.stderr) key = load_private_key(args.key, _password(f"Passwort für {args.key.name}: ")) data, sig = sign(args.tag, key, digest=digest, now=int(time.time())) files = write_files(args.out, data, sig) print(f"Freigabe für {version}: {digest}, Schlüssel {json.loads(data)['key_id']}, 365 Tage gültig.") print(f"Geschrieben: {files[0]} und {files[1]}") if args.upload: upload(args.tag, files) print(f"An den Entwurf {args.tag} gehängt.") return 0 except GrantError as exc: print(f"Fehler: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())