""" 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, the current tip of `main`, or the branch this checkout is currently ON (or --ref names) -- the latter exists so a catalog-signing-key rotation, or any other catalog change landed on a branch, can be reviewed and SIGNED before that branch is ever merged, instead of merging a PR that leaves main red and fixing it up afterwards (issue #68). 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` or a branch, the diff is against the last catalog a maintainer actually SIGNED on that same ref (the bytes covered by the current data/catalog.json.sig there), 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 to the SAME ref that was reviewed (never a hardcoded "main") -- so that ref is never red between a catalog merge and its signature, whether that ref is `main` or a rotation branch. 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. Confused about which key is which, or what state either is in? Run `python catalog_console.py keys` -- it needs no passphrase and prints a plain-English status report for both keys: where each private half lives, whether it's present, its fingerprint, whether that fingerprint matches what's committed in bcc_core.py / ci.yml / scripts/sign_checksums.py, and whether data/catalog.json.sig currently verifies -- ending with exactly what to run next. 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 from typing import ClassVar 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) def _pubkey_cache_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}.pub" def store_public_key(pubkey: bytes, kind: str = "catalog") -> None: """Cache the PUBLIC half of a signing key, in plain base64, next to its encrypted private counterpart (OS keychain if available, else the file cache). Public keys are not secret -- this cache exists purely so `catalog_console.py keys` can report a fingerprint and compare it against what's committed in source WITHOUT ever decrypting (or asking for a passphrase to unlock) the private key. Called by cmd_keygen() right after a keypair is generated. """ keyring = _keyring_module() pub_b64 = base64.b64encode(pubkey).decode("ascii") if keyring is not None: try: keyring.set_password(_KEYRING_SERVICE, f"{_keyring_username(kind)}-pub", pub_b64) return except Exception: pass # fall through to the file-based cache KEY_STORAGE_DIR.mkdir(parents=True, exist_ok=True) _pubkey_cache_file(kind).write_text(pub_b64 + "\n", encoding="utf-8") def load_public_key(kind: str = "catalog") -> bytes | None: """The cached PUBLIC key of the given `kind`, or None if no key of that kind has been generated yet (or it predates public-key caching -- an older keygen run that never called store_public_key). Never touches the encrypted private blob and never asks for a passphrase.""" keyring = _keyring_module() if keyring is not None: try: pub_b64 = keyring.get_password(_KEYRING_SERVICE, f"{_keyring_username(kind)}-pub") if pub_b64: return base64.b64decode(pub_b64) except Exception: pass path = _pubkey_cache_file(kind) if not path.exists(): return None text = path.read_text(encoding="utf-8").strip() if not text: return None try: return base64.b64decode(text) except ValueError: return None def has_encrypted_key(kind: str = "catalog") -> bool: """Whether a private key of this `kind` has been generated on this machine -- checked WITHOUT decrypting or asking for a passphrase, so `catalog_console.py keys` can report existence unconditionally.""" keyring = _keyring_module() if keyring is not None: try: if keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind)): return True except Exception: pass return _key_storage_file(kind).exists() def describe_local_key_location(kind: str) -> str: """Human-readable description of where a `kind` key's PRIVATE half lives on this machine, for the `keys` report and the sign-flow prompt. Never the release key's CI location -- that's cmd_keys' job to append, since it's true regardless of whether a local copy also exists.""" keyring = _keyring_module() if keyring is not None: try: if keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind)): return "on this machine, in the OS keychain (encrypted, passphrase-protected)" except Exception: pass key_file = _key_storage_file(kind) if key_file.exists(): return f"on this machine, at {key_file} (encrypted, passphrase-protected)" return "not generated yet" # --------------------------------------------------------------------------- # # 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 current_branch(repo_dir: Path) -> str | None: """The branch currently checked out at `repo_dir`, or None if it can't be determined (detached HEAD, bare repo, mid-rebase, ...). This is what lets the Console offer "sign against the branch I already have checked out" as a source (issue #68 rotation-completability fix) without the maintainer having to type the branch name -- ReviewWindow defaults to it unless --ref names one explicitly. """ try: name = _git(repo_dir, "rev-parse", "--abbrev-ref", "HEAD").strip() except GitError: return None return None if name in ("", "HEAD") else name def compute_own_refs(explicit_ref: str | None, detected_branch: str | None) -> list[str]: """ "main" plus (if different) `explicit_ref` or `detected_branch` -- the exact list of refs ReviewWindow offers as "own" sources (loadable, signable, AND pushable directly, unlike a PR's read-only refs/pull//head). Pure: `detected_branch` is injected (normally current_branch(repo_dir)) so this ref-resolution seam -- the fix for issue #68's "rotation can't complete without a red main" -- is testable without git or Qt. "main" is always index 0 so a stale/absent list-widget selection still defaults sanely (see ReviewWindow._on_load's row-clamping, and cmd_gui/ReviewWindow.__init__ for how `explicit_ref` is threaded from --ref). """ refs = ["main"] branch = explicit_ref or detected_branch if branch and branch not in refs: refs.append(branch) return refs 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. `branch` MUST be the same ref that was actually reviewed (ReviewWindow._on_sign passes `session.loaded_ref`, never a hardcoded "main" -- issue #68 completability fix): a catalog change reviewed on a branch has to be signed and pushed to THAT branch so the branch itself is never red, rather than landing the signature on main after a merge. This is deliberate: if signing happened in a commit AFTER the catalog merge, `branch` would be red (payload present, signature missing) between the catalog merge and its signing commit. Routine red branches train exactly the alarm fatigue this whole design exists to prevent. Emitting one commit with both files means `branch` is never in that state -- whether `branch` is main or a rotation-in-progress branch. """ _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 # --------------------------------------------------------------------------- # # Key status ("keys" command, issue #62/#68 follow-up: "make key handling # comprehensible"). File I/O only -- the actual status/report logic is pure # and lives in catalog_review (key_status / render_key_status_report), so # it's unit-testable without touching disk. These wrappers read the THREE # places a signing key's public half is expected to be committed, from # `repo_dir`'s working tree, so `keys` reports on whatever ref/branch is # actually checked out there (never this process's own sys.path import). # --------------------------------------------------------------------------- # def read_catalog_pubkeys_from_source(repo_dir: Path) -> list[bytes]: path = repo_dir / "bcc_core.py" if not path.exists(): return [] return review.extract_pubkey_list_literal(path.read_text(encoding="utf-8"), "CATALOG_PUBKEYS") def read_release_pubkeys_from_source(repo_dir: Path) -> list[bytes]: path = repo_dir / "scripts" / "sign_checksums.py" if not path.exists(): return [] return review.extract_pubkey_list_literal(path.read_text(encoding="utf-8"), "RELEASE_PUBKEYS") def read_ci_trust_anchor_pubkeys(repo_dir: Path) -> list[bytes]: path = repo_dir / ".github" / "workflows" / "ci.yml" if not path.exists(): return [] pubkey = review.extract_ci_trust_anchor_pubkey(path.read_text(encoding="utf-8")) return [pubkey] if pubkey is not None else [] def catalog_sig_status_on_disk(repo_dir: Path, committed_catalog_pubkeys: list[bytes]) -> str: """ "valid" / "invalid" / "missing" for data/catalog.json.sig as it sits in `repo_dir`'s WORKING TREE right now (not a git ref -- the `keys` command deliberately reports on-disk state, which is what "the current checked-out branch" concretely means and avoids a network fetch just to print a status line). Verified against `committed_catalog_pubkeys` (whatever bcc_core.py on this same working tree currently says), not the running process's own bcc_core import, so this stays correct no matter which ref/branch happens to be checked out. """ catalog_path = repo_dir / CATALOG_PATH sig_path = repo_dir / SIG_PATH if not catalog_path.exists() or not sig_path.exists(): return "missing" raw = catalog_path.read_bytes() sig = sig_path.read_bytes() if core.verify_catalog_signature(raw, sig, committed_catalog_pubkeys): return "valid" return "invalid" # --------------------------------------------------------------------------- # # 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. # --------------------------------------------------------------------------- # # PySide6 is imported defensively: `keygen`, `show-seed-b64`, and `keys` # (the commands a maintainer runs most often, and the ones this file's # tests/py_compile-only CI environment can exercise) have no GUI dependency # at all and must keep working even somewhere PySide6 isn't installed or # won't import (e.g. no system Qt libs). Only `gui` needs it -- cmd_gui() # checks _PYSIDE6_AVAILABLE and fails with a clear message instead of an # ImportError stack trace if it's missing. try: from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( QApplication, QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, ) except ImportError as _pyside6_exc: # pragma: no cover - only hit where PySide6 is absent _PYSIDE6_IMPORT_ERROR: str | None = str(_pyside6_exc) else: _PYSIDE6_IMPORT_ERROR = None _PYSIDE6_AVAILABLE = _PYSIDE6_IMPORT_ERROR is None if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where PySide6 is absent 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): """Prompts for a signing key's passphrase. `prompt` must say plainly WHICH key (CATALOG or RELEASE) is about to be unlocked, and its fingerprint when known -- issue #62/#68 follow-up: a maintainer must see which key he's about to type a passphrase for BEFORE typing it, not infer it from context. This is the exact ambiguity that led to a private key being pasted into a chat window. """ def __init__(self, prompt: str, window_title: str = "Signing key passphrase", parent=None): super().__init__(parent) self.setWindowTitle(window_title) layout = QFormLayout(self) layout.addRow(plain_label(prompt)) self.edit = QLineEdit() self.edit.setEchoMode(QLineEdit.EchoMode.Password) layout.addRow("Passphrase:", 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): #: Placeholder empty catalog used when there is nothing to diff against #: yet (no prior signature, or a key rotation invalidated the old one). _EMPTY_CATALOG: ClassVar[dict] = {"schema": 1, "version": 0, "servers": []} def __init__(self, repo_dir: Path, ref: str | None = None): super().__init__() self.repo_dir = repo_dir self.session: review.ReviewSession | None = None self.cards: dict[str, EntryCard] = {} # "Own" refs this clone can load/diff/sign+push against directly -- # issue #68 rotation-completability fix. "main" is always offered; # if the checkout is on a different branch (or --ref names one # explicitly), that branch is offered too, so a rotation in # progress on a branch can be reviewed, signed, and pushed to ITS # OWN ref -- completing the rotation before merge, never forcing a # red main in between. See _load_own_ref() / _on_sign(). self._own_refs = self._compute_own_refs(ref) self.setWindowTitle("BCC Catalog Console -- maintainer-only, never shipped") central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) top = QHBoxLayout() self.source_list = QListWidget() for own_ref in self._own_refs: label = ( f"{own_ref} (current tip)" if own_ref == "main" else f"{own_ref} (current branch)" ) self.source_list.addItem(QListWidgetItem(label)) 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 _compute_own_refs(self, explicit_ref: str | None) -> list[str]: """Thin GUI-side wrapper: injects current_branch(self.repo_dir) (a git call) into the pure compute_own_refs() -- see that function's docstring for what this list actually means.""" return compute_own_refs(explicit_ref, current_branch(self.repo_dir)) def _refresh_pr_list(self): self._prs = list_open_catalog_prs(self._token) while self.source_list.count() > len(self._own_refs): self.source_list.takeItem(len(self._own_refs)) for pr in self._prs: self.source_list.addItem(QListWidgetItem(f"PR #{pr.number}: {pr.title}")) def _load_own_ref(self, loaded_ref: str): """Load + diff `loaded_ref` -- "main" or the maintainer's own working branch, ANY ref this clone's origin can fetch directly (as opposed to a PR's read-only refs/pull//head). Diffs against the last catalog a maintainer actually SIGNED on that ref (never against itself -- an empty diff must mean "nothing to sign", never "sign unlocked", issue #68 finding 1) and enters KEY-ROTATION RE-ATTESTATION mode if the committed .sig doesn't verify under the currently-trusted bcc_core.CATALOG_PUBKEYS (issue #68 finding 5 follow-up). This is the exact logic "main" always used -- pulled into its own method, parameterized on the ref, so a rotation on a branch gets the SAME treatment as one on main and can be signed (and pushed back to that SAME branch, see _on_sign) before merge -- the completability fix this method exists for. Returns (new_raw, new_blob_sha, new_catalog, old_catalog, reattest). """ 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) if not catalog_signature_valid_at(self.repo_dir, commit, new_raw): reattest = True old_catalog = dict(self._EMPTY_CATALOG) else: reattest = False old_raw = last_signed_catalog_raw(self.repo_dir, commit) old_catalog = ( core.load_catalog(old_raw) if old_raw is not None else dict(self._EMPTY_CATALOG) ) return new_raw, new_blob_sha, new_catalog, old_catalog, reattest def _on_load(self): row = self.source_list.currentRow() if row < 0: row = 0 # nothing explicitly selected -- default to index 0 ("main") try: if row < len(self._own_refs): loaded_ref = self._own_refs[row] new_raw, new_blob_sha, new_catalog, old_catalog, reattest = self._load_own_ref( loaded_ref ) else: pr = self._prs[row - len(self._own_refs)] loaded_ref = pr.head_ref reattest = False 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 CATALOG key can re-sign this catalog -- " "this is the intended cost of rotating the CATALOG 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 # Show WHICH key is about to be used, and its fingerprint, BEFORE # the passphrase field even appears -- issue #62/#68 follow-up. A # maintainer must never have to infer which key a bare "Enter # passphrase" prompt means; that ambiguity is exactly what led to a # private key being pasted into a chat window. catalog_pubkey = load_public_key("catalog") if catalog_pubkey is not None: fp_note = f"fingerprint {review.fingerprint_pubkey(catalog_pubkey)}" else: fp_note = "fingerprint unknown (generated before fingerprint caching -- re-run keygen to cache it)" prompt = ( f"About to sign with the CATALOG signing key ({fp_note}).\n" "This is the root of trust for what BCC executes on a user's machine -- " "it is never the RELEASE key.\n\n" "Enter the CATALOG key's passphrase:" ) dialog = PassphraseDialog(prompt, "CATALOG signing key passphrase", self) if dialog.exec() != QDialog.DialogCode.Accepted: return try: seed = unlock_signing_key(dialog.passphrase(), kind="catalog") 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: # Push to the SAME ref that was reviewed (session.loaded_ref), # never a hardcoded "main" -- issue #68 completability fix. A # rotation (or any other catalog change) reviewed on a branch # must land on THAT branch so it can be signed and pushed # before the branch is ever merged, instead of forcing a merge # of a red PR followed by a fix-up on main. new_commit = commit_and_push_signed_catalog( self.repo_dir, self._new_raw, signature, branch=self.session.loaded_ref ) except GitError as e: QMessageBox.critical(self, "Commit/push failed", html.escape(str(e))) return QMessageBox.information( self, "Signed", f"Signed with the CATALOG key and pushed to {self.session.loaded_ref} " f"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) store_public_key(pubkey, kind=kind) # non-secret; lets `keys` fingerprint without a passphrase pubkey_b64 = base64.b64encode(pubkey).decode("ascii") print(f"{kind.upper()} 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: if not _PYSIDE6_AVAILABLE: print( "error: PySide6 is not available in this Python environment, so the GUI " f"can't launch ({_PYSIDE6_IMPORT_ERROR}). `keygen`, `show-seed-b64`, and " "`keys` don't need it and still work here.", file=sys.stderr, ) return 1 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, ref=args.ref) win.resize(900, 700) win.show() return app.exec() def cmd_keys(args: argparse.Namespace) -> int: """`python catalog_console.py keys` -- "which key is what, and what state is everything in?" (issue #62/#68 follow-up). Reads the CURRENT WORKING TREE at --repo (so it reports on whatever branch/ref is actually checked out there -- issue #68 rotation-completability fix means that's often not "main" during a rotation), gathers every input the pure catalog_review.key_status()/render_key_status_report() need, and prints the result. Never touches, decrypts, or prints a private key -- everything gathered here is a cached PUBLIC key, a fingerprint, file text, or a signature verification result. """ repo_dir = Path(args.repo).resolve() branch = current_branch(repo_dir) or "(unknown -- detached HEAD or not a git checkout)" print(f"Catalog Console -- key status for {repo_dir}") print(f"Checked-out ref: {branch}") print() committed_catalog_pubkeys = read_catalog_pubkeys_from_source(repo_dir) ci_trust_anchor_pubkeys = read_ci_trust_anchor_pubkeys(repo_dir) committed_release_pubkeys = read_release_pubkeys_from_source(repo_dir) catalog_status = review.key_status( "catalog", display_name="CATALOG", purpose=( "Signs the server list that BCC writes into your Claude config. This is " "what decides which programs run on a user's machine -- the root of trust." ), private_key_location=describe_local_key_location("catalog"), local_exists=has_encrypted_key("catalog"), local_pubkey=load_public_key("catalog"), locations=[ ("bcc_core.CATALOG_PUBKEYS", committed_catalog_pubkeys), ("ci.yml trust anchor (EXPECTED_CATALOG_PUBKEY_B64)", ci_trust_anchor_pubkeys), ], catalog_sig_status=catalog_sig_status_on_disk(repo_dir, committed_catalog_pubkeys), ) release_where = describe_local_key_location("release") if release_where == "not generated yet": release_where = ( "not generated yet, and not verifiable from here as present in CI " "(check the Gitea repo secret RELEASE_SIGNING_KEY directly)" ) else: release_where = ( f"{release_where} -- intended to be pasted into the Gitea secret " "RELEASE_SIGNING_KEY (via `show-seed-b64 --release`) and not kept as the " "primary copy once that's done" ) release_status = review.key_status( "release", display_name="RELEASE", purpose=( "Signs the SHA256SUMS checksum manifest for release downloads only. " "CI-resident on purpose: a CI compromise burns this key, never the " "catalog key -- that asymmetry is the whole point of having two keys." ), private_key_location=release_where, local_exists=has_encrypted_key("release"), local_pubkey=load_public_key("release"), locations=[("scripts/sign_checksums.py RELEASE_PUBKEYS", committed_release_pubkeys)], catalog_sig_status=None, ) print(review.render_key_status_report([catalog_status, release_status])) return 0 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.add_argument( "--ref", default=None, help=( "branch to offer as an additional load/sign/push source, alongside " "'main' and any open PRs -- e.g. a key-rotation branch, so rotation " "can be reviewed and signed BEFORE merge instead of forcing a red main " "(issue #68). Defaults to whatever branch --repo is currently checked " "out on; only needed if that's not the branch you mean." ), ) 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) p_keys = sub.add_parser( "keys", help=( "print a plain-English status report: which key is what, where its " "private half lives, whether it matches what's committed, and whether " "data/catalog.json.sig currently verifies" ), ) p_keys.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)") p_keys.set_defaults(func=cmd_keys) return parser _SUBCOMMANDS = ("gui", "keygen", "show-seed-b64", "keys", "-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())