""" catalog_console.py -- Catalog Console: maintainer-only review + signing tool for data/catalog.json (issue #62). MAINTAINER-ONLY. Run from a source checkout. NEVER shipped to users and NEVER included in the release bundle -- see bcc.spec (Analysis only ever starts from bcc.py) and tests/test_packaging.py, which asserts this file and catalog_review.py are absent from the packaged bundle. Flow: Load -> Review -> Sign. 1. Load -- pick a source: an open Gitea PR touching data/catalog.json, or the current tip of `main`. The Console fetches the exact git blob (via a local clone's git plumbing) and PINS its blob SHA *and the ref it came from* for the rest of this review pass. For a PR, the diff is against `main`; for `main`, the diff is against the last catalog a maintainer actually SIGNED (the bytes covered by the current data/catalog.json.sig), never against itself -- an empty diff must mean "nothing to sign", never "sign unlocked". 2. Review -- a semantic diff (catalog_review.diff_catalogs), one card per changed entry, with risk annotations (catalog_review.entry_risk_findings). A registry lookup for each entry's npm/PyPI package kicks off automatically, one worker thread per entry, the moment the cards are built -- it is the one check a reviewer can't do by eye, so it must never depend on a click. It fails soft (a dead registry shows "unavailable", never blocks review or Sign) and a per-card "Re-check" button covers manual retries. Every changed entry must be individually acknowledged (its checkbox ticked) before Sign unlocks. There is no "acknowledge all" -- see catalog_review.py. 3. Sign -- re-resolves the current blob SHA from the SAME ref that was reviewed (never a hardcoded "main") and refuses to sign unless it still matches the pinned SHA from step 1, the diff is non-empty, and no changed entry has an outstanding blocking risk finding (catalog_review.sign_precondition / can_sign -- the TOCTOU fix). On success, writes data/catalog.json + data/catalog.json.sig and commits BOTH in a single commit, then pushes -- so main is never red between a catalog merge and its signature. Signing uses the CATALOG key ONLY -- see "Two signing keys" below. The signature must be the artefact of an actual review, not a step that follows one. Signing IS the approval act. Two signing keys (issue #68 finding 5): the CATALOG key (offline, Console-only, `keygen` / `keygen --release` picks which) is the root of trust for what BCC executes and must never touch CI. The RELEASE key is CI-resident and signs ONLY the release SHA256SUMS manifest (scripts/sign_checksums.py) -- `show-seed-b64 --release` is the only supported way to get a seed out of this tool, and it refuses to run without `--release` so the catalog seed can never be exported by habit. See the README's "Signing keys" section. """ from __future__ import annotations import argparse import base64 import contextlib import getpass import html import json import re import subprocess import sys import urllib.error import urllib.request from dataclasses import dataclass from pathlib import Path import bcc_core as core import catalog_review as review # --------------------------------------------------------------------------- # # Constants # --------------------------------------------------------------------------- # GITEA_HOST = "git.avezzano.io" GITEA_API_BASE = f"https://{GITEA_HOST}/api/v1" REPO_OWNER = "the_og" REPO_NAME = "better-claude-config" CATALOG_PATH = "data/catalog.json" SIG_PATH = "data/catalog.json.sig" # Outside the repo, per issue #62 ("never committed, never plaintext"). A # maintainer-only tool, so a dotfile under $HOME is an acceptable fallback # when the OS keychain isn't available -- the blob stored there is always # passphrase-encrypted (see catalog_review.encrypt_private_key), never raw. # # Two SEPARATE keys are stored here, never conflated (issue #68 finding 5): # "catalog" -- offline, Console-only. Verified by bcc_core.CATALOG_PUBKEYS. # Roots of trust for every catalog entry BCC ships. Must # NEVER leave this machine, never touch CI, never become an # env var or a repo secret. # "release" -- CI-resident. Verified by scripts.sign_checksums. # RELEASE_PUBKEYS. Signs ONLY the release SHA256SUMS # manifest. Its private seed is deliberately meant to be # pasted into the RELEASE_SIGNING_KEY Gitea Actions secret # (via `show-seed-b64 --release`) -- that is its normal, # intended flow. A CI compromise burns this key, not the # catalog key: that asymmetry is the whole point of having # two keys instead of one. KEY_STORAGE_DIR = Path.home() / ".bcc-catalog-console" _KEY_KINDS = ("catalog", "release") def _key_storage_file(kind: str) -> Path: assert kind in _KEY_KINDS, f"unknown key kind {kind!r}, expected one of {_KEY_KINDS}" return KEY_STORAGE_DIR / f"signing_key_{kind}.enc" HTTP_TIMEOUT = 6.0 # --------------------------------------------------------------------------- # # Key storage: OS keychain if available, else a passphrase-encrypted file # outside the repo. Never plaintext, never an env var, never committed. # --------------------------------------------------------------------------- # def _keyring_module(): """Best-effort import of the optional `keyring` package. Returns None if it isn't installed -- this tool must work without it, falling back to the encrypted-file path. `keyring` is deliberately NOT added to requirements-dev.txt: this is a maintainer-only tool excluded from the shipped app, so it doesn't need to justify a new runtime dependency for every user the way bcc.py's dependencies do.""" try: import keyring return keyring except ImportError: return None _KEYRING_SERVICE = "bcc-catalog-console" def _keyring_username(kind: str) -> str: assert kind in _KEY_KINDS, f"unknown key kind {kind!r}, expected one of {_KEY_KINDS}" return f"signing-key-{kind}" def store_encrypted_key(blob: bytes, kind: str = "catalog") -> str: """Persist an already-encrypted key blob (see catalog_review.encrypt_private_key) under the given `kind` ("catalog" or "release" -- see the KEY_STORAGE_DIR comment above; the two are stored under different keychain entries / filenames so they can never be loaded interchangeably). Prefers the OS keychain; falls back to a file under KEY_STORAGE_DIR (outside the repo) with restrictive permissions. Returns a human-readable description of where it went.""" keyring = _keyring_module() if keyring is not None: try: keyring.set_password(_KEYRING_SERVICE, _keyring_username(kind), blob.hex()) return "OS keychain (via the `keyring` package)" except Exception: pass # fall through to the file-based path KEY_STORAGE_DIR.mkdir(parents=True, exist_ok=True) key_file = _key_storage_file(kind) key_file.write_bytes(blob) with contextlib.suppress(OSError): # best-effort on platforms without POSIX perm bits key_file.chmod(0o600) return f"encrypted file at {key_file}" def load_encrypted_key(kind: str = "catalog") -> bytes: """Load the encrypted key blob of the given `kind` from wherever store_encrypted_key() put it. Raises FileNotFoundError if no key of that kind has been generated yet.""" keyring = _keyring_module() if keyring is not None: try: hex_blob = keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind)) if hex_blob: return bytes.fromhex(hex_blob) except Exception: pass key_file = _key_storage_file(kind) if not key_file.exists(): flag = " --release" if kind == "release" else "" raise FileNotFoundError( f"No {kind} signing key found (checked the OS keychain and {key_file}). " f"Run `python catalog_console.py keygen{flag}` first." ) return key_file.read_bytes() def unlock_signing_key(passphrase: str, kind: str = "catalog") -> bytes: """Load + decrypt the signing key seed of the given `kind`. Raises ValueError on a wrong passphrase, FileNotFoundError if no key of that kind exists yet. Defaults to "catalog" because that's the key ReviewWindow._on_sign uses -- the GUI never touches the release key.""" blob = load_encrypted_key(kind) return review.decrypt_private_key(blob, passphrase) # --------------------------------------------------------------------------- # # git plumbing against a local clone. The clone's `origin` remote is assumed # to already carry credentials (the "tokened remote" every other BCC # maintainer script relies on) -- this module never handles a token itself. # --------------------------------------------------------------------------- # class GitError(RuntimeError): pass def _git(repo_dir: Path, *args: str, capture_bytes: bool = False): cmd = ["git", "-C", str(repo_dir), *args] result = subprocess.run(cmd, capture_output=True, check=False) if result.returncode != 0: stderr = result.stderr.decode("utf-8", "replace") raise GitError(f"git {' '.join(args)} failed: {stderr}") return result.stdout if capture_bytes else result.stdout.decode("utf-8", "replace") def fetch_ref(repo_dir: Path, ref: str) -> str: """Fetch `ref` from origin and return the resulting commit SHA.""" _git(repo_dir, "fetch", "origin", ref) return _git(repo_dir, "rev-parse", "FETCH_HEAD").strip() def blob_sha_at(repo_dir: Path, commit: str, path: str) -> str: """The git blob SHA of `path` as it exists at `commit`. This is what gets pinned at review-start and re-checked immediately before signing (catalog_review.can_sign) -- the TOCTOU fix.""" return _git(repo_dir, "rev-parse", f"{commit}:{path}").strip() def blob_bytes(repo_dir: Path, blob_sha: str) -> bytes: return _git(repo_dir, "cat-file", "blob", blob_sha, capture_bytes=True) def read_catalog_at_commit(repo_dir: Path, commit: str) -> tuple[bytes, str]: """Return (raw_bytes, blob_sha) for data/catalog.json at `commit`.""" sha = blob_sha_at(repo_dir, commit, CATALOG_PATH) return blob_bytes(repo_dir, sha), sha def catalog_blob_history(repo_dir: Path, ref: str, limit: int = 200) -> list[str]: """Blob SHAs of CATALOG_PATH at each commit that touched it, walking back from `ref`, most-recent-first. Used by last_signed_catalog_raw() to find "the version of the catalog the current signature actually covers" without assuming it's the tip commit.""" log_out = _git(repo_dir, "log", f"--max-count={limit}", "--format=%H", ref, "--", CATALOG_PATH) commits = [line for line in log_out.splitlines() if line] shas: list[str] = [] for commit in commits: try: shas.append(blob_sha_at(repo_dir, commit, CATALOG_PATH)) except GitError: continue return shas def last_signed_catalog_raw(repo_dir: Path, commit: str) -> bytes | None: """The raw data/catalog.json bytes that verify against data/catalog.json.sig as of `commit` -- i.e. "the last catalog a maintainer actually signed", found by walking the catalog's git history on that ref until a version verifies against the *current* signature. This is what source="main (current tip)" diffs against (issue #68 finding 1): if main's tip catalog.json already matches its .sig, this returns that same content and the diff is correctly empty (nothing new to sign). If someone merged a catalog change to main without running it through the Console -- the exact bypass that produced commit b08cf21 -- the signature still covers the OLDER content, so this returns that older version and the diff surfaces exactly what was never actually reviewed. Returns None if there's no signature yet, or none of the recent history verifies against it (caller should treat this as "diff against nothing ever signed", i.e. every current entry shows as newly added). """ try: sig_sha = blob_sha_at(repo_dir, commit, SIG_PATH) except GitError: return None sig_bytes = blob_bytes(repo_dir, sig_sha) candidates = [blob_bytes(repo_dir, sha) for sha in catalog_blob_history(repo_dir, commit)] return review.find_last_signed_catalog_raw(candidates, sig_bytes, core.CATALOG_PUBKEYS) def catalog_signature_valid_at(repo_dir: Path, commit: str, raw: bytes) -> bool: """Whether data/catalog.json.sig as of `commit` verifies `raw` against the CURRENTLY-TRUSTED bcc_core.CATALOG_PUBKEYS. False whenever there is no .sig file, or the .sig exists but was produced by a key that is not (or no longer) in CATALOG_PUBKEYS -- most notably right after a catalog signing-key rotation (issue #68 finding 5 follow-up), when the committed .sig was produced by the now-retired old key. This is the rotation-detection primitive ReviewWindow._on_load uses to decide whether to enter re-attestation mode (review.start_review(..., reattest=True)) instead of the ordinary diff-against-last-signed path. Delegates to bcc_core.verify_catalog_signature -- never reimplemented, per this module's docstring ("one source of truth"). """ try: sig_sha = blob_sha_at(repo_dir, commit, SIG_PATH) except GitError: return False sig_bytes = blob_bytes(repo_dir, sig_sha) return core.verify_catalog_signature(raw, sig_bytes, core.CATALOG_PUBKEYS) def commit_and_push_signed_catalog( repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main" ) -> str: """Write data/catalog.json + data/catalog.json.sig and commit BOTH in a single commit, then push to `branch`. Returns the new commit SHA. This is deliberate: if signing happened in a commit AFTER the catalog merge, main would be red (payload present, signature missing) between every catalog merge and its signing commit. Routine red-main trains exactly the alarm fatigue this whole design exists to prevent. Emitting one commit with both files means main is never in that state. """ _git(repo_dir, "checkout", branch) _git(repo_dir, "pull", "--ff-only", "origin", branch) (repo_dir / CATALOG_PATH).write_bytes(raw_bytes) (repo_dir / SIG_PATH).write_bytes(signature) _git(repo_dir, "add", CATALOG_PATH, SIG_PATH) _git( repo_dir, "commit", "-m", "chore: sign data/catalog.json (Catalog Console, #62)\n\n" "Payload and detached Ed25519 signature land together so main is " "never red between a catalog merge and its signature.", ) _git(repo_dir, "push", "origin", branch) return _git(repo_dir, "rev-parse", "HEAD").strip() # --------------------------------------------------------------------------- # # Gitea REST API: list open PRs touching data/catalog.json # --------------------------------------------------------------------------- # def _gitea_get(path: str, token: str | None = None) -> object: url = f"{GITEA_API_BASE}{path}" req = urllib.request.Request(url) if token: req.add_header("Authorization", f"token {token}") with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: return json.loads(resp.read().decode("utf-8")) @dataclass(frozen=True) class CatalogPR: number: int title: str head_ref: str # refs/pull//head def list_open_catalog_prs(token: str | None = None) -> list[CatalogPR]: """Open PRs against REPO_OWNER/REPO_NAME whose diff touches data/catalog.json. Fails soft: on any network error, returns [] rather than raising into the GUI (Load still offers the `main` source).""" try: prs = _gitea_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/pulls?state=open", token) except (urllib.error.URLError, TimeoutError, ValueError): return [] matches: list[CatalogPR] = [] for pr in prs or []: number = pr.get("number") if not isinstance(number, int): continue if _pr_touches_catalog(number, token): matches.append( CatalogPR( number=number, title=str(pr.get("title", f"PR #{number}")), head_ref=f"refs/pull/{number}/head", ) ) return matches def _pr_touches_catalog(pr_number: int, token: str | None) -> bool: url = f"https://{GITEA_HOST}/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}.diff" req = urllib.request.Request(url) if token: req.add_header("Authorization", f"token {token}") try: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: diff_text = resp.read().decode("utf-8", "replace") except (urllib.error.URLError, TimeoutError): return False return CATALOG_PATH in diff_text def token_from_git_remote(repo_dir: Path) -> str | None: """Best-effort extraction of a PAT embedded in `origin`'s URL (https://@host/...), matching the "tokened remote" every other BCC maintainer flow already relies on. Returns None if there isn't one (public read-only API calls still work, just rate-limited).""" try: url = _git(repo_dir, "remote", "get-url", "origin").strip() except GitError: return None match = re.match(r"https://([^@/]+)@", url) if not match: return None token = match.group(1) # `user:token` form -- keep only the token half if present. return token.split(":", 1)[-1] # --------------------------------------------------------------------------- # # Registry lookup fetchers (npm / PyPI). Kept out of catalog_review.py so the # pure module never makes a network call itself -- these are injected as the # `Fetcher` callable review.lookup_registry_info() expects. # --------------------------------------------------------------------------- # def fetch_npm_info(ref: review.PackageRef) -> dict | None: url = f"https://registry.npmjs.org/{ref.name}" try: req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: data = json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, ValueError): return None time_info = data.get("time") or {} created = time_info.get("created") modified = time_info.get("modified") age_days = _iso_age_days(created) maintainers = data.get("maintainers") or [] publisher = maintainers[0].get("name") if maintainers else None downloads = None try: dl_url = f"https://api.npmjs.org/downloads/point/last-week/{ref.name}" with urllib.request.urlopen(dl_url, timeout=HTTP_TIMEOUT) as resp: downloads = json.loads(resp.read().decode("utf-8")).get("downloads") except (urllib.error.URLError, TimeoutError, ValueError): pass # fail soft -- downloads are a nice-to-have, not required return { "publisher": publisher, "age_days": age_days, "last_release": modified, "downloads": downloads, } def fetch_pypi_info(ref: review.PackageRef) -> dict | None: url = f"https://pypi.org/pypi/{ref.name}/json" try: with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as resp: data = json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, ValueError): return None info = data.get("info") or {} releases = data.get("releases") or {} last_release = None earliest_upload = None for files in releases.values(): for f in files: uploaded = f.get("upload_time_iso_8601") if not uploaded: continue if last_release is None or uploaded > last_release: last_release = uploaded if earliest_upload is None or uploaded < earliest_upload: earliest_upload = uploaded return { "publisher": info.get("author") or info.get("maintainer"), "age_days": _iso_age_days(earliest_upload), "last_release": last_release, "downloads": None, # PyPI JSON API doesn't include download counts } def _iso_age_days(iso_timestamp: str | None) -> int | None: if not iso_timestamp: return None import datetime as _dt try: parsed = _dt.datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) now = _dt.datetime.now(_dt.timezone.utc) return max((now - parsed).days, 0) except ValueError: return None def registry_fetcher(ref: review.PackageRef) -> dict | None: """The Fetcher passed to review.lookup_registry_info(). Never raises -- both fetch_npm_info/fetch_pypi_info already fail soft, and lookup_registry_info() wraps this in a try/except regardless.""" if ref.ecosystem == "npm": return fetch_npm_info(ref) if ref.ecosystem == "pypi": return fetch_pypi_info(ref) return None # --------------------------------------------------------------------------- # # GUI (PySide6). Everything above this line has no Qt dependency and is # exercised by tests/test_catalog_review.py; everything below is a thin # shell that calls into it. # --------------------------------------------------------------------------- # from PySide6.QtCore import Qt, QThread, Signal # noqa: E402 from PySide6.QtWidgets import ( # noqa: E402 QApplication, QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, ) def plain_label(text: object) -> QLabel: """A QLabel guaranteed to render `text` as plain text, never HTML. Qt's QLabel auto-interprets HTML by default (Qt.AutoText), which means an attacker-controlled description/notes/URL/package-name string containing `` or `` would render as markup instead of visible text -- exactly the kind of thing that could hide a homoglyph swap or make a risk warning easy to miss. Every catalog-derived string shown by this Console MUST go through this helper (or otherwise set Qt.PlainText explicitly) rather than a bare QLabel(...). """ label = QLabel(html.escape(str(text))) label.setTextFormat(Qt.PlainText) label.setWordWrap(True) return label _SEVERITY_PREFIX = {"blocking": "✖ BLOCKING", "warning": "⚠ WARNING", "info": "ℹ INFO"} class RegistryLookupWorker(QThread): """Off-UI-thread registry lookups, mirroring bcc.py's ConnTester/ SpawnTester pattern. Never blocks the review UI on a slow/dead network.""" done = Signal(object) # list[review.RegistryInfo] def __init__(self, refs: list[review.PackageRef], all_entry_ids: list[str]): super().__init__() self._refs = refs self._all_entry_ids = all_entry_ids def run(self): results = [ review.lookup_registry_info(ref, registry_fetcher, self._all_entry_ids) for ref in self._refs ] self.done.emit(results) class EntryCard(QWidget): """One changed catalog entry: the diff, risk findings, and the acknowledge checkbox that gates Sign. `command`/`args` are rendered visually dominant (bold-weight, larger, first) since they're the fields that execute. """ acknowledged_changed = Signal(str, bool) def __init__(self, change: review.EntryChange, all_entry_ids: list[str]): super().__init__() self.change = change self._all_entry_ids = all_entry_ids self._worker: RegistryLookupWorker | None = None outline = QVBoxLayout(self) box = QGroupBox(f"[{change.status.upper()}] {change.entry_id}") outline.addWidget(box) layout = QVBoxLayout(box) entry = change.new or change.old or {} config = entry.get("config") or {} cmd_label = plain_label(f"command: {config.get('command', '(none)')}") cmd_label.setStyleSheet("font-weight: bold; font-size: 13pt;") layout.addWidget(cmd_label) args_label = plain_label(f"args: {config.get('args', [])}") args_label.setStyleSheet("font-weight: bold;") layout.addWidget(args_label) for fc in change.field_changes: if fc.field in ("config.command", "config.args"): continue # already shown dominant, above layout.addWidget(plain_label(f"{fc.field}: {fc.old!r} -> {fc.new!r}")) findings = review.entry_risk_findings(change) for finding in findings: prefix = _SEVERITY_PREFIX.get(finding.severity, finding.severity.upper()) flabel = plain_label(f"{prefix}: {finding.message}") if finding.severity == "blocking": flabel.setStyleSheet("color: #c62828; font-weight: bold;") elif finding.severity == "warning": flabel.setStyleSheet("color: #ef6c00;") else: flabel.setStyleSheet("color: #1565c0;") layout.addWidget(flabel) self.registry_label = plain_label("Registry lookup: loading...") layout.addWidget(self.registry_label) recheck_btn = QPushButton("Re-check") recheck_btn.clicked.connect(self._run_registry_lookup) layout.addWidget(recheck_btn) self.blocking = any(f.severity == "blocking" for f in findings) self.checkbox = QCheckBox( "I have reviewed this entry, including command/args and the risk" " annotations above, and approve it." ) if self.blocking: self.checkbox.setEnabled(False) self.checkbox.setToolTip( "This entry has a BLOCKING finding and cannot be acknowledged " "until the underlying change is fixed (edit the PR, don't sign around it)." ) self.checkbox.toggled.connect( lambda checked: self.acknowledged_changed.emit(change.entry_id, checked) ) layout.addWidget(self.checkbox) # Registry lookup is the one check a reviewer can't do by eye -- it's # what catches a typosquatted/hijacked package (it already caught # firecrawl-mcp in the seed data). It must run automatically as soon # as the card exists, not wait on a click a tired maintainer might # skip at 11pm. Off the GUI thread (RegistryLookupWorker is a # QThread) and fails soft: a dead/slow registry can never gate # review or signing, it just leaves this entry's lookup showing # "unavailable". The "Re-check" button above stays for retrying a # failed/unavailable lookup by hand. self._run_registry_lookup() def _run_registry_lookup(self): entry = self.change.new or {} refs = review.extract_package_refs(entry) if not refs: self.registry_label.setText("Registry lookup: no npm/PyPI package in this entry.") return self.registry_label.setText("Registry lookup: loading...") self._worker = RegistryLookupWorker(refs, self._all_entry_ids) self._worker.done.connect(self._on_registry_result) self._worker.start() def _on_registry_result(self, results: list[review.RegistryInfo]): lines = [] for info in results: if not info.available: lines.append(f"{info.ref.name}: unavailable (network/registry unreachable)") continue neighbor_note = ( f" | NEAR-NEIGHBOUR of: {', '.join(info.near_neighbor_ids)}" if info.near_neighbor_ids else "" ) lines.append( f"{info.ref.name}: publisher={info.publisher!r} age_days={info.age_days} " f"last_release={info.last_release} downloads={info.downloads}{neighbor_note}" ) text = "Registry lookup:\n" + "\n".join(lines) self.registry_label.setText(html.escape(text)) self.registry_label.setTextFormat(Qt.PlainText) class PassphraseDialog(QDialog): def __init__(self, prompt: str, parent=None): super().__init__(parent) self.setWindowTitle("Signing key passphrase") layout = QFormLayout(self) self.edit = QLineEdit() self.edit.setEchoMode(QLineEdit.EchoMode.Password) layout.addRow(prompt, self.edit) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addRow(buttons) def passphrase(self) -> str: return self.edit.text() class ReviewWindow(QMainWindow): def __init__(self, repo_dir: Path): super().__init__() self.repo_dir = repo_dir self.session: review.ReviewSession | None = None self.cards: dict[str, EntryCard] = {} self.setWindowTitle("BCC Catalog Console -- maintainer-only, never shipped") central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) top = QHBoxLayout() self.source_list = QListWidget() self.source_list.addItem(QListWidgetItem("main (current tip)")) top.addWidget(self.source_list, 1) side = QVBoxLayout() load_btn = QPushButton("Load selected source") load_btn.clicked.connect(self._on_load) side.addWidget(load_btn) refresh_prs_btn = QPushButton("Refresh open PR list") refresh_prs_btn.clicked.connect(self._refresh_pr_list) side.addWidget(refresh_prs_btn) side.addStretch(1) top.addLayout(side) root.addLayout(top) # KEY-ROTATION RE-ATTESTATION BANNER (issue #68 finding 5 follow-up). # Hidden until _on_load() detects that the loaded catalog's # signature does NOT verify under the currently-trusted # bcc_core.CATALOG_PUBKEYS (catalog_signature_valid_at()) -- most # commonly, right after the maintainer rotates the catalog signing # key. This mode must NEVER be entered silently: this banner is the # only thing standing between "every entry needs re-review" and a # maintainer wondering why the diff view suddenly shows 19 # "added" entries with no explanation. self.reattest_banner = plain_label("") self.reattest_banner.setStyleSheet( "background-color: #c62828; color: white; font-weight: bold; padding: 8px;" ) self.reattest_banner.setVisible(False) root.addWidget(self.reattest_banner) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.card_container = QWidget() self.card_layout = QVBoxLayout(self.card_container) self.scroll.setWidget(self.card_container) root.addWidget(self.scroll, 1) self.status_label = plain_label("Load a source to begin review.") root.addWidget(self.status_label) self.sign_btn = QPushButton("Sign") self.sign_btn.setEnabled(False) self.sign_btn.clicked.connect(self._on_sign) root.addWidget(self.sign_btn) self._token = token_from_git_remote(self.repo_dir) self._prs: list[CatalogPR] = [] self._refresh_pr_list() def _refresh_pr_list(self): self._prs = list_open_catalog_prs(self._token) while self.source_list.count() > 1: self.source_list.takeItem(1) for pr in self._prs: self.source_list.addItem(QListWidgetItem(f"PR #{pr.number}: {pr.title}")) def _on_load(self): row = self.source_list.currentRow() reattest = False try: if row <= 0: # source = main: diff against the last catalog a maintainer # actually SIGNED (the bytes covered by the current # data/catalog.json.sig), never against itself. Diffing # main-vs-main is what made an empty diff -> instantly # "signable" in the first place (issue #68 finding 1) -- # this is the only path that reaches sign_catalog_bytes(), # so if it can't be trusted nothing can. loaded_ref = "main" commit = fetch_ref(self.repo_dir, loaded_ref) new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit) new_catalog = core.load_catalog(new_raw) # KEY-ROTATION RE-ATTESTATION (issue #68 finding 5 # follow-up): if the committed .sig does not verify under # the CURRENTLY-trusted bcc_core.CATALOG_PUBKEYS, the # trusted key changed since this catalog was last signed. # The new key has never vouched for ANY of this catalog's # content, so there is nothing meaningful to diff against # -- every entry needs a fresh acknowledgement under the # new key. Do NOT fall through to last_signed_catalog_raw() # in this case: it walks history looking for a match under # the (now-untrusted) old key's signature, which is exactly # backwards after a rotation. if not catalog_signature_valid_at(self.repo_dir, commit, new_raw): reattest = True old_catalog = {"schema": 1, "version": 0, "servers": []} else: old_raw = last_signed_catalog_raw(self.repo_dir, commit) old_catalog = ( core.load_catalog(old_raw) if old_raw is not None else { "schema": 1, "version": 0, "servers": [], } ) else: pr = self._prs[row - 1] loaded_ref = pr.head_ref commit = fetch_ref(self.repo_dir, loaded_ref) old_commit = fetch_ref(self.repo_dir, "main") new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit) new_catalog = core.load_catalog(new_raw) old_raw, _old_sha = read_catalog_at_commit(self.repo_dir, old_commit) old_catalog = core.load_catalog(old_raw) except (GitError, ValueError) as e: QMessageBox.critical(self, "Load failed", html.escape(str(e))) return self._new_raw = new_raw # `loaded_ref` is pinned into the session (not just this method's # local variable) so _on_sign can re-resolve the TOCTOU blob SHA # from the SAME ref that was reviewed, instead of a hardcoded # "main" -- see review.sign_precondition and issue #68 finding 1. self.session = review.start_review( new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref, reattest=reattest ) if reattest: self.reattest_banner.setText( "KEY ROTATION IN PROGRESS -- the trusted catalog signing key changed. " "The existing data/catalog.json.sig does NOT verify under the current " "bcc_core.CATALOG_PUBKEYS, so it is NOT trusted. Every one of the " f"{len(self.session.changes)} entries below must be re-reviewed and " "acknowledged before the new key can re-sign this catalog -- this is " "the intended cost of rotating the key, not a bug." ) self.reattest_banner.setVisible(True) else: self.reattest_banner.setVisible(False) self.reattest_banner.setText("") self._render_cards() def _render_cards(self): while self.card_layout.count(): item = self.card_layout.takeAt(0) if item.widget(): item.widget().deleteLater() self.cards.clear() assert self.session is not None all_ids = sorted( {e.get("id") for e in (self.session.new_catalog.get("servers") or []) if e.get("id")} ) for change in self.session.changes: card = EntryCard(change, all_ids) card.acknowledged_changed.connect(self._on_acknowledge_changed) self.cards[change.entry_id] = card self.card_layout.addWidget(card) self.card_layout.addStretch(1) self._update_status() def _on_acknowledge_changed(self, entry_id: str, checked: bool): assert self.session is not None if checked: review.acknowledge_entry(self.session, entry_id) else: review.unacknowledge_entry(self.session, entry_id) self._update_status() def _update_status(self): assert self.session is not None all_ack = review.all_entries_acknowledged(self.session) self.sign_btn.setEnabled(all_ack) pending = len(self.session.changes) - len(self.session.acknowledged) if self.session.reattest: self.status_label.setText( f"RE-ATTESTATION under the new key: {len(self.session.changes)} entries, " f"{pending} not yet acknowledged." ) else: self.status_label.setText( f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged." ) def _on_sign(self): assert self.session is not None def _resolve_blob_sha(ref: str) -> str: # Re-fetch fresh, immediately before signing, from the SAME ref # that was reviewed (session.loaded_ref) -- NEVER hardcode # "main" here. Hardcoding "main" is the bug that made the PR # review path unable to sign at all: _on_load() pins the PR # head's blob SHA, so comparing against main's SHA differs by # definition for any PR that actually changes the catalog, and # this refused every PR review permanently (see issue #68 # finding 1 and review.sign_precondition's docstring). return blob_sha_at(self.repo_dir, fetch_ref(self.repo_dir, ref), CATALOG_PATH) try: decision = review.sign_precondition(self.session, _resolve_blob_sha) except GitError as e: QMessageBox.critical(self, "Sign failed", html.escape(str(e))) return if not decision.ok: QMessageBox.warning(self, "Cannot sign", html.escape(decision.reason or "")) # Only the TOCTOU blob-SHA-mismatch reason should trigger a # reload -- "mismatch" appears ONLY in that reason (deliberately # checked instead of the broader "changed", which also matches # "Not every CHANGED entry has been acknowledged yet" and would # wrongly force a reload -- and with it a fresh review.py # ReviewSession -- every time a reviewer pauses partway through # ticking checkboxes). if decision.reason and "mismatch" in decision.reason.lower(): # Force a genuine re-review against the new bytes -- this # must call _on_load() (which re-fetches and re-diffs), NOT # re-invoke the same stale resolver, or this becomes the # infinite loop described in issue #68 finding 1: re-pinning # the same wrong SHA forever instead of ever converging. self._on_load() return dialog = PassphraseDialog("Enter CATALOG signing key passphrase:", self) if dialog.exec() != QDialog.DialogCode.Accepted: return try: seed = unlock_signing_key(dialog.passphrase()) except (FileNotFoundError, ValueError) as e: QMessageBox.critical(self, "Sign failed", html.escape(str(e))) return signature = review.sign_catalog_bytes(self._new_raw, seed) try: new_commit = commit_and_push_signed_catalog(self.repo_dir, self._new_raw, signature) except GitError as e: QMessageBox.critical(self, "Commit/push failed", html.escape(str(e))) return QMessageBox.information(self, "Signed", f"Signed and pushed as commit {new_commit[:12]}.") self.sign_btn.setEnabled(False) # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def cmd_keygen(args: argparse.Namespace) -> int: # Two SEPARATE keypairs, never conflated (issue #68 finding 5): the # catalog key is the offline root of trust for what BCC executes and # must never touch CI; the release key is CI-resident and signs ONLY # the release SHA256SUMS manifest. Which one this run generates is # explicit via --release, and the printed instructions differ sharply # so it's obvious which key is safe to paste into a CI secret (release) # and which one never is (catalog). kind = "release" if getattr(args, "release", False) else "catalog" seed, pubkey = review.generate_keypair() passphrase = getpass.getpass(f"Choose a passphrase to encrypt the new {kind} signing key: ") confirm = getpass.getpass("Confirm passphrase: ") if passphrase != confirm: print("error: passphrases did not match", file=sys.stderr) return 1 if not passphrase: print("error: a non-empty passphrase is required", file=sys.stderr) return 1 blob = review.encrypt_private_key(seed, passphrase) where = store_encrypted_key(blob, kind=kind) pubkey_b64 = base64.b64encode(pubkey).decode("ascii") print(f"{kind.capitalize()} private key encrypted and stored in: {where}") print() if kind == "catalog": print( "This is the CATALOG key. It is the root of trust for every catalog " "entry BCC ships -- it must stay offline and Console-only. NEVER paste " "it, its seed, or `show-seed-b64` output into CI, an env var, or a repo " "secret. (If the key currently in bcc_core.CATALOG_PUBKEYS has ever been " "pasted into a CI secret, treat it as burned for catalog use -- generate " "a fresh one with this command and rotate.)" ) print() print( "PUBLIC key (base64, safe to commit/share) -- hand this to whoever " "maintains bcc_core.py so they can add it to CATALOG_PUBKEYS (this tool " "does not edit that file). There is no PRIVATE-key output for the catalog " "key: it is never meant to leave this machine, and this tool has no " "command that exports it (show-seed-b64 refuses without --release, and " "even then only exports the release key)." ) print(f" {pubkey_b64}") else: print( "This is the RELEASE key. It signs ONLY the release SHA256SUMS " "manifest in CI -- it is intentionally CI-resident and is NOT trusted " "to sign the catalog (bcc_core.CATALOG_PUBKEYS does not and must not " "contain it)." ) print() print( "1. PUBLIC key (base64, safe to commit/share) -- paste into " "scripts/sign_checksums.py RELEASE_PUBKEYS:" ) print(f" {pubkey_b64}") print() print( "2. PRIVATE key (secret -- NEVER commit, NEVER paste into chat/email) -- " "add it as the Gitea repo secret RELEASE_SIGNING_KEY (base64 of the " "32-byte private seed). This command does not print it; fetch it " "separately, when you're ready to paste it straight into the Gitea " "secret field, with:" ) print( " python catalog_console.py show-seed-b64 --release " "# prints the PRIVATE key -- read the warning banner it shows" ) return 0 def cmd_show_seed_b64(args: argparse.Namespace) -> int: # Deliberately requires --release: this command's whole purpose is to # produce a value that gets pasted into a CI secret, and the catalog key # must NEVER be pasted into CI (issue #68 finding 5 -- that is exactly # how the catalog key ended up burned in the first place). Refusing to # run without --release makes "export the catalog seed for CI" a # structurally different, more deliberate action than a typo away. if not getattr(args, "release", False): print( "error: show-seed-b64 only ever exports the RELEASE key -- it is the " "only key allowed to leave this machine, for the RELEASE_SIGNING_KEY CI " "secret. Re-run as `show-seed-b64 --release`. The catalog key must never " "be exported this way; see issue #68 finding 5.", file=sys.stderr, ) return 1 # The prior wording ("Release signing key passphrase:") was ambiguous # enough that a maintainer who ran this command, saw that prompt, and # then saw a base64 blob printed with zero surrounding context, pasted # the output into a chat believing it was the PUBLIC key. It is not -- # it is the raw private seed. Every string this command prints from here # down exists to make that mistake structurally harder to make again. passphrase = getpass.getpass( "Passphrase for the release signing key (the one YOU chose when generating it): " ) try: seed = unlock_signing_key(passphrase, kind="release") except (FileNotFoundError, ValueError) as e: print(f"error: {e}", file=sys.stderr) return 1 # Loud banner on STDERR, seed alone on STDOUT -- so `show-seed-b64 # --release | pbcopy` (or piping into the Gitea secret field) still # gets ONLY the seed, while anyone watching the terminal still sees the # warning. Never merge these into one stream. print("!!! PRIVATE KEY BELOW -- this is the RELEASE_SIGNING_KEY secret value.", file=sys.stderr) print( "!!! Paste it ONLY into the Gitea secret field. Never into chat, email, " "a file, or a commit.", file=sys.stderr, ) print( "!!! Anyone holding this value can forge release checksum signatures.", file=sys.stderr, ) print(base64.b64encode(seed).decode("ascii")) return 0 def cmd_gui(args: argparse.Namespace) -> int: repo_dir = Path(args.repo).resolve() if not (repo_dir / CATALOG_PATH).exists(): print( f"error: {repo_dir} doesn't look like a BCC checkout (no {CATALOG_PATH})", file=sys.stderr, ) return 1 app = QApplication(sys.argv) app.setApplicationName("BCC Catalog Console") win = ReviewWindow(repo_dir) win.resize(900, 700) win.show() return app.exec() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command") p_gui = sub.add_parser("gui", help="launch the review/sign GUI (default)") p_gui.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)") p_gui.set_defaults(func=cmd_gui) p_keygen = sub.add_parser( "keygen", help="generate a new Ed25519 signing keypair (catalog key by default)" ) p_keygen.add_argument( "--release", action="store_true", help=( "generate the RELEASE key (CI-resident, signs SHA256SUMS only) instead " "of the CATALOG key (offline, Console-only, signs data/catalog.json -- " "see issue #68 finding 5)" ), ) p_keygen.set_defaults(func=cmd_keygen) p_seed = sub.add_parser( "show-seed-b64", help="print a base64 private seed for a CI secret -- RELEASE key only", ) p_seed.add_argument( "--release", action="store_true", help="required: only the release key may ever be exported this way", ) p_seed.set_defaults(func=cmd_show_seed_b64) return parser _SUBCOMMANDS = ("gui", "keygen", "show-seed-b64", "-h", "--help") def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if argv is None else list(argv) # `python catalog_console.py` with no subcommand (or with GUI-only flags # like --repo) launches the GUI -- "gui" is the default action. if not argv or argv[0] not in _SUBCOMMANDS: argv = ["gui", *argv] parser = build_parser() args = parser.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())