Files
better-claude-config/catalog_console.py
BCC Agent aa40f8e139
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 25s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Failing after 6s
feat(catalog-console): a keys status command, and complete rotation without a red main (#62, #68)
Two problems from issue #68's follow-up review:

1. The maintainer -- the only person who will ever use this tool -- cannot
   reliably tell which of the two signing keys is which or what state
   either is in. He already pasted a private key into a chat window
   because a prompt was ambiguous. That's a defect in this tool, not user
   error.

2. PR #71 rotates bcc_core.CATALOG_PUBKEYS, which makes
   data/catalog.json.sig (signed by the retired key) stop verifying and
   the CI catalog-signature job go red. The Console could previously only
   load/sign against `main`, so the only way through was to merge a red
   PR and fix main afterwards -- normalizing exactly the alarm fatigue
   this whole design exists to prevent.

Task 1 -- `python catalog_console.py keys`:
  A plain-English-first status report for BOTH keys: purpose, where the
  private half lives, whether it exists locally, its fingerprint, whether
  that fingerprint matches every place its public half is expected to be
  committed (bcc_core.CATALOG_PUBKEYS, ci.yml's trust anchor, and
  scripts/sign_checksums.RELEASE_PUBKEYS -- checked independently, since
  issue #68 finding 4 was exactly bcc_core.py and ci.yml silently
  drifting apart), and whether data/catalog.json.sig currently verifies --
  ending with the exact command to run next. Needs no passphrase and never
  touches private key bytes: a plaintext public-key cache
  (store_public_key/load_public_key) is written alongside the existing
  encrypted private blob at keygen time, precisely so this command can
  report a fingerprint without decrypting anything.

  The status/report logic (key_status, render_key_status_report,
  recommend_next_steps, fingerprint_pubkey, extract_pubkey_list_literal,
  extract_ci_trust_anchor_pubkey) is pure and lives in catalog_review.py;
  cmd_keys in catalog_console.py is a thin printer over it, per the
  project's existing pure-core/thin-GUI split.

Task 2 -- rotation completable without a red main:
  ReviewWindow now offers a "current branch" source (auto-detected via
  `current_branch()`, or --ref to name one explicitly) alongside "main"
  and open PRs. Loading it runs the exact same diff-against-last-signed /
  rotation-detection logic "main" always used (_load_own_ref, extracted
  from the old hardcoded-to-main _on_load), just parameterized on the
  ref. Signing now pushes to session.loaded_ref, never a hardcoded "main"
  (commit_and_push_signed_catalog's branch param was already there --
  only the call site was wrong). The ref-list computation itself is a
  pure function (compute_own_refs) so this seam is unit-testable without
  git or Qt. None of can_sign()'s guards (empty-diff, acknowledge-all,
  blocking-risk, TOCTOU) were touched.

  This lets a rotation branch be reviewed, re-attested (every entry,
  since the new key never vouched for any of them -- issue #68 finding 5
  follow-up), signed, and pushed to ITS OWN branch before it's ever
  merged.

Task 3 -- label the keys everywhere:
  PassphraseDialog now shows which key (CATALOG vs RELEASE) and its
  fingerprint before the passphrase field, both in its window title and
  its prompt text -- the exact ambiguity that led to a private key being
  pasted into a chat window. cmd_keygen's stored-key confirmation now
  reads "CATALOG private key encrypted..." / "RELEASE private key
  encrypted..." instead of a capitalized-lowercase kind. The reattest
  banner now says "CATALOG signing key" / "CATALOG key" throughout
  instead of "the key".

PySide6's import is now guarded (try/except -> _PYSIDE6_AVAILABLE) and
every GUI class definition that depends on it moved under
`if _PYSIDE6_AVAILABLE:`. `keygen`, `show-seed-b64`, and the new `keys`
command have no GUI dependency and now work (and are testable) in an
environment without PySide6 -- which is exactly this repo's own `test`
CI job (pytest + cryptography only, no PySide6). `gui` fails with a clear
message instead of an ImportError stack trace if it's missing.

Tests: 26 new pure-function tests in tests/test_catalog_review.py
(fingerprint_pubkey, extract_pubkey_list_literal,
extract_ci_trust_anchor_pubkey, key_status, recommend_next_steps,
render_key_status_report) and a new tests/test_catalog_console_git.py
(14 tests) covering compute_own_refs, current_branch,
commit_and_push_signed_catalog's branch targeting, and
catalog_sig_status_on_disk against real local git repos -- importing
catalog_console.py directly, proving it works without PySide6. 400
passed, 1 skipped (pre-existing). ruff check / ruff format --check clean.
2026-07-13 13:10:36 -04:00

1530 lines
68 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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/<n>/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/<n>/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://<token>@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 `<b>` or `<img onerror=...>` 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/<n>/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())