Files
pristontale-eu-linux/tools/sign_release_artifact.py

140 lines
3.6 KiB
Python

#!/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())