diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa53fbc..ba3ab86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,3 +68,61 @@ jobs: - 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 + + - name: Verify data/catalog.json.sig + run: | + python - <<'PY' + import pathlib, sys + import bcc_core as c + + 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.") + + 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 and the catalog validates clean.") + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59fca06..616ed94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,69 @@ jobs: name: ${{ matrix.artifact }} path: ${{ matrix.artifact }} + # ── Signing-key smoke test (workflow_dispatch only) ───────────────────── + # + # The Publish job is gated on a tag, so a manual run never exercises the + # signing step — which means a wrong/missing RELEASE_SIGNING_KEY secret + # would only be discovered at the worst possible moment: during a real + # release. This job signs a throwaway manifest with the secret and verifies + # the result against the PUBLIC key already compiled into bcc_core. + # + # It proves the two halves of the keypair actually match, without + # publishing anything. Run it from the Actions tab after setting or + # rotating the secret. + signing-smoke-test: + name: Signing key smoke test + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install cryptography + + - name: Sign a throwaway manifest and verify against the shipped pubkey + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + run: | + if [ -z "$RELEASE_SIGNING_KEY" ]; then + echo "FAIL: RELEASE_SIGNING_KEY secret is not set." + echo "Generate it with: python catalog_console.py show-seed-b64" + echo "then add it under Settings -> Actions -> Secrets." + exit 1 + fi + mkdir -p smoke && echo "smoke test payload" > smoke/hello.txt + python3 scripts/sign_checksums.py generate smoke --out smoke/SHA256SUMS + python3 scripts/sign_checksums.py sign --sums smoke/SHA256SUMS --out smoke/SHA256SUMS.sig + python - <<'PY' + import base64, pathlib, sys + import bcc_core as c + from scripts.sign_checksums import verify_checksums + + # The public half that ships inside the binary. If the secret is a + # DIFFERENT key than the one users' copies trust, this fails here -- + # which is the entire point of the job. + pub_b64 = base64.b64encode(c.CATALOG_PUBKEYS[0]).decode() + + sums = pathlib.Path("smoke/SHA256SUMS").read_text() + sig = pathlib.Path("smoke/SHA256SUMS.sig").read_bytes() + + if not verify_checksums(pub_b64, sums, sig): + sys.exit( + "FAIL: the signature produced by RELEASE_SIGNING_KEY does NOT verify\n" + "against the public key in bcc_core.CATALOG_PUBKEYS.\n" + "\n" + "The secret and the shipped public key are different keypairs. Users\n" + "would reject every signature this CI produces. Re-copy the seed from\n" + "`catalog_console.py show-seed-b64`, or update CATALOG_PUBKEYS." + ) + print("OK: RELEASE_SIGNING_KEY matches the public key shipped in bcc_core.") + PY + # ── Create GitHub Release with all three artifacts ────────────────────── release: