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
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:
+36
-1
@@ -442,18 +442,43 @@ class ReviewSession:
|
||||
from the reviewed ref on every PR review (the bug that made the PR path
|
||||
unable to sign at all, and forced everyone onto the vacuous
|
||||
main-vs-itself path instead).
|
||||
|
||||
`reattest` marks a KEY-ROTATION re-attestation pass (issue #68 finding 5
|
||||
follow-up): the currently-trusted `bcc_core.CATALOG_PUBKEYS` key changed
|
||||
and the existing `data/catalog.json.sig` no longer verifies under it.
|
||||
Content-wise nothing may have changed -- `diff_catalogs(old, new)` can be
|
||||
genuinely empty -- but the NEW key has never vouched for any of this
|
||||
catalog before, so every entry needs a first-time attestation under the
|
||||
new key, not a diff against the old one. When `reattest` is set,
|
||||
`changes` is built as "every entry in `new_catalog`, presented as if
|
||||
newly added" (via `diff_catalogs(None, new_catalog)`) instead of a
|
||||
diff against `old_catalog`, so the acknowledge-gate in `can_sign()`
|
||||
requires re-reviewing everything the new key will sign -- which is the
|
||||
intended cost of a key rotation, not a bypass of the empty-diff guard.
|
||||
"""
|
||||
|
||||
pinned_blob_sha: str
|
||||
old_catalog: dict
|
||||
new_catalog: dict
|
||||
loaded_ref: str = "main"
|
||||
reattest: bool = False
|
||||
changes: list[EntryChange] = field(default_factory=list)
|
||||
acknowledged: set[str] = field(default_factory=set)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.changes:
|
||||
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
|
||||
if self.reattest:
|
||||
# Every entry in the new catalog is treated as though it
|
||||
# were newly added -- because, under the NEW signing key,
|
||||
# it is: nothing this key signs was ever attested by it
|
||||
# before. Reusing diff_catalogs(None, new) (rather than a
|
||||
# bespoke code path) means the same "added" risk predicate
|
||||
# (risk_new_entry) and the same EntryChange shape the rest
|
||||
# of this module and the GUI already know how to render
|
||||
# apply here unmodified.
|
||||
self.changes = diff_catalogs(None, self.new_catalog)
|
||||
else:
|
||||
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
|
||||
|
||||
|
||||
def start_review(
|
||||
@@ -461,12 +486,14 @@ def start_review(
|
||||
old_catalog: dict,
|
||||
new_catalog: dict,
|
||||
loaded_ref: str = "main",
|
||||
reattest: bool = False,
|
||||
) -> ReviewSession:
|
||||
return ReviewSession(
|
||||
pinned_blob_sha=pinned_blob_sha,
|
||||
old_catalog=old_catalog,
|
||||
new_catalog=new_catalog,
|
||||
loaded_ref=loaded_ref,
|
||||
reattest=reattest,
|
||||
)
|
||||
|
||||
|
||||
@@ -531,6 +558,14 @@ def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
|
||||
mean "nothing to sign", never "sign unlocked". (Issue #68 finding 1;
|
||||
this is what let commit b08cf21 sign all 19 entries with zero of them
|
||||
ever reviewed.)
|
||||
|
||||
This gate is unaffected by `session.reattest`: a key-rotation
|
||||
re-attestation session's `changes` is built from
|
||||
`diff_catalogs(None, new_catalog)` (see ReviewSession), which is
|
||||
empty ONLY if the catalog itself has zero entries -- a genuinely
|
||||
empty catalog either way. Rotation never manufactures a non-empty
|
||||
changeset out of an empty one; it just changes *what* "non-empty"
|
||||
is computed against.
|
||||
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing,
|
||||
from the ref that was actually reviewed -- see sign_precondition())
|
||||
must match the blob SHA pinned when review began. If the bytes on the
|
||||
|
||||
Reference in New Issue
Block a user