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
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.
1530 lines
68 KiB
Python
1530 lines
68 KiB
Python
"""
|
||
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())
|