"""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