Closes the remaining follow-up from #68 finding 5 (the catalog/release keys were split but the content of the split — actually rotating the exposed key — hadn't landed yet), plus the CLI-prompt ambiguity from #62's show-seed-b64 that led to a private key being pasted into a chat.
Task 1 — install the new keys
Both values below are public keys — safe to commit, nothing private is generated, requested, or handled by this PR.
README.md: "Verifying your download" / "Signing keys" sections filled in with both pubkeys, explicit about which is which.
The old catalog key 082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4= is retired — it was exposed to CI. CATALOG_PUBKEYS deliberately does not keep it: retaining a burned key in the trust list would defeat the point of rotating it.
Problem: post-rotation, data/catalog.json.sig (signed with the OLD key) fails to verify under the NEW CATALOG_PUBKEYS. 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 stays red forever.
Fix — rotation is 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() itself is unchanged: it still refuses a genuinely-empty changeset and still enforces the TOCTOU blob-SHA pin and the blocking-risk check. Reattest sessions simply never produce an empty changeset (unless the catalog itself is empty), so gate 0 passes for the right reason.
catalog_console.py: new catalog_signature_valid_at(repo_dir, commit, raw) calls bcc_core.verify_catalog_signature directly (not reimplemented) to detect whether the committed .sig verifies under the currentCATALOG_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 silently. Status text and Sign-button gating flow through the same can_sign() / all_entries_acknowledged() path as normal review; no separate code path to drift.
tests/test_catalog_review.py: 6 new tests — reattest mode yields one change-entry per server and gates on all being acknowledged before permitting; normal mode with unchanged content still refuses (rotation is not a general bypass of the empty-diff guard); reattest mode still enforces the blocking-risk check; reattest mode still enforces the TOCTOU pin; reattest defaults to False.
Task 3 — harden show-seed-b64
The maintainer ran show-seed-b64 --release, saw the ambiguous prompt "Release signing key passphrase:", 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 3-line banner prints to stderr immediately before the seed:
!!! PRIVATE KEY BELOW -- this is the RELEASE_SIGNING_KEY secret value.
!!! Paste it ONLY into the Gitea secret field. Never into chat, email, a file, or a commit.
!!! Anyone holding this value can forge release checksum signatures.
The seed itself stays alone on stdout, so | pbcopy / piping into a CI secret field still works cleanly.
cmd_keygen: labels for both key kinds now say PUBLIC/PRIVATE explicitly and state the safety property 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 individually removed then restored, confirmed red in between:
Removed the blocking-risk gate (gate 2) → test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledged and test_reattest_mode_still_enforces_blocking_risk_check failed.
Removed the TOCTOU blob-SHA pin (gate 1) → test_can_sign_refuses_on_blob_mismatch_even_if_acknowledged, test_sign_precondition_refuses_when_loaded_ref_blob_moved, and test_reattest_mode_still_enforces_toctou_pin failed.
Removed the acknowledge-all gate (gate 3) → test_no_acknowledge_all_shortcut_and_gate_is_real, test_can_sign_false_when_not_all_acknowledged, and test_reattest_mode_can_sign_refuses_until_all_acknowledged_then_permits failed.
Removed the reattest branch in ReviewSession.__post_init__ entirely → all 4 reattest-specific tests failed.
Maintainer follow-up: re-signing the catalog under the new key
Regenerate the catalog keypair (if not already done) — python catalog_console.py keygen. Confirm the printed public key matches 0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k= (this PR already assumes that key; if it doesn't match, bcc_core.CATALOG_PUBKEYS needs a follow-up correction).
Merge this PR to main.
python catalog_console.py gui --repo <checkout>, select main (current tip) as the source, click Load selected source. The Console will detect that data/catalog.json.sig no longer verifies under the new key and enter re-attestation mode (red banner, all 19 entries shown as needing acknowledgement).
Review and acknowledge all 19 entries, then click Sign (passphrase for the new catalog key). This commits and pushes both data/catalog.json and a freshly-produced data/catalog.json.sig in one commit.
Confirm the catalog-signature CI job goes green on the resulting commit.
Do NOT / not touched
data/catalog.json itself is untouched — re-signing is the maintainer's job via the Console, per step above.
No private key generated, requested, or committed anywhere in this diff.
can_sign()'s empty-diff, acknowledge, blocking-risk, and TOCTOU guards are unchanged in strictness — only a new, explicitly-flagged input mode was added upstream of them.
Closes the remaining follow-up from #68 finding 5 (the catalog/release keys were split but the *content* of the split — actually rotating the exposed key — hadn't landed yet), plus the CLI-prompt ambiguity from #62's `show-seed-b64` that led to a private key being pasted into a chat.
## Task 1 — install the new keys
Both values below are **public** keys — safe to commit, nothing private is generated, requested, or handled by this PR.
- `bcc_core.py`: `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 from #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 is which.
**The old catalog key `082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4=` is retired** — it was exposed to CI. `CATALOG_PUBKEYS` deliberately does **not** keep it: retaining a burned key in the trust list would defeat the point of rotating it.
## Task 2 — key-rotation re-attestation mode (Catalog Console)
**Problem:** post-rotation, `data/catalog.json.sig` (signed with the OLD key) fails to verify under the NEW `CATALOG_PUBKEYS`. 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 stays red forever.
**Fix — rotation is 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()` itself is unchanged**: it still refuses a genuinely-empty changeset and still enforces the TOCTOU blob-SHA pin and the blocking-risk check. Reattest sessions simply never produce an empty changeset (unless the catalog itself is empty), so gate 0 passes for the right reason.
- `catalog_console.py`: new `catalog_signature_valid_at(repo_dir, commit, raw)` calls `bcc_core.verify_catalog_signature` directly (not 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 silently**. Status text and Sign-button gating flow through the same `can_sign()` / `all_entries_acknowledged()` path as normal review; no separate code path to drift.
- `tests/test_catalog_review.py`: 6 new tests — reattest mode yields one change-entry per server and gates on all being acknowledged before permitting; normal mode with unchanged content still refuses (rotation is not a general bypass of the empty-diff guard); reattest mode still enforces the blocking-risk check; reattest mode still enforces the TOCTOU pin; `reattest` defaults to `False`.
## Task 3 — harden `show-seed-b64`
The maintainer ran `show-seed-b64 --release`, saw the ambiguous prompt "Release signing key passphrase:", 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 3-line banner prints to **stderr** immediately before the seed:
```
!!! PRIVATE KEY BELOW -- this is the RELEASE_SIGNING_KEY secret value.
!!! Paste it ONLY into the Gitea secret field. Never into chat, email, a file, or a commit.
!!! Anyone holding this value can forge release checksum signatures.
```
The seed itself stays alone on **stdout**, so `| pbcopy` / piping into a CI secret field still works cleanly.
- `cmd_keygen`: labels for both key kinds now say PUBLIC/PRIVATE explicitly and state the safety property 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 individually removed then restored, confirmed red in between:
1. Removed `can_sign()`'s empty-diff guard (gate 0) → `test_can_sign_false_on_empty_changeset` and `test_normal_mode_with_unchanged_content_still_refuses_empty_diff` failed.
2. Removed the blocking-risk gate (gate 2) → `test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledged` and `test_reattest_mode_still_enforces_blocking_risk_check` failed.
3. Removed the TOCTOU blob-SHA pin (gate 1) → `test_can_sign_refuses_on_blob_mismatch_even_if_acknowledged`, `test_sign_precondition_refuses_when_loaded_ref_blob_moved`, and `test_reattest_mode_still_enforces_toctou_pin` failed.
4. Removed the acknowledge-all gate (gate 3) → `test_no_acknowledge_all_shortcut_and_gate_is_real`, `test_can_sign_false_when_not_all_acknowledged`, and `test_reattest_mode_can_sign_refuses_until_all_acknowledged_then_permits` failed.
5. Removed the `reattest` branch in `ReviewSession.__post_init__` entirely → all 4 reattest-specific tests failed.
## Maintainer follow-up: re-signing the catalog under the new key
1. Regenerate the catalog keypair (if not already done) — `python catalog_console.py keygen`. Confirm the printed public key matches `0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=` (this PR already assumes that key; if it doesn't match, `bcc_core.CATALOG_PUBKEYS` needs a follow-up correction).
2. Merge this PR to `main`.
3. `python catalog_console.py gui --repo <checkout>`, select **main (current tip)** as the source, click **Load selected source**. The Console will detect that `data/catalog.json.sig` no longer verifies under the new key and enter re-attestation mode (red banner, all 19 entries shown as needing acknowledgement).
4. Review and acknowledge all 19 entries, then click **Sign** (passphrase for the *new* catalog key). This commits and pushes both `data/catalog.json` and a freshly-produced `data/catalog.json.sig` in one commit.
5. Confirm the `catalog-signature` CI job goes green on the resulting commit.
## Do NOT / not touched
- `data/catalog.json` itself is untouched — re-signing is the maintainer's job via the Console, per step above.
- No private key generated, requested, or committed anywhere in this diff.
- `bcc.py` untouched (open PR #67).
- `can_sign()`'s empty-diff, acknowledge, blocking-risk, and TOCTOU guards are unchanged in strictness — only a new, explicitly-flagged input mode was added upstream of them.
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).
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.
Decision — treat this rotation as hygiene, not urgent (2026-08-12)
After a threat-model pass (full rationale on #61): nothing users rely on today depends on the catalog signing key — the catalog is bundled only, and remote refresh (#61) is "someday, not soon." So the CI-exposed key should still be rotated eventually (don't leave a burned key trusted), but there's no live exposure forcing it now, and it's not worth hand-cranking the scary GUI re-attestation by hand.
Plan: park this PR and fold the rotation into a future one-command, keychain-based sign flow (no seeds/passphrase, auto re-attest) so the rotation + re-sign is a single painless step instead of the current ceremony. Not a P0. The confusing manual re-attestation is exactly what that flow removes.
## Decision — treat this rotation as hygiene, not urgent (2026-08-12)
After a threat-model pass (full rationale on #61): nothing users rely on **today** depends on the catalog signing key — the catalog is bundled only, and remote refresh (#61) is "someday, not soon." So the CI-exposed key should still be rotated eventually (don't leave a burned key trusted), but there's **no live exposure forcing it now**, and it's not worth hand-cranking the scary GUI re-attestation by hand.
Plan: **park this PR** and fold the rotation into a future **one-command, keychain-based `sign`** flow (no seeds/passphrase, auto re-attest) so the rotation + re-sign is a single painless step instead of the current ceremony. Not a P0. The confusing manual re-attestation is exactly what that flow removes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes the remaining follow-up from #68 finding 5 (the catalog/release keys were split but the content of the split — actually rotating the exposed key — hadn't landed yet), plus the CLI-prompt ambiguity from #62's
show-seed-b64that led to a private key being pasted into a chat.Task 1 — install the new keys
Both values below are public keys — safe to commit, nothing private is generated, requested, or handled by this PR.
bcc_core.py:CATALOG_PUBKEYS→ new catalog pubkey0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=(signsdata/catalog.json, Console-only, offline)..github/workflows/ci.yml:EXPECTED_CATALOG_PUBKEY_B64→ same new catalog pubkey (the CI trust anchor from #68 finding 4).scripts/sign_checksums.py:RELEASE_PUBKEYS→ new release pubkey6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA=(signsSHA256SUMSonly, CI-resident).README.md: "Verifying your download" / "Signing keys" sections filled in with both pubkeys, explicit about which is which.The old catalog key
082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4=is retired — it was exposed to CI.CATALOG_PUBKEYSdeliberately does not keep it: retaining a burned key in the trust list would defeat the point of rotating it.Task 2 — key-rotation re-attestation mode (Catalog Console)
Problem: post-rotation,
data/catalog.json.sig(signed with the OLD key) fails to verify under the NEWCATALOG_PUBKEYS. Content is unchanged, sodiff_catalogs(last_signed, current)is empty — andcan_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 stays red forever.Fix — rotation is a full re-attestation, not a diff:
catalog_review.py:ReviewSession/start_reviewgainreattest: bool = False. When set,changesis built viadiff_catalogs(None, new_catalog)— every entry presented as if newly added, requiring a fresh acknowledgement — instead of diffing againstold_catalog.can_sign()itself is unchanged: it still refuses a genuinely-empty changeset and still enforces the TOCTOU blob-SHA pin and the blocking-risk check. Reattest sessions simply never produce an empty changeset (unless the catalog itself is empty), so gate 0 passes for the right reason.catalog_console.py: newcatalog_signature_valid_at(repo_dir, commit, raw)callsbcc_core.verify_catalog_signaturedirectly (not reimplemented) to detect whether the committed.sigverifies under the currentCATALOG_PUBKEYS.ReviewWindow._on_load'ssource=mainpath uses this to decidereattest=True/Falseand shows a loud, explicit red banner ("KEY ROTATION IN PROGRESS…") whenever reattest mode is entered — never silently. Status text and Sign-button gating flow through the samecan_sign()/all_entries_acknowledged()path as normal review; no separate code path to drift.tests/test_catalog_review.py: 6 new tests — reattest mode yields one change-entry per server and gates on all being acknowledged before permitting; normal mode with unchanged content still refuses (rotation is not a general bypass of the empty-diff guard); reattest mode still enforces the blocking-risk check; reattest mode still enforces the TOCTOU pin;reattestdefaults toFalse.Task 3 — harden
show-seed-b64The maintainer ran
show-seed-b64 --release, saw the ambiguous prompt "Release signing key passphrase:", 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 3-line banner prints to stderr immediately before the seed:| pbcopy/ piping into a CI secret field still works cleanly.cmd_keygen: labels for both key kinds now say PUBLIC/PRIVATE explicitly and state the safety property 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.can_sign()'s empty-diff guard (gate 0) →test_can_sign_false_on_empty_changesetandtest_normal_mode_with_unchanged_content_still_refuses_empty_difffailed.test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledgedandtest_reattest_mode_still_enforces_blocking_risk_checkfailed.test_can_sign_refuses_on_blob_mismatch_even_if_acknowledged,test_sign_precondition_refuses_when_loaded_ref_blob_moved, andtest_reattest_mode_still_enforces_toctou_pinfailed.test_no_acknowledge_all_shortcut_and_gate_is_real,test_can_sign_false_when_not_all_acknowledged, andtest_reattest_mode_can_sign_refuses_until_all_acknowledged_then_permitsfailed.reattestbranch inReviewSession.__post_init__entirely → all 4 reattest-specific tests failed.Maintainer follow-up: re-signing the catalog under the new key
python catalog_console.py keygen. Confirm the printed public key matches0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=(this PR already assumes that key; if it doesn't match,bcc_core.CATALOG_PUBKEYSneeds a follow-up correction).main.python catalog_console.py gui --repo <checkout>, select main (current tip) as the source, click Load selected source. The Console will detect thatdata/catalog.json.signo longer verifies under the new key and enter re-attestation mode (red banner, all 19 entries shown as needing acknowledgement).data/catalog.jsonand a freshly-produceddata/catalog.json.sigin one commit.catalog-signatureCI job goes green on the resulting commit.Do NOT / not touched
data/catalog.jsonitself is untouched — re-signing is the maintainer's job via the Console, per step above.bcc.pyuntouched (open PR #67).can_sign()'s empty-diff, acknowledge, blocking-risk, and TOCTOU guards are unchanged in strictness — only a new, explicitly-flagged input mode was added upstream of them.keysstatus command, and complete rotation without a red main (#62, #68)Decision — treat this rotation as hygiene, not urgent (2026-08-12)
After a threat-model pass (full rationale on #61): nothing users rely on today depends on the catalog signing key — the catalog is bundled only, and remote refresh (#61) is "someday, not soon." So the CI-exposed key should still be rotated eventually (don't leave a burned key trusted), but there's no live exposure forcing it now, and it's not worth hand-cranking the scary GUI re-attestation by hand.
Plan: park this PR and fold the rotation into a future one-command, keychain-based
signflow (no seeds/passphrase, auto re-attest) so the rotation + re-sign is a single painless step instead of the current ceremony. Not a P0. The confusing manual re-attestation is exactly what that flow removes.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.