#!/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|" # Public half of the RELEASE signing key(s) -- a SEPARATE keypair from # bcc_core.CATALOG_PUBKEYS (issue #68 finding 5). The catalog key is the # offline, Console-only root of trust for what BCC executes; this key is # CI-resident and signs ONLY the release SHA256SUMS manifest, never the # catalog. Keeping them apart means a CI/repo-secret compromise burns the # release key -- annoying, but it never lets an attacker sign a catalog a # user's binary would trust. A LIST (not a single key), mirroring # CATALOG_PUBKEYS, so the release key can be rotated without invalidating # the signature on every past release: verification accepts a match against # ANY key here. # # Empty until the maintainer generates the release keypair (separately from # the catalog keypair) and pastes the public half in: # python catalog_console.py keygen --release # This is intentionally NOT pre-populated with a placeholder that looks # like a real key -- release.yml's signing-smoke-test fails closed (loudly) # on an empty list rather than silently verifying against nothing. RELEASE_PUBKEYS: list[bytes] = [] 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") def verify_checksums_against_any(pubkeys: list[bytes], sums_text: str, signature: bytes) -> bool: """Verify `signature` against ANY key in `pubkeys` (each a raw 32-byte Ed25519 public key). Mirrors bcc_core.verify_catalog_signature's rotation-friendly "any currently-trusted key" semantics, applied to RELEASE_PUBKEYS instead of the catalog's key list. Returns False (never raises) for an empty `pubkeys` list -- fails closed rather than vacuously verifying against nothing.""" return any( verify_checksums(base64.b64encode(pk).decode("ascii"), sums_text, signature) for pk in pubkeys ) # --------------------------------------------------------------------------- # # 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())