Paid code signing is out of budget (macOS Developer ID ~$99/yr, Windows Authenticode ~$200-400/yr) and Sigstore keyless needs a Fulcio-trusted OIDC issuer, which self-hosted Gitea isn't. So this does the free half: publish SHA256SUMS for every release archive and sign it with a detached Ed25519 signature (SHA256SUMS.sig).
This makes tampering detectable by anyone who checks. It does not remove Gatekeeper/SmartScreen warnings — the README and release notes say so explicitly, no overstatement.
generate <dir> --out SHA256SUMS — hashes every file in a directory into a sha256sum(1)-compatible manifest, sorted by filename.
sign --sums ... --out ... — detached-signs the manifest. Signed message is domain-separated: b"bcc-release-v1|" + raw_bytes_of_SHA256SUMS, so a signature can't be replayed against an unrelated message under the same key. Key comes from $RELEASE_SIGNING_KEY (base64 raw 32-byte Ed25519 seed) or --key-b64.
verify --sums ... --sig ... --pubkey-b64 ... — the same code path checks a signature (this satisfies the "give it a --verify mode" ask as a subcommand).
tests/test_checksums.py (new) — 15 tests: hashing, manifest formatting/sorting/exclusion, sign→verify roundtrip, tamper detection, wrong-key rejection, domain-separation (a signature over the raw unprefixed bytes must NOT verify), bad key-length errors, and the CLI end-to-end via subprocess — including that sign with no key errors loudly and never writes a signature file.
Checks out the repo (previously it only downloaded artifacts).
Flattens the three build artifacts into release-files/.
Generates release-files/SHA256SUMS.
Checks whether secrets.RELEASE_SIGNING_KEY is set (via a step output, not a direct secret reference in if:).
If set: installs cryptography, signs → release-files/SHA256SUMS.sig.
If not set: emits a loud ::warning:: and continues — the release always publishes, it just ships without a .sig rather than failing.
Attaches artifacts/**/* and release-files/SHA256SUMS* (the glob only picks up .sig when it exists, so a missing signature never breaks the release action).
Release notes body gains a short pointer to the README's verification section.
Kept existing Gitea Actions quirks intact: no generate_release_notes, artifact actions stay on v3.
README.md — new "Verifying your download" section: placeholder for the public key, sha256sum -c SHA256SUMS / Get-FileHash commands, an explicit "what this does/doesn't prove" statement, the macOS right-click→Open workaround, and an optional scripts/sign_checksums.py verify command for checking the signature itself.
requirements-dev.txt / .github/workflows/ci.yml — added cryptography as a dev/test dependency (only place it's needed; the release job installs it separately, only when signing).
Scope discipline
Deliberately touched no files from bcc_core.py, tests/test_core.py, pyproject.toml, or bcc.spec — all new Python lives in scripts/sign_checksums.py + tests/test_checksums.py to avoid colliding with concurrent work on those files on another branch.
Verification
ruff check . — clean.
ruff format --check . — clean.
pytest -q — 212 passed, 1 skipped (pre-existing skip, unrelated), including the new 15 in test_checksums.py.
Manual steps required before the next release tag
Add the RELEASE_SIGNING_KEY repo secret in Gitea (Settings → Actions → Secrets) — base64-encoding of the raw 32-byte Ed25519 seed. The key itself should be generated by the Catalog Console (#62), not by this PR or by hand — I did not generate or commit a key.
Fill in the public-key placeholder in README.md (## Verifying your download section) with the corresponding base64 public key once the Console generates the pair.
Optionally do a workflow_dispatch test run of release.yml to confirm the signing step behaves as expected once the secret is set (it degrades gracefully — loud warning, no .sig — if you tag before doing this).
Closes #63.
## What / why
Paid code signing is out of budget (macOS Developer ID ~$99/yr, Windows Authenticode ~$200-400/yr) and Sigstore keyless needs a Fulcio-trusted OIDC issuer, which self-hosted Gitea isn't. So this does the free half: publish `SHA256SUMS` for every release archive and sign it with a detached Ed25519 signature (`SHA256SUMS.sig`).
**This makes tampering detectable by anyone who checks. It does not remove Gatekeeper/SmartScreen warnings — the README and release notes say so explicitly, no overstatement.**
## Changes
- **`scripts/sign_checksums.py`** (new) — dependency-light (`cryptography` only) helper:
- `generate <dir> --out SHA256SUMS` — hashes every file in a directory into a `sha256sum(1)`-compatible manifest, sorted by filename.
- `sign --sums ... --out ...` — detached-signs the manifest. Signed message is domain-separated: `b"bcc-release-v1|" + raw_bytes_of_SHA256SUMS`, so a signature can't be replayed against an unrelated message under the same key. Key comes from `$RELEASE_SIGNING_KEY` (base64 raw 32-byte Ed25519 seed) or `--key-b64`.
- `verify --sums ... --sig ... --pubkey-b64 ...` — the same code path checks a signature (this satisfies the "give it a --verify mode" ask as a subcommand).
- **`tests/test_checksums.py`** (new) — 15 tests: hashing, manifest formatting/sorting/exclusion, sign→verify roundtrip, tamper detection, wrong-key rejection, domain-separation (a signature over the raw unprefixed bytes must NOT verify), bad key-length errors, and the CLI end-to-end via subprocess — including that `sign` with no key errors loudly and **never writes a signature file**.
- **`.github/workflows/release.yml`** — Publish Release job now:
1. Checks out the repo (previously it only downloaded artifacts).
2. Flattens the three build artifacts into `release-files/`.
3. Generates `release-files/SHA256SUMS`.
4. Checks whether `secrets.RELEASE_SIGNING_KEY` is set (via a step output, not a direct secret reference in `if:`).
5. If set: installs `cryptography`, signs → `release-files/SHA256SUMS.sig`.
6. If not set: emits a loud `::warning::` and continues — **the release always publishes**, it just ships without a `.sig` rather than failing.
7. Attaches `artifacts/**/*` and `release-files/SHA256SUMS*` (the glob only picks up `.sig` when it exists, so a missing signature never breaks the release action).
8. Release notes body gains a short pointer to the README's verification section.
- Kept existing Gitea Actions quirks intact: no `generate_release_notes`, artifact actions stay on v3.
- **`README.md`** — new "Verifying your download" section: placeholder for the public key, `sha256sum -c SHA256SUMS` / `Get-FileHash` commands, an explicit "what this does/doesn't prove" statement, the macOS right-click→Open workaround, and an optional `scripts/sign_checksums.py verify` command for checking the signature itself.
- **`requirements-dev.txt`** / **`.github/workflows/ci.yml`** — added `cryptography` as a dev/test dependency (only place it's needed; the release job installs it separately, only when signing).
## Scope discipline
Deliberately touched **no** files from `bcc_core.py`, `tests/test_core.py`, `pyproject.toml`, or `bcc.spec` — all new Python lives in `scripts/sign_checksums.py` + `tests/test_checksums.py` to avoid colliding with concurrent work on those files on another branch.
## Verification
- `ruff check .` — clean.
- `ruff format --check .` — clean.
- `pytest -q` — 212 passed, 1 skipped (pre-existing skip, unrelated), including the new 15 in `test_checksums.py`.
## Manual steps required before the next release tag
1. **Add the `RELEASE_SIGNING_KEY` repo secret in Gitea** (Settings → Actions → Secrets) — base64-encoding of the raw 32-byte Ed25519 seed. **The key itself should be generated by the Catalog Console (#62), not by this PR or by hand** — I did not generate or commit a key.
2. **Fill in the public-key placeholder in `README.md`** (`## Verifying your download` section) with the corresponding base64 public key once the Console generates the pair.
3. Optionally do a `workflow_dispatch` test run of `release.yml` to confirm the signing step behaves as expected once the secret is set (it degrades gracefully — loud warning, no `.sig` — if you tag before doing this).
Publish SHA256SUMS for every release archive and sign it with a
detached Ed25519 signature (SHA256SUMS.sig), since paid code signing
(macOS Developer ID, Windows Authenticode) and Sigstore keyless (needs
a Fulcio-trusted OIDC issuer; self-hosted Gitea isn't one) are both
out of budget/scope.
- scripts/sign_checksums.py: dependency-light (cryptography only)
helper to hash a directory of files into a sha256sum(1)-compatible
SHA256SUMS manifest, sign it (domain-separated: b"bcc-release-v1|"
+ raw manifest bytes), and verify a signature. CLI has generate/
sign/verify subcommands; verify doubles as the check path.
- tests/test_checksums.py: 15 unit + CLI-subprocess tests covering
hashing, manifest formatting, sign/verify roundtrip, tamper
detection, wrong-key rejection, domain-separation, and the
no-key-provided failure path (must error, never write an empty/
bogus .sig).
- .github/workflows/release.yml: Publish Release job now checks out
the repo, flattens build artifacts, generates SHA256SUMS, and signs
it from the RELEASE_SIGNING_KEY secret (base64 raw Ed25519 seed) if
present. If the secret is absent, the release still publishes with
a loud ::warning:: and no .sig — it never fails the release or
publishes a bogus signature.
- README.md: new 'Verifying your download' section with the (still
placeholder) public key, sha256sum -c / Get-FileHash commands, and
an explicit statement that this does not remove Gatekeeper/
SmartScreen warnings.
- requirements-dev.txt / ci.yml: add cryptography as a dev/test
dependency for the new script and its tests.
Touches no files from bcc_core.py / tests/test_core.py /
pyproject.toml / bcc.spec to avoid colliding with concurrent work on
those files.
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 #63.
What / why
Paid code signing is out of budget (macOS Developer ID ~$99/yr, Windows Authenticode ~$200-400/yr) and Sigstore keyless needs a Fulcio-trusted OIDC issuer, which self-hosted Gitea isn't. So this does the free half: publish
SHA256SUMSfor every release archive and sign it with a detached Ed25519 signature (SHA256SUMS.sig).This makes tampering detectable by anyone who checks. It does not remove Gatekeeper/SmartScreen warnings — the README and release notes say so explicitly, no overstatement.
Changes
scripts/sign_checksums.py(new) — dependency-light (cryptographyonly) helper:generate <dir> --out SHA256SUMS— hashes every file in a directory into asha256sum(1)-compatible manifest, sorted by filename.sign --sums ... --out ...— detached-signs the manifest. Signed message is domain-separated:b"bcc-release-v1|" + raw_bytes_of_SHA256SUMS, so a signature can't be replayed against an unrelated message under the same key. Key comes from$RELEASE_SIGNING_KEY(base64 raw 32-byte Ed25519 seed) or--key-b64.verify --sums ... --sig ... --pubkey-b64 ...— the same code path checks a signature (this satisfies the "give it a --verify mode" ask as a subcommand).tests/test_checksums.py(new) — 15 tests: hashing, manifest formatting/sorting/exclusion, sign→verify roundtrip, tamper detection, wrong-key rejection, domain-separation (a signature over the raw unprefixed bytes must NOT verify), bad key-length errors, and the CLI end-to-end via subprocess — including thatsignwith no key errors loudly and never writes a signature file..github/workflows/release.yml— Publish Release job now:release-files/.release-files/SHA256SUMS.secrets.RELEASE_SIGNING_KEYis set (via a step output, not a direct secret reference inif:).cryptography, signs →release-files/SHA256SUMS.sig.::warning::and continues — the release always publishes, it just ships without a.sigrather than failing.artifacts/**/*andrelease-files/SHA256SUMS*(the glob only picks up.sigwhen it exists, so a missing signature never breaks the release action).generate_release_notes, artifact actions stay on v3.README.md— new "Verifying your download" section: placeholder for the public key,sha256sum -c SHA256SUMS/Get-FileHashcommands, an explicit "what this does/doesn't prove" statement, the macOS right-click→Open workaround, and an optionalscripts/sign_checksums.py verifycommand for checking the signature itself.requirements-dev.txt/.github/workflows/ci.yml— addedcryptographyas a dev/test dependency (only place it's needed; the release job installs it separately, only when signing).Scope discipline
Deliberately touched no files from
bcc_core.py,tests/test_core.py,pyproject.toml, orbcc.spec— all new Python lives inscripts/sign_checksums.py+tests/test_checksums.pyto avoid colliding with concurrent work on those files on another branch.Verification
ruff check .— clean.ruff format --check .— clean.pytest -q— 212 passed, 1 skipped (pre-existing skip, unrelated), including the new 15 intest_checksums.py.Manual steps required before the next release tag
RELEASE_SIGNING_KEYrepo secret in Gitea (Settings → Actions → Secrets) — base64-encoding of the raw 32-byte Ed25519 seed. The key itself should be generated by the Catalog Console (#62), not by this PR or by hand — I did not generate or commit a key.README.md(## Verifying your downloadsection) with the corresponding base64 public key once the Console generates the pair.workflow_dispatchtest run ofrelease.ymlto confirm the signing step behaves as expected once the secret is set (it degrades gracefully — loud warning, no.sig— if you tag before doing this).