chore/68-key-rotation
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa40f8e139 |
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. |
||
|
|
4836c6cb48 |
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). |
||
|
|
82483e693d |
fix(catalog-console): close the vacuous review gate; split catalog/release signing keys
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 34s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
Fixes #68 findings 1 and 5.
Finding 1 -- the review gate signed without reviewing anything:
- ReviewWindow._on_sign hardcoded "main" as the TOCTOU comparison ref, so
any PR review (where _on_load pins the PR head's blob SHA) could never
sign; the only working path was main-vs-itself, whose empty diff made
can_sign() vacuously True (set() <= set()). Commit
|
||
|
|
d6fc6845c4 |
fix(catalog-console): run registry lookup automatically, not on click (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
Review feedback: making the registry lookup an on-demand 'Check registry' button was a deviation from the design intent, not a style choice. The registry lookup is the one check a human reviewer genuinely cannot do by eye -- it's what caught firecrawl-mcp's unrelated npm publisher in the seed data. Gating it behind a button makes it optional, and an optional check is the one a tired maintainer skips at 11pm -- exactly the failure mode this tool exists to defend against. The friction belongs on approval, never on information. EntryCard now kicks off its registry lookup automatically at construction time (i.e. as soon as _render_cards() builds the cards for a loaded review), one RegistryLookupWorker (QThread) per changed entry, all starting concurrently as the cards are built. Nothing about the lookup's pure logic changed -- catalog_review.lookup_registry_info was already fail-soft (RegistryInfo(available=False) on any fetch problem, never an exception) and can_sign() never depended on registry state, so a dead registry still cannot gate review or signing. Renamed the button 'Check registry' -> 'Re-check' and kept it wired to the same _run_registry_lookup(), for manually retrying a failed/unavailable lookup. Label copy now reads loading... while a lookup is in flight (was 'not checked yet.' / 'checking...'), matching the loading -> result | unavailable per-entry states. No change to acknowledge-gating or the TOCTOU blob-SHA pin in catalog_review.py. ruff check/format clean; full suite still 321 passed, 1 pre-existing unrelated skip -- catalog_review.py (the tested pure-logic module) is untouched, only catalog_console.py's GUI wiring moved from button-triggered to auto-triggered. |
||
|
|
f0d0ab7a08 |
feat: Catalog Console -- maintainer-only review + signing tool (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
Adds a separate PySide6 tool (catalog_console.py) that reviews proposed changes to data/catalog.json and signs the approved result. Never shipped to users, never in the release bundle -- maintainer runs it from source. Pure, GUI-free logic lives in a new catalog_review.py (kept out of both the GUI and bcc_core.py to avoid merge conflicts on the latter): - diff_catalogs(old, new) -> list[EntryChange]: semantic (per-entry) diff, not a text diff, with per-field before/after values. - Six independent risk predicates, each unit-tested: non-empty env_required value, command outside bcc_core.CATALOG_ALLOWED_COMMANDS (imported, not redefined), non-ASCII code points in id/command/args (rendered with escapes -- homoglyph/RTL-override defence), unpinned npm/docker package references, URL domain changes (lookalike-domain swap defence), brand-new entries flagged for extra scrutiny. - ReviewSession + can_sign(): the Sign button stays disabled until every changed entry is individually acknowledged -- no "acknowledge all" shortcut exists, and a comment in the code says never to add one. - TOCTOU fix (adversarial review on #62): the git blob SHA of data/catalog.json is pinned when review begins; can_sign() refuses to sign if the current blob differs, forcing a re-review. The Console re-fetches the blob SHA immediately before signing and enforces this. - catalog_signing_message() imports bcc_core's domain-separation prefix (_CATALOG_SIG_DOMAIN) rather than retyping it, so the Console's signatures and bcc_core.verify_catalog_signature can't drift apart -- proven by a round-trip test (sign here, verify via bcc_core). - encrypt_private_key/decrypt_private_key: the signing key is never stored plaintext (scrypt + AES-256-GCM at rest, OS keychain via the optional keyring package if available, else an encrypted file under $HOME outside the repo). - Registry lookup (lookup_registry_info + injected Fetcher): the network call is kept out of this module for offline testability; catalog_console.py supplies npm/PyPI HTTP fetchers. Fails soft -- network down means "unavailable", never a block on review. near_neighbor_ids() flags edit-distance <=2 typosquat candidates against existing catalog ids. catalog_console.py wires the above into a Qt GUI: Load (open PRs touching data/catalog.json via the Gitea REST API, or main) -> Review (one EntryCard per changed entry, command/args rendered visually dominant, risk findings colour-coded, per-card registry-lookup button running off the UI thread like bcc.py's ConnTester/SpawnTester) -> Sign (re-checks the pinned blob SHA, prompts for the key passphrase, writes data/catalog.json + data/catalog.json.sig and commits+pushes BOTH in a single commit -- so main is never red between a catalog merge and its signature). Every attacker-controlled string renders through a plain_label() helper that both escapes HTML and forces Qt.PlainText, so a script/image payload in a description/notes/URL can't render as markup. Also provides keygen (generates + stores an encrypted keypair, prints the base64 public key) and show-seed-b64 (prints the base64 private seed for the RELEASE_SIGNING_KEY CI secret) CLI subcommands. Excluded from the release bundle: bcc.spec's Analysis() only ever starts from bcc.py, and tests/test_catalog_console_packaging.py asserts neither new file is named anywhere in bcc.spec and that bcc.py never imports either module. Tests: 67 new (62 in test_catalog_review.py, 5 in test_catalog_console_packaging.py) covering diff_catalogs, every risk predicate individually, the acknowledge-gating + TOCTOU can_sign() logic, the sign/verify round-trip against bcc_core, key encryption (including wrong-passphrase and corrupted-blob rejection), edit-distance/near-neighbour matching, and registry-lookup fail-soft behaviour. Full suite: 321 passed, 1 pre-existing unrelated skip. ruff check and ruff format --check both clean. catalog_console.py (Qt/GUI) could not be executed in the sandbox this was developed in (no system EGL/GL libraries available for PySide6) -- it was syntax-checked (py_compile) and lint/format-checked but not smoke-tested; see PR body for what AJ should verify. Closes #62 |