From cd38fd0c789ae51d09323cd2b93086e2b5b157ad Mon Sep 17 00:00:00 2001 From: BCC Agent Date: Sun, 12 Jul 2026 17:30:09 -0400 Subject: [PATCH] Sign release checksums with Ed25519 (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 3 +- .github/workflows/release.yml | 68 +++++++++- README.md | 60 +++++++++ requirements-dev.txt | 1 + scripts/sign_checksums.py | 235 ++++++++++++++++++++++++++++++++++ tests/test_checksums.py | 234 +++++++++++++++++++++++++++++++++ 6 files changed, 599 insertions(+), 2 deletions(-) create mode 100755 scripts/sign_checksums.py create mode 100644 tests/test_checksums.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9b0b8c..fa53fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,8 +62,9 @@ jobs: # 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 + run: pip install pytest cryptography - name: Run tests run: python -m pytest -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5f21a6..59fca06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,11 +107,72 @@ jobs: contents: write steps: + # Needed for scripts/sign_checksums.py — the release job otherwise + # only downloads build artifacts, it doesn't check out the repo. + - name: Checkout + uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v3 with: path: artifacts + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # download-artifact@v3 nests each artifact under a directory named + # after it (artifacts//). Flatten into one directory so + # SHA256SUMS lists plain filenames, matching what `sha256sum -c` + # expects when run from inside an extracted release download. + - name: Collect release files + run: | + mkdir -p release-files + find artifacts -type f -exec cp {} release-files/ \; + ls -la release-files + + - name: Generate SHA256SUMS + run: python3 scripts/sign_checksums.py generate release-files --out release-files/SHA256SUMS + + # ── Sign the checksum manifest (best-effort) ────────────────────── + # + # BCC binaries are not code-signed (no budget for a paid cert). This + # is the free half: a checksum manifest, detached-signed with + # Ed25519, so a tampered download is detectable by anyone who + # checks. It does NOT remove Gatekeeper/SmartScreen warnings. + # + # The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw + # Ed25519 seed) generated via the Catalog Console (#62). If it's not + # set, we still publish the release — just without a .sig — rather + # than fail the release outright. + - name: Check for signing key + id: signing + run: | + if [ -n "${{ secrets.RELEASE_SIGNING_KEY }}" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install signing dependencies + if: steps.signing.outputs.has_key == 'true' + run: pip install cryptography + + - name: Sign SHA256SUMS + if: steps.signing.outputs.has_key == 'true' + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + run: | + python3 scripts/sign_checksums.py sign \ + --sums release-files/SHA256SUMS \ + --out release-files/SHA256SUMS.sig + + - name: Warn — release will be unsigned + if: steps.signing.outputs.has_key != 'true' + run: | + echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Add the secret (base64 raw Ed25519 seed, generated via the Catalog Console, #62) before the next tag." + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -119,7 +180,9 @@ jobs: draft: false prerelease: false generate_release_notes: false - files: artifacts/**/* + files: | + artifacts/**/* + release-files/SHA256SUMS* body: | ## Better Claude Config ${{ github.ref_name }} @@ -139,5 +202,8 @@ jobs: xattr -cr /Applications/BetterClaudeConfig.app ``` + ### Verifying your download + Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings. + ### Requirements No Python installation needed — the app is self-contained. diff --git a/README.md b/README.md index fbba496..d28ffd3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,65 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r > **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal. +## Verifying your download + +BCC isn't code-signed — there's no budget for a paid certificate (macOS +Developer ID, Windows Authenticode). Instead, every release publishes a +`SHA256SUMS` file listing the checksum of each archive, detached-signed with +Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the +binaries. + +**What this proves:** the file you downloaded is byte-for-byte what we +published, and the manifest itself was signed by our release key. + +**What this does NOT do:** it does not make the binary "safe," and it does +**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those +are only suppressed by a paid OS-vendor certificate, which this project +doesn't have. Verifying checksums is about detecting tampering in transit or +on a mirror, not about vouching for the software. + +**Release signing public key** (Ed25519, base64, raw 32 bytes): + +``` + +``` + +### macOS / Linux + +```bash +# From inside the folder you downloaded the release files into: +sha256sum -c SHA256SUMS +``` + +If your `sha256sum` complains about missing files, download `SHA256SUMS` +into the same directory as the archive you downloaded — it lists every +platform's archive, and only the one(s) present will be checked. + +To also verify the manifest's signature (optional, requires Python + +`pip install cryptography` and a checkout of this repo): + +```bash +python3 scripts/sign_checksums.py verify \ + --sums SHA256SUMS --sig SHA256SUMS.sig \ + --pubkey-b64 "" +``` + +### Windows (PowerShell) + +```powershell +Get-FileHash .\BetterClaudeConfig-Windows.zip -Algorithm SHA256 +``` + +Compare the printed hash (case-insensitively) against the matching line in +`SHA256SUMS`. + +### If a release has no `SHA256SUMS.sig` + +The signing key is a repo secret that has to be configured manually; if a +release is missing the `.sig` file, the checksums themselves are still +valid and safe to check against — the release workflow only skips signing, +never checksum generation. + ## Run from source ```bash @@ -92,6 +151,7 @@ file is also listed, marked *legacy*, so you can copy them over. - `test_core.py` — unit suite for the core (`python test_core.py`). - `bcc.spec` — PyInstaller build spec (cross-platform). - `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs. +- `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)). ## Building from source diff --git a/requirements-dev.txt b/requirements-dev.txt index 9f219d9..28b1e08 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,4 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build) # Test / lint pytest>=8.0 ruff>=0.6 +cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py) diff --git a/scripts/sign_checksums.py b/scripts/sign_checksums.py new file mode 100755 index 0000000..25b6cb7 --- /dev/null +++ b/scripts/sign_checksums.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +Generate a SHA256SUMS file for release artifacts and sign it with Ed25519. + +BCC ships PyInstaller binaries that are not code-signed (no budget for a +macOS Developer ID / Windows Authenticode certificate). This script provides +the free half of supply-chain integrity: a checksum manifest, detached-signed +so downloaders can verify the file they got is the file we published. + +This does NOT remove Gatekeeper/SmartScreen warnings and does NOT prove the +binary is safe to run -- only that it matches what the release signing key +attested to. + +Usage: + # Hash every file in a directory into a SHA256SUMS-format manifest. + python scripts/sign_checksums.py generate --out SHA256SUMS + + # Sign a manifest, producing a detached signature. + # Private key comes from $RELEASE_SIGNING_KEY (base64 raw Ed25519 seed) + # unless --key-b64 is given explicitly (mostly for tests). + python scripts/sign_checksums.py sign --sums SHA256SUMS --out SHA256SUMS.sig + + # Verify a manifest against a detached signature and a public key. + python scripts/sign_checksums.py verify --sums SHA256SUMS --sig SHA256SUMS.sig \ + --pubkey-b64 + +The private key is generated and rotated via the Catalog Console (#62) -- +this script never generates or stores a key itself. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import os +import sys +from pathlib import Path + +# Domain separation prefix: ties every signature to "a BCC release checksum +# manifest" so a signature can never be replayed against an unrelated +# message signed by the same key. +DOMAIN_PREFIX = b"bcc-release-v1|" + +CHUNK_SIZE = 1024 * 1024 + + +def sha256_file(path: Path) -> str: + """Return the lowercase hex SHA-256 digest of a file's contents.""" + digest = hashlib.sha256() + with open(path, "rb") as fh: + while chunk := fh.read(CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def build_checksums_text(files: dict[str, str]) -> str: + """Build a sha256sum(1)-compatible manifest body. + + `files` maps filename -> hex digest. Entries are sorted by filename for + a deterministic, diffable output. Format matches `sha256sum` exactly: + " \n" (two spaces, no path components). + """ + lines = [f"{digest} {name}" for name, digest in sorted(files.items())] + body = "\n".join(lines) + return body + "\n" if body else "" + + +def generate_checksums(directory: Path, *, exclude: set[str] | None = None) -> str: + """Hash every regular file directly inside `directory` (non-recursive) + and return the SHA256SUMS text. Filenames are recorded without any + directory prefix so the manifest can be verified from inside the + directory it describes. + """ + exclude = exclude or set() + files: dict[str, str] = {} + for entry in sorted(directory.iterdir()): + if not entry.is_file(): + continue + if entry.name in exclude: + continue + files[entry.name] = sha256_file(entry) + return build_checksums_text(files) + + +def _signing_message(sums_text: str) -> bytes: + """The exact bytes that get signed: the domain prefix followed by the + raw bytes of the SHA256SUMS file content.""" + return DOMAIN_PREFIX + sums_text.encode("utf-8") + + +def sign_checksums(seed_b64: str, sums_text: str) -> bytes: + """Sign `sums_text` with the Ed25519 private key encoded (base64, raw + 32-byte seed) in `seed_b64`. Returns the raw 64-byte signature.""" + # Imported lazily so `generate` mode (used on every CI run) never + # requires the `cryptography` package to be installed. + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + seed = base64.b64decode(seed_b64) + if len(seed) != 32: + raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes") + private_key = Ed25519PrivateKey.from_private_bytes(seed) + return private_key.sign(_signing_message(sums_text)) + + +def verify_checksums(pubkey_b64: str, sums_text: str, signature: bytes) -> bool: + """Verify `signature` over `sums_text` against the base64-encoded raw + 32-byte Ed25519 public key. Returns True/False; never raises for a bad + signature (only for malformed inputs).""" + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + pubkey_bytes = base64.b64decode(pubkey_b64) + if len(pubkey_bytes) != 32: + raise ValueError( + f"expected a 32-byte raw Ed25519 public key, got {len(pubkey_bytes)} bytes" + ) + public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes) + try: + public_key.verify(signature, _signing_message(sums_text)) + return True + except InvalidSignature: + return False + + +def public_key_b64_from_seed(seed_b64: str) -> str: + """Derive the base64 raw public key from a base64 raw seed. Handy for + local key-pair sanity checks; not used by the release workflow.""" + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + seed = base64.b64decode(seed_b64) + private_key = Ed25519PrivateKey.from_private_bytes(seed) + raw = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return base64.b64encode(raw).decode("ascii") + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def _cmd_generate(args: argparse.Namespace) -> int: + directory = Path(args.directory) + exclude = {"SHA256SUMS", "SHA256SUMS.sig"} + text = generate_checksums(directory, exclude=exclude) + out_path = Path(args.out) + out_path.write_text(text, encoding="utf-8") + print(f"Wrote {out_path} ({len(text.splitlines())} entries)") + return 0 + + +def _cmd_sign(args: argparse.Namespace) -> int: + seed_b64 = args.key_b64 or os.environ.get(args.key_env, "") + if not seed_b64: + print( + f"error: no signing key provided (checked --key-b64 and ${args.key_env})", + file=sys.stderr, + ) + return 1 + sums_text = Path(args.sums).read_text(encoding="utf-8") + signature = sign_checksums(seed_b64, sums_text) + Path(args.out).write_bytes(signature) + print(f"Wrote {args.out} ({len(signature)} bytes)") + return 0 + + +def _cmd_verify(args: argparse.Namespace) -> int: + pubkey_b64 = args.pubkey_b64 or os.environ.get(args.pubkey_env, "") + if not pubkey_b64: + print( + f"error: no public key provided (checked --pubkey-b64 and ${args.pubkey_env})", + file=sys.stderr, + ) + return 1 + sums_text = Path(args.sums).read_text(encoding="utf-8") + signature = Path(args.sig).read_bytes() + ok = verify_checksums(pubkey_b64, sums_text, signature) + if ok: + print("OK: signature is valid") + return 0 + print("FAILED: signature is invalid", file=sys.stderr) + return 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = parser.add_subparsers(dest="mode", required=True) + + p_gen = sub.add_parser( + "generate", help="hash every file in a directory into a SHA256SUMS manifest" + ) + p_gen.add_argument("directory", help="directory whose files should be hashed (non-recursive)") + p_gen.add_argument("--out", required=True, help="path to write the SHA256SUMS manifest to") + p_gen.set_defaults(func=_cmd_generate) + + p_sign = sub.add_parser("sign", help="detached-sign a SHA256SUMS manifest with Ed25519") + p_sign.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest to sign") + p_sign.add_argument("--out", required=True, help="path to write the detached signature to") + p_sign.add_argument( + "--key-b64", default=None, help="base64 raw Ed25519 seed (overrides --key-env)" + ) + p_sign.add_argument( + "--key-env", + default="RELEASE_SIGNING_KEY", + help="environment variable holding the base64 seed (default: RELEASE_SIGNING_KEY)", + ) + p_sign.set_defaults(func=_cmd_sign) + + p_verify = sub.add_parser( + "verify", help="verify a SHA256SUMS manifest against a detached signature" + ) + p_verify.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest") + p_verify.add_argument("--sig", required=True, help="path to the detached signature") + p_verify.add_argument( + "--pubkey-b64", default=None, help="base64 raw Ed25519 public key (overrides --pubkey-env)" + ) + p_verify.add_argument( + "--pubkey-env", + default="RELEASE_SIGNING_PUBKEY", + help="environment variable holding the base64 public key (default: RELEASE_SIGNING_PUBKEY)", + ) + p_verify.set_defaults(func=_cmd_verify) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_checksums.py b/tests/test_checksums.py new file mode 100644 index 0000000..af3ca8e --- /dev/null +++ b/tests/test_checksums.py @@ -0,0 +1,234 @@ +"""Tests for scripts/sign_checksums.py: SHA256SUMS generation and detached +Ed25519 signing/verification for release artifacts.""" + +from __future__ import annotations + +import base64 +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import sign_checksums as sc + +cryptography = pytest.importorskip("cryptography") +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402 +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402 + + +def _make_keypair() -> tuple[str, str]: + """Return (seed_b64, pubkey_b64) for a fresh Ed25519 keypair.""" + private_key = Ed25519PrivateKey.generate() + seed = private_key.private_bytes_raw() + pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return base64.b64encode(seed).decode("ascii"), base64.b64encode(pubkey).decode("ascii") + + +# --------------------------------------------------------------------------- # +# sha256_file / build_checksums_text / generate_checksums +# --------------------------------------------------------------------------- # +def test_sha256_file_matches_hashlib(tmp_path): + f = tmp_path / "a.txt" + f.write_bytes(b"hello world") + import hashlib + + assert sc.sha256_file(f) == hashlib.sha256(b"hello world").hexdigest() + + +def test_build_checksums_text_sorted_and_formatted(): + files = {"zeta.zip": "aa" * 32, "alpha.zip": "bb" * 32} + text = sc.build_checksums_text(files) + lines = text.splitlines() + assert lines[0].endswith("alpha.zip") + assert lines[1].endswith("zeta.zip") + # Standard sha256sum format: hash, two spaces, filename. + assert lines[0] == f"{'bb' * 32} alpha.zip" + + +def test_build_checksums_text_empty(): + assert sc.build_checksums_text({}) == "" + + +def test_generate_checksums_from_directory(tmp_path): + (tmp_path / "b.bin").write_bytes(b"second") + (tmp_path / "a.bin").write_bytes(b"first") + (tmp_path / "subdir").mkdir() + (tmp_path / "subdir" / "ignored.bin").write_bytes(b"nested, not hashed") + + text = sc.generate_checksums(tmp_path) + lines = text.splitlines() + assert len(lines) == 2 + assert lines[0].endswith("a.bin") + assert lines[1].endswith("b.bin") + assert "subdir" not in text + + +def test_generate_checksums_excludes_manifest_files(tmp_path): + (tmp_path / "archive.zip").write_bytes(b"payload") + (tmp_path / "SHA256SUMS").write_text("stale") + (tmp_path / "SHA256SUMS.sig").write_bytes(b"stale-sig") + + text = sc.generate_checksums(tmp_path, exclude={"SHA256SUMS", "SHA256SUMS.sig"}) + assert "archive.zip" in text + assert "SHA256SUMS" not in text.replace("archive.zip", "") + + +# --------------------------------------------------------------------------- # +# sign_checksums / verify_checksums +# --------------------------------------------------------------------------- # +def test_sign_then_verify_roundtrip(): + seed_b64, pubkey_b64 = _make_keypair() + sums_text = "deadbeef" * 8 + " BetterClaudeConfig-Linux.tar.gz\n" + + signature = sc.sign_checksums(seed_b64, sums_text) + assert len(signature) == 64 + assert sc.verify_checksums(pubkey_b64, sums_text, signature) is True + + +def test_verify_rejects_tampered_checksums(): + seed_b64, pubkey_b64 = _make_keypair() + sums_text = "aa" * 32 + " file.zip\n" + signature = sc.sign_checksums(seed_b64, sums_text) + + tampered = "bb" * 32 + " file.zip\n" + assert sc.verify_checksums(pubkey_b64, tampered, signature) is False + + +def test_verify_rejects_wrong_key(): + seed_b64, _ = _make_keypair() + _, other_pubkey_b64 = _make_keypair() + sums_text = "cc" * 32 + " file.zip\n" + signature = sc.sign_checksums(seed_b64, sums_text) + + assert sc.verify_checksums(other_pubkey_b64, sums_text, signature) is False + + +def test_domain_prefix_is_applied(): + """The signed message must be prefixed, not the raw manifest bytes -- + otherwise a signature over this manifest could be replayed as a + signature over an unrelated message with the same bytes elsewhere.""" + seed_b64, pubkey_b64 = _make_keypair() + sums_text = "11" * 32 + " file.zip\n" + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as PK + + seed = base64.b64decode(seed_b64) + raw_signature = PK.from_private_bytes(seed).sign(sums_text.encode("utf-8")) + + # A signature over the raw (unprefixed) bytes must NOT verify via our + # domain-separated verify function. + assert sc.verify_checksums(pubkey_b64, sums_text, raw_signature) is False + + # But our own sign_checksums() output does verify. + good_signature = sc.sign_checksums(seed_b64, sums_text) + assert sc.verify_checksums(pubkey_b64, sums_text, good_signature) is True + + +def test_sign_checksums_rejects_bad_seed_length(): + bad_seed_b64 = base64.b64encode(b"too-short").decode("ascii") + with pytest.raises(ValueError): + sc.sign_checksums(bad_seed_b64, "irrelevant\n") + + +def test_verify_checksums_rejects_bad_pubkey_length(): + seed_b64, _ = _make_keypair() + sig = sc.sign_checksums(seed_b64, "irrelevant\n") + bad_pubkey_b64 = base64.b64encode(b"too-short").decode("ascii") + with pytest.raises(ValueError): + sc.verify_checksums(bad_pubkey_b64, "irrelevant\n", sig) + + +def test_public_key_b64_from_seed_matches_generated_pubkey(): + seed_b64, pubkey_b64 = _make_keypair() + assert sc.public_key_b64_from_seed(seed_b64) == pubkey_b64 + + +# --------------------------------------------------------------------------- # +# CLI (end-to-end, via subprocess so argparse wiring is exercised too) +# --------------------------------------------------------------------------- # +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "sign_checksums.py" + + +def _run(*args, env=None): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_cli_generate_sign_verify_roundtrip(tmp_path, monkeypatch): + seed_b64, pubkey_b64 = _make_keypair() + + release_dir = tmp_path / "release-files" + release_dir.mkdir() + (release_dir / "BetterClaudeConfig-Linux.tar.gz").write_bytes(b"fake archive contents") + (release_dir / "BetterClaudeConfig-macOS.zip").write_bytes(b"fake zip contents") + + sums_path = release_dir / "SHA256SUMS" + sig_path = release_dir / "SHA256SUMS.sig" + + gen = _run("generate", str(release_dir), "--out", str(sums_path)) + assert gen.returncode == 0, gen.stderr + assert sums_path.exists() + body = sums_path.read_text() + assert "BetterClaudeConfig-Linux.tar.gz" in body + assert "BetterClaudeConfig-macOS.zip" in body + + sign = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64) + assert sign.returncode == 0, sign.stderr + assert sig_path.exists() + assert sig_path.stat().st_size == 64 + + verify = _run( + "verify", + "--sums", + str(sums_path), + "--sig", + str(sig_path), + "--pubkey-b64", + pubkey_b64, + ) + assert verify.returncode == 0, verify.stderr + assert "OK" in verify.stdout + + +def test_cli_sign_without_key_fails_loudly(tmp_path): + sums_path = tmp_path / "SHA256SUMS" + sums_path.write_text("aa" * 32 + " file.zip\n") + sig_path = tmp_path / "SHA256SUMS.sig" + + import os + + env = {k: v for k, v in os.environ.items() if k != "RELEASE_SIGNING_KEY"} + result = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), env=env) + + assert result.returncode != 0 + assert not sig_path.exists(), "must never write a bogus/empty signature file" + assert "no signing key" in result.stderr.lower() + + +def test_cli_verify_detects_tampering(tmp_path): + seed_b64, pubkey_b64 = _make_keypair() + sums_path = tmp_path / "SHA256SUMS" + sums_path.write_text("aa" * 32 + " file.zip\n") + sig_path = tmp_path / "SHA256SUMS.sig" + + _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64) + + sums_path.write_text("bb" * 32 + " file.zip\n") # tamper after signing + verify = _run( + "verify", + "--sums", + str(sums_path), + "--sig", + str(sig_path), + "--pubkey-b64", + pubkey_b64, + ) + assert verify.returncode != 0 + assert "FAILED" in verify.stdout + verify.stderr