Ctrl K
ComunidadGuías…

scripts/release_grant.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <Ordner>                    # einmalig: Schlüssel current und next
    python scripts/release_grant.py sign v0.12.0 --key <current.pem> --expect-digest sha256:<aus release.yml> [--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:<version>`, 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())