Merge branch 'main' into feat/10-catalog-core
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 43s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 43s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user