#!/usr/bin/env python3 """Create and verify an atomic OpenSSL signature for one release artifact.""" from __future__ import annotations import argparse from pathlib import Path import shutil import subprocess import sys from typing import Sequence from uuid import uuid4 _BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1] if str(_BOOTSTRAP_ROOT) not in sys.path: sys.path.insert(0, str(_BOOTSTRAP_ROOT)) from scripts.release_lib import ( # noqa: E402 ReleaseToolError, _safe_signing_key_path, ) class ReleaseSigningError(RuntimeError): """Raised when a release artifact cannot be signed safely.""" def sign_release_artifact( artifact: Path, *, signing_key: Path, public_key: Path, ) -> Path: """Sign an artifact and verify the result with the published public key.""" source = _regular_file(artifact, "release artifact") try: private = _safe_signing_key_path(signing_key) except ReleaseToolError as exc: raise ReleaseSigningError(str(exc)) from exc public = _regular_file(public_key, "public key") openssl = shutil.which("openssl") if openssl is None: raise ReleaseSigningError("OpenSSL is required to sign release artifacts") signature = Path(f"{source}.sig") if signature.is_dir() and not signature.is_symlink(): raise ReleaseSigningError(f"signature path is a directory: {signature}") signature.unlink(missing_ok=True) temporary = signature.with_name(f".{signature.name}.{uuid4().hex}.tmp") try: _sign(openssl, source, temporary, private) _verify(openssl, source, temporary, public) temporary.chmod(0o644) temporary.replace(signature) except BaseException: temporary.unlink(missing_ok=True) raise return signature def _regular_file(path: Path, label: str) -> Path: value = path.expanduser() if value.is_symlink() or not value.is_file(): raise ReleaseSigningError(f"{label} is missing or not a regular file: {value}") return value.resolve() def _sign(openssl: str, artifact: Path, signature: Path, private_key: Path) -> None: _run_openssl( [ openssl, "dgst", "-sha256", "-sign", str(private_key), "-out", str(signature), str(artifact), ], "sign the release artifact", ) def _verify(openssl: str, artifact: Path, signature: Path, public_key: Path) -> None: _run_openssl( [ openssl, "dgst", "-sha256", "-verify", str(public_key), "-signature", str(signature), str(artifact), ], "verify the release artifact signature", ) def _run_openssl(command: list[str], action: str) -> None: try: result = subprocess.run( command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: raise ReleaseSigningError(f"OpenSSL could not {action}: {exc}") from exc if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() or "unknown error" raise ReleaseSigningError(f"OpenSSL could not {action}: {detail}") def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("artifact", type=Path) parser.add_argument("--signing-key", type=Path, required=True) parser.add_argument("--public-key", type=Path, required=True) arguments = parser.parse_args(argv) try: signature = sign_release_artifact( arguments.artifact, signing_key=arguments.signing_key, public_key=arguments.public_key, ) except (OSError, ReleaseSigningError) as exc: print(f"Could not sign release artifact: {exc}", file=sys.stderr) return 1 print(signature) return 0 if __name__ == "__main__": raise SystemExit(main())