Sign release checksums with Ed25519 (#63) #64

Merged
the_og merged 1 commits from feat/63-signed-checksums into main 2026-07-12 17:42:09 -04:00
Owner

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).
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).
the_og added 1 commit 2026-07-12 17:30:34 -04:00
Sign release checksums with Ed25519 (#63)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
cd38fd0c78
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.
the_og merged commit 672d78f903 into main 2026-07-12 17:42:09 -04:00
the_og deleted branch feat/63-signed-checksums 2026-07-12 17:42:09 -04:00
Sign in to join this conversation.