chore(#68): rotate signing keys, key-rotation re-attestation mode, harden show-seed-b64
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 35s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Failing after 6s

Wires the maintainer's freshly-rotated signing keys into BCC (issue #68
finding 5: the old key was shared between catalog+release and had been
exposed to CI), adds a key-rotation re-attestation mode to the Catalog
Console so the Console can actually re-sign under the new key, and
hardens the show-seed-b64 CLI prompt that led to a private key being
pasted into a chat.

## New keys (#68 finding 5)

- bcc_core.CATALOG_PUBKEYS -> new CATALOG pubkey
  (0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=). Signs data/catalog.json,
  Console-only, offline.
- .github/workflows/ci.yml EXPECTED_CATALOG_PUBKEY_B64 -> same new
  catalog pubkey (the CI trust anchor added for #68 finding 4).
- scripts/sign_checksums.py RELEASE_PUBKEYS -> new RELEASE pubkey
  (6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA=). Signs SHA256SUMS only,
  CI-resident.
- README.md 'Verifying your download' / 'Signing keys' sections filled in
  with both pubkeys, explicit about which key is which.

The old key 082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4= is retired (it
was shared and exposed to CI) and is deliberately NOT retained in either
trust list -- keeping a burned key in CATALOG_PUBKEYS would defeat the
point of rotating it.

## Key-rotation re-attestation mode (Catalog Console)

Problem: after the key swap, the existing data/catalog.json.sig (signed
with the OLD key) no longer verifies under the NEW CATALOG_PUBKEYS, but
catalog content is unchanged, so diff_catalogs(last_signed, current) is
empty -- and can_sign()'s empty-diff guard (load-bearing, #68 finding 1)
correctly refuses to sign an empty changeset. Without a rotation-aware
path, the Console could never re-sign and CI would stay red forever.

Fix: treat rotation as a full re-attestation, not a diff.

- catalog_review.py: ReviewSession/start_review gain reattest: bool =
  False. When set, changes is built via diff_catalogs(None, new_catalog)
  -- every entry presented as if newly added, requiring a fresh
  acknowledgement -- instead of diffing against old_catalog. can_sign()
  is UNCHANGED: it still refuses a genuinely-empty changeset and still
  enforces the TOCTOU blob-SHA pin and the blocking-risk check, because
  reattest sessions simply never produce an empty changeset (unless the
  catalog itself is empty).
- catalog_console.py: adds catalog_signature_valid_at(repo_dir, commit,
  raw), which calls bcc_core.verify_catalog_signature directly (never
  reimplemented) to detect whether the committed .sig verifies under the
  CURRENT CATALOG_PUBKEYS. ReviewWindow._on_load's source=main path uses
  this to decide reattest=True/False, and shows a loud, explicit red
  banner ("KEY ROTATION IN PROGRESS...") whenever reattest mode is
  entered -- never silent. Status text and Sign-button gating flow
  through the same can_sign()/all_entries_acknowledged() path as normal
  review.
- tests/test_catalog_review.py: 6 new tests covering re-attest mode
  (one change-entry per server, gating until all acknowledged, then
  permits), confirming normal mode still refuses an empty diff (rotation
  path is not a general bypass), and confirming reattest mode still
  enforces the blocking-risk check and the TOCTOU pin.

## show-seed-b64 hardening (Task 3)

The maintainer ran show-seed-b64 --release, saw an ambiguous prompt,
and pasted the printed PRIVATE seed into a chat believing it was public.

- cmd_show_seed_b64: passphrase prompt is now explicit ("Passphrase for
  the release signing key (the one YOU chose when generating it)"). A
  loud three-line warning banner prints to STDERR immediately before the
  seed ("!!! PRIVATE KEY BELOW..."); the seed itself stays alone on
  STDOUT so piping into pbcopy or a CI secret field still works cleanly.
- cmd_keygen: labels for both key kinds now say PUBLIC/PRIVATE explicitly
  and state safety properties inline (safe to commit vs. never commit),
  so the printed output can't be mistaken for the other key's.

## Verification

- ruff check . / ruff format --check .: clean.
- pytest: full suite green (360 passed, 1 skipped).
- Delete-the-check-and-watch-it-fail: each of can_sign()'s four gates
  (empty-diff, TOCTOU pin, blocking-risk, acknowledge-all) and the
  reattest branch itself were individually removed and confirmed to turn
  a test red, then restored -- see PR description for the exact
  failures.

Not touched: data/catalog.json (content-signed, maintainer's job via the
Console). No private key generated, requested, or committed. bcc.py
untouched (open PR #67).
This commit is contained in:
BCC Agent
2026-07-13 12:22:22 -04:00
parent cd2ac2f6f8
commit 4836c6cb48
7 changed files with 292 additions and 32 deletions
+133 -21
View File
@@ -283,6 +283,30 @@ def last_signed_catalog_raw(repo_dir: Path, commit: str) -> bytes | None:
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:
@@ -709,6 +733,22 @@ class ReviewWindow(QMainWindow):
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()
@@ -737,6 +777,7 @@ class ReviewWindow(QMainWindow):
def _on_load(self):
row = self.source_list.currentRow()
reattest = False
try:
if row <= 0:
# source = main: diff against the last catalog a maintainer
@@ -750,16 +791,32 @@ class ReviewWindow(QMainWindow):
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)
old_raw = last_signed_catalog_raw(self.repo_dir, commit)
old_catalog = (
core.load_catalog(old_raw)
if old_raw is not None
else {
"schema": 1,
"version": 0,
"servers": [],
}
)
# KEY-ROTATION RE-ATTESTATION (issue #68 finding 5
# follow-up): if the committed .sig does not verify under
# the CURRENTLY-trusted bcc_core.CATALOG_PUBKEYS, the
# trusted key changed since this catalog was last signed.
# The new key has never vouched for ANY of this catalog's
# content, so there is nothing meaningful to diff against
# -- every entry needs a fresh acknowledgement under the
# new key. Do NOT fall through to last_signed_catalog_raw()
# in this case: it walks history looking for a match under
# the (now-untrusted) old key's signature, which is exactly
# backwards after a rotation.
if not catalog_signature_valid_at(self.repo_dir, commit, new_raw):
reattest = True
old_catalog = {"schema": 1, "version": 0, "servers": []}
else:
old_raw = last_signed_catalog_raw(self.repo_dir, commit)
old_catalog = (
core.load_catalog(old_raw)
if old_raw is not None
else {
"schema": 1,
"version": 0,
"servers": [],
}
)
else:
pr = self._prs[row - 1]
loaded_ref = pr.head_ref
@@ -781,8 +838,21 @@ class ReviewWindow(QMainWindow):
# 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
new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref, reattest=reattest
)
if reattest:
self.reattest_banner.setText(
"KEY ROTATION IN PROGRESS -- the trusted catalog signing key changed. "
"The existing data/catalog.json.sig does NOT verify under the current "
"bcc_core.CATALOG_PUBKEYS, so it is NOT trusted. Every one of the "
f"{len(self.session.changes)} entries below must be re-reviewed and "
"acknowledged before the new key can re-sign this catalog -- this is "
"the intended cost of rotating the key, not a bug."
)
self.reattest_banner.setVisible(True)
else:
self.reattest_banner.setVisible(False)
self.reattest_banner.setText("")
self._render_cards()
def _render_cards(self):
@@ -817,9 +887,15 @@ class ReviewWindow(QMainWindow):
all_ack = review.all_entries_acknowledged(self.session)
self.sign_btn.setEnabled(all_ack)
pending = len(self.session.changes) - len(self.session.acknowledged)
self.status_label.setText(
f"{len(self.session.changes)} changed entries, {pending} not yet 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
@@ -920,8 +996,12 @@ def cmd_keygen(args: argparse.Namespace) -> int:
)
print()
print(
"Public key (base64) -- hand this to whoever maintains bcc_core.py so "
"they can add it to CATALOG_PUBKEYS (this tool does not edit that file):"
"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:
@@ -932,14 +1012,23 @@ def cmd_keygen(args: argparse.Namespace) -> int:
"contain it)."
)
print()
print("1. Public key (base64) -- paste into scripts/sign_checksums.py RELEASE_PUBKEYS:")
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 -- add it as the Gitea repo secret RELEASE_SIGNING_KEY "
"(base64 of the 32-byte private seed). Get that value with:"
"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"
)
print(" python catalog_console.py show-seed-b64 --release # prints the raw key")
return 0
@@ -959,12 +1048,35 @@ def cmd_show_seed_b64(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 1
passphrase = getpass.getpass("Release signing key passphrase: ")
# 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