feat(catalog-console): a keys status command, and complete rotation without a red main (#62, #68)
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.
This commit is contained in:
BCC Agent
2026-07-13 13:10:36 -04:00
parent 4836c6cb48
commit aa40f8e139
5 changed files with 1513 additions and 422 deletions
+242
View File
@@ -16,6 +16,8 @@ new surface" recurring-bug lesson).
from __future__ import annotations
import base64
import hashlib
import os
import re
from collections.abc import Callable
@@ -851,3 +853,243 @@ _NON_ASCII_RE = re.compile(r"[^\x00-\x7f]")
def contains_non_ascii(s: str) -> bool:
return bool(_NON_ASCII_RE.search(s))
# --------------------------------------------------------------------------- #
# Key status reporting (issue #62/#68 follow-up: "make key handling
# comprehensible"). Pure functions only -- `catalog_console.py cmd_keys` is a
# thin printer that gathers inputs (local key caches, source-file text, the
# catalog + its .sig) and hands them here. NEVER touches private key bytes:
# every input/output here is a public key, a fingerprint, or a status string.
# --------------------------------------------------------------------------- #
def fingerprint_pubkey(pubkey: bytes) -> str:
"""Short, human-comparable fingerprint of a raw Ed25519 public key: the
first 16 hex chars of its SHA-256 digest, grouped in 4s (e.g. "3F2A 9C1B
44DE 08AA") so two fingerprints can be eyeballed for a mismatch the way a
PGP fingerprint is. Deliberately NOT the raw base64 pubkey itself in the
default short form (that's available via the full committed value in the
report) -- a fixed-width grouped hex string is easier to compare at a
glance and to read aloud/type over chat if needed. Never derived from,
and never printed alongside, any private key material.
"""
digest = hashlib.sha256(pubkey).hexdigest().upper()[:16]
return " ".join(digest[i : i + 4] for i in range(0, len(digest), 4))
_PUBKEY_LIST_B64_RE = re.compile(r'base64\.b64decode\(\s*"([^"]+)"\s*\)')
def extract_pubkey_list_literal(source_text: str, var_name: str) -> list[bytes]:
"""Best-effort extraction of a `<var_name>: list[bytes] = [...]` literal
(each entry a `base64.b64decode("...")` call, matching the exact style
bcc_core.CATALOG_PUBKEYS and scripts.sign_checksums.RELEASE_PUBKEYS are
both written in) straight out of Python source TEXT.
Deliberately a regex over text, not an import: `catalog_console.py keys`
must report on whatever ref/branch is checked out at the inspected repo
path, which may not be (and need not be) importable from the running
process's own sys.path. Returns [] if the variable isn't found in this
exact shape -- callers treat that as "nothing committed here", not an
error, since a report that can't parse a file should say so plainly
rather than crash the whole `keys` command over one malformed file.
"""
match = re.search(
rf"{re.escape(var_name)}\s*:\s*list\[bytes\]\s*=\s*\[(.*?)\]", source_text, re.DOTALL
)
if not match:
return []
keys: list[bytes] = []
for b64 in _PUBKEY_LIST_B64_RE.findall(match.group(1)):
try:
keys.append(base64.b64decode(b64))
except ValueError:
continue
return keys
_CI_TRUST_ANCHOR_RE = re.compile(r'EXPECTED_CATALOG_PUBKEY_B64:\s*"([^"]+)"')
def extract_ci_trust_anchor_pubkey(ci_yml_text: str) -> bytes | None:
"""Best-effort extraction of ci.yml's `EXPECTED_CATALOG_PUBKEY_B64` trust
anchor (issue #68 finding 4) from the workflow file's TEXT. Returns None
if the constant isn't found -- the `keys` report shows that plainly
("not found in ci.yml") rather than raising.
"""
match = _CI_TRUST_ANCHOR_RE.search(ci_yml_text)
if not match:
return None
try:
return base64.b64decode(match.group(1))
except ValueError:
return None
@dataclass(frozen=True)
class PubkeyLocationCheck:
"""One place in the source tree a key's public half is expected to be
committed, and whether the fingerprint(s) found there match the key
stored locally."""
location: str
committed_fingerprints: tuple[str, ...]
status: str # "match" | "mismatch" | "unknown" (no local key to compare against)
@dataclass(frozen=True)
class KeyStatus:
"""Everything `catalog_console.py keys` reports about ONE signing key.
Built by key_status() below; rendered by render_key_status_report().
Never carries private key material -- every field here is safe to print.
"""
kind: str # "catalog" | "release"
display_name: str # "CATALOG" | "RELEASE"
purpose: str # one-line plain-English purpose
private_key_location: str # human-readable, e.g. "on this machine, in the OS keychain"
local_exists: bool
local_fingerprint: str | None
locations: tuple[PubkeyLocationCheck, ...]
catalog_sig_status: str | None = None # "valid" | "invalid" | "missing" | None (n/a)
def key_status(
kind: str,
*,
display_name: str,
purpose: str,
private_key_location: str,
local_exists: bool,
local_pubkey: bytes | None,
locations: list[tuple[str, list[bytes]]],
catalog_sig_status: str | None = None,
) -> KeyStatus:
"""Pure assembly of a KeyStatus from already-resolved inputs (no file or
git I/O here -- that's catalog_console.py's job). `locations` is a list
of (label, committed_pubkeys) pairs, e.g.
[("bcc_core.CATALOG_PUBKEYS", [...]), ("ci.yml trust anchor", [...])],
so a key can be checked against every place its public half is expected
to be committed, independently -- this is the check that would have
caught bcc_core.CATALOG_PUBKEYS and ci.yml's trust anchor silently
drifting apart (issue #68 finding 4 was exactly that kind of drift).
"""
checks: list[PubkeyLocationCheck] = []
for label, committed_pubkeys in locations:
fps = tuple(fingerprint_pubkey(pk) for pk in committed_pubkeys)
if local_pubkey is None:
status = "unknown"
elif local_pubkey in committed_pubkeys:
status = "match"
else:
status = "mismatch"
checks.append(
PubkeyLocationCheck(location=label, committed_fingerprints=fps, status=status)
)
return KeyStatus(
kind=kind,
display_name=display_name,
purpose=purpose,
private_key_location=private_key_location,
local_exists=local_exists,
local_fingerprint=fingerprint_pubkey(local_pubkey) if local_pubkey is not None else None,
locations=tuple(checks),
catalog_sig_status=catalog_sig_status,
)
_LOCATION_STATUS_ICON = {"match": "", "mismatch": "", "unknown": "⚠️"}
_LOCATION_STATUS_VERDICT = {
"match": "MATCHES the local private key",
"mismatch": "DOES NOT MATCH the local private key",
"unknown": "cannot compare -- no local key to check against",
}
_CATALOG_SIG_STATUS_LINE = {
"valid": "✅ data/catalog.json.sig verifies under the committed CATALOG_PUBKEYS.",
"invalid": (
"❌ data/catalog.json.sig does NOT verify under the committed CATALOG_PUBKEYS -- "
"the catalog needs re-signing (Load → acknowledge all → Sign)."
),
"missing": (
"⚠️ data/catalog.json.sig is missing entirely -- the catalog has never been signed."
),
}
def recommend_next_steps(statuses: list[KeyStatus]) -> list[str]:
"""The pure "what to do next" logic behind the keys report's closing
section -- one concrete, runnable-looking instruction per problem found,
naming the exact key involved (never just "the key"). Returns a single
reassuring line if nothing needs attention."""
steps: list[str] = []
for s in statuses:
if not s.local_exists:
flag = " --release" if s.kind == "release" else ""
steps.append(
f"{s.display_name} key has never been generated on this machine -- run "
f"`python catalog_console.py keygen{flag}`."
)
continue
for loc in s.locations:
if loc.status == "mismatch":
steps.append(
f"{s.display_name} key's local fingerprint does not match "
f"{loc.location} -- update {loc.location} to the fingerprint shown "
"above (or, if this is unexpected, treat the committed key as "
"untrusted and investigate before doing anything else)."
)
elif loc.status == "unknown":
steps.append(
f"{s.display_name} key's local fingerprint could not be checked against "
f"{loc.location} -- re-run keygen (or, for an older install, unlock the "
"key once) so its public half is cached locally."
)
if s.kind == "catalog" and s.catalog_sig_status in ("invalid", "missing"):
steps.append(
"The catalog needs re-signing: run `python catalog_console.py gui --repo .` "
"and Load → acknowledge every entry → Sign. If main is red because "
"of a key rotation, load the branch with the rotation instead of main "
"(current-branch / --ref source) so the fix lands before merge."
)
if not steps:
steps.append("Everything is consistent -- no action needed.")
return steps
def render_key_status_report(statuses: list[KeyStatus]) -> str:
"""Render a full, plain-English-first key status report as one string.
`catalog_console.py cmd_keys` prints this verbatim -- the CLI is a thin
printer over this pure function, which is what makes the report's
content (not just its plumbing) unit-testable."""
lines: list[str] = []
for s in statuses:
lines.append(f"=== {s.display_name} KEY ===")
lines.append(s.purpose)
lines.append(f"Private half lives: {s.private_key_location}")
if s.local_exists and s.local_fingerprint:
lines.append(f"Exists locally: yes (fingerprint {s.local_fingerprint})")
elif s.local_exists:
lines.append("Exists locally: yes (fingerprint unknown -- re-run keygen to cache it)")
else:
lines.append("Exists locally: no")
for loc in s.locations:
icon = _LOCATION_STATUS_ICON.get(loc.status, "?")
fps = (
", ".join(loc.committed_fingerprints)
if loc.committed_fingerprints
else "(nothing committed here)"
)
verdict = _LOCATION_STATUS_VERDICT.get(loc.status, loc.status)
lines.append(f" {icon} {loc.location}: {fps} -- {verdict}")
if s.catalog_sig_status is not None:
lines.append(
f"Catalog signature: {_CATALOG_SIG_STATUS_LINE.get(s.catalog_sig_status, s.catalog_sig_status)}"
)
lines.append("")
lines.append("What to do next:")
for step in recommend_next_steps(statuses):
lines.append(f" - {step}")
return "\n".join(lines)