Files
BCC Agent 4836c6cb48
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
chore(#68): rotate signing keys, key-rotation re-attestation mode, harden show-seed-b64
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).
2026-07-13 12:24:28 -04:00

177 lines
7.2 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
name: Lint (ruff)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: ruff check
run: ruff check .
- name: ruff format --check
run: ruff format --check .
test:
runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python: ["3.10", "3.12", "3.13"]
include:
# Windows tests on 3.12 only — the version the release binaries ship
# with. The self-hosted Windows runner blocks setup-python's install
# script (PowerShell execution policy), so it uses the host's `py`
# launcher + venv, same as release.yml.
- os: windows-latest
python: "3.12"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }} (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Set up Python venv (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
py -${{ matrix.python }} -m venv .venv
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
# keeps CI fast and avoids Qt system-library headaches on the runner.
# cryptography is for tests/test_checksums.py (release signing helper).
- name: Install test dependencies
run: pip install pytest cryptography
- name: Run tests
run: python -m pytest -v
# ── Catalog signature gate (#61) ─────────────────────────────────────────
#
# data/catalog.json is a list of command+args entries that BCC writes into
# the user's Claude config, which Claude then EXECUTES. The catalog is only
# trusted if it carries a valid Ed25519 signature from the maintainer key.
#
# The threat this gate exists for is NOT an outsider pushing to the repo —
# it is the maintainer merging a friendly-looking PR without really reading
# it. A contributor can change catalog.json but cannot produce a matching
# signature, so a blindly-merged PR lands here as a RED BUILD within a
# minute, instead of quietly riding into the next release.
#
# Public-key verification only. No secret is used or needed.
catalog-signature:
name: Catalog signature
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install cryptography
# 🔴 TRUST ANCHOR — issue #68 finding 4.
#
# This step used to do `import bcc_core as c` FROM THE CHECKED-OUT PR
# BRANCH and verify the catalog against c.CATALOG_PUBKEYS — i.e. it
# trusted the public key shipped in the very diff it was reviewing. A
# PR that changed data/catalog.json AND bcc_core.CATALOG_PUBKEYS (to
# an attacker key, with a matching signature produced by the attacker's
# matching private key) went green, because there was nothing outside
# the PR's own content to check the key against. The gate's whole
# point is catching a friendly-looking PR the maintainer merges
# without really reading it — and that hole made it a two-file diff.
#
# EXPECTED_CATALOG_PUBKEY_B64 below is hardcoded HERE, in the workflow
# file, independent of whatever bcc_core.py says on the PR branch. It
# is intentionally the only line in this step that matters for
# security review: changing it changes what this gate is willing to
# trust. THIS CONSTANT IS A TRUST ANCHOR. A PR that changes this line
# in the same diff as a catalog change is exactly the attack this gate
# exists to prevent — review a change to this line on its own,
# never bundled with a catalog update.
#
# NOTE for the next key rotation: update EXPECTED_CATALOG_PUBKEY_B64
# below to the new key's base64 form, as its own reviewed change.
- name: Verify data/catalog.json.sig
env:
EXPECTED_CATALOG_PUBKEY_B64: "0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k="
run: |
python - <<'PY'
import base64, os, pathlib, sys
import bcc_core as c
expected_pubkey_b64 = os.environ["EXPECTED_CATALOG_PUBKEY_B64"]
raw = pathlib.Path("data/catalog.json").read_bytes()
sig_path = pathlib.Path("data/catalog.json.sig")
if not sig_path.exists():
sys.exit("FAIL: data/catalog.json.sig is missing. The catalog must be "
"signed via the Catalog Console (#62) before it can land.")
if b"\x00" * 32 in c.CATALOG_PUBKEYS:
sys.exit("FAIL: CATALOG_PUBKEYS still holds the placeholder key.")
# Trust anchor check FIRST, before verifying anything against
# bcc_core.CATALOG_PUBKEYS: a PR is not allowed to bring its own
# key. CATALOG_PUBKEYS on the checked-out branch must be EXACTLY
# the key(s) this workflow file itself expects -- no more, no
# fewer, no substitutions.
actual_pubkeys_b64 = [base64.b64encode(k).decode() for k in c.CATALOG_PUBKEYS]
if actual_pubkeys_b64 != [expected_pubkey_b64]:
sys.exit(
"FAIL: bcc_core.CATALOG_PUBKEYS on this branch does not match the "
"trust anchor hardcoded in .github/workflows/ci.yml.\n"
f" expected: {[expected_pubkey_b64]}\n"
f" actual: {actual_pubkeys_b64}\n"
"\n"
"This PR is changing (or has changed) the catalog signing key. That "
"change must be reviewed on its own, separately from any catalog "
"content change, and the workflow's EXPECTED_CATALOG_PUBKEY_B64 "
"updated deliberately -- not accepted because it happened to match "
"whatever bcc_core.py says on this branch."
)
if not c.verify_catalog_signature(raw, sig_path.read_bytes(), c.CATALOG_PUBKEYS):
sys.exit(
"FAIL: data/catalog.json does NOT match its signature.\n"
"\n"
"The catalog changed without being re-signed. Either someone edited\n"
"it directly (a PR you merged?), or a signing pass was forgotten.\n"
"Re-review and re-sign with the Catalog Console — do not bypass this."
)
problems = c.validate_catalog(c.load_catalog(raw))
if problems:
sys.exit("FAIL: catalog failed validation:\n " + "\n ".join(problems))
print("OK: catalog signature verifies, the pubkey matches the CI trust anchor, "
"and the catalog validates clean.")
PY